《《软件设计的哲学》》阅读笔记-注释篇一

213 阅读16分钟

一、阅读背景

在公司内网看到本书推荐,假期找时间看了下。里面主要讲了如何复杂度相关的内容,其中我比较感兴趣的是注释这块的阐述。在降低复杂度方法中,注释是一个很好的工具,因此将这部分做了下笔记,分两大部分,怎么写注释和将注释作为设计的一部分,第二部分后面在写。

二、为什么不写注释

之前看代码整洁之道或者其他文章,有反对注释的,跟本文的论述有点矛盾。本书列出了四种不写注释的理由:

  • 好代码本身就是自文档的
  • 我没时间写注释
  • 注释容易过时,反而会产生误导
  • 我见过的注释都没用,写它干嘛

1、好代码本身就是自文档的

在编写代码时可以通过某些做法来减少对注释的依赖,比如选择好的变量名(详见第14章)。然而,仍有大量的设计信息无法通过代码表达

如果你写代码时默认用户会去读方法实现,就会倾向于把每个方法写得尽可能短,以便更容易阅读;若方法有一定复杂性,你可能还得将其拆分成多个更小的方法。这样一来,就会出现大量“浅方法”,对代码可读性其实没什么实质改善:读者仍需理解所有嵌套方法的行为,才能真正理解顶层方法的行为。

如果使用者必须读完整个方法的代码才能知道怎么使用它,那么抽象就不存在了(抽象的目的是隐藏复杂性:它提供的是一个简化视图,保留关键信息,忽略具体实现的细节。)。方法的复杂性全部暴露无遗。没有注释时,方法唯一的抽象就是它的声明,而这只能说明方法的名字及其参数和返回值的类型,缺失了太多关键信息,无法构成有用的抽象。

2、我没时间写注释

如果你希望编写一个结构清晰的软件,以便未来高效地进行开发和维护,那么你就必须在一开始多投入一些时间,为这个结构打好基础。优质注释能极大提升软件的可维护性,花在上面的时间很快就能回本。

此外,写注释其实并不怎么花时间。问问自己,在开发过程中你真正花在“敲代码”上的时间有多少?(不包括注释,只考虑编程、设计、调试等)大多数人不会超过10%。那么,即使你写注释的时间和写代码的时间一样多,也只不过将总开发时间增加了10%左右。而高质量文档所带来的效益,远远超过这点成本

尤其值得一提的是,与抽象相关的注释(如类和方法的顶层文档)往往是最关键的。第15章将说明,这类注释应当成为设计流程的一部分,而写这些注释的过程本身就是对设计的深度思考,能有效优化系统结构。这类注释的回报是即时的。

3、注释会过时,容易误导

注释确实有可能过时,但这在实践中并不需要成为大问题。保持文档的更新其实不需要耗费大量精力。只有当代码发生重大变更时,才需要对文档进行大幅更新,而且代码的修改本身就比注释的修改花更多时间。

4、我见过的注释都没用

四个借口中,这个可能是最有道理的。每位开发者都见过那些毫无帮助的注释,大多数现存文档也只是中等水平。好在这个问题是可以解决的:一旦你掌握了方法,写出高质量的文档并不难

三、注释的好处

注释的核心理念是:记录下设计者脑中的信息,这些信息是无法在代码中表达的。这些信息的范围很广,从底层细节(比如某段复杂代码是因某个硬件问题而写成这样)到高层概念(比如某个类设计背后的原理)。当其他开发者后来来维护或修改代码时,这些注释能帮助他们更快速、更准确地理解系统。没有文档时,后来的开发者只能重新推理或猜测原设计者的意图,这不仅浪费时间,还有可能因理解错误而引入缺陷。

即使是原设计者本人,若过了几周未接触该部分代码,也会忘记当初的一些设计细节,此时注释仍然非常有价值。

良好的文档可以缓解认知负担和未知的未知(这两个也是增加复杂度的重要原因)

它能帮助开发者迅速获取所需信息,并屏蔽不相关的信息,从而降低认知负担。如果没有足够的文档,开发者可能不得不通读大量代码,才能重建原有设计思路。文档还可以澄清系统结构,减少“未知的未知”,使开发者清楚哪些代码和信息与当前的改动相关

四、如何写注释

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

编写注释的原因在于,编程语言中的语句无法捕捉开发者在编写代码时脑海中所有重要的信息。注释记录了这些信息,以便后来的开发人员能够更容易地理解和修改代码。编写注释的指导原则是:注释应描述代码中不明显的内容

有许多信息是从代码中看不出来的。有时是底层的细节不明显。 注释最重要的用途之一是抽象。抽象通常包含许多代码中看不出来的信息。抽象的目的是提供一种更简单的思考方式,但代码过于细节化,很难通过阅读代码看出抽象。注释可以提供一个更简洁、更高层次的视角(例如:“调用此方法后,网络流量将限制为每秒 maxBandwidth 字节”)。即使这些信息可以通过阅读代码推断出来,我们也不希望模块的使用者被迫这样做:阅读代码既耗时,又要求处理许多无关的信息。**开发者应该能够在不阅读除了外部可见声明以外任何代码的情况下理解一个模块提供的抽象。**实现这一点的唯一方式是通过注释补充声明信息。

2、选择注释规范

编写注释的第一步是确定注释的规范,包括你将注释哪些内容,以及你将使用的注释格式。如果你使用的语言有文档生成工具,比如 Java 的 Javadoc、C++ 的 Doxygen 或 Go 的 godoc,请遵循这些工具的规范。这些规范并不完美,但工具带来的好处足以弥补缺陷。如果你所用的开发环境没有现成的规范可以遵循,试着借用其他语言或类似项目的注释规范;这样可以帮助其他开发者更容易地理解并遵循你的注释风格。

规范的作用有两个。第一,它能保证一致性,从而使注释更易读、更易理解。第二,它能促使你确实去写注释。如果你不清楚要注释什么以及怎么注释,就很容易最终什么注释也没写。

大多数注释可分为以下几类:

  • 接口注释: 位于模块声明之前的一段注释,例如类、数据结构、函数或方法。该注释描述该模块的接口。对于类,它描述该类提供的整体抽象;对于方法或函数,它描述其总体行为、参数和返回值(如有)、可能的副作用或抛出的异常,以及调用者在调用该方法之前必须满足的前提条件。
  • 数据结构成员注释:位于数据结构字段(如类的实例变量或静态变量)声明旁边的注释。
  • 实现注释:出现在方法或函数内部的注释,描述代码的内部工作方式。
  • 跨模块注释:描述模块之间依赖关系的注释。

最重要的是前两类注释。每个类都应该有一个接口注释,每个类的成员变量都应有注释,每个方法也应有接口注释。某些声明可能显而易见(如 getter 和 setter),因此不需要注释,但这比较罕见;与其费劲琢磨是否需要注释,不如直接全部注释。实现注释通常不太必要(见第 13.6 节)。跨模块注释最少见,也最难写,但在必要时非常重要。

3、不要重复代码

很多注释并不特别有用。。最常见的原因是这些注释重复了代码:注释中的所有信息都可以轻松从旁边的代码中推断出来。

写完注释后问问自己:一个从未见过这段代码的人是否能仅凭这段代码写出同样的注释? 如果答案是“是”,那说明这个注释没有让代码更容易理解。这也是为什么有些人认为注释毫无价值的原因。

另一个常见的错误是,注释中使用了与函数或变量名中一样的词汇:

如果注释中的信息已经从旁边的代码中显而易见,那么这个注释就是无效的。尤其是当注释和被注释对象使用了相同的词汇时。

写出好注释的第一步是:在注释中使用与变量或函数名不同的词汇。 选择能提供额外语义信息的词,而不是重复其名称。

4、更低层级的注释增加精确性

现在你已经了解了不该做什么,我们来讨论应该在注释中写些什么内容。注释通过提供与代码不同层级的信息来增强代码的表达力。有些注释提供的信息比代码更详细,属于低层级注释,它们通过澄清代码的确切含义来增加精确性;而另一些注释则比代码更抽象,属于高层级注释,它们提供的是直觉,例如代码背后的设计思路,或一种更简单、更抽象的理解方式。

低层级注释在为变量声明(如类的实例变量、方法参数、返回值)添加注释时最为有用。变量声明中的名称和类型通常不够精确,而注释可以补充以下缺失信息:

  • 这个变量的单位是什么?
  • 边界条件是包含还是不包含?
  • 如果允许为 null,null 代表什么含义?
  • 如果该变量引用一个必须释放或关闭的资源,谁负责释放或关闭它?
  • 是否存在对该变量始终成立的属性(不变式),例如“这个列表总是包含至少一个元素”?

虽然这些信息也许可以通过查阅所有使用该变量的代码推断出来,但这非常耗时而且容易出错。变量声明的注释应该足够清晰和完整,让读者无需通读所有代码就能理解变量的用法。当我说“注释应描述代码中不明显的内容”时,这里的“代码”指的是与注释相邻的声明部分代码,不是整个程序的所有代码。

**给变量写注释时最常见的问题是:注释过于模糊。 **

为变量添加注释时,要关注名词而不是动词。换句话说,重点在于变量表示什么,而不是它是如何被操作的。

5、更高层级的注释增强理解

注释增强代码的第二种方式是提供直觉性的理解。这些注释的层级高于代码本身,它们省略了细节,帮助读者理解代码的整体意图与结构。这类注释常用于方法内部和接口定义中。

写高层级注释比写低层级注释更难,因为你必须换一种思维方式。问问自己:

  • 这段代码想做什么?
  • 什么是可以概括所有代码行为的最简单说法?
  • 这段代码最重要的点是什么?

工程师通常非常关注细节,这对做工程来说确实很重要。但优秀的软件设计者还能从细节中抽离出来,用更高层次的视角看待系统——也就是确定哪些方面最重要,忽略底层细节,从最本质的特性出发去理解系统。这正是抽象的核心所在(用一种简单的方式看待复杂系统),也是写高层级注释所必须具备的能力。

6、接口文档注释

6.1 编写抽象文档的第一步是将接口注释与实现注释分开

接口注释提供使用某个类或方法所需的信息;它们定义了抽象。而实现注释描述的是类或方法为了实现抽象,在内部是如何工作的。将这两种注释分开非常重要,以免接口使用者接触到不必要的实现细节。此外,这两种注释形式本身也必须是不同的。如果接口注释也不得不描述实现细节,那说明这个类或方法的抽象层次太浅。这也就意味着,编写注释的过程本身可以揭示设计质量的线索。

方法的接口注释则包括抽象层面的高阶信息和为了准确性所需的低阶细节:

  • 注释通常从一到两句话开始,描述方法在调用者看来具有怎样的行为;这是抽象层次的描述。
  • 注释必须描述每个参数和返回值(如果有的话)。这些注释必须非常精确,包括参数的约束条件以及参数之间的依赖关系。
  • 如果方法有副作用,也必须在接口注释中说明。副作用是指那些影响系统未来行为,但不是方法返回值一部分的影响。例如,如果方法向一个内部数据结构中添加了值,而这个值可能在后续调用中被访问到,这就是副作用;写入文件系统同样也是副作用。
  • 方法的接口注释还必须说明它可能抛出的任何异常。
  • 如果调用方法之前必须满足某些前置条件,这些条件也必须明确说明(例如可能必须先调用其他方法,或者在调用二分查找方法前列表必须是排序好的)。虽然最好尽量减少前置条件,但如果存在就必须加以记录。

当接口注释(如方法注释)描述了不必要的实现细节时,就属于“实现文档污染接口”的典型案例。

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

实现注释是出现在方法内部的注释,目的是帮助读者理解方法的内部工作原理。大多数方法都很简短且简单,因此不需要任何实现注释:只看代码和接口注释,就能轻松理解方法的工作方式。

实现注释的主要目标是帮助读者理解代码在做什么(而不是怎么做)。一旦读者知道代码试图完成什么任务,通常就能轻松理解它的工作方式。对于简短的方法,代码只做一件事,而这件事已经在接口注释中说明了,因此无需再加实现注释。对于较长的方法,代码会分成几个逻辑块,每个块完成方法整体任务的一部分。在每个主要代码块前加上一条注释,提供该块功能的高级(更抽象)描述。

除了说明代码在做什么,实现注释也应说明为什么这么做。如果代码中有不易察觉的棘手细节,应该加以注释。

7、跨模块的设计决策

在理想的世界中,每一个重要的设计决策都会封装在一个类中。但在现实系统中,设计决策往往会影响多个类。例如,一个网络协议的设计会同时影响发送端和接收端,这两个部分可能分布在不同的地方。跨模块的设计决策通常复杂且微妙,并且容易引发大量bug,因此对它们进行良好文档化至关重要。

跨模块文档的最大挑战是找到一个自然、易于开发者发现的位置来放置这些信息。有时候,有一个明显的中心位置可供使用。例如,RAMCloud 存储系统定义了一个 Status 值,每个请求返回此值以表示成功或失败。若要为新的错误情况添加一个新的 Status,需要修改多个文件(一个文件将 Status 映射到异常,另一个提供人类可读的信息等)。幸运的是,开发者在添加新 Status 值时一定会去到一个地方,即 Status 枚举的声明处。我们利用这一点,在该枚举中添加了注释,说明还需要修改哪些其他部分

一种方法是在所有相关位置重复文档的部分内容,但这样很不方便,也难以保持更新一致。另一种方法是在某个相关的位置集中放置文档,但这样开发者可能不知道它存在,也不知道去哪里找。

作者是将所有跨模块问题集中记录在一个名为 designNotes 的文件中。该文件按主题划分为不同的部分。