第 13 章 注释应该描述从代码中不易看出的信息
之所以需要编写注释,是因为编程语言的语句无法表达开发人员编写代码时想到的所有重要信息。注释记录这些信息,让后来的开发人员能够轻松理解和修改代码。编写注释的指导原则是:注释应该描述从代码中不易看出的信息。
本章概览注释应该描述什么信息?
有许多信息不容易从代码中看出来。有时是底层细节,例如,用一对索引描述一个范围时,这两个索引所指的元素是否包含在范围内,往往并不明显。有时不清楚为什么需要某段代码,或者为什么要以特定方式实现。有时,开发人员遵循一些规则,例如“总是在 b 之前调用 a”。你也许能通过查看所有代码猜出规则,但这既费力又容易出错;注释可以把规则说明白。
编写注释最重要的原因之一是描述抽象,其中包含许多从代码中不易看出的信息。抽象提供了一种思考问题的简单方式,但代码包含大量细节,仅靠阅读代码往往很难看清抽象。注释可以提供一个更简单、更高层级的视图(比如:调用此方法后,网络流量将被限制为每秒 maxBandwidth 字节)。即使可以通过阅读代码推断出这些信息,我们也不应强迫模块的使用者这样做:阅读代码很耗时,而且会迫使他们考虑许多使用该模块时并不需要的信息。开发人员应该只需阅读模块对外可见的声明,而不必阅读其他代码,就能理解模块提供的抽象。 要做到这一点,唯一的方法就是用注释补充声明。
本章讨论注释需要描述哪些信息,以及如何写好注释。你将看到,好的注释通常在详细程度上与代码有所不同:有时比代码更详细,有时则省略细节,提供更抽象的描述。
13.1 选择约定
编写注释的第一步是确定约定,例如哪些内容需要注释、注释采用什么格式。如果所用语言已有文档生成工具,例如 Java 的 Javadoc、C++ 的 Doxygen 或 Go!的 godoc,请遵循这些工具的约定。这些约定并不完美,但工具带来的好处足以弥补这一点。如果当前环境没有现成的约定可循,可以尝试采用类似语言或项目中的约定,让其他开发人员更容易理解和遵守。
约定有两个目的。首先,确保一致性,使注释更易于阅读和理解。其次,促使你真正把注释写出来。如果不清楚哪些内容需要注释、应该怎样写,最后很容易索性不写。
大多数注释属于以下类别之一:
接口:位于模块声明(例如类、数据结构、函数或方法)之前的注释块,用于描述模块的接口。对于类,注释描述该类提供的整体抽象。对于方法或函数,注释描述其整体行为、参数和返回值(如果有)、任何副作用、可能抛出的异常,以及调用者在调用前必须满足的其他要求。
数据结构成员:数据结构中字段声明旁边的注释,例如类的实例变量或静态变量。
实现注释:方法或函数代码内部的注释,它描述代码在内部的工作方式。
跨模块注释:描述跨越模块边界的依赖关系的注释。
最重要的是前两类注释。每个类都应有接口注释,每个类变量都应有注释,每个方法都应有接口注释。有时,变量或方法的声明已经足够清楚,再添加注释也没有实际帮助(getter 和 setter 有时属于此类),但这种情况很少。为所有这些内容编写注释,比逐一纠结是否需要注释容易得多。实现注释通常不是必需的(请参阅下面的第 13.6 节)。跨模块注释最少见,也比较难写,但在需要时非常重要。第 13.7 节将详细讨论这类注释。
13.2 不要重复代码
不幸的是,许多注释并不是特别有用。最常见的原因是注释重复了代码:可以轻松地从注释旁边的代码中推断出注释中的所有信息。这是最近研究论文中出现的代码示例:
ptr_copy = get_copy(obj) # Get pointer copy
if is_unlocked(ptr_copy): # Is obj free?
return obj # return current obj
if is_copy(ptr_copy): # Already a copy?
return obj # return obj
thread_id = get_thread_id(ptr_copy)
if thread_id == ctx.thread_id: # Locked by current ctx
return ptr_copy # Return copy
这些注释几乎没有提供有用的信息,只有 Locked by 是个例外:它提示了某些从代码中可能看不出的线程相关信息。注意,注释与代码的详细程度大致相同,每条注释都在描述所在行的代码。这样的注释基本没用。
以下是注释重复了代码的更多示例:
// Add a horizontal scroll bar
hScrollBar = new JScrollBar(JScrollBar.HORIZONTAL);
add(hScrollBar, BorderLayout.SOUTH);
// Add a vertical scroll bar
vScrollBar = new JScrollBar(JScrollBar.VERTICAL);
add(vScrollBar, BorderLayout.EAST);
// Initialize the caret-position related values
caretX = 0;
caretY = 0;
caretMemX = null;
这些注释都没有提供额外的价值。前两处代码已经很清楚,实际上不需要注释。第三处添加注释可能有用,但现有注释不够详细,帮不上忙。
写完注释后,请问自己:从未看过这段代码的人,能否仅凭注释旁边的代码写出同样的注释?如果答案是肯定的(如上述示例所示),注释就没有让代码更易于理解。有些人认为注释毫无价值,原因正是见到了太多这样的注释。
另一个常见的错误是在注释中重复被注释实体名称里的词:
/*
* Obtain a normalized resource name from REQ.
*/
private static String[] getNormalizedResourceNames(HTTPRequest req) ...
/*
* Downcast PARAMETER to TYPE.
*/
private static Object downCastParameter(String parameter, String type) ...
/*
* The horizontal padding of each line in the text.
*/
private static final int textHorizontalPadding = 4;
危险信号:注释重复了代码
如果注释中的信息很容易从旁边的代码中看出,那么注释就没有帮助。例如,注释只是重复所描述事物名称中的词。
这些注释只是从方法或变量名中提取单词,再从参数名称和类型中补上几个词,拼成一个句子。例如,第二个注释中唯一不在代码里的单词是 to!同样,仅凭声明就能写出这些注释,无需了解变量的方法,因此它们没有价值。
译者注(原文疑点):原文此处为
methods of variables,即“变量的方法”。结合本节讨论的方法和变量注释,疑应为methods or variables,即“方法或变量”。正文保留原文表述。
同时,注释中缺少一些重要信息:例如,什么是规范化的资源名称(normalized resource name)?getNormalizedResourceNames 返回的数组的元素是什么?“downcast” 是什么意思?填充(padding)的单位是什么,是仅在每行的一边填充还是两边都填充?在注释中描述这些内容将很有帮助。
写好注释的第一步是 在注释中使用与被描述实体名称不同的词。注释的用词应补充实体的含义,而不是仅仅重复名称。例如,下面这段针对 textHorizontalPadding 的注释就更好:
/*
* The amount of blank space to leave on the left and
* right sides of each line of text, in pixels.
*/
private static final int textHorizontalPadding = 4;
这段注释补充了声明本身没有说明的信息:单位是像素,填充位于每行两侧。考虑到读者可能不熟悉“填充”这个术语,注释没有直接使用它,而是解释了它的含义。
译者图解4 是多少,留在哪一边?
13.3 更低层级的注释可提高精确度
现在已经知道哪些做法应当避免,接下来讨论注释应该补充什么信息。注释通过提供不同详细程度的信息来补充代码。 有些注释比代码更低层级、更详细,通过阐明代码的确切含义来提高精确度。另一些注释则比代码更高层级、更抽象,帮助读者形成直观认识,例如说明代码背后的考量,或提供一种更简单、更抽象的理解方式。与代码处于同一层级的注释很可能只是在重复代码。本节讨论更低层级的注释,下一节讨论更高层级的注释。
为变量声明(例如类实例变量、方法参数和返回值)编写注释时,精确性尤为重要。声明中的名称和类型通常不够精确,注释可以补充缺少的细节,例如:
- 此变量的单位是什么?
- 是否包含边界值?
- 如果允许使用空值,那么它意味着什么?
- 如果变量引用了最终必须释放或关闭的资源,那么谁负责释放或关闭该资源?
- 变量是否具有某些始终成立的性质(不变量),例如“此列表始终包含至少一个条目”?
其中一些信息可以通过检查使用该变量的所有代码推断出来,但这既耗时又容易出错。声明的注释应该清晰、完整,让读者不必查看所有使用该变量的代码就能了解这些信息。另外,当我说声明的注释应描述从代码中不易看出的信息时,这里的“代码”指的是注释旁边的代码(即声明),而不是“应用程序中的所有代码”。
变量注释最常见的问题是注释太模糊。这是两个不够精确的注释示例:
// Current offset in resp Buffer
uint32_t offset;
// Contains all line-widths inside the document and
// number of appearances.
private TreeMap<Integer, Integer> lineWidths;
在第一个示例中,Current 的含义不清楚。在第二个示例中,不清楚 TreeMap 中的键是否表示行宽、值是否表示出现次数。另外,宽度的单位是像素还是字符?以下修订后的注释提供了更多细节:
// Position in this buffer of the first object that hasn't
// been returned to the client.
uint32_t offset;
// Holds statistics about line lengths of the form <length, count>
// where length is the number of characters in a line (including
// the newline), and count is the number of lines with
// exactly that many characters. If there are no lines with
// a particular length, then there is no entry for that length.
private TreeMap<Integer, Integer> numLinesWithLength;
第二个声明采用更长的名称 numLinesWithLength,传达了更多信息。它还把“宽度(Width)”改成“长度(Length)”,因为后者更容易让人想到以字符而非像素为单位。注意,第二条注释不仅说明了每个条目的含义,也解释了没有某个条目意味着什么。
为变量添加注释时,可以考虑使用名词而不是动词。换句话说,关注变量代表什么,而不是代码如何操作它。考虑以下注释:
/* FOLLOWER VARIABLE: indicator variable that allows the Receiver and the
* PeriodicTasks thread to communicate about whether a heartbeat has been
* received within the follower's election timeout window.
* Toggled to TRUE when a valid heartbeat is received.
* Toggled to FALSE when the election timeout window is reset.
*/
private boolean receivedValidHeartbeat;
这段注释描述了类中的几段代码如何修改变量。如果改为描述变量代表什么,而不是重复代码逻辑,注释就会更简短、更有用:
/* True means that a heartbeat has been received since the last time
* the election timer was reset. Used for communication between the
* Receiver and PeriodicTasks threads.
*/
private boolean receivedValidHeartbeat;
根据这段注释,很容易推断出:收到心跳信号时,必须将变量设为真;重置选举计时器时,必须将它设为假。
译者图解一条映射记录与一个缺席条目
13.4 更高层级的注释可增强直觉
注释提高代码可读性的第二种方式,是帮助读者形成直观认识。这类注释比代码更高层级,省略细节,帮助读者理解代码的整体意图和结构。这种方式常用于方法内部的注释和接口注释。例如,考虑以下代码:
// If there is a LOADING readRpc using the same session
// as PKHash pointed to by assignPos, and the last PKHash
// in that readRPC is smaller than current assigning
// PKHash, then we put assigning PKHash into that 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;
}
}
这段注释层级太低,细节太多。一方面,它部分重复了代码:比如 if there is a LOADING readRPC 只是重复了 readRpc[i].status == LOADING。另一方面,它没有解释代码的总体目的,也没有说明这段代码在所属方法中的作用,因此无法帮助读者理解代码。
这是一个更好的注释:
// Try to append the current key hash onto an existing
// RPC to the desired server that hasn't been sent yet.
这段注释没有描述具体细节,而是在更高层级上说明代码的整体功能。有了这些信息,读者就能理解代码中几乎所有的操作:循环一定是在遍历所有现有的远程过程调用(RPC);会话检查很可能用于判断某个 RPC 是否发往正确的服务器;LOADING 检查暗示 RPC 可以处于多个状态,而在某些状态下添加更多哈希值并不安全;MAX_PKHASHES_PERRPC 检查暗示单个 RPC 能发送的哈希值数量有上限。注释中唯一没有解释的是 maxPos 检查。此外,新注释还为读者判断代码是否正确提供了依据:代码是否完成了将键的哈希值添加到现有 RPC 所需的全部操作?原注释没有描述代码的整体意图,因此读者很难判断其行为是否正确。
译者图解从追加意图读懂判断,但不越过未知条件
更高层级的注释比更低层级的注释更难写,因为你必须换一种方式思考代码。问问自己:这段代码要做什么?怎样才能用最简单的方式解释整段代码?其中最重要的是什么?
工程师往往非常注重细节。我们喜欢细节,也善于处理大量细节,这是优秀工程师必备的能力。但优秀的软件设计师还能够跳出细节,从更高层级思考系统:确定哪些方面最重要,忽略底层细节,从系统最基本的特征出发来理解它。这就是抽象的本质,即找到一种思考复杂实体的简单方法,也是编写更高层级注释时必须做的事。好的高层级注释用一个或几个简单的想法提供概念框架,例如“追加到现有的 RPC”。借助这个框架,读者就能轻松看出具体代码语句与总体目标之间的关系。
下面是另一个带有更高层级注释的代码示例:
if (numProcessedPKHashes < readRpc[i].numHashes) {
// Some of the key hashes couldn't be looked up in
// this request (either because they aren't stored
// on the server, the server crashed, or there
// wasn't enough space in the response message).
// Mark the unprocessed hashes so they will get
// reassigned to new RPCs.
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;
}
}
}
}
这段注释做了两件事。第二句话对代码的功能作了抽象描述;第一句话则从更高层级解释为什么需要执行这段代码。说明“程序为何执行到这里”的注释,对理解代码很有帮助。例如,为方法添加注释时,说明它通常在什么情况下被调用会很有用,尤其是那些只在非正常情况下才需要调用的方法。
13.5 接口文档
注释最重要的作用之一就是定义抽象。回顾第 4 章,抽象是实体的简化视图,保留基本信息,省略可以安全忽略的细节。代码不适合描述抽象,因为它的层级太低,包含了不应该出现在抽象中的实现细节。描述抽象的唯一方法是使用注释。如果你希望代码提供良好的抽象,就必须用注释记录这些抽象。
记录抽象的第一步是将接口注释与实现注释分开。接口注释提供使用类或方法时需要知道的信息,定义了抽象;实现注释则描述类或方法如何在内部工作,以实现这个抽象。区分这两类注释很重要,可以避免向接口的使用者暴露实现细节。而且,这两类注释的内容也应当不同。如果接口注释也必须描述实现,则该类或方法是浅的。 因此,编写注释的过程也能帮助判断设计质量,第 15 章将再次讨论这一点。
类的接口注释从高层级描述该类提供的抽象,例如:
/**
* This class implements a simple server-side interface to the HTTP
* protocol: by using this class, an application can receive HTTP
* requests, process them, and return responses. Each instance of
* this class corresponds to a particular socket used to receive
* requests. The current implementation is single-threaded and
* processes one request at a time.
*/
public class Http {...}
这段注释描述了类的整体功能,没有涉及实现细节,甚至没有涉及具体方法的细节。它还说明了每个实例代表什么。最后,注释指出该类的限制(不支持多个线程并发访问),这对考虑是否使用它的开发人员可能很重要。
方法的接口注释既要用高层级信息描述抽象,也要用低层级细节保证精确性:
- 注释通常以一两个句子开头,描述调用者能感知到的方法的行为。这是更高层级的抽象。
- 注释必须描述每个参数和返回值(如果有)。这些注释必须非常精确,并且必须描述对参数值的任何约束以及参数之间的依赖关系。
- 如果方法有任何副作用,就必须在接口注释中记录。副作用是方法对系统后续行为产生的影响,但不属于返回结果。例如,方法将一个值添加到内部数据结构中,后续的方法调用可以取回这个值,这就是副作用。写入文件系统也是一种副作用。
- 方法的接口注释必须描述该方法可能产生的任何异常。
- 如果调用方法之前必须满足某些前提条件,就必须加以说明(也许必须先调用其他方法;对于二分查找方法,被查找的列表必须已经排序)。应尽量减少前提条件,但保留下来的每一项都必须记录。
这是一个从 Buffer 对象复制数据的方法的接口注释:
/**
* Copy a range of bytes from a buffer to an external location.
*
* \param offset
* Index within the buffer of the first byte to copy.
* \param length
* Number of bytes to copy.
* \param dest
* Where to copy the bytes: must have room for at least
* length bytes.
*
* \return
* The return value is the actual number of bytes copied,
* which may be less than length if the requested range of
* bytes extends past the end of the buffer. 0 is returned
* if there is no overlap between the requested range and
* the actual buffer.
*/
uint32_t
Buffer::copy(uint32_t offset, uint32_t length, void* dest)
...
这段注释的语法(例如 \return)遵循 Doxygen 的约定,该工具从 C / C++ 代码中提取注释并生成 Web 页面。注释提供了调用该方法所需的全部信息,包括特殊情况的处理方式(注意,这个方法遵循了第 10 章的建议,通过定义来规避范围指定方面的错误)。开发人员不必阅读方法体就能调用它,接口注释也没有描述实现细节,例如如何扫描内部数据结构来查找所需数据。
接下来看一个更全面的示例:分布式存储系统中的 IndexLookup 类。这个存储系统包含一组表,每张表中有许多对象,也可以有一个或多个索引。每个索引都根据对象的某个字段,提供高效访问表中对象的方式。例如,一个索引可以按名称字段查找对象,另一个索引可以按年龄字段查找。借助这些索引,应用程序可以快速取出具有特定名称、或年龄在给定范围内的所有对象。
IndexLookup 类为索引查找提供了方便的接口。下面的示例展示了应用程序如何使用它:
query = new IndexLookup(table, index, key1, key2);
while (true) {
object = query.getNext();
if (object == NULL) {
break;
}
... process object ...
}
应用程序首先构造一个 IndexLookup 类型的对象,并提供用于选择表、索引和索引范围的参数(例如,如果索引基于年龄字段,可以将 key1 和 key2 指定为 21 和 65,以选择年龄介于这两个值之间的所有对象)。然后,应用程序重复调用 getNext 方法,每次调用返回一个位于所需范围内的对象。所有匹配对象都返回后,getNext 将返回 NULL。由于存储系统是分布式的,这个类的实现有些复杂。表中的对象可以分布在多台服务器上,每个索引也可以分布在另一组服务器上。IndexLookup 类中的代码必须先与所有相关的索引服务器通信,收集指定范围内对象的信息,再与实际存储对象的服务器通信,取回对象的值。
现在,让我们考虑该类的接口注释中需要包含哪些信息。对于下面给出的每条信息,问问自己,开发人员是否需要知道该信息才能使用该类(我对这些问题的回答在本章的结尾):
IndexLookup类发送给索引服务器和对象服务器的消息格式。- 用于确定特定对象是否在所需范围内的比较函数(比较是使用整数、浮点数还是字符串来完成的?)。
- 用于在服务器上存储索引的数据结构。
IndexLookup是否同时向多个服务器发出多个请求。- 处理服务器崩溃的机制。
下面是 IndexLookup 类的接口注释的原始版本;摘录还包含类定义中被注释引用的几行内容:
/*
* 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 a 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);
}
在进一步阅读之前,看看你是否能找出这个注释的问题。这些是我发现的问题:
- 第一段的大部分内容涉及实现,而不是接口。例如,使用者不需要知道与服务器通信时使用了哪些具体的远程过程调用。第一段后半部分提到的配置参数都是私有变量,只有类的维护者需要了解,使用者不需要。这些实现信息都应从接口注释中省略。
- 这段注释还包含一些显而易见的信息。例如,不必告诉用户要包含
IndexLookup.h:任何编写 C++ 代码的人都能猜到这一点。另外,“通过提供所有必要的信息(by 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.
*/
严格来说,这段注释的最后一段并非必需,因为它主要重复了几个方法的注释。不过,在类文档中举例说明各方法如何协同工作可能很有帮助,尤其是那些使用方式不明显的深类。注意,新注释没有提及 getNext 的 NULL 返回值。它并不打算记录每个方法的全部细节,只提供高层级信息,帮助读者理解各方法如何协同工作,以及何时可以调用。读者可以查阅各方法的接口注释来了解细节。这里也没有提及服务器崩溃,因为服务器崩溃对该类的使用者不可见(系统会自动从中恢复)。
危险信号:实现文档污染了接口
当接口文档(例如方法的文档)记录了使用过程中不需要知道的详细实现信息时,就会出现此危险信号。
再看下面这段代码,它展示了 IndexLookup 类中 isReady 方法文档的初稿:
/**
* Check if the next object is RESULT_READY. This function is
* implemented in a DCFT module, each execution of isReady() tries
* to make small progress, and getNext() invokes isReady() in a
* while loop, until isReady() returns true.
*
* isReady() is implemented in a rule-based approach. We check
* different rules by following a particular order, and perform
* certain actions if some rule is satisfied.
*
* \return
* True means the next Object is available. Otherwise, return
* false.
*/
bool IndexLookup::isReady() { ... }
这里也有同样的问题:注释中的大部分内容,例如对 DCFT 的引用和整个第二段,都与实现有关,不应放在这里。这是接口注释中最常见的错误之一。有些实现文档很有用,但应放在方法内部,与接口文档明确分开。此外,第一句话含糊不清(RESULT_READY 是什么意思?),还遗漏了一些重要信息。最后,也不需要在这里描述 getNext 的实现。下面是一个更好的版本:
/*
* Indicates whether an indexed read has made enough progress for
* getNext to return immediately without blocking. In addition, this
* method does most of the real work for indexed reads, so it must
* be invoked (either directly, or indirectly by calling getNext) in
* order for the indexed read to make progress.
*
* \return
* True means that the next invocation of getNext will not block
* (at least one object is available to return, or the end of the
* lookup has been reached); false means getNext may block.
*/
这个版本更精确地说明了“就绪(ready)”的含义,还补充了一条重要信息:要让索引检索继续推进,最终必须调用此方法。
译者图解ready 不等于一定有对象
13.6 实现注释:做什么以及为什么这么做,而不是如何做
实现注释位于方法内部,帮助读者理解方法的内部工作方式。大多数方法简短而简单,不需要实现注释:仅凭代码和接口注释,就很容易弄清楚其工作原理。
实现注释的主要目的是帮助读者理解代码在做什么(而不是代码如何工作)。知道代码要做什么后,通常就很容易理解其工作原理。简短的方法只做一件事,接口注释已经说明了这件事,因此不需要实现注释。较长的方法包含多个代码块,分别执行不同的操作,共同完成方法的任务。可以在每个主要代码块之前添加注释,从更高层级(更抽象地)说明它的作用。例如:
// Phase 1: Scan active RPCs to see if any have completed.
这样的注释能帮助读者找到关心的代码。对于循环,在前面加注释说明每次迭代做什么,也会很有帮助:
// Each iteration of the following loop extracts one request from
// the request message, increments the corresponding object, and
// appends a response to the response message.
注意,这段注释对循环的描述更抽象、更直观,没有详述如何从请求消息中提取请求,也没有解释如何递增对象。只有较长或较复杂、作用可能不够明显的循环才需要注释。许多循环简短而简单,其行为已经很清楚。
除了描述代码在做什么,实现注释还可以解释为什么这样做。如果某些复杂之处难以直接从代码中看出,就应加以记录。例如,修复缺陷时添加的代码如果目的不明显,就应通过注释说明为什么需要它。如果已有缺陷报告清楚地描述了问题,注释可以引用缺陷跟踪数据库中的问题编号,不必重复所有细节(比如“修复 RAM-436,与 Linux 2.4.x 中的设备驱动程序崩溃有关。”)。开发人员可以到缺陷数据库中查阅详情(这是避免注释重复的一个例子,第 16 章将讨论这一点)。
对于较长的方法,为最重要的几个局部变量写注释可能有帮助。不过,大多数局部变量只要名称选得好,就不需要注释。如果变量的全部用法都集中在几行代码中,通常无需注释也能轻松理解其用途,此时让读者阅读代码来了解变量的含义是合理的。但如果变量的使用位置跨越大段代码,就应考虑添加注释。注释应关注变量表示什么,而不是代码如何操作它。
13.7 跨模块设计决策
理想情况下,每个重要的设计决策都封装在一个类中。但在实际系统中,设计决策不可避免地会影响多个类。例如,网络协议的设计会影响发送方和接收方,而两者可能在不同的地方实现。跨模块决策通常复杂而微妙,容易导致代码缺陷,因此必须有良好的文档。
跨模块文档最大的挑战,是找到一个开发人员自然就能看到的位置。有时,集中放置这类文档的位置很明确。例如,RAMCloud 存储系统为每个请求返回一个状态值,表示成功或失败。为新的错误情况添加状态时,需要修改多个文件(一个文件将状态值映射到异常,另一个为每个状态提供可读的消息,等等)。幸运的是,添加状态值时,开发人员必然要查看状态枚举的声明。我们利用这一点,在枚举中添加注释,列出其他必须同步修改的位置。
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,
// Note: if you add a new status value you must make the following
// additional updates:
// (1) Modify STATUS_MAX_VALUE to have a value equal to the
// largest defined status value, and make sure its definition
// is the last one in the list. STATUS_MAX_VALUE is used
// primarily for testing.
// (2) Add new entries in the tables "messages" and "symbols" in
// Status.cc.
// (3) Add a new exception class to ClientException.h
// (4) Add a new "case" to ClientException::throwException to map
// from the status value to a status-specific ClientException
// subclass.
// (5) In the Java bindings, add a static class for the exception
// to ClientException.java
// (6) Add a case for the status of the exception to throw the
// exception in ClientException.java
// (7) Add the exception to the Status enum in Status.java, making
// sure the status is in the correct position corresponding to
// its status code.
}
新的状态值会添加到列表末尾,因此注释也放在那里,让开发人员最有可能看到它。
但在许多情况下,并没有一个适合集中放置跨模块文档的明显位置。RAMCloud 存储系统中处理僵尸服务器的代码就是一个例子:系统认为这些服务器已经崩溃,但它们实际上仍在运行。使僵尸服务器失效需要多个模块中的代码相互配合,却没有哪一处明显适合集中放置文档。一种做法是在每个需要文档的位置复制相关部分,但这并不理想,而且随着系统演进,很难让各份文档都保持最新。另一种做法是只把文档放在其中一处,但开发人员可能看不到它,也不知道该去哪里查找。
我最近在尝试把跨模块问题集中记录在一个名为 designNotes 的文件中。文件分成几个标识清楚的部分,每部分讨论一个主题。例如,下面是其中的一段摘录:
...
Zombies
-------
A zombie is a server that is considered dead by the rest of the
cluster; any data stored on the server has been recovered and will
be managed by other servers. However, if a zombie is not actually
dead (e.g., it was just disconnected from the other servers for a
while) two forms of inconsistency can arise:
* A zombie server must not serve read requests once replacement
servers have taken over; otherwise it may return stale data that
does not reflect writes accepted by the replacement servers.
* The zombie server must not accept write requests once replacement
servers have begun replaying its log during recovery; if it does,
these writes may be lost (the new values may not be stored on the
replacement servers and thus will not be returned by reads).
RAMCloud uses two techniques to neutralize zombies. First,
...
然后,在涉及其中任一问题的代码段中,用简短注释引用 designNotes 文件:
// See "Zombies" in designNotes.
采用这种方法,文档只保留一份,开发人员需要时也比较容易找到。缺点是文档离所有相关代码都较远,因此随着系统演进,可能难以保持最新。
13.8 结论
注释的目的是让读者容易理解系统的结构和行为,从而快速找到所需信息,有把握地修改系统,并确信修改能够正常工作。有些信息可以通过代码本身清晰地表达,但还有大量信息不容易从代码中推断出来,注释就是用来补充这些信息的。
遵循“注释应该描述从代码中不易看出的信息”这一原则时,应站在第一次阅读代码的人,而不是你自己的角度,判断哪些信息不易看出。写注释时,试着从读者的角度思考:他们需要知道哪些关键信息?如果代码审查者告诉你某些内容难以理解,不要争辩;读者认为难以理解,就说明确实如此。与其争论,不如弄清楚他们困惑在哪里,再用更好的注释或代码加以澄清。
13.9 回答第 13.5 节中的问题
开发人员是否需要了解以下每条信息才能使用 IndexLookup 类?
IndexLookup类发送给索引服务器和对象服务器的消息格式。否:这是应该隐藏在类中的实现细节。- 用于确定特定对象是否在所需范围内的比较函数(比较是使用整数、浮点数还是字符串来完成的?)。是:该类的用户需要了解此信息。
- 用于在服务器上存储索引的数据结构。否:此信息应封装在服务器上;甚至
IndexLookup的实现都不需要知道这一点。 IndexLookup是否同时向多个服务器发出多个请求。有可能:如果IndexLookup使用特殊技术来提高性能,则文档应提供相关的一些高层级信息,因为用户可能会在意性能。- 处理服务器崩溃的机制。否:RAMCloud 可从服务器崩溃中自动恢复,崩溃对应用程序级软件不可见,因此
IndexLookup的接口文档无需提及。如果崩溃会暴露给应用程序,接口文档就需要说明应用程序会看到什么,而不是详细介绍崩溃恢复的工作方式。