minikv 重构项目 · 任务 7 / 8

m07:名字、注释,以及先写注释

对应第 12~15 章(为什么写注释、写什么注释、起名字、先写注释)

背景与对应章节

只看 store.h,不读 store.cpp,你能正确使用 Store 吗?

前面几个任务都在改结构。这一个只改"文字":名字和注释。但别小看它。Store 是 minikv 最核心的类,它的头文件现在几乎没有接口注释,两个最重要的成员叫 total_ 和 dead_,而唯一一段较长的注释描述的是实现步骤。结果是:任何想用 Store 的人都得去读实现。

这个任务分两半。前一半是修:对照 第 14 章 · 起名字 和 第 13 章 · 注释应描述代码里看不出来的东西 改名字、删废话、补接口注释。后一半是练 第 15 章 · 先写注释:给 minikv 加一个 RENAME 命令,先写接口注释,再写实现。为什么值得花这个力气,第 12 章已经讲过了。

你要找的问题

位置读的时候问自己
src/store.h 的私有成员和取值方法只看名字,你能说出 total_、dead_、count() 的单位和含义吗?同一个概念,在 README、STATS 输出、Database::maybeCompact() 里分别叫什么?
Store::handle不看实现,这个名字能告诉你它在什么时候被调用、做什么吗?
Store::compact() 的注释这段注释是写给调用者的,还是写给实现者的?调用者最想知道的三件事(之后会怎样、失败了数据还在不在、中途崩溃会怎样)它回答了吗?
Store 的其他公有方法只看声明,回答:get 找不到时返回什么?getRange 的 end 包含吗?del 删不存在的 key 会怎样?出错时内存和日志是否一致?
src/index.h、src/store.cpp 里的注释用书里的检验:只看旁边那一行代码,一个没读过实现的人能不能写出这条注释?反过来,Index::find() 返回的指针什么时候失效,写在哪里了?

先感受一下现状:只读头文件,看看你能回答多少问题。

cd project/minikv
make test
grep -n "total\|dead\|count()\|handle" src/store.h src/database.cpp
grep -n "//" src/index.h src/store.cpp

任务与约束

前一半:修名字和注释

  1. 给 Store 里含糊的名字换成精确的名字,并和项目里其他地方对同一概念的叫法保持一致。
  2. 删掉复述代码的注释;在真正有"看不出来的信息"的地方补注释(包括 index.h)。
  3. 重写 Store::compact() 的注释,让它只说调用者需要知道的东西。
  4. 给 Store 的类和每个公有方法写接口注释:返回值、不存在时的行为、边界是否包含、抛什么异常、失败后数据处于什么状态。

后一半:先写注释,再加 RENAME

要加的命令是 RENAME src dst:把 src 的值交给 dst,并删掉 src。

在写任何实现之前,先在下面的笔记框里写出 Store::rename 的完整接口注释。写不下去的地方,就是你还没做的设计决定,先把它们想清楚。写完了再去实现,实现时如果发现注释不对,先改注释。

你的注释至少要回答这几个问题:

约束:

自查清单

提示

提示 1:名字从哪里找

先列出几个候选词,再挑最精确的那个。total_ 记录的是日志文件的字节数;dead_ 记录的东西,在 STATS 输出里已经有一个更好的名字了。第 14 章说的一致性有三条:同一用途总用同一个名字、这个名字不用于别的用途、用途要窄到所有同名变量行为一致。

提示 2:接口注释写什么

站在调用者的角度:一两句话说清它做什么;每个参数和返回值的精确含义(单位、范围、包含与否、"不存在"时是什么);副作用;会抛什么异常、抛了之后数据处于什么状态。写到"失败后会怎样"时,回头仔细读一遍实现,注释必须是真的。compact() 在这里藏着一个惊喜。

提示 3:注释写不顺的时候

写 exists() 的注释时,如果要写全,你会被迫写下一个奇怪的副作用;写 compact() 的失败语义时,你会写出一句很别扭的话。第 15 章说过:一个简单而完整的注释写不出来,往往说明设计本身有问题。这个任务里,把问题如实写下来就够了,别在这里修结构。

提示 4:RENAME 和崩溃

RENAME 可以用现有的 set 和 del 组合出来,日志里就是两条记录。两条记录的顺序决定了"在中间崩溃"时的结果:先删 src 再写 dst,崩溃后值就丢了;先写 dst 再删 src,崩溃后两个 key 都有值。真正原子的做法需要新的记录类型,要改日志格式。选哪种都行,但必须写进注释。

参考答案与讨论

先自己做完、测试全绿,尤其要先写出 RENAME 的注释,再打开下面的内容对照。

打开参考答案

名字

改之前改之后为什么
total_ / total()logBytes_ / logBytes()"总"什么不清楚;它是日志文件的字节数,STATS 里本来就叫 log_bytes
dead_ / dead()garbageBytes_ / garbageBytes()README、STATS、Database 都叫 garbage,只有这里叫 dead
count()keyCount()count of what
handle(const Record&)replayRecord(const Record&)说出它在什么时候、对什么起作用

接口注释

// Loops over index_ and writes each entry with a second LogWriter opened
// with O_TRUNC on path_ + ".compact", fsyncs it, closes writer_,
// rename(2)s the new file over path_, reopens writer_ with O_APPEND,
// then sets total_ to the new size and dead_ to 0.
void compact();

std::pair<bool, std::string> get(const std::string& key) const;
bool exists(const std::string& key) const;
void del(const std::string& key, const WriteOptions& wo);
std::string getRange(const std::string& key, int64_t start, int64_t end) const;
// Rewrites the log so that it holds only the current value of each key,
// which brings garbageBytes() to 0. Takes time proportional to the size
// of the live data. If the process dies during compaction, the old log
// is still complete. Throws std::runtime_error on I/O errors; the log on
// disk then still holds all the data, but if the failure happened while
// switching to the new file, every later write fails too until the Store
// is reopened.
void compact();

