读前:这一章要回答什么
注释应该在什么时候写?能不能让写注释这件事反过来帮我们做设计?
第 12、13 章回答了"为什么要写注释"和"注释该写什么"。但即使认同这两章,大多数人仍然会把注释留到最后。这一章只讲一件事:写注释的时机。作者的主张是把它挪到最前面,在写实现之前先用注释把抽象写出来。
这一章很短,却是第 4 章"深模块"和第 13 章"接口注释"的交汇点:接口注释写出来有多长,几乎就是接口有多复杂的直接读数。所以先写注释不只是个文档习惯,而是一种廉价的设计评审:在一行实现都没有的时候,就能看出这个抽象深不深。它和第 11 章"设计两次"一样,都是在设计阶段多花一点时间的方法。
- 15.1 节:作者认为大家推迟写注释有两个原因,一个是嘴上说的,一个是心里想的。留意作者分别怎么回应,以及所描述的"事后补注释"的三个毛病。
- 15.3 节是全章最重要的一节。注意红旗"难以描述"后面紧跟的那个限制条件:什么样的注释不能拿来衡量复杂性?
- 15.5 节的估算很粗糙,但值得用你自己的数字重算一遍:上周你有多少时间真正在敲键盘?
核心观点
推迟 = 不写,或写得差
拖到最后的注释,要么永远没写,要么是收尾时看着代码匆匆补的:复述代码,设计时的考虑已经想不起来了。
顺序:先接口注释,后实现
类注释 → 关键方法的签名和注释(方法体空着)→ 关键成员变量 → 最后写方法体。写完代码时,注释也写完了。
注释是设计工具
作者认为抽象只有靠注释才能完整记下来。先写出来,就能在写实现之前审查、调整抽象。
注释是复杂性的预警器
方法或变量要写很长的注释才说得清,说明抽象不好。接口注释必须描述实现的主要机制,说明方法浅。
量尺要准,注释先要完整
只有完整、清楚的注释才能衡量复杂性。缺信息或含糊的短注释说明不了任何问题。
省不了多少时间
敲代码和注释只占开发时间的一小部分。推迟注释省下的很少,抽象早早稳定下来反而可能更快。
心智模型:问题在哪一步被发现
两种做法写出来的注释,篇幅可能差不多。真正的差别在于"接口设计得别扭"这件事在哪一步被发现,以及发现时要改多少东西。
Comments serve as a canary in the coal mine of complexity.— 第 15.3 节
矿井里的金丝雀会比人更早对毒气起反应。注释也一样:在接口复杂到伤害调用者之前,写注释的人会先感到别扭。前提是你在设计阶段就写,并且认真写完整。
跟着做:用注释设计一个类
读这一章最好的方式是真的试一次。下面按书中 15.2 节的顺序走一遍,每一步先在文本框里写(自动保存在本机),写完再展开参考。不必追求和参考一致,重要的是你在写的过程中被迫做出了哪些决定。
需求可续传的分块上传
移动端要上传最大 4 GB 的视频。网络随时会断,客户端会把文件切成固定大小的块,可能乱序发送,也可能重发同一块。服务端为每次上传创建一个 UploadSession 对象。方法签名已经有了,方法体还没写:
// ① 类注释:?
class UploadSession {
public:
// ② ?
UploadSession(std::string uploadId, uint64_t totalBytes, uint32_t chunkBytes);
// ③ ?
void putChunk(uint32_t index, std::span<const std::byte> data);
// ④ ?
std::vector<uint32_t> missingChunks() const;
// ⑤ ?
std::filesystem::path commit();
};
第 1 步类注释
用两三句话说清楚:这个类代表什么?调用者用它能得到什么?有没有使用上的大前提(线程安全、生命周期)?
展开参考
// 一次可续传的分块上传。客户端可以按任意顺序上传各块,也可以重传;
// 所有块到齐后调用 commit() 得到拼好的完整文件。
// 不是线程安全的:同一个 uploadId 同一时刻只应由一个线程操作。
注意参考里没有的东西:块存在哪个临时目录、用什么数据结构记录收到了哪些块。那些是实现,类注释只写抽象。
第 2 步方法的接口注释(②~⑤)
逐个写。每个方法问自己:调用者要知道什么才能正确地调用它?参数有什么约束?出错时会怎样?调用之后对象处于什么状态?
展开参考
// ② totalBytes 是文件总大小;除最后一块外,每块都是 chunkBytes 字节。
// 块的个数为 ceil(totalBytes / chunkBytes),下标从 0 开始。
// ③ 保存第 index 块。重传同一块时,新数据覆盖旧数据。
// index 越界、或 data 长度不等于该块应有的长度(最后一块可能较短)时
// 抛 std::invalid_argument,已收到的数据不受影响。
// commit() 成功之后再调用,抛 std::logic_error。
// ④ 还没收到的块的下标,升序。客户端断线重连后据此决定从哪里续传。
// ⑤ 把所有块拼成一个文件,返回它的路径;之后这个对象不能再使用。
// 仍有块缺失时抛 std::logic_error,对象状态不变,可以继续 putChunk()。
大多数人写到这一步会第一次认真考虑这些问题:重传是覆盖还是忽略?长度不对算谁的错?commit 失败后还能不能继续?这些都是设计决定。如果先写实现,它们会在写代码时被顺手、甚至无意识地决定掉,事后补注释时也未必想得起来写。
第 3 步让金丝雀叫一次
一位同事觉得四个方法太多,提议合成一个:
// ?
int put(uint32_t index, std::span<const std::byte> data, bool isLast, bool dryRun);
其中 isLast 为真时自动尝试拼接文件,dryRun 为真时只检查长度不保存。请试着给它写一份完整的接口注释:返回值在各种情况下分别是什么意思?isLast 为真但还有块没到怎么办?两个开关都为真呢?
展开讨论
完整的注释至少要写清:返回值在"保存成功""拼接成功""还缺块""dryRun 检查通过/失败"几种情况下的编码;isLast 只能在最后一块上传吗?还是任意一块都行?两个开关组合的四种情况各自的语义……很快就会超过十行,而且读起来像在描述一张状态表。
这就是红旗:不是你注释写得不好,而是这个接口把几件互不相干的事塞在了一起。方法个数少了,但接口反而更复杂(回忆第 4 章:深度看的是接口有多复杂,而不是方法有几个)。好消息是,你只写了一行声明就发现了问题,回到四个方法的方案几乎没有成本。
第 4 步关键成员变量
现在才轮到实现层面。你打算用哪些成员变量记录状态?每个变量写一行注释:它表示什么、取值的含义、和别的变量之间有什么必须保持的关系。
展开参考
std::filesystem::path dir_; // 本次上传专用的临时目录,第 i 块存为 dir_/i
std::vector<bool> received_; // received_[i] 为真表示第 i 块已完整写入磁盘
uint32_t numReceived_ = 0; // received_ 中为真的个数,等于块数时才能 commit
bool committed_ = false; // commit() 成功后为真,此后所有操作都抛 logic_error
如果某个变量需要好几行注释才能说清(比如用一个 int state_ 同时表示"收到几块""是否已提交""提交是否失败"),那是同一只金丝雀在提醒你:变量拆得不对。
最后一步是写方法体,并在需要的地方加实现注释。写的过程中如果发现还需要新的方法或变量,照样先写注释、再写代码。这样走一遍,代码写完的那一刻,注释也已经写完,没有欠账。
一个原创例子:事后补的注释 vs 先写的注释
同一个方法,两份注释。第一份是功能上线后补文档时加的,第二份是设计时、方法体还空着的时候写的。
// 检查 activeSegment_ 的大小是否大于 maxSegmentBytes_,
// 如果是就调用 sealSegment(),然后新建一个 segment。
void LogStore::rollIfNeeded();
看着代码写,于是注释在复述代码(第 13 章的红旗 )。设计时考虑过的事情一件都没留下:为什么在这里切段?切段后旧段会怎样?调用者什么时候必须调用它?
// 当前段写满时把它封存(之后只读、可以被压缩和删除),并开启新段。
// 每次追加写之后调用;未写满时什么也不做。
// 封存不会等待落盘:需要持久化保证的调用者应在之后调用 sync()。
void LogStore::rollIfNeeded();
方法体还不存在,作者只能写抽象:封存意味着什么、什么时候调用、有什么不提供的保证。最后一句尤其重要:它是设计时做出的一个取舍,事后补注释时最容易漏掉。
两份注释差不多长,写第二份也不比第一份多花时间。代价在别处:写第二份时你必须已经想清楚这些问题。这正是作者想要的效果。如果你发现自己想不清楚,那恰好说明还不该动手写实现。
边界与反方
"注释长 = 接口复杂"只在注释完整、清楚时成立,这是作者自己在 15.3 节加的限制。一行 // 处理订单 很短,但只能说明注释不合格。实际使用时的顺序应该是:先努力把注释写完整,再看它有多长。反过来也要小心:有些注释长是因为领域本身复杂(比如一个严格遵循 RFC 的解析器),这时更合适的做法是引用外部文档(第 16 章会讲),而不是硬塞进接口注释里。
本站补充:探索新领域时(试一个陌生的库、做技术验证),你可能确实写不出接口注释,因为你还不知道该有什么接口。这时写一段用完即弃的试验代码是合理的。但要分清两件事:试验代码是为了学习,学完应该回到"先写注释"的流程重新设计正式代码,而不是把试验代码直接补上注释交差。写不出注释本身就是一条信息:说明设计还没想好。
本站补充:两者都把"写实现"往后推,表面上很像。但作者在第 19.4 节明确表示不赞成 TDD,理由是它让注意力集中在一个个具体功能能不能跑通,而不是寻找最好的设计,是典型的战术式编程。先写注释则是一次把整个抽象想清楚。两者关注的东西不同:测试描述"这些输入下应该得到什么",接口注释描述"这个东西是什么、对调用者承诺什么"。作者同时也是单元测试的坚定支持者,所以这不是二选一。
15.4、15.5 两节的论据都来自作者个人经验和一个粗略估算,作者在结尾也只是邀请读者自己试一段时间,再反馈体验是否一致。读的时候不妨保持这种态度:先按 15.2 节的顺序认真做两三个类,再判断它是否适合你。
以下不在书里,来自书中给出的读者讨论组。2018 年有读者问哪里能看到"把注释当设计工具"的更多资料或示范。Ousterhout 回答说,不记得在别处见过这个提法,这是自己在写代码的过程中体会出来的,除了书里写的也没有别的例子或录像(2018-12)。同一串里有读者指出,《代码大全》里的"伪代码编程过程"(先用伪代码写出例程,再把伪代码变成注释)是相近的做法。本站没有核对原书,这里只转述读者的说法;可以确定的只是,Ousterhout 是独立得出这个做法的,并没有声称首创。
练一练
下面三道题来自训练场。先自己判断再看解析,答题记录会进入间隔复习。
动手:项目任务
任务 m07 覆盖第 12~15 章。和本章相关的部分是它的最后一步:给 minikv 新增 RENAME src dst 命令。严格按"先写注释"的顺序来:在写任何实现之前,先写出 Store::rename 的接口注释。写的时候你会被迫回答几个设计问题:src 不存在怎么办?dst 已经存在呢?src 和 dst 相同呢?如果进程恰好在写完两条日志记录之间崩溃,重启后会看到什么?把答案写进注释,再动手实现。
任务 m07:给 store.h / store.cpp / index.h 里含糊的名字改名、重写复述代码和写满实现步骤的注释,再先写接口注释、后实现新命令 RENAME src dst。详见 任务页。
cd project/minikv && make test自测题
小结与读后一句话
- 推迟的注释往往没写,或者写得差:复述代码、遗漏设计信息。
- 先写类注释和方法接口注释,再写成员变量,最后写实现;写完代码时注释也写完了。
- 注释是设计工具:抽象要靠注释才能完整写下来,写下来才能在实现前审查。
- 完整的注释写不短,是设计有问题的信号(难以描述);短而不完整的注释不算数。
- 成本很低:注释只占开发时间的一小部分,而更早稳定的抽象能减少返工。
第 15 章讲的是写新代码。可大多数时间我们在改旧代码,第 16 章接着回答:修改代码时怎样保持战略式的心态,怎样让注释不随修改而过时。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你最近写过的某个类。