小马的世界

读书笔记-软件设计的哲学【13】注释应该描述那些从代码中看不出来的事情

2026-06-21 · 38 min read

编写注释的原因在于:编程语言中的语句无法完整表达开发者在编写代码时脑海中的所有重要信息。注释记录了这些信息,使后来接手代码的开发者能够更容易地理解和修改代码。

编写注释的指导原则是: 注释应该描述那些从代码中无法直接看出来的事情。

代码中有许多内容并不显而易见。有时是不明显的底层细节。

例如,当一对索引用于描述一个范围时,人们往往无法从代码中看出这些索引对应的元素是否包含在范围内部。有时,人们也无法理解为什么某段代码是必要的,或者为什么它会采用特定方式实现。还有一些情况涉及开发者遵循的规则,例如:

“总是先调用 a,再调用 b”。

通过阅读全部代码,也许能够推断出这样的规则,但这种方式既痛苦又容易出错;而注释则能够将规则明确而清晰地表达出来。注释最重要的作用之一,是帮助表达抽象(abstraction)。抽象包含大量无法直接从代码中看出的信息。抽象的目标是提供一种简单的思考方式,而代码往往充满细节,以至于很难仅靠阅读代码看出其抽象本质。注释能够提供一种更简单、更高层次的视角。

例如:

“调用此方法后,网络流量将被限制在每秒 maxBandwidth 字节以内。”

即使这些信息理论上可以通过阅读代码推导出来,我们也不应该强迫模块使用者这么做。阅读代码既耗时,又会迫使他们关注大量与使用模块无关的细节。

开发者应当能够仅通过模块的对外声明(declarations)来理解模块所提供的抽象,而不需要阅读任何其他实现代码。

实现这一目标的唯一办法,就是利用注释补充声明所无法表达的信息。本章将讨论:

  • 哪些信息应该写进注释;
  • 如何写出优秀的注释。

正如你将看到的那样,优秀的注释通常从与代码不同的层次来解释问题:有时比代码更具体,有时则比代码更加抽象。

选择注释规范(Pick conventions)

编写注释的第一步,是确定注释规范(conventions)。例如:

  • 什么内容需要写注释;
  • 注释采用什么格式;

如果你使用的编程语言已经有成熟的文档生成工具,例如:

  • Java 的 Javadoc
  • C++ 的 Doxygen
  • Go 的 godoc

那么应当遵循这些工具所采用的规范。这些规范都不是完美的,但工具所带来的收益足以弥补其不足。如果你的开发环境没有现成规范可遵循,那么应尽量借鉴与自己项目相似的语言或项目中的规范。这样做可以帮助其他开发者更容易理解并遵守你的规则。规范有两个作用。首先,它保证一致性,从而使注释更容易阅读和理解。其次,它帮助你真正写出注释。

如果你没有明确规定:

  • 要写什么注释;
  • 怎样写注释;

那么最终很容易变成完全不写注释。大多数注释都属于以下几类:

接口注释(Interface)

位于模块声明之前的注释块。模块可以是:

  • 类(class)
  • 数据结构(data structure)
  • 函数(function)
  • 方法(method)

这类注释描述模块对外暴露的接口。对于类来说,注释描述类所提供的整体抽象。对于方法或函数来说,注释描述:

  • 整体行为
  • 参数
  • 返回值(如果有)
  • 副作用
  • 可能抛出的异常
  • 调用者在调用前必须满足的条件

数据结构成员注释(Data structure member)

位于数据结构字段声明旁边的注释。

例如:

  • 类成员变量(instance variable)
  • 静态变量(static variable)

实现注释(Implementation comment)

位于方法或函数内部的注释。

用于描述代码内部实现机制。

跨模块注释(Cross-module comment)

描述跨越模块边界的依赖关系的注释。最重要的注释通常属于前两类。每个类都应该拥有接口注释;每个类成员变量都应该拥有注释;每个方法都应该拥有接口注释。偶尔会出现某个变量或方法过于简单,以至于没有任何值得补充的信息(getter 和 setter 有时属于这种情况)。

但这种情况非常少见。与其花费精力纠结“这条注释是否必要”,不如直接写上注释。实现注释往往是不必要的。跨模块注释是所有注释中最少见的一类,同时也是最难编写的一类。但当它们确实需要时,又往往非常重要。

不要重复代码

遗憾的是,许多注释实际上并没有太大帮助。最常见的问题是:

注释只是把代码重新说了一遍。

也就是说,注释中的所有信息都可以直接从旁边的代码轻松推导出来。下面是摘自某篇研究论文的一个例子:

ptr_copy = get_copy(obj)          # 获取指针副本

if is_unlocked(ptr_copy):         # 对象未锁定?
    return obj                    # 返回当前对象

if is_copy(ptr_copy):             # 已经是副本?
    return obj                    # 返回对象