// Returns {true, value} if `key` is present and {false, ""} otherwise.
// Counts one keyspace hit or miss in stats().
std::pair<bool, std::string> get(const std::string& key) const;

// Returns whether `key` is present. Like get(), counts one keyspace hit
// or miss in stats().
bool exists(const std::string& key) const;

// Removes `key`. Throws KeyNotFound if it is absent.
void del(const std::string& key, const WriteOptions& wo);

// Returns bytes start..end of the value of `key`, both ends inclusive,
// counting from 0. Throws KeyNotFound if `key` is absent and
// std::out_of_range unless 0 <= start <= end < length of the value.
std::string getRange(const std::string& key, int64_t start, int64_t end) const;

类注释也补上了:Store 是什么、修改何时写入日志、写日志失败时内存和日志各处于什么状态、不是线程安全的。Database 不重复这些注释,只说一句"语义同 store.h 中的同名方法,另外每次写都可能触发压缩"。

注释暴露出来的设计问题

  • compact() 的失败语义写不简单。本想写"失败时数据不变",回头核对实现才发现:基线先关写端再 rename,rename 失败后所有写都会失败。注释只能写成那句别扭的话。这就是第 15 章说的金丝雀:注释难写,设计有问题。(m01 的 LogFile::rewrite 顺带修掉了它。)
  • exists() 会改统计数据。写全了注释,这个副作用就摆到了明面上,GET 先 exists 再 get,一次命中计两次。m08 会处理它。
  • Index::find() 的指针有效期原来只存在于作者脑子里,现在写进了 index.h。

实现注释:只写"为什么"

// write the record
writer_.append(rec, wo);

uint64_t size = kRecordHeaderSize + key.size() + value.size();
// update dead
const IndexEntry* old = index_.find(key);
if (old) dead_ += old->recordSize;
// update total
total_ += size;
// update index
index_.put(key, value, size);
// Log first: if the write throws, memory still matches the log.
writer_.append(rec, wo);

uint64_t size = kRecordHeaderSize + key.size() + value.size();
const IndexEntry* old = index_.find(key);
if (old) garbageBytes_ += old->recordSize;
logBytes_ += size;
index_.put(key, value, size);

改名以后,后面三行已经不需要注释了。剩下的一条说的是顺序为什么重要,这是代码本身表达不出来的。

RENAME:注释先行

// Gives the value of `from` to `to` and removes `from`, as if by
// set(to, value) followed by del(from). An existing value of `to` is
// replaced. Renaming a key to itself does nothing. Throws KeyNotFound if
// `from` is absent; nothing changes then.
// Not atomic across crashes: the change is logged as two records, so a
// crash between them leaves both keys holding the value after a restart
// (never neither).
void rename(const std::string& from, const std::string& to, const WriteOptions& wo);
注释逼出来的问题决定
src 不存在抛 KeyNotFound,不改任何东西,回复 ERR no such key
dst 已存在覆盖,和 SET 一致
src == dst什么都不做。机械地"先写 dst 再删 src"会把值删掉:这是写注释时才会认真去想的边界
两条记录之间崩溃先写 dst 再删 src,重启后两个 key 都有值,而不是都没了

真正原子的 RENAME 需要新的记录类型,要改日志格式和重放。参考答案没有做,而是把"不原子,但只多不少"写进接口。如果你写注释时觉得这句话无法接受,那正说明应该去改格式:注释在这里起了设计评审的作用。决定都做完了,实现只有几行:

void Store::rename(const std::string& from, const std::string& to, const WriteOptions& wo) {
    const IndexEntry* source = index_.find(from);
    if (!source) throw KeyNotFound(from);
    if (from == to) return;
    // Copy: set() may rehash the index and invalidate `source`.
    std::string value = source->value;
    // Write `to` before deleting `from`, so that a crash in between leaves
    // the value under both keys rather than under neither.
    set(to, value, wo);
    del(from, wo);
}

数字:store.h 从 62 行变成 116 行,其中注释从 6 行变成 49 行。

代价与取舍

头文件长了一倍。第 13.1 节的立场是每个类、每个成员、每个方法都该有注释,例外极少;参考答案只省略了 contains()、keyCount() 这类名字已经说完一切的声明。衡量标准不是行数,而是每一行有没有提供代码里看不出来的信息。和 Clean Code 关于"注释是失败"的分歧,见专题。

书中第 16.2 节主张把接口注释放在实现旁边,改代码时更容易顺手更新;C/C++ 的普遍习惯是放在头文件,方便调用者阅读。参考答案遵循项目已有的约定(头文件)。第 17 章会讲:遵守已有约定的价值,通常大于一个"更好的想法"。

名字是接口的一部分,改名要改所有使用者:total_ 改成 logBytes_ 让 database.cpp 也进了 diff。越早改越便宜,这也是第 14 章说名字值得一开始就花时间的原因。

审稿人仍会指出的问题

  • get() 仍然返回 std::pair,exists() 的副作用只是被写明了、没有被去掉,Server::bulk(s, true, true) 的两个 bool 仍然看不出含义:m08。
  • del、getRange 的错误情况本可以定义掉(m06)。注释越写越长的地方,往往就是设计可以更简单的地方。
  • RENAME 的处理函数又复制了一遍参数检查和 try/catch:重复的问题见 m05。

完整讨论见 solutions/m07/DISCUSSION.md。对照自己的改动:

cd project            # 仓库里 minikv/ 和 solutions/ 所在的目录
make -C minikv clean && make -C solutions/m07 clean
diff -ru minikv solutions/m07
make -C solutions/m07 test