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任务与约束
前一半:修名字和注释
- 给
Store里含糊的名字换成精确的名字,并和项目里其他地方对同一概念的叫法保持一致。 - 删掉复述代码的注释;在真正有"看不出来的信息"的地方补注释(包括
index.h)。 - 重写
Store::compact()的注释,让它只说调用者需要知道的东西。 - 给
Store的类和每个公有方法写接口注释:返回值、不存在时的行为、边界是否包含、抛什么异常、失败后数据处于什么状态。
后一半:先写注释,再加 RENAME
要加的命令是 RENAME src dst:把 src 的值交给 dst,并删掉 src。
在写任何实现之前,先在下面的笔记框里写出 Store::rename 的完整接口注释。写不下去的地方,就是你还没做的设计决定,先把它们想清楚。写完了再去实现,实现时如果发现注释不对,先改注释。
你的注释至少要回答这几个问题:
- src 不存在时怎么办?数据会被改动吗?
- dst 已经存在时怎么办?
- src 和 dst 是同一个 key 时会发生什么?(试着按"先写 dst 再删 src"在脑子里执行一遍。)
- RENAME 在日志里会写几条记录?如果进程在两条记录之间崩溃,重启后看到的是什么?这个结果可以接受吗?
约束:
- 原有测试一个都不许改,必须全绿;为 RENAME 新建
tests/test_rename.cpp。 - 代码里的名字和注释保持英文(和项目一致)。
- 只做名字和注释(以及 RENAME)。注释写出来以后你可能会发现一些设计问题,如实写进注释即可,结构性的修改留给对应任务。
- README 的命令表加上 RENAME。
自查清单
- 任何一个成员名,单独拿出来看,都能猜出它的含义和单位;同一概念全项目只有一个名字。
- 每条注释都通过"不是代码的复述"检验:它用的词和它旁边的名字不一样,提供的信息在代码里看不出来。
store.h的接口注释里没有出现任何私有成员名、系统调用名或临时文件名。- 把
store.cpp藏起来,只给同事store.h:对方能正确地调用每个方法,并说出每个方法失败时的后果。 - RENAME 的注释是你在写实现之前写的;实现后对照一下,注释有没有需要改的地方?如果有,为什么当时没想到?
make test全绿,编译零警告。
提示
提示 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