thread_id = get_thread_id(ptr_copy)

if thread_id == ctx.thread_id:    # 被当前上下文锁定
    return ptr_copy               # 返回副本

这样的注释很少真正有帮助。下面还有更多重复代码的例子:

// 添加水平滚动条
hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL);
add(hScrollBar, BorderLayout.SOUTH);

// 添加垂直滚动条
vScrollBar = new JScrollBar(JScrollBar.VERTICAL);

这些注释只是把代码翻译成自然语言,并没有增加任何新的信息,因此价值极低。

add(vScrollBar, BorderLayout.EAST);

// 初始化与光标位置相关的变量
caretX = 0;
caretY = 0;
caretMemX = null;

这些注释都没有提供任何价值。

对于前两个注释而言,代码本身已经足够清晰,根本不需要注释;对于第三个例子,虽然理论上注释可能有帮助,但当前的注释并没有提供足够的细节,因此依然没有实际价值。

写完注释后,可以问自己这样一个问题:

一个从未见过这段代码的人,是否只看注释旁边的代码,就能够写出同样的注释?

如果答案是肯定的(就像前面的例子一样),那么这条注释并没有让代码变得更容易理解。正是这种类型的注释,让很多人认为注释毫无价值。

另一种常见错误是:在注释中直接使用被描述实体名称中的相同词汇。

/*
 * 从 REQ 中获取规范化资源名。
 */
private static String[] getNormalizedResourceNames(
        HTTPRequest req) ...
/*
 * 将 PARAMETER 向下转换为 TYPE。
 */
private static Object downCastParameter(
        String parameter,
        String type) ...
/*
 * 文本中每一行的水平内边距。
 */
private static final int textHorizontalPadding = 4;

这些注释只是把方法名或变量名中的单词拿出来,再加上一些参数名和类型,拼成一句自然语言。

例如,第二个注释中唯一没有直接出现在代码里的词是“向”(to)。再进一步说,这些注释甚至不需要理解方法或变量的含义,仅仅查看声明本身就能写出来。因此,它们没有任何价值。

与此同时,真正重要的信息却缺失了。

例如:

  • “规范化资源名(normalized resource name)”到底是什么?
  • getNormalizedResourceNames 返回数组中的元素具体表示什么?
  • “downcast(向下转型)”具体是什么意思?
  • padding 的单位是什么?
  • padding 是作用于每一行的一侧还是两侧?

如果把这些信息写进注释中,才是真正有帮助的。

红旗信号:注释重复代码

如果注释中的信息已经能够直接从旁边的代码看出来,那么这条注释就没有帮助。一个典型表现是:

注释直接使用了被描述对象名称中的相同单词。

写好注释的第一步是:

在注释中使用与被描述实体名称不同的词语。

选择那些能够补充实体含义的新信息,而不是简单重复名称本身。例如,下面是针对 textHorizontalPadding 的更好注释:

/*
 * 每行文本左右两侧保留的空白宽度,
 * 单位为像素。
 */
private static final int textHorizontalPadding = 4;

这条注释提供了声明本身无法体现的重要信息:

  • 单位是像素(pixels)
  • padding 同时作用于左右两侧

此外,注释没有简单重复 “padding” 这个词,而是解释了 padding 的实际含义,即使读者此前不了解该术语,也能够理解。

更低层级的注释提供精确性

现在我们已经知道哪些注释不该写,接下来讨论应该写什么。

优秀的注释会通过提供不同层级的信息来补充代码。

有些注释提供的信息比代码更底层、更具体。这类注释通过澄清代码的精确含义来增加精确性(precision)。还有一些注释提供的信息比代码更高层、更抽象。

它们提供的是直觉(intuition),例如:

  • 代码背后的原因
  • 一种更简单、更抽象的理解方式

与代码处于同一层级的注释往往只是重复代码。本节讨论第一种——更底层、更具体的注释;下一节则讨论更高层级的注释。

精确性在以下场景中特别重要:

  • 类成员变量
  • 方法参数
  • 返回值

变量声明中的名称和类型通常并不够精确。注释可以补充如下信息:

  • 这个变量的单位是什么?

  • 边界条件是包含端点还是不包含端点?

  • 如果允许出现 null,它代表什么含义?

  • 如果变量引用的是一个最终需要释放或关闭的资源,那么谁负责释放或关闭它?

  • 变量是否始终满足某些不变条件(invariants)?

    • 例如:“这个列表始终至少包含一个元素”

这些信息理论上可能通过阅读所有使用该变量的代码推导出来。然而,这样既耗时又容易出错。变量声明旁边的注释应当足够清晰和完整,从而避免读者必须去做这种推导。顺便说明一下:

当我说注释应该描述“代码中看不出来的内容”时,这里的“代码”指的是注释旁边的代码(即声明本身),而不是“整个应用程序中的所有代码”。

变量注释最常见的问题是:

过于模糊。

下面是两个不够精确的例子:

// 当前在响应缓冲区中的偏移量
uint32_t offset;
// 包含文档中所有行宽以及
// 出现次数
private TreeMap<Integer, Integer> lineWidths;

第一个例子中,“当前(current)”究竟是什么意思并不明确。

第二个例子中,也不清楚:

  • TreeMap 的 key 是行宽
  • value 是出现次数

还是反过来。

此外:

  • 行宽的单位是什么?
  • 是像素还是字符数?

都无法得知。

下面的改进版本补充了这些细节:

// 本缓冲区中第一个尚未
// 返回给客户端对象的位置
uint32_t offset;
// 保存形如 <length, count> 的行长度统计信息。
// length 表示一行中的字符数量(包括换行符),
// count 表示恰好具有该长度的行数。
// 如果不存在对应长度的记录,则该长度不会出现在映射中。
private TreeMap<Integer, Integer> numLinesWithLength;

第二个声明使用了一个信息量更大的名字。它还将 “width(宽度)” 改成了 “length(长度)”,因为这个词更容易让人联想到单位是字符数而不是像素。注意,第二个注释不仅说明了每个条目的含义,还说明了当某个条目不存在时意味着什么。

在为变量编写文档时,要思考名词(noun),而不是动词(verb)。换句话说,关注变量表示什么,而不是它如何被修改。

考虑下面这个注释:

/*
 * FOLLOWER VARIABLE:用于指示 Receiver 线程与
 * PeriodicTasks 线程之间是否已经在 follower 的
 * election timeout 窗口内收到 heartbeat。
 *
 * 收到有效 heartbeat 时设为 TRUE。
 *
 * election timeout 窗口重置时设为 FALSE。
 */
private boolean receivedValidHeartbeat;

这份文档描述的是:这个变量如何被类中的不同代码修改。如果改为描述变量本身代表什么,而不是映射代码结构,那么注释会更短,也更有用:

/*
 * true 表示自上次 election timer 重置以来已经收到 heartbeat。
 *
 * 用于 Receiver 与 PeriodicTasks两个线程之间的通信。
 */
private boolean receivedValidHeartbeat;

有了这样的文档,读者很容易推断出:

  • 收到 heartbeat 时变量应被设置为 true;
  • election timer 重置时变量应被设置为 false。

更高层级的注释增强直觉

注释增强代码的第二种方式,是提供 直觉(intuition)

这类注释位于比代码更高的抽象层级。它们省略细节,帮助读者理解代码的整体意图与结构。

这种方式经常用于:

  • 方法内部的注释
  • 接口注释

例如下面这段代码:

// 如果存在一个处于 LOADING 状态的 readRpc,
// 它与当前 session 相同,
// 并且其最后一个 PKHash 指向的位置
// 小于当前 assignPos,
// 那么就把 assigning PKHash
// 放入那个 readRpc 中。
int readActiveRpcId = RPC_ID_NOT_ASSIGNED;
for (int i = 0; i < NUM_READ_RPC; i++) {
if (session == readRpc[i].session
        && readRpc[i].status == LOADING
        && readRpc[i].maxPos < assignPos
        && readRpc[i].numHashes < MAX_PKHASHES_PERRPC) {
    readActiveRpcId = i;
    break;
}

这条注释过于底层,也过于详细。一方面,它部分重复了代码:

“如果存在一个 LOADING readRPC” 本质上只是把

readRpc[i].status == LOADING

重新用自然语言写了一遍。另一方面,它没有解释这段代码的整体目的,也没有说明它在所在方法中的作用。因此,它并不能帮助读者理解代码。更好的注释如下:

// 尝试将当前 key hash
// 追加到一个尚未发送、
// 且目标服务器正确的现有 RPC 中。

这条注释没有描述任何细节。相反,它从更高层次描述了代码的整体功能。有了这个高层信息,读者几乎可以解释代码中的所有内容:

  • 循环是在遍历已有 RPC;
  • session 判断用于确认 RPC 是否属于正确服务器;
  • LOADING 状态暗示 RPC 存在多种状态,并且只有部分状态允许继续添加 hash;
  • MAX_PKHASHES_PERRPC 说明单个 RPC 可携带的 hash 数量有限制。

唯一没有被解释的是 maxPos 判断。此外,这种高层注释还给读者提供了一个评判代码的依据:

这段代码是否真的完成了“向现有 RPC 追加 key hash”所需的全部工作?

而原来的注释没有描述整体意图,因此读者很难判断代码是否正确。

高层注释比底层注释更难写。因为你必须换一种方式思考代码。问自己几个问题:

  • 这段代码到底在做什么?
  • 用最简单的话概括整段代码是什么?
  • 这段代码最重要的事情是什么?

工程师通常都非常关注细节。我们喜欢细节,也擅长处理细节;这正是成为优秀工程师的重要能力。

但优秀的软件设计者还需要具备另一种能力:从细节中抽离出来,从更高层级理解系统。这意味着:

  • 判断系统中哪些方面最重要;
  • 能够忽略低层细节;
  • 只从最核心、最本质的特征来理解系统。

这正是抽象的本质:

为复杂事物找到一种简单的思考方式。

而编写高层注释时,也需要采用同样的方法。好的高层注释通常表达一个或少数几个简单概念,并为代码提供一个概念框架(conceptual framework)。

例如:

“把数据追加到已有 RPC 中”。

有了这个框架之后,就很容易理解具体代码语句与整体目标之间的关系。

下面还有一个具有优秀高层注释的例子:

if (numProcessedPKHashes < readRpc[i].numHashes) {

    // 有些 key hash 无法在这次请求中查到:
    // 可能是服务器上没有保存,
    // 服务器崩溃,
    // 或响应消息空间不足。
    //
    // 将这些尚未处理的 hash 标记出来,
    // 以便之后重新分配到新的 RPC。
    for (size_t p = removePos; p < insertPos; p++) {
        if (activeRpcId[p] == i) {
            if (numProcessedPKHashes > 0) {
                numProcessedPKHashes--;
            } else {
                if (p < assignPos)
                    assignPos = p;
                activeRpcId[p] = RPC_ID_NOT_ASSIGNED;
            }
        }
    }
}

这条注释做了两件事:第二句话从抽象层面描述了代码在做什么。第一句话则有所不同:

它解释了为什么会执行到这里。这种“我们是怎么走到这里的(how we get here)”类型的注释,对于帮助人们理解代码非常有价值。例如,在为方法编写文档时,说明:

  • 方法通常会在什么情况下被调用;
  • 哪些条件最可能触发该方法;

会非常有帮助。特别是对于那些只会在异常或特殊场景下执行的方法。

接口文档(Interface documentation)

注释最重要的职责之一,就是定义抽象(abstraction)。回顾第 4 章:抽象是对实体的简化视图。

它保留最重要的信息,同时隐藏可以安全忽略的细节。代码并不适合描述抽象:因为代码的层级太低,而且充满了实现细节。

如果你希望代码能够呈现良好的抽象,那么就必须用注释来记录这些抽象。

文档化抽象的第一步,是将 接口注释(interface comments)实现注释(implementation comments) 区分开来。

接口注释提供的是使用类或方法所必须知道的信息;它们定义抽象本身。

实现注释则描述类或方法内部是如何工作的,以及为了实现该抽象所采用的机制。

必须将这两类注释分离开来,这样接口的使用者才不会暴露于实现细节之中。

此外,这两类注释的形式最好明显不同。

如果接口注释也不得不描述实现细节,那么这个类或方法就是浅层(shallow)的。

这说明:编写注释这件事本身,就能够暴露设计质量的问题。

第 15 章会再次讨论这一点。类的接口注释应当提供该类所实现抽象的高层描述,例如:

/**
 * 本类实现了一个简单的服务端 HTTP 协议接口。
 * 使用该类,应用程序可以接收 HTTP 请求、
 * 处理请求并返回响应。
 *
 * 本类的每个实例对应一个用于接收请求的 socket。
 *
 * 当前实现为单线程,一次只能处理一个请求。
 */
public class Http {...}

这段注释描述了类的整体能力,而没有涉及任何实现细节,甚至没有提及具体方法。它还说明了类实例代表什么。最后,它还描述了这个类的限制:它不支持多个线程同时访问。这对于评估是否应当使用该类的开发者来说十分重要。

方法的接口注释同时需要:

  • 用高层信息描述抽象;
  • 用低层信息提供精确性。

具体来说:

  • 注释通常先用一两句话描述调用者视角下的方法行为,这是高层抽象。
  • 注释必须描述每个参数以及返回值(如果有)。这些描述必须非常精确,包括参数约束以及参数之间的依赖关系。
  • 如果方法有副作用(side effect),必须在接口注释中说明。

副作用是指:

方法执行后会影响系统未来行为,但这种影响并不属于返回结果本身。

例如:

  • 向内部数据结构中添加一个未来可能被读取的值;
  • 向文件系统写入数据;

这些都属于副作用。

  • 方法接口注释必须说明该方法可能抛出的异常。
  • 如果调用前存在必须满足的前置条件(preconditions),也必须明确记录下来。

例如:

  • 调用某方法之前必须先调用另一个方法;
  • 二分查找要求待查列表已经排序;

虽然应尽量减少前置条件,但凡是保留下来的前置条件,都必须被文档化。下面是一个从 Buffer 对象中复制数据的方法接口注释示例:

/**
 * 将一段字节从缓冲区复制到外部位置。
 *
 * @param offset
 *     待复制第一个字节在缓冲区中的索引。
 *
 * @param length
 *     要复制的字节数。
 *
 * @param dest
 *     复制目标地址;
 *     必须至少能够容纳 length 个字节。
 *
 * @return
 *     实际复制的字节数。
 *
 *     如果请求范围超出了缓冲区末尾,
 *     返回值可能小于 length。
 *
 *     如果请求范围与实际缓冲区没有重叠,
 *     则返回 0。
 */
uint32_t
Buffer::copy(uint32_t offset,
             uint32_t length,
             void* dest)
...

该注释采用了 Doxygen 风格(例如 @return 等标签)。

Doxygen 是一个能够从 C/C++ 代码中提取注释并生成网页文档的工具。这类注释的目标是:为开发者提供调用该方法所需的全部信息,包括:

  • 如何调用方法;
  • 各种特殊情况如何处理;
  • 方法可能出现的错误情况(注意这里遵循了第 10 章“定义错误不存在”的思想:区间越界不会被视为错误,而是被纳入接口规范)。

开发者不应为了调用该方法而去阅读方法实现。接口注释不应包含实现细节,例如:

  • 它如何扫描内部数据结构;
  • 它通过什么算法找到目标数据。

下面来看一个更完整的例子。

假设有一个名为 IndexLookup 的类,它属于一个分布式存储系统。存储系统包含许多表(table),每个表中存放大量对象。此外,每个表还可以拥有一个或多个索引(index)。索引能够基于对象的某个字段快速定位对象。

例如:

  • 一个索引可以基于姓名查找对象;
  • 另一个索引可以基于年龄查找对象。

利用这些索引,应用程序可以快速获得:

  • 所有姓名匹配某个值的对象;
  • 所有年龄位于指定区间内的对象。

IndexLookup 类为执行索引查询提供了一个方便的接口。

下面是一个典型用法:

query = new IndexLookup(table, index, key1, key2);

while (true) {
    object = query.getNext();

    if (object == NULL) {
        break;
    }

    ... process object ...
}

应用程序首先创建一个 IndexLookup 对象。构造函数参数指定:

  • 查询的表;
  • 使用的索引;
  • 索引范围。

例如:

如果索引字段是年龄,

则:

key1 = 21
key2 = 65

表示选择所有年龄在 21 至 65 岁之间的对象。随后应用程序不断调用 getNext()。每次调用都会返回一个符合条件的对象。当所有匹配对象都已经返回之后:

getNext() -> NULL

由于这是一个分布式存储系统,该类的实现相当复杂。一个表中的对象可能分布在多个服务器上。而一个索引本身也可能分布在另一组服务器上。

因此:

  1. IndexLookup 首先需要与所有相关索引服务器通信,收集符合范围条件的对象信息;
  2. 然后再与真正存储这些对象的服务器通信,以获取对象内容。

现在来思考:这个类的接口注释中应该包含哪些信息?对于下面列出的每一项内容,请问自己:

开发者是否必须知道这条信息,才能正确使用这个类?

现在思考下面这些信息,哪些应该出现在 IndexLookup 类的接口注释中:

  1. IndexLookup 向保存索引和对象的服务器发送消息时所使用的消息格式。
  2. 用于判断对象是否落在目标范围内的比较函数(例如比较是基于整数、浮点数还是字符串)。
  3. 服务器上用于存储索引的数据结构。
  4. IndexLookup 是否会同时向多个服务器发起请求。
  5. 处理服务器宕机的机制。

下面是这个类最初版本的接口注释(节选)。为了方便说明,作者同时展示了类定义中的部分内容,因为注释中引用了这些定义:

/*
 * This class implements the client side framework for index range
 * lookups. It manages a single LookupIndexKeys RPC and multiple
 * IndexedRead RPCs. Client side just includes "IndexLookup.h" in
 * its header to use IndexLookup class. Several parameters can be set
 * in the config below:
 * - The number of concurrent indexedRead RPCs
 * - The max number of PKHashes an indexedRead RPC can hold at a time
 * - The size of the active PKHashes
 *
 * To use IndexLookup, the client creates an object of this class by
 * providing all necessary information. After construction of
 * IndexLookup, client can call getNext() function to move to next
 * available object. If getNext() returns NULL, it means we reached
 * the last object. Client can use getKey, getKeyLength, getValue,
 * and getValueLength to get object data of current object.
 */
class IndexLookup {
    ...
private:
    /// Max number of concurrent indexedRead RPCs
    static const uint8_t NUM_READ_RPC = 10;

    /// Max number of PKHashes that can be sent in one
    /// indexedRead RPC
    static const uint32_t MAX_PKHASHES_PERRPC = 256;

    /// Max number of PKHashes that activeHashes can
    /// hold at once.
    static const size_t MAX_NUM_PK =
        (1 << LG_BUFFER_SIZE);
};

在继续阅读之前,可以先试着找出这段注释存在的问题。

作者发现的问题如下:

  • 第一段的大部分内容都在描述实现,而不是接口。例如,用户并不需要知道系统内部使用了哪些 RPC 来与服务器通信。
  • 第一段后半部分提到的那些配置参数,其实都是私有变量,仅与类的维护者有关,而与使用者无关。所有这些实现细节都应该从接口文档中删除。
  • 注释里还包含一些显而易见的信息。例如,没有必要告诉用户需要包含 IndexLookup.h;任何写过 C++ 的人都知道这一点。
  • 类似“提供所有必要信息(providing all necessary information)”这样的描述毫无信息量,也应该删除。

对于这个类来说,一段更短的注释就已经足够了(而且更好):

/*
 * This class is used by client applications to make range queries
 * using indexes. Each instance represents a single range query.
 *
 * To start a range query, a client creates an instance of this
 * class. The client can then call getNext() to retrieve the objects
 * in the desired range. For each object returned by getNext(), the
 * caller can invoke getKey(), getKeyLength(), getValue(), and
 * getValueLength() to get information about that object.
 */

这段注释的最后一段其实并非绝对必要,因为它大部分内容与各个方法自己的注释重复。

不过,在类级别文档中提供一个示例,展示多个方法如何协同使用,往往仍然很有帮助,特别是对于那些使用方式不够直观的深层(deep)类。

🚩危险信号:实现文档污染接口文档(Implementation Documentation Contaminates Interface)

当接口文档(例如方法说明)开始描述使用者根本不需要知道的实现细节时,就出现了这个危险信号。

接口文档应该帮助人们 使用 某个模块。

实现文档则应该帮助人们 维护 某个模块。

一旦两者混在一起:

  • 用户会被无关细节淹没;
  • 抽象边界被破坏;
  • 模块显得更复杂;
  • 文档可读性下降。

好的接口文档只回答一个问题:

“我需要知道什么,才能正确使用这个东西?”

而不是:

“这个东西内部是怎么实现的?”

注释应描述代码中不明显的内容

新的注释没有提及 getNext 返回 NULL 的情况。这条注释并不是为了记录每个方法的所有细节;它只是提供了高层次的信息,帮助读者理解这些方法是如何协同工作的,以及各个方法会在什么场景下被调用。至于具体方法的细节,读者可以查看各自的方法注释。

这条注释也没有提及服务器崩溃的问题,因为对于这个类的使用者来说,服务器崩溃是不可见的(系统会自动从崩溃中恢复)。

现在来看下面这段代码,它展示了 IndexLookupisReady 方法文档的第一个版本:

/**
 * 检查下一个对象是否处于 RESULT_READY 状态。该函数
 * 在 DCFT 模块中实现,每次执行 isReady() 时都会尝试
 * 推进一点进度,而 getNext() 会在一个 while 循环中
 * 反复调用 isReady(),直到 isReady() 返回 true。
 *
 * isReady() 采用基于规则(rule-based)的实现方式。我们会
 * 按照特定顺序检查不同规则,并在某条规则满足时执行
 * 相应操作。
 *
 * \return
 *     true 表示下一个对象已经可用;否则返回
 *     false。
 */
bool IndexLookup::isReady() { ... }

同样地,这段文档的大部分内容——例如对 DCFT 的引用以及整个第二段——都在描述实现细节,因此不应该出现在这里。这也是接口注释中最常见的错误之一。

其中部分实现说明确实有价值,但应该放到方法内部,这样它们就能与接口文档明确分离。此外,这段文档的第一句话表述得十分晦涩(RESULT_READY 到底是什么意思?),而且还遗漏了一些重要信息。最后,这里也没有必要描述 getNext 的实现方式。

下面是更好的注释版本:

/*
 * 表示一个索引读取(indexed read)是否已经取得了足够的进展,
 * 从而使 getNext 可以立即返回而无需阻塞。此外,该方法承担了
 * 索引读取的大部分实际工作,因此必须被调用(无论是直接调用,
 * 还是通过调用 getNext 间接调用),索引读取才能继续推进。
 *
 * \return
 *     true 表示下一次调用 getNext 时不会发生阻塞
 *     (要么至少已有一个对象可返回,要么已经到达查找结束位置);
 *     false 表示 getNext 可能会阻塞。
 */

这个版本的注释更准确地说明了“ready”的含义,同时补充了一个重要信息:如果希望索引检索继续推进,这个方法最终必须被调用。


实现注释:说明“做什么”和“为什么”,而不是“怎么做”

实现注释(Implementation Comments)是出现在方法内部的注释,用于帮助读者理解代码在内部是如何工作的。

大多数方法都足够短小和简单,因此并不需要实现注释:只要结合代码和接口注释,就很容易理解其工作方式。

实现注释最重要的目标,是帮助读者理解代码正在做什么(what),而不是如何做(how)。

一旦读者知道代码试图完成什么任务,通常就很容易理解它是如何实现的。对于短小的方法来说,代码通常只完成一件事情,而这件事已经在接口注释中描述过了,因此不需要额外的实现注释。

较长的方法往往由多个代码块组成,每个代码块负责整体任务中的一个部分。可以在每个主要代码块前添加注释,用较高层次(更抽象)的方式说明该代码块的作用。例如:

// 第一阶段:扫描活跃的 RPC,查看是否有已经完成的请求。

这样的注释能够帮助读者快速定位他们关心的代码部分。

对于循环(loop)来说,在循环前添加注释,说明每次迭代执行的内容,也会非常有帮助:

// 下面循环的每次迭代都会从请求消息中提取一个请求,
// 更新对应对象,并向响应消息追加一个响应。

注意,这条注释是在更抽象、更符合直觉的层面描述循环的作用;它没有深入说明请求是如何从消息中提取出来的,也没有解释对象是如何递增或更新的。

循环注释通常只在较长或较复杂的循环中才有必要,因为这类循环的行为不一定一眼就能看明白;许多简单循环本身已经足够清晰,不需要额外说明。

除了说明代码在做什么之外,实现注释还经常用于解释为什么这样做。

如果代码中存在一些从代码本身无法明显看出的巧妙之处,那么就应该把这些内容记录下来。例如,如果修复某个 Bug 需要加入一段用途并不十分明显的代码,那么应该添加注释说明这样做的原因。

对于已经有完善 Bug 报告的问题,注释可以直接引用缺陷跟踪系统中的 Issue,而不必重复所有细节:

修复 RAM-436:与 Linux 2.4.x 中设备驱动崩溃相关的问题。

开发人员可以在缺陷数据库中查阅更多信息(这是避免在注释中重复信息的一个例子,相关内容将在第 16 章讨论)。

对于较长的方法,为少数几个最重要的局部变量添加注释也会有所帮助。不过,大多数局部变量如果命名合理,就不需要额外文档。

如果一个变量的所有使用位置都集中在几行代码范围内,那么通常无需注释,读者很容易从上下文推断出变量的用途。

但如果一个变量跨越较大范围的代码被使用,就应该考虑添加注释说明它的含义。

在为变量编写注释时,应重点说明这个变量代表什么(what it represents),而不是它在代码中如何被操作。

跨模块设计决策(Cross-module Design Decisions)

理想情况下,每一个重要的设计决策都应该被封装在单个类中。

然而现实系统不可避免地会出现影响多个类的设计决策。例如,一个网络协议的设计会同时影响发送方和接收方,而这些逻辑可能分别实现于不同的位置。

跨模块决策通常复杂而微妙,并且会导致大量缺陷,因此为它们编写良好的文档至关重要。跨模块文档最大的挑战,在于找到一个开发者能够自然发现(naturally discover)的存放位置。有时候会存在一个显而易见的中心位置来存放这类文档。

例如,在 RAMCloud 存储系统中定义了一个 Status 值,每个请求都会返回该值,用于表示成功或失败。

如果要新增一种错误状态,就必须修改许多不同文件(例如,一个文件负责把 Status 值映射为异常;另一个文件负责为每个 Status 提供人类可读的错误信息,等等)。

幸运的是,开发人员在新增状态值时一定会访问一个明确的位置——那就是 Status 枚举(enum)的定义。

因此,我们利用这一点,在该枚举定义中添加注释,列出所有必须同步修改的其他位置:

typedef enum Status {
    STATUS_OK = 0,
    STATUS_UNKNOWN_TABLET      = 1,
    STATUS_WRONG_VERSION       = 2,
    ...
    STATUS_INDEX_DOESNT_EXIST  = 29,
    STATUS_INVALID_PARAMETER   = 30,
    STATUS_MAX_VALUE           = 30,

// 注意:如果新增一个状态值,必须同时完成以下更新:
// (1) 修改 STATUS_MAX_VALUE,使其值等于最大的已定义状态值,
//     并确保它仍然是列表中的最后一个定义。
//     STATUS_MAX_VALUE 主要用于测试。
// (2) 在 Status.cc 中的 "messages" 和 "symbols"
//     表中添加对应的新条目。
// (3) 在 ClientException.h 中添加新的异常类。
// (4) 在 ClientException::throwException 中新增一个
//     case,将该状态值映射到对应的
//     ClientException 子类。
// (5) 在 Java 绑定中,为该异常新增一个静态类,
//     添加到 ClientException.java。
// (6) 在 ClientException.java 中新增对应 case,
//     根据状态值抛出该异常。
// (7) 将该异常添加到 Status.java 中的
//     Status 枚举,确保其位置与状态码对应。

新的状态值总是会被添加到现有列表的末尾,因此这些注释也放在末尾,这样最容易被开发人员注意到。

遗憾的是,很多情况下并不存在一个显而易见的中心位置来存放跨模块文档。

RAMCloud 存储系统中的一个例子是处理“僵尸服务器(Zombie Server)”的代码。所谓僵尸服务器,是指系统认为已经崩溃、但实际上仍在运行的服务器。

消除僵尸服务器带来的问题需要多个不同模块中的代码协同工作,而这些代码彼此依赖。但这些代码中的任何一个都不是存放相关文档的天然中心位置。

一种可能的做法是在每个依赖该机制的地方都复制一份文档。然而这种方式既笨拙,又难以随着系统演进而保持同步更新。

另一种做法是把文档放在某个需要它的位置,但这样开发人员未必能看到或知道去哪里查找。

最近我一直在尝试一种做法:将跨模块问题统一记录在一个名为 designNotes 的中心文件中。

该文件按照主题划分为多个清晰标识的章节,每个重要主题对应一个章节。例如,下面是其中关于僵尸服务器的一个节选:

...
Zombies

僵尸服务器(Zombie)是指被集群中其他服务器认为已经死亡的服务器;
该服务器上的所有数据都会被恢复,并迁移到其他服务器上。

然而,如果这个僵尸服务器实际上并没有真正死亡
(例如只是暂时与其他服务器断开连接),
那么可能会产生两种一致性问题:

* 一旦替代服务器已经接管,僵尸服务器就不应继续处理读请求;
  否则它可能返回过期数据,因为这些数据并不反映替代服务器
  已经接受的写入。

* 一旦替代服务器开始在恢复过程中重放其日志,
  僵尸服务器就不应再接受写请求;
  否则这些写入可能会丢失
  (新值不会被保存到替代服务器上,因此后续读取也无法返回)。

RAMCloud 使用两种技术来消除僵尸服务器的问题。首先,
...

随后,在任何涉及这些问题的代码中,只需要加入一条简短注释,指向 designNotes 文件:

// 参见 designNotes 中的 "Zombies" 一节。

采用这种方式后,文档只保留一份副本,开发人员在需要时也比较容易找到。

但这种方法也有缺点:文档并不位于依赖它的代码附近,因此随着系统不断演进,保持文档与代码同步更新可能会变得困难。

总结

注释的目标,是让系统的结构和行为对读者来说尽可能显而易见,从而让他们能够快速找到所需信息,并有信心地对系统进行修改。其中一部分信息可以直接通过代码表达,因此读者阅读代码即可理解;但仍然有大量信息无法轻易从代码中推导出来。注释的作用正是填补这部分信息空缺。

遵循“注释应描述代码中不明显内容”这一原则时,需要注意:这里所谓的“不明显”,是站在第一次阅读代码的人(而不是你自己)的角度而言的。编写注释时,应当尽量设身处地站在读者角度思考,问自己:

对于阅读这段代码的人来说,最需要知道的是什么?

如果你的代码正在进行评审,而评审者指出某些内容并不明显,那么不要和他们争论;如果读者认为某件事不明显,那么它就是不明显的。与其争辩,不如尝试理解究竟是什么让他们感到困惑,然后通过更好的注释或者更好的代码来消除这种困惑。

13.9 第 13.5 节问题的答案

开发人员在使用 IndexLookup 类时,是否需要了解以下信息?

1. IndexLookup 向保存索引和对象的服务器发送的消息格式。

不需要。
这是一个实现细节,应当被封装在类的内部。

2. 用于判断对象是否落入指定范围的比较函数。

(比较是基于整数、浮点数还是字符串?)

需要。
类的使用者需要了解这些信息。

3. 服务器上用于存储索引的数据结构。

不需要。
这些信息应当封装在服务器内部,甚至 IndexLookup 的实现本身也不应该依赖这些细节。

4. IndexLookup 是否会同时向多个服务器发送请求。

可能需要。
如果 IndexLookup 使用了一些特殊技术来提升性能,那么文档应提供一定程度的高层说明,因为使用者可能会关心性能特征。

5. 处理服务器崩溃的机制。

不需要。
RAMCloud 会自动从服务器崩溃中恢复,因此崩溃对应用层软件是不可见的。

因此,没有必要在 IndexLookup 的接口文档中提及服务器崩溃。

如果崩溃会反映到应用层,那么接口文档需要描述崩溃将以何种形式体现出来(但不需要解释崩溃恢复机制本身是如何工作的)。