第 13 章 注释应该描述代码中不明显的内容
编写注释的原因是,使用编程语言编写的语句无法捕获编写代码时开发人员想到的所有重要信息。注释记录了这些信息,以便后来的开发人员可以轻松地理解和修改代码。注释的指导原则是,注释应描述代码中不明显的内容。
从代码中看不到很多事情。有时,底层细节并不明显。例如,当一对索引描述一个范围时,由索引给出的元素是在范围之内还是之外并不明显。有时不清楚为什么需要代码,或者为什么要以特定方式实现代码。有时,开发人员遵循一些规则,例如“总是在 b 之前调用 a”。您可能可以通过查看所有代码来猜测规则,但这很痛苦且容易出错。注释可以使规则清晰明了。
注释的最重要原因之一是抽象,其中包括许多从代码中看不到的信息。抽象的思想是提供一种思考问题的简单方法,但是代码是如此详细,以至于仅通过阅读代码就很难看到抽象。注释可以提供一个更简单,更高级的视图(“调用此方法后,网络流量将被限制为每秒 maxBandwidth 字节”)。即使可以通过阅读代码推断出此信息,我们也不想强迫模块用户这样做:阅读代码很耗时,并且迫使他们考虑很多不需要使用的信息模块。开发人员应该能够理解模块提供的抽象,而无需阅读其外部可见声明以外的任何代码。
本章讨论需要在注释中描述哪些信息以及如何编写良好的注释。就像您将看到的那样,好的注释通常以与代码不同的详细程度来解释事物,在某些情况下,注释会更详细,而在某些情况下,代码则较不抽象(更抽象)。
13.1 选择约定
编写注释的第一步是确定注释的约定,例如您要注释的内容和注释的格式。如果您正在使用存在文档编译工具的语言进行编程,例如 Java 的 Javadoc,C ++的 Doxygen 或 Go!的 godoc,请遵循工具的约定。这些约定都不是完美的,但是这些工具可提供足够的好处来弥补这一缺点。如果在没有现有约定可遵循的环境中进行编程,请尝试从其他类似的语言或项目中采用这些约定;这将使其他开发人员更容易理解和遵守您的约定。
约定有两个目的。首先,它们确保一致性,这使得注释更易于阅读和理解。其次,它们有助于确保您实际编写评论。如果您不清楚要发表的评论以及发表评论的方式,那么很容易最终根本不发表评论。
大多数评论属于以下类别之一:
接口:在模块声明(例如类,数据结构,函数或方法)之前的注释块。注释描述模块的接口。对于一个类,注释描述了该类提供的整体抽象。对于方法或函数,注释描述其整体行为,其参数和返回值(如果有),其生成的任何副作用或异常,以及调用者在调用该方法之前必须满足的任何其他要求。
数据结构成员:数据结构中字段声明旁边的注释,例如类的实例变量或静态变量。
实现注释:方法或函数代码内部的注释,它描述代码在内部的工作方式。
跨模块注释:描述跨模块边界的依赖项的注释。
最重要的评论是前两个类别中的评论。每个类都应有一个接口注释,每个类变量应有一个注释,每个方法都应有一个接口注释。有时,变量或方法的声明是如此明显,以至于在注释中没有添加任何有用的东西(getter 和 setter 有时都属于此类),但这很少见。评论所有内容要比花精力担心是否需要评论要容易得多。实施注释通常是不必要的(请参阅下面的 13.6 节)。跨模块注释是最罕见的,而且编写起来很成问题,但是当需要它们时,它们就很重要。第 13.7 节将更详细地讨论它们。
13.2 不要重复代码
不幸的是,许多评论并不是特别有用。最常见的原因是注释重复了代码:可以轻松地从注释旁边的代码中推断出注释中的所有信息。这是最近研究论文中出现的代码示例:
1 | ptr_copy = get_copy(obj) # Get pointer copy |
这些注释中没有任何有用的信息,但“ Locked by”注释除外,该注释暗示了有关线程的某些信息可能在代码中并不明显。请注意,这些注释的详细程度与代码大致相同:每行代码有一个注释,用于描述该行。这样的注释很少有用。
以下是重复代码的注释的更多示例:
1 | // Add a horizontal scroll bar |
这些评论均未提供任何价值。对于前两个注释,代码已经很清楚了,它实际上不需要注释。在第三种情况下,注释可能有用,但是当前注释没有提供足够的细节来提供帮助。
编写评论后,请问自己以下问题:从未看过代码的人能否仅通过查看评论旁边的代码来编写评论?如果答案是肯定的(如上述示例所示),则注释不会使代码更易于理解。像这样的评论是为什么有些人认为评论毫无价值的原因。
另一个常见的错误是在注释中使用与要记录的实体名称相同的词:
1 | /* |
这些注释只是从方法或变量名中提取单词,或者从参数名称和类型中添加几个单词,然后将它们组成一个句子。例如,第二个注释中唯一不在代码中的是单词“ to”!再说一次,这些注释可以仅通过查看声明来编写,而无需任何了解变量的方法。结果,它们没有价值。
危险信号
如果注释旁边的代码中的注释信息已经很明显,则注释无济于事。这样的一个例子是,当注释使用与所描述事物名称相同的单词时。
同时,注释中缺少一些重要信息:例如,什么是“标准化资源名称”,以及 getNormalizedResourceNames 返回的数组的元素是什么?“贬低”是什么意思?填充的单位是什么,填充是在每行的一侧还是在两者的两侧?在注释中描述这些内容将很有帮助。
编写良好评论的第一步是在评论中使用与所描述实体名称不同的词。为注释选择单词,以提供有关实体含义的更多信息,而不仅仅是重复其名称。例如,以下是针对 textHorizontalPadding 的更好注释:
1 | /* |
该注释提供了从声明本身不明显的其他信息,例如单位(像素)以及填充适用于每行两边的事实。如果读者不熟悉该术语,则注释将解释什么是填充,而不是使用术语“填充”。
13.3 低级注释可提高精度
现在您知道了不应该做的事情,让我们讨论应该在注释中添加哪些信息。注释通过提供不同详细程度的信息来增强代码。一些注释提供了比代码更低,更详细的信息。这些注释通过阐明代码的确切含 义来增加精度。其他注释提供了比代码更高,更抽象的信息。这些注释提供了直觉,例如代码背后的推理,或者更简单,更抽象的代码思考方式。与代码处于同一级别的注释可能会重复该代码。本节将更详细地讨论下层方法,而下一节将讨论上层方法。
在注释变量声明(例如类实例变量,方法参数和返回值)时,精度最有用。变量声明中的名称和类型通常不是很精确。注释可以填写缺少的详细信息,例如:
- 此变量的单位是什么?
- 边界条件是包容性还是排他性?
- 如果允许使用空值,则意味着什么?
- 如果变量引用了最终必须释放或关闭的资源,那么谁负责释放或关闭该资源?
- 是否存在某些对于变量始终不变的属性(不变量),例如“此列表始终包含至少一个条目”?
通过检查使用该变量的所有代码,可以潜在地了解某些信息。但是,这很耗时且容易出错。宣言的评论应清晰,完整,以免不必要。当我说声明的注释应描述代码中不明显的内容时,“代码”是指注释(声明)旁边的代码,而不是“应用程序中的所有代码”。
变量注释最常见的问题是注释太模糊。这是两个不够精确的注释示例:
1 | // Current offset in resp Buffer |
在第一个示例中,尚不清楚“当前”的含义。在第二个示例中,尚不清楚 TreeMap 中的键是线宽,值是出现次数。另外,宽度是以像素或字符为单位测量的吗?以下修订后的注释提供了更多详细信息:
1 | // Position in this buffer of the first object that hasn't |
第二个声明使用一个较长的名称来传达更多信息。它还将“宽度”更改为“长度”,因为该术语更可能使人们认为单位是字符而不是像素。请注意,第二条注释不仅记录了每个条目的详细信息,还记录了缺少条目的含义。
在记录变量时,请考虑名词而不是动词。换句话说,关注变量代表什么,而不是如何操纵变量。考虑以下评论:
1 | /* FOLLOWER VARIABLE: indicator variable that allows the Receiver and the |
本文档描述了如何通过类中的几段代码来修改变量。如果注释描述变量代表什么而不是镜像代码结构,则注释将更短且更有用:
1 | /* True means that a heartbeat has been received since the last time |
根据本文档,很容易推断出,当接收到心跳信号时,变量必须设置为 true;而当重置选举计时器时,则必须将变量设置为 false。
13.4 高级注释可增强直觉
注释可以增加代码的第二种方法是提供直觉。这些注释是在比代码更高的级别上编写的。它们忽略了细节,并帮助读者理解了代码的整体意图和结构。此方法通常用于方法内部的注释以及接口注释。例如,考虑以下代码:
1 | // If there is a LOADING readRpc using the same session |
该评论太底层和太详细。一方面,它部分重复了代码:“如果有 LOADING readRPC”仅重复测试 readRpc[i].status == LOADING。另一方面,注释不能解释此代码的总体目的,也不能解释其如何适合包含此代码的方法。如此一来注释不能帮助读者理解代码。
这是一个更好的评论:
1 | // Try to append the current key hash onto an existing |
此评论不包含任何详细信息。相反,它在更高级别上描述了代码的整体功能。有了这些高级信息,读者就可以解释代码中几乎发生的所有事情:循环必须遍历所有现有的远程过程调用(RPC);会话测试可能用于查看特定的 RPC 是否发往正确的服务器;LOADING 测试表明 RPC 可以具有多个状态,在某些状态下添加更多的哈希值是不安全的;MAX-PKHASHES_PERRPC 测试表明在单个 RPC 中可以发送多少个哈希值是有限制的。注释中唯一没有解释的是 maxPos 测试。此外,新注释为读者判断代码提供了基础:它可以完成将密钥哈希添加到现有 RPC 所需的一切吗?原始注释并未描述代码的整体意图,因此,读者很难确定代码是否行为正确。
高级别的注释比低级别的注释更难编写,因为您必须以不同的方式考虑代码。问问自己:这段代码要做什么?您能说什么最简单的东西来解释代码中的所有内容?这段代码最重要的是什么?
工程师往往非常注重细节。我们喜欢细节,善于管理其中的许多细节;这对于成为一名优秀的工程师至关重要。但是,优秀的软件设计师也可以从细节退后一步,从更高层次考虑系统。这意味着要确定系统的哪些方面最重要,并且能够忽略底层细节,仅根据系统的最基本特征来考虑系统。这是抽象的本质(找到一种思考复杂实体的简单方法),这也是编写高级注释时必须执行的操作。一个好的高层注释表达了一个或几个简单的想法,这些想法提供了一个概念框架,例如“附加到现有的 RPC”。使用该框架,可以很容易地看到特定的代码语句与总体目标之间的关系。
这是另一个代码示例,具有较高层次的注释:
1 | if (numProcessedPKHashes < readRpc[i].numHashes) { |
此评论有两件事。第二句话提供了代码功能的抽象描述。第一句话是不同的:它以高级的方式解释了为什么执行代码。“如何到达这里”形式的注释对于帮助人们理解代码非常有用。例如,在记录方法时,描述最有可能在什么情况下调用该方法的条件(特别是仅在异常情况下调用该方法的情况)会非常有帮助。
13.5 接口文档
注释最重要的作用之一就是定义抽象。回想一下第 4 章,抽象是实体的简化视图,它保留了基本信息,但省略了可以安全忽略的细节。代码不适合描述抽象;它的级别太低,它包含实现细节,这些细节在抽象中不应该看到。描述抽象的唯一方法是使用注释。如果您想要呈现良好抽象的代码,则必须用注释记录这些抽象。
记录抽象的第一步是将接口注释与实现注释分开。接口注释提供了使用类或方法时需要知道的信息。他们定义了抽象。实现注释描述了类或方法如何在内部工作以实现抽象。区分这两种注释很重要,这样界面的用户就不会暴露于实现细节。此外,这两种形式最好有所不同。如果接口注释也必须描述实现,则该类或方法很浅。这意味着撰写评论的行为可以提供有关设计质量的线索;第 15 章将回到这个想法。
类的接口注释提供了该类提供的抽象的高级描述,例如:
1 | /** |
该注释描述了类的整体功能,没有任何实现细节,甚至没有特定方法的细节。它还描述了该类的每个实例代表什么。最后,注释描述了该类的限制(它不支持从多个线程的并发访问),这对于考虑是否使用它的开发人员可能很重要。
方法的接口注释既包括用于抽象的高层信息,又包括用于精度的低层细节:
- 注释通常以一两个句子开头,描述调用者感知到的方法的行为。这是更高层次的抽象。
- 注释必须描述每个参数和返回值(如果有)。这些注释必须非常精确,并且必须描述对参数值的任何约束以及参数之间的依赖关系。
- 如果该方法有任何副作用,则必须在界面注释中记录这些副作用。副作用是该方法的任何结果都会影响系统的未来行为,但不属于结果的一部分。例如,如果该方法将一个值添加到内部数据结构中,可以通过将来的方法调用来检索该值,则这是副作用。写入文件系统也是一个副作用。
- 方法的界面注释必须描述该方法可能产生的任何异常。
- 如果在调用某个方法之前必须满足任何前提条件,则必须对其进行描述(也许必须先调用其他方法;对于二进制搜索方法,必须对要搜索的列表进行排序)。尽量减少前提条件是一个好主意,但是任何保留的条件都必须记录在案。
这是从 Buffer 对象复制数据的方法的接口注释:
1 | /** |
此注释的语法(例如\ return)遵循 Doxygen 的约定,该程序从 C / C ++代码中提取注释并将其编译为 Web 页。注释的目的是提供开发人员调用该方法所需的所有信息,包括特殊情况的处理方式(请注意,此方法如何遵循第 10 章的建议并定义与范围规范相关的任何错误。 )。开发人员不必为了调用它而阅读方法的主体,并且接口注释不提供有关如何实现该方法的信息,例如它如何扫描其内部数据结构以查找所需的数据。
对于更扩展的示例,让我们考虑一个称为 IndexLookup 的类,该类是分布式存储系统的一部分。存储系统拥有一个表集合,每个表包含许多对象。另外,每个表可以具有一个或多个索引;每个索引都基于对象的特定字段提供对表中对象的有效访问。例如,一个索引可以用于根据对象的名称字段查找对象,而另一个索引可以用于根据对象的年龄字段查找对象。使用这些索引,应用程序可以快速提取具有特定名称的所有对象,或具有给定范围内的年龄的所有对象。
IndexLookup 类为执行索引查找提供了一个方便的接口。这是一个如何在应用程序中使用的示例:
1 | query = new IndexLookup(table, index, key1, key2); |
应用程序首先构造一个类型为 IndexLookup 的对象,并提供用于选择表,索引和索引内范围的参数(例如,如果索引基于年龄字段,则 key1 和 key2 可以指定为 21 和 65 选择年龄介于这些值之间的所有对象)。然后,应用程序重复调用 getNext 方法。每次调用都返回一个位于所需范围内的对象。一旦返回所有匹配的对象,getNext 将返回 NULL。因为存储系统是分布式的,所以此类的实现有些复杂。表中的对象可以分布在多个服务器上,每个索引也可以分布在一组不同的服务器上。
现在,让我们考虑该类的接口注释中需要包含哪些信息。对于下面给出的每条信息,问自己一个开发人员是否需要知道该信息才能使用该类(我对问题的回答在本章的结尾):
- IndexLookup 类发送给包含索引和对象的服务器的消息格式。
- 用于确定特定对象是否在所需范围内的比较功能(使用整数,浮点数或字符串进行比较吗?)。
- 用于在服务器上存储索引的数据结构。
- IndexLookup 是否同时向多个服务器发出多个请求。
- 处理服务器崩溃的机制。
这是 IndexLookup 类的接口注释的原始版本;摘录还包括类定义的几行内容,在注释中进行了引用:
1 | /* |
在进一步阅读之前,请先查看您是否可以使用此注释确定问题所在。这是我发现的问题:
- 第一段的大部分与实现有关,而不是接口。举一个例子,用户不需要知道用于与服务器通信的特定远程过程调用的名称。在第一段的后半部分中提到的配置参数都是所有私有变量,它们仅与类的维护者相关,而与类的用户无关。所有这些实现信息都应从注释中省略。
- 该评论还包括一些显而易见的事情。例如,不需要告诉用户包括 IndexLookup.h:任何编写 C ++代码的人都可以猜测这是必要的。另外,“通过提供所有必要的信息”一词无语,因此可以省略。
对此类的简短评论就足够了(并且更可取):
1 | /* |
此注释的最后一段不是严格必需的,因为它主要针对单个方法复制了注释中的信息。但是,在类文档中提供示例来说明其方法如何协同工作可能会有所帮助,特别是对于使用模式不明显的深层类尤其如此。注意,新注释未提及 getNext 的 NULL 返回值。此注释无意记录每种方法的每个细节;它只是提供高级信息,以帮助读者了解这些方法如何协同工作以及何时可以调用每种方法。有关详细信息,读者可以参考接口注释中的各个方法。此评论也没有提到服务器崩溃;这是因为此类服务器的用户看不到服务器崩溃(系统会自动从中恢复)。
危险信号
当接口文档(例如方法的文档)描述了不需要使用要记录的事物的实现详细信息时,就会出现此红色标记。
现在考虑以下代码,该代码显示 IndexLookup 中 isReady 方法的文档的第一版:
1 | /** |
再一次,本文档中的大多数内容,例如对 DCFT 的引用以及整个第二段,都与实现有关,因此不属于此处。这是界面注释中最常见的错误之一。某些实现文档很有用,但应放在方法内部,在该方法中应将其与接口文档明确分开。此外,文档的第一句话是含糊的(RESULT_READY 是什么意思?),并且缺少一些重要信息。最后,无需在此处描述 getNext 的实现。这是评论的更好版本:
1 | /* |
此注释版本提供了有关“就绪”含义的更精确信息,并且提供了重要信息,如果要继续进行索引检索,则必须最终调用此方法。
13.6 实施注释:什么以及为什么,而不是如何
实现注释是出现在方法内部的注释,以帮助读者了解它们在内部的工作方式。大多数方法是如此简短,简单,以至于它们不需要任何实现注释:有了代码和接口注释,就很容易弄清楚方法的工作原理。
实现注释的主要目的是帮助读者理解代码在做什么(而不是代码如何工作)。一旦读者知道了代码要做什么,通常就很容易理解代码的工作原理。对于简短的方法,代码只做一件事,该问题已在其接口注释中进行了描述,因此不需要实现注释。较长的方法具有多个代码块,这些代码块作为方法的整体任务的一部分执行不同的操作。在每个主要块之前添加注释,以提供对该块的作用的高级(更抽象)描述。这是一个例子:
1 | // Phase 1: Scan active RPCs to see if any have completed. |
对于循环,在循环前加一个注释来描述每次迭代中发生的事情是有帮助的:
1 | // Each iteration of the following loop extracts one request from |
请注意,此注释如何更抽象和直观地描述循环。它没有详细介绍如何从请求消息中提取请求或对象如何递增。仅对于更长或更复杂的循环才需要循环注释,在这种情况下,循环的作用可能并不明显。许多循环足够短且简单,以至于其行为已经很明显。
除了描述代码在做什么之外,实现注释还有助于解释原因。如果代码中有些棘手的方面从阅读中看不出来,则应将它们记录下来。例如,如果一个错误修复程序需要添加目的不是很明显的代码,请添加注释以说明为什么需要该代码。对于错误修复,其中有写得很好的错误报告来描述问题,该注释可以引用错误跟踪数据库中的问题,而不是重复其所有详细信息(“修复 RAM-436,与 Linux 2.4 中的设备驱动程序崩溃有关。” X”)。开发人员可以在 bug 数据库中查找更多详细信息(这是一个避免注释重复的示例,这将在第 16 章中进行讨论)。
对于更长的方法,为一些最重要的局部变量写注释会很有帮助。但是,如果大多数局部变量具有好名字,则不需要文档。如果变量的所有用法在几行之内都是可见的,则通常无需注释即可轻松理解变量的用途。在这种情况下,可以让读者阅读代码来弄清楚变量的含义。但是,如果在大量代码中使用了该变量,则应考虑添加注释以描述该变量。在记录变量时,应关注变量表示的内容,而不是代码中如何对其进行操作。
13.7 跨模块设计决策
在理想环境中,每个重要的设计决策都将封装在一个类中。不幸的是,真实的系统不可避免地最终会影响到多个类的设计决策。例如,网络协议的设计将影响发送方和接收方,并且它们可以在不同的地方实现。跨模块决策通常是复杂而微妙的,并且会导致许多错误,因此,为它们提供良好的文档至关重要。
跨模块文档的最大挑战是找到一个放置它的位置,以便开发人员自然地发现它。有时,放置此类文档的中心位置很明显。例如,RAMCloud 存储系统定义一个状态值,每个请求均返回该状态值以指示成功或失败。为新的错误状况添加状态需要修改许多不同的文件(一个文件将状态值映射到异常,另一个文件为每个状态提供人类可读的消息,依此类推)。幸运的是,添加新的状态值(即 Status 枚举的声明)时,开发人员必须去一个明显的地方。我们通过在该枚举中添加注释来标识所有其他必须修改的地方,从而利用了这一点:在理想环境中,每个重要的设计决策都将封装在一个类中。不幸的是,真实的系统不可避免地最终会影响到多个类的设计决策。例如,网络协议的设计将影响发送方和接收方,并且它们可以在不同的地方实现。跨模块决策通常是复杂而微妙的,并且会导致许多错误,因此,为它们提供良好的文档至关重要。
1 | typedef enum Status { |
新状态值将添加到现有列表的末尾,因此注释也将放置在最有可能出现的末尾。
不幸的是,在许多情况下,并没有一个明显的中心位置来放置跨模块文档。RAMCloud 存储系统中的一个例子是处理僵尸服务器的代码,僵尸服务器是系统认为已经崩溃但实际上仍在运行的服务器。中和 zombie server 需要几个不同模块中的代码,这些代码都相互依赖。没有一段代码明显是放置文档的中心位置。一种可能性是在每个依赖文档的位置复制文档的部分。然而,这是令人尴尬的,并且随着系统的发展,很难使这样的文档保持最新。或者,文档可以位于需要它的位置之一,但是在这种情况下,开发人员不太可能看到文档或者知道在哪里查找它。
我最近一直在尝试一种方法,该方法将跨模块问题记录在一个名为 designNotes 的中央文件中。该文件分为清楚标记的部分,每个主要主题一个。例如,以下是该文件的摘录:
1 | ... |
然后,在与这些问题之一相关的任何代码段中,都有一条简短的注释引用了 designNotes 文件:
1 | // See "Zombies" in designNotes. |
使用这种方法,文档只有一个副本,因此开发人员在需要时可以相对容易地找到它。但是,这样做的缺点是,文档离它依赖的任何代码段都不近,因此随着系统的发展,可能难以保持最新。
13.8 结论
评论的目的是确保系统的结构和行为对读者来说是显而易见的,因此他们可以快速找到所需的信息,并有信心对其进行修改,以对系统进行修改。这些信息中的某些信息可以以对读者来说显而易见的方式表示在代码中,但是有大量信息无法从代码中轻易推导出。注释将填写此信息。
当遵循注释应描述代码中不明显的内容的规则时,“明显”是从第一次读取您的代码的人(不是您)的角度出发。在撰写评论时,请尝试使自己进入读者的心态,并问自己他或她需要知道哪些关键事项。如果您的代码正在接受审核,并且审核者告诉您某些不明显的内容,请不要与他们争论。如果读者认为它不明显,那么它就不明显。不用争论,而是尝试了解他们发现的令人困惑的地方,并查看是否可以通过更好的注释或更好的代码来澄清它们。
13.9 回答第 13.5 节中的问题
开发人员是否需要了解以下每条信息才能使用 IndexLookup 类?
- IndexLookup 类发送给包含索引和对象的服务器的消息格式。否:这是应隐藏在类中的实现细节。
- 用于确定特定对象是否在所需范围内的比较功能(使用整数,浮点数或字符串进行比较吗?)。是:该课程的用户需要了解此信息。
- 用于在服务器上存储索引的数据结构。否:此信息应封装在服务器上;甚至 IndexLookup 的实现都不需要知道这一点。
- IndexLookup 是否同时向多个服务器发出多个请求。可能:如果 IndexLookup 使用特殊技术来提高性能,则文档应提供有关此问题的一些高级信息,因为用户可能会在意性能。
- 处理服务器崩溃的机制。否:RAMCloud 可从服务器崩溃中自动恢复,因此崩溃对于应用程序级软件不可见;因此,在 IndexLookup 的接口文档中无需提及崩溃。如果崩溃反映到应用程序中,则接口文档将需要描述它们如何表现出来(而不是崩溃恢复如何工作的详细信息)。