读前:这一章要回答什么

代码已经写在那里了,注释还能补充什么?怎样判断一条注释是在帮读者,还是只在占地方?

第 12 章论证了"为什么要写注释":注释是抽象的一部分,代码表达不了接口的非正式部分。这一章回答"写什么、怎么写",是全书最长、例子最多的一章,也是最能直接改变你明天写代码方式的一章。

全章围绕一条原则展开:注释应该描述代码里看不出来的东西。由此推出两类坏注释(重复代码的注释、把实现写进接口的注释)和两类好注释(比代码更精确的、比代码更抽象的)。本页把这四类整理成审一条注释时依次要问的四个问题,再用一个交互练一遍。第 14~16 章(名字、先写注释、注释的维护)都建立在这个框架上。

  1. 13.2 节里有一个检验问题,作者建议每写完一条注释都问一次。读的时候记下它,然后翻翻你最近写的代码,用它检验几条注释。
  2. "看不出来"有两个参照物:13.3 节说"代码"指的是注释旁边的代码,13.8 节说"明显"要从第一次读的人的角度判断。想想去掉任何一个,这条原则会怎样被滥用。
  3. 13.5 节中间有一个五道小题的练习(某个类的接口注释该不该写某条信息)。先自己作答,再翻到 13.9 节看答案,尤其留意作者对"是否并发请求"和"服务器崩溃"两题的回答。

核心观点

只写代码里看不出来的

一个下标区间含不含端点、一段看似多余的代码为什么必须在、"rotate() 之前必须先 flush()"这类约定:代码要么表达不了,要么要读很多代码才能推出来。

"明显"由读者判断

参照物是注释旁边的那几行,和第一次读它的人。审查者说看不懂,就是看不懂:别争论,弄清对方卡在哪,用注释或更好的代码解决。

换一个细节层次

低一层的注释补充精确性(单位、边界、空值、归属、不变量);高一层的注释提供直觉(意图、原因、整体框架)。同一层的注释多半在重复。

不要重复代码

如果没见过这段代码的人只看旁边那行就能写出这条注释,它就没有价值。用名字里的词造句是最常见的形式。

接口注释定义抽象

只读声明和注释,就应该能理解一个模块提供的抽象。接口注释写给使用者,实现注释写给维护者,两者必须分开。

每个声明都有注释

每个类、每个类成员变量、每个方法都应该有注释(接口注释与数据成员注释最重要);方法体内的实现注释反而常常不需要。

跨模块的决定要有家

影响多个模块的设计决定,最难的是找一个开发者一定会经过的地方放文档;实在没有,就集中写一份设计说明,代码里留指引。

实现注释:做什么、为什么

不是"怎么做"。长方法给每个主要代码块一句概括;难懂的修复说明原因;短方法通常不需要实现注释。

心智模型:注释和代码的细节层次

把代码想成一个固定的"细节层次"。注释的价值来自于它和代码不在同一层:要么往下钻,把代码没说死的地方说死;要么往上走,告诉读者这些细节合起来是为了什么。

高一层:直觉 这段代码想做什么?为什么需要它?最重要的是什么? // 回收空闲太久的连接,但至少保留 minIdle_ 个,免得重新握手 同一层:代码本身 注释如果停在这一层,只是把代码换成自然语言再说一遍 // 如果 state == Idle 并且超时,就 close 并 erase 低一层:精确性 单位?边界含不含?空值代表什么?谁释放?有什么不变量? // 要下载的字节范围 [begin_, end_);end_ == 0 表示到文件末尾 ↑ ✕ ↓
好注释离开代码所在的那一层,往上提供直觉,或往下提供精确性。本站自绘示意。

很多人觉得"注释都是废话"(第 12 章的第四个借口),是因为见到的注释大多停在中间那一层。把这张图变成审查动作,就是本页的骨架。看到一条注释时,依次问自己:

  1. 只看旁边的代码,能写出它吗?能,它就是重复代码,删掉或重写。
  2. 它写给谁,放对地方了吗?写给调用者的注释里出现了实现,就是污染接口。
  3. 往下:读者会在哪里猜错?回答这个问题的注释在补充精确性。
  4. 往上:读者缺一个框架吗?回答这个问题的注释在补充直觉。

问题一:只看旁边的代码,能写出它吗?

这是作者在 13.2 节给的检验:一个从没见过这段代码的人,只看注释旁边的代码,能不能写出这条注释?能,它就没有让代码更好懂。这个问题里有两个参照物,都不能换:

重复最常见的伪装,是用被注释对象名字里的词造句:

// 刷新间隔(毫秒)。
int flushIntervalMs_ = 200;

这条注释看起来认真(还写了单位!),但"毫秒"已经在名字里了。读者真正想知道的是:刷新什么?间隔从什么时候算起?到点时缓冲区是空的会怎样?设为 0 呢?换一组不同于名字的词来写:

// 后台线程把写缓冲刷到磁盘的最长间隔:缓冲区里最早的一条数据,
// 最晚在写入后这么久落盘(缓冲区写满时会提前刷)。0 表示每次写入都立即刷。
int flushIntervalMs_ = 200;

书中 13.2 节举了几条从名字和参数类型里拼出来的注释,其中一条比代码多出来的只有一个介词。写注释的第一步,就是避开名字里已经有的词。

另一种伪装:描述"谁在改它"

作者在 13.3 节提醒,给变量写注释要想名词,不要想动词:写它代表什么,而不是它在哪里被怎么改。作者说动词式注释在照搬代码结构;本站因此把它算作重复的变体:它说的每件事都能从那几个写入点抄出来。

// 收到同步完成消息时置为 true;开始新一轮同步时置为 false;
// replicate() 会检查它决定是否走增量路径。
bool caughtUp_;

读者要从几个动作里自己归纳出含义;改代码时多一个写入点,注释就过期了。

// true 表示这个副本已经拥有主节点截至上一轮同步的全部日志,
// 可以只接收增量;false 时需要先做一次全量同步。
bool caughtUp_;

定义清楚了,"什么时候该置 true、什么时候置 false"读者自己就能推出来。

同样的标准也适用于局部变量(13.6 节):几行之内就用完的变量,起好名字就够了;跨越很长一段代码、含义又不明显的,值得一句"它代表什么"的注释。

问题二:它写给谁,放对地方了吗?

作者在 13.1 节把注释分成四类:接口注释、数据成员注释、实现注释、跨模块注释。本站换一个角度,按读者来排,因为"写给谁"决定了"放在哪"和"写什么":

读者放在哪写什么典型错误
调用者头文件里类和方法的声明前(接口注释)抽象:调用者感知到的行为和约定讲实现
维护者成员变量声明旁;方法体内(实现注释)成员的精确含义;代码块在做什么、为什么逐行复述怎么做
以后改别处的人改动时一定会经过的地方跨模块的约定:改这里还得改哪里散落各处、互相不全

对写给调用者的那一行,作者的标准是:

Developers should be able to understand the abstraction provided by a module without reading any code other than its externally visible declarations.— 第 13 章开篇

代码太底层,还夹带了不该出现在抽象里的实现细节,所以描述抽象的唯一方式是注释(13.5 节)。最常见的错误,是把写给维护者的内容写进了写给调用者的位置:

// 从连接池获取连接。先检查 idle_ 列表,非空就 pop_back();
// 否则如果 total_ < maxConns_ 就调用 dial() 新建;
// 否则在 cv_ 上 wait_until(deadline)。
PooledConn acquire(Deadline deadline);

第一句重复了名字;其余全是实现。调用者真正需要的(等不到怎么办、连接怎么还、能不能多线程调用)一条都没有。

/**
 * 取得一条到后端的可用连接;池中暂时没有时会等待。
 *
 * @param deadline  最多等到这个时刻;传入已经过去的时刻表示不等待。
 * @return  一条已建立的连接。PooledConn 析构时自动归还到池中,
 *          不要自己关闭它;如果用的过程中连接出错,调用 discard(),
 *          池会丢弃这条连接而不是归还。
 * @throws PoolTimeout  到 deadline 仍没有空闲连接,且连接数已达上限。
 * @throws DialError    需要新建连接,但建立失败。
 *
 * 可以从多个线程同时调用。
 */
PooledConn acquire(Deadline deadline);
PooledConn ConnectionPool::acquire(Deadline deadline) {
    std::unique_lock lk(mu_);
    // 优先复用空闲连接;从尾部取,因为最近归还的连接
    // 最不可能已被服务端因空闲超时关闭。
    ...
    // 没有空闲连接、但还没到上限:先占住名额再解锁去建连,
    // 以免建连期间别的线程也来建,超出 maxConns_。
    ...
}

原来那些"怎么做"的信息并不都是废话,有些值得保留,但应该放进方法体,并且改成"做什么、为什么"。

审查一条方法接口注释时,可以按一次调用的时间线逐段对照它缺了什么(内容来自 13.5 节的要求,分组是本站的):

类的接口注释则站得更高:这个类整体提供什么能力、每个实例代表什么、有哪些局限(例如不支持多线程并发访问),不必逐个讲方法;对用法不明显的深类,可以加一段"这些方法怎么配合"的示例。被模块自动处理、使用者感知不到的故障(如服务器崩溃)不必提;感知得到的,描述它表现为什么,而不是恢复机制(13.9 节)。

反过来用:如果接口注释不描述实现就写不下去,说明这个方法(或类)是浅的,没有真正藏住什么。写注释因此也在检查设计,第 15 章会展开。

第三种读者最难照顾。跨模块的设计决定(例如一种网络协议同时影响发送方和接收方)复杂又容易出 bug,难点是找一个改动者必经的位置(13.7 节)。有天然中心位置就放那里:书中在一个状态码枚举末尾列出"新增状态码时还要改的其他地方"。没有时,作者在尝试一份按主题分节的集中设计说明(designNotes),代码里只留一行 // 见 design-notes 中的"副本追赶"一节 这样的指引;代价见"边界与反方"。

C++ 里的约定:接口注释和实现注释要分开,实现注释写在 .cpp 的函数体里。接口注释放哪儿,常见做法是头文件的声明前(不少团队规范如此);作者本人则主张放在 .cpp 里紧挨方法体,让改代码的人一眼看到,使用者读 Doxygen 生成的文档(16.2 节,第 16 章会讨论)。选一种,全团队一致。书中建议使用语言现成的文档工具约定,C++ 就是 Doxygen(@param 或 \param 都行)。约定的价值有两个:风格一致更好读;有了明确的约定,你才会真的去写(13.1 节)。

问题三:往下钻,读者会在哪里猜错?

变量、参数、返回值的声明只有名字和类型,通常不够精确(13.3 节)。最常见的毛病是含糊:"当前位置""范围"这类词,读者只能猜。下表是作者列的几项,本站按"C++ 类型能分担多少"排序:

要问的问题含糊的写法精确的写法C++ 类型能帮忙吗?
单位是什么?int timeout; // 超时单次请求的超时,毫秒能:std::chrono::milliseconds
资源由谁释放?FILE* fp_; // 文件由本对象拥有,析构时关闭能:std::unique_ptr 及自定义删除器
空值代表什么?Peer* leader_;nullptr 表示当前不知道谁是主节点(选举中)部分:std::optional 说明"可能没有",但"没有"代表什么仍需注释
边界含不含?// 起止位置字节范围 [begin, end),左闭右开部分:可以约定统一用半开区间,但仍要写明
有什么不变量?std::vector<Range> free_;按起点升序、互不重叠、相邻区间已合并基本不能:只能靠注释(和断言)

表越往下,注释越不可替代。对容器类成员还要多问一句:某一项不存在代表什么(书中改写一个统计行长度的映射表时特意写明了这一点)。方法的参数和返回值同样适用这张表。

OSTEP 第 29 章的并发数据结构里,每个共享字段"由哪把锁保护"就是一条典型的不变量注释:类型系统表达不了它,读声明的人却必须知道。Linux 内核的结构体里大量注释都是这种形式("protected by xxx_lock")。参见 OSTEP 学习站第 29 章。

问题四:往上走,读者缺一个框架吗?

另一类好注释站得比代码高:省略细节,告诉读者这些细节合起来要做什么、为什么。方法体里的注释和接口注释的开头一句都靠它。这类注释比低层注释难写,因为你得换一种方式思考代码。作者建议先问自己三个问题:这段代码想做什么?能解释全部代码的最简单说法是什么?关于这段代码,最重要的一件事是什么?书中 13.4 节用一个循环的两种注释说明效果:一句话说出目的的那条,让读者能自己解释几乎每个条件,还能据此判断代码对不对。下面用这三个问题改一条原创的注释:

// 如果 resp.term > term_,把 term_ 设为 resp.term,role_ 设为
// Follower,votedFor_ 清空,然后 persist()。
if (resp.term > term_) {
    term_ = resp.term;
    role_ = Role::Follower;
    votedFor_.reset();
    persist();
    return;
}

注释是代码的同义复述。读者还是不知道:这段代码为什么在这里?漏了哪一步会出什么问题?

// 对方的任期比我们新,说明集群里已经有了更新的主节点或选举:
// 立即退回跟随者,并在回复任何请求之前把新任期落盘,
// 否则重启后可能在同一任期里投出第二票。
if (resp.term > term_) {
    ...
}

第一句是"我们是怎么走到这里的"(书中说这类注释特别有用;方法注释里说明它通常在什么情况下被调用也是同理),第二句说清要做什么,第三句解释最容易被删掉的那一步(persist())为什么必须在。

上面那条注释的第三句就是一条"为什么"。在方法体里,这个问题落实为 13.6 节的"写做什么和为什么,不写怎么做":知道了意图,看懂做法通常不难。值得写的位置:

短方法通常不需要实现注释:它只做一件事,接口注释已经说过了。

交互:注释 X 光

把四个问题用一遍。下面是一个原创的带过期时间的缓存(头文件 + 实现),里面有 11 条注释,每条前面标了编号。逐条判断它属于哪一类:

交互

注释 X 光:这条注释在做什么?

点编号(或直接点代码里的注释行)选中一条注释,然后从四类里选一个:重复代码、补充精确性(低一层)、补充直觉(高一层)、污染接口(接口注释里写了实现)。选完会看到解析。
0 / 11
已判断
0
判断正确
0
值得保留的注释

分类不是唯一的:一条方法接口注释往往同时有高层的一句话和低层的精确描述,这里按它主要的贡献归类。重要的是判断理由。

边界与反方

作者的立场比很多团队规范更强:每个类、每个类成员变量、每个方法都应该有注释。作者也承认偶尔会有显而易见、实在没什么可补充的声明(某些 getter/setter),但认为这很少见,全部都写比逐个纠结"这里要不要写"更省力。很多规范(以及第 12 章的 Clean Code)主张"只在必要时写"。本站的看法:对类成员和公开方法,作者的默认值更安全,因为"必要"往往是写的人判断的,而写的人恰恰最难看出哪里不明显。

书的例子多是 Java 和较老的 C++,没有展开这一点。问题三的表已经显示,单位、所有权、"可能没有"都能放进类型;noexcept、[[nodiscard]] 也是编译器能检查、不会过期的接口信息。这和本章原则不冲突:类型让信息变成"代码里看得出来"的,注释就把篇幅留给类型表达不了的部分。但类型再丰富,也写不出"为什么"。

作者在 13.8 节说:审查者觉得不明显,那就是不明显。另一方面,作者也批评过"告诉 C++ 程序员要 include 头文件"这种注释:对目标读者来说太显而易见。两者的共同点是站在实际读者的位置:你的读者是懂这门语言、但第一次读这段代码的同事。语言本身的知识不用解释,这段代码特有的意图和约定要解释。

if a reader thinks it’s not obvious, then it’s not obvious.— 第 13.8 节

作者对 designNotes 并不是无保留地推荐,用的词是"在尝试":集中写一份,只有一个副本、容易找到,但离代码远,改代码的人未必记得去更新;在每处代码里各写一份,又是重复,同样难以同步。如果可以,更好的出路往往是减少跨模块的决定本身:例如把两边共享的协议格式收进一个共享头文件(这也是第 5 章说的消除信息泄漏),文档就自然有了中心位置。

以下不在书里,来自书中给出的读者讨论组。常有人把这本书读成"到处写注释"。2018 年有读者抱怨注释夹在代码里难看、让人分心,Ousterhout 描述了自己的习惯(2018-12):行内注释其实不多,大部分注释写在方法头部和数据结构上;有了这些,多数方法体本身就不难懂。方法内部如果有注释,通常是成块地放在每个主要代码段之前,先交代这段代码要做什么。判断标准只有一条:如果一条注释没有让后面的代码更容易懂,它多半就不需要。Ousterhout 反问:让你分心的,会不会只是写得不好的注释?

同一帖里承认了注释的一个真实代价:它增加代码的纵向高度,一屏能看到的代码变少;但认为一屏五十来行通常够用。几年后回答另一位读者时又补了一句:接口注释让类的声明变长这件事,编辑器把头部注释折叠起来就能缓解,而不写注释的代价要大得多(2023-04)。

练一练

下面三道题来自训练场:一道识别实现污染、一道"名词还是动词"、一道真正动手写接口注释。本章题库以写作题为主,建议去训练场把 5 道写作题都做一遍。

再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):

动手:项目任务

第 12~15 章对应 minikv 的任务 m07。minikv 是按"战术式"风格写成的,本章的几种问题在它的头文件里都能找到:有的方法注释只是把方法名念一遍,有的成员变量注释和变量名一样含糊,还有接口注释在一步步复述实现。读完本章后,可以先做任务里的注释部分:用 X 光的四个类别给头文件里的注释逐条分类,删改重复代码的注释,按精确性清单重写成员注释,把混进接口注释的实现细节挪走、换成调用者需要的信息;第 14、15 章读完后再回来处理命名。

任务 m07:给 minikv 补上真正有用的接口注释和成员注释,删改重复代码的注释,并修正含糊的名字。详见 任务页。

cd project/minikv && make test

自测题

按书中 13.2 节的检验问题,判断一条注释是否"重复代码",应该问什么?
如果只看旁边的代码就能写出来,注释就没有提供新的信息。长短、语言都不是标准:一条很长的注释也可能全是重复,一条短注释也可能补上关键的边界语义。
一个方法的接口注释写道:"内部用一个最小堆维护定时器,每次调用把新节点上浮到合适位置。" 这属于哪种问题?
接口注释应该只包含使用者需要知道的信息。如果性能特征对调用者重要,应该写成调用者能用的形式(例如"插入是 O(log n)"),而不是描述内部数据结构如何操作。实现细节可以放到方法体里的实现注释中。
你的接口注释怎么也绕不开对内部数据结构的描述,否则调用者就会用错。按本章的说法,这最可能说明什么?
如果调用者确实需要知道实现才能正确使用,那么把注释挪到方法体里只是把问题藏起来了。书中 13.5 节说,接口注释不得不描述实现时,说明这个类或方法是浅的;第 15 章会把"注释难写"进一步当成设计问题的信号。
一个对象存储 SDK 提供分页列举某个前缀下所有对象的类 ObjectLister。下面哪条信息最不应该出现在它的类接口注释里?
私有常量只和维护者有关(如果分页大小真的影响使用者,应该在高层描述它带来的可感知行为,而不是给出常量名)。结果顺序和"列举期间的新对象"都是使用者看得见、会影响正确性的行为,必须写;实例代表什么和如何结束是基本用法。
一个约定影响客户端和服务端两个模块,代码里没有任何一个地方是"改它时必然会经过的"。书中 13.7 节提到的做法是?
两边各写一份会重复、难以同步;只写一边,另一边的人看不到也不知道去哪找。集中设计说明只有一份、容易找到,代价是离代码远,更新可能被遗忘。作者对这个方法的措辞是"正在尝试"。
uint64_t end_; // 结束位置。下面哪个改写最好地补充了精确性?
C 回答了单位(字节)和边界含不含(不含),并给出了它和 begin_ 的关系。D 重复了名字;A 描述的是"谁在改它"(动词),而不是它代表什么(名词);B 解释了类型选择,对理解这个变量的含义帮助不大。
关于实现注释(方法体内的注释),下列哪项最符合书中的建议?
作者认为读者知道了代码要做什么,通常就能看懂怎么做。循环注释只在长而复杂的循环上需要;局部变量只有跨度很大、含义不明显时才需要注释。

小结与读后一句话

本章不断提到"用不同于名字的词"。那名字本身该怎么起?第 14 章专门讨论命名:好名字是一种不需要单独维护的文档,它和注释分工合作。

读后一句话:从你最近的代码里挑一条注释,用四个问题给它分个类,写下你会怎么改。