读前:这一章要回答什么
代码已经写在那里了,注释还能补充什么?怎样判断一条注释是在帮读者,还是只在占地方?
第 12 章论证了"为什么要写注释":注释是抽象的一部分,代码表达不了接口的非正式部分。这一章回答"写什么、怎么写",是全书最长、例子最多的一章,也是最能直接改变你明天写代码方式的一章。
全章围绕一条原则展开:注释应该描述代码里看不出来的东西。由此推出两类坏注释(重复代码的注释、把实现写进接口的注释)和两类好注释(比代码更精确的、比代码更抽象的)。本页把这四类整理成审一条注释时依次要问的四个问题,再用一个交互练一遍。第 14~16 章(名字、先写注释、注释的维护)都建立在这个框架上。
- 13.2 节里有一个检验问题,作者建议每写完一条注释都问一次。读的时候记下它,然后翻翻你最近写的代码,用它检验几条注释。
- "看不出来"有两个参照物:13.3 节说"代码"指的是注释旁边的代码,13.8 节说"明显"要从第一次读的人的角度判断。想想去掉任何一个,这条原则会怎样被滥用。
- 13.5 节中间有一个五道小题的练习(某个类的接口注释该不该写某条信息)。先自己作答,再翻到 13.9 节看答案,尤其留意作者对"是否并发请求"和"服务器崩溃"两题的回答。
核心观点
只写代码里看不出来的
一个下标区间含不含端点、一段看似多余的代码为什么必须在、"rotate() 之前必须先 flush()"这类约定:代码要么表达不了,要么要读很多代码才能推出来。
"明显"由读者判断
参照物是注释旁边的那几行,和第一次读它的人。审查者说看不懂,就是看不懂:别争论,弄清对方卡在哪,用注释或更好的代码解决。
换一个细节层次
低一层的注释补充精确性(单位、边界、空值、归属、不变量);高一层的注释提供直觉(意图、原因、整体框架)。同一层的注释多半在重复。
不要重复代码
如果没见过这段代码的人只看旁边那行就能写出这条注释,它就没有价值。用名字里的词造句是最常见的形式。
接口注释定义抽象
只读声明和注释,就应该能理解一个模块提供的抽象。接口注释写给使用者,实现注释写给维护者,两者必须分开。
每个声明都有注释
每个类、每个类成员变量、每个方法都应该有注释(接口注释与数据成员注释最重要);方法体内的实现注释反而常常不需要。
跨模块的决定要有家
影响多个模块的设计决定,最难的是找一个开发者一定会经过的地方放文档;实在没有,就集中写一份设计说明,代码里留指引。
实现注释:做什么、为什么
不是"怎么做"。长方法给每个主要代码块一句概括;难懂的修复说明原因;短方法通常不需要实现注释。
心智模型:注释和代码的细节层次
把代码想成一个固定的"细节层次"。注释的价值来自于它和代码不在同一层:要么往下钻,把代码没说死的地方说死;要么往上走,告诉读者这些细节合起来是为了什么。
很多人觉得"注释都是废话"(第 12 章的第四个借口),是因为见到的注释大多停在中间那一层。把这张图变成审查动作,就是本页的骨架。看到一条注释时,依次问自己:
- 只看旁边的代码,能写出它吗?能,它就是重复代码,删掉或重写。
- 它写给谁,放对地方了吗?写给调用者的注释里出现了实现,就是污染接口。
- 往下:读者会在哪里猜错?回答这个问题的注释在补充精确性。
- 往上:读者缺一个框架吗?回答这个问题的注释在补充直觉。
问题一:只看旁边的代码,能写出它吗?
这是作者在 13.2 节给的检验:一个从没见过这段代码的人,只看注释旁边的代码,能不能写出这条注释?能,它就没有让代码更好懂。这个问题里有两个参照物,都不能换:
- "旁边的代码"是注释紧挨着的那行声明或那段代码,而不是整个程序(13.3 节的澄清)。翻遍所有用到某个变量的地方也许能推出它的含义,但那费时又容易出错;声明处的注释就是为了让人不必去翻。
- "没见过这段代码的人"不是你。作者在 13.8 节说,"明显"是从第一次读这段代码的人的角度判断的;你觉得显然的东西,多半是因为你刚写完它。
重复最常见的伪装,是用被注释对象名字里的词造句:
// 刷新间隔(毫秒)。
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 节的"写做什么和为什么,不写怎么做":知道了意图,看懂做法通常不难。值得写的位置:
- 为什么:看起来多余、删掉会出事的代码,要说明原因。修 bug 加的代码可以直接引用缺陷单号,而不是把缺陷报告抄一遍:
// 见 KV-1182:部分内核版本上 O_DIRECT 写入在这种情况下会返回 EINVAL。 - 代码块概括:长方法里每个主要代码块前一句话("第 2 步:把过期的段移到回收队列"),方便读者跳到关心的部分。
- 循环说明:对复杂的长循环,说明"每一轮做什么"。简单的循环不需要。
短方法通常不需要实现注释:它只做一件事,接口注释已经说过了。
交互:注释 X 光
把四个问题用一遍。下面是一个原创的带过期时间的缓存(头文件 + 实现),里面有 11 条注释,每条前面标了编号。逐条判断它属于哪一类:
注释 X 光:这条注释在做什么?
分类不是唯一的:一条方法接口注释往往同时有高层的一句话和低层的精确描述,这里按它主要的贡献归类。重要的是判断理由。
边界与反方
作者的立场比很多团队规范更强:每个类、每个类成员变量、每个方法都应该有注释。作者也承认偶尔会有显而易见、实在没什么可补充的声明(某些 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自测题
ObjectLister。下面哪条信息最不应该出现在它的类接口注释里?uint64_t end_; // 结束位置。下面哪个改写最好地补充了精确性?小结与读后一句话
- 注释应该描述代码里看不出来的东西;"看不出来"是相对注释旁边的代码、从第一次读的人的角度判断的。
- 问题一(重复):只看旁边的代码就能写出来的注释没有价值。名字里的词造句、描述"谁在改它"的动词式注释,都是重复的变体。
- 问题二(写给谁):调用者、维护者、改别处的人各有位置。接口注释定义抽象,按"总括 / 调用前 / 调用后 / 出错时"检查;不描述实现就写不下去,说明设计是浅的。
- 问题三(往下):单位、归属、空值、边界、不变量;类型能分担前几项,最后一项只能靠注释。
- 问题四(往上):意图、原因、"怎么走到这里的";方法体里写做什么和为什么,不写怎么做。
本章不断提到"用不同于名字的词"。那名字本身该怎么起?第 14 章专门讨论命名:好名字是一种不需要单独维护的文档,它和注释分工合作。
读后一句话:从你最近的代码里挑一条注释,用四个问题给它分个类,写下你会怎么改。