读前:这一章要回答什么

注释应该在什么时候写?能不能让写注释这件事反过来帮我们做设计?

第 12、13 章回答了"为什么要写注释"和"注释该写什么"。但即使认同这两章,大多数人仍然会把注释留到最后。这一章只讲一件事:写注释的时机。作者的主张是把它挪到最前面,在写实现之前先用注释把抽象写出来。

这一章很短,却是第 4 章"深模块"和第 13 章"接口注释"的交汇点:接口注释写出来有多长,几乎就是接口有多复杂的直接读数。所以先写注释不只是个文档习惯,而是一种廉价的设计评审:在一行实现都没有的时候,就能看出这个抽象深不深。它和第 11 章"设计两次"一样,都是在设计阶段多花一点时间的方法。

  1. 15.1 节:作者认为大家推迟写注释有两个原因,一个是嘴上说的,一个是心里想的。留意作者分别怎么回应,以及所描述的"事后补注释"的三个毛病。
  2. 15.3 节是全章最重要的一节。注意红旗"难以描述"后面紧跟的那个限制条件:什么样的注释不能拿来衡量复杂性?
  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.3 节开头明确说改进设计是第二个、也是最重要的好处。"节省时间"不在三个好处之列,15.5 节只是论证先写注释不会多花多少时间,甚至可能更快。
你给一个方法写接口注释,发现必须依次描述它内部的五个处理步骤,调用者才能正确使用它。这最可能说明什么?
作者给出的量尺:拿接口注释和实现比较,如果接口注释必须描述实现的所有主要特性,方法就是浅的。删减注释不能解决问题,因为调用者确实需要这些信息;要改的是设计。
关于"事后补注释",下列哪一项不是书中 15.1 节指出的问题?
A、B、D 都是书中列出的后果。"一定不一致"并不是作者的论点:事后补的注释恰恰是对照着当前代码写的,它的问题是没用(复述、缺信息),而不是过时。注释过时是第 16 章讨论的问题。
关于"注释越长,说明设计越有问题",下列哪种理解是对的?
信号来自"完整的描述写不短",而不是注释本身的长度。作者明确说同样的道理也适用于变量:一个变量要很长的注释才说得清,说明变量的拆分可能不对。

小结与读后一句话

第 15 章讲的是写新代码。可大多数时间我们在改旧代码,第 16 章接着回答:修改代码时怎样保持战略式的心态,怎样让注释不随修改而过时。

读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你最近写过的某个类。