第 14 章 选取名称
为变量、方法和其他实体选择名称,是软件设计中最容易被低估的工作之一。好的名称本身就是一种文档:它们使代码更易于理解,减少了对其他文档的需求,也让错误更容易被发现。相反,名称选择不当会增加代码的复杂性,产生歧义和误解,进而可能导致代码缺陷。命名选择体现了复杂性逐步累积这一原则。为某个变量选择一个平庸而非最佳的名称,可能不会对系统的整体复杂性产生太大影响。但软件系统有成千上万个变量,为它们都选好名称,就会显著影响系统的复杂性和可管理性。
本章概览怎样为变量和方法选一个好名称?
14.1 示例:坏名称会导致代码缺陷
有时,即使只有一个变量命名不当,也会造成严重后果。我修复过的最棘手的代码缺陷,就是由命名不当造成的。在 1980 年代末和 1990 年代初,我和研究生们创建了一个名为 Sprite 的分布式操作系统。后来,我们注意到文件偶尔会丢失数据:即使用户没有修改文件,其中一个数据块也会突然全部变成零。这个问题很少发生,因此很难追查。一些研究生尝试查找缺陷,却一直没有进展,最终放弃了。但在我看来,任何未解决的缺陷都是对我个人难以容忍的冒犯,因此我决定继续追查。
花了六个月,我终于找到并修复了这个缺陷。问题实际上很简单,和大多数缺陷一样,找出原因后就不难理解了。文件系统代码将变量名 block 用于两种不同的用途:有时,block 表示磁盘上的物理块号;有时,block 表示文件中的逻辑块号。不幸的是,某处代码把一个保存逻辑块号的变量误用在了需要物理块号的地方,结果将磁盘上一个无关的块清零了。
追查这个缺陷时,包括我在内的几个人都读过有问题的代码,却始终没有发现问题。看到变量 block 被用作物理块号,我们就本能地以为它保存的确实是物理块号。经过长时间添加诊断代码进行追踪,确定数据损坏必然发生在某条语句后,我才摆脱名称带来的思维误导,检查这个值究竟来自哪里。如果对不同类型的块使用不同的变量名(例如 fileBlock 和 diskBlock),这个缺陷很可能就不会发生,因为程序员会知道哪些地方不能使用 fileBlock。更好的做法是为这两种块号定义不同的类型,使它们不能被混用。
不幸的是,大多数开发人员不会花太多时间思考名称,往往采用最先想到的那个,只要含义与所指事物大致相符就行。例如,“块”既可以指磁盘上的物理块,也可以指文件中的逻辑块,看起来并不是一个很糟糕的名称。即便如此,它还是让我们花了大量时间追查一个微妙的缺陷。因此,不要满足于“大致相符”的名称,要多花一点时间,选出准确、明确而直观的名称。这点投入很快就能得到回报,随着经验积累,你也会学会更快地选好名称。
译者图解同一个数字,两个地址域
14.2 创造画面
选择名称时,目标是让读者对所指事物的本质形成清晰的印象。好的名称能传达许多信息,说明这个实体是什么,以及同样重要的,它不是什么。考虑一个名称时,请问自己:“如果读者只看到这个名称,没有看到声明、文档或任何使用它的代码,能多准确地猜出它指的是什么?换一个名称,能否让这个印象更清晰?”当然,名称能承载的信息有限,单词超过两三个就会显得累赘。因此,难点在于只用几个词,就表达出实体最重要的特征。
名称是一种抽象形式:名称提供了一种简化的方式来思考更复杂的底层实体。与其他抽象形式一样,最好的名称会突出底层实体最重要的东西,而忽略那些次要的细节。
14.3 名称应该是精确的
好的名称具有两个属性:精确性和一致性。先看精确性。名称最常见的问题是过于笼统或含糊,让读者难以判断它指的是什么,甚至产生与实际情况不符的理解,如前面的 block 缺陷所示。考虑以下方法声明:
/**
* Returns the total number of indexlets this object is managing.
*/
int IndexletManager::getCount() {...}
术语 count 太笼统了:对什么计数?如果有人看到此方法的调用,除非他们阅读了它的文档,否则他们不太可能知道它的作用。像 numActiveIndexlets 这样的更精确的名称会更好:很多读者可能无需查看其文档就能猜测该方法返回的内容。
危险信号:模糊的名称
如果变量或方法的名称过于宽泛,可以指代许多不同的事物,就无法向开发人员传达足够的信息,所指的实体也很可能被误用。
以下是其他一些来自学生项目的名称不够精确的示例:
一个图形界面文本编辑器项目使用
x和y表示字符在文件中的位置。这两个名称太笼统,可以有许多含义,例如字符在屏幕上的坐标(以像素为单位)。只看到名称x的读者,不太可能想到它表示字符在一行文本中的位置。如果使用charIndex和lineIndex这样的名称,体现代码所实现的具体抽象,代码就会清楚得多。另一个编辑器项目包含以下代码:
// Blink state: true when cursor visible. private boolean blinkStatus = true;blinkStatus这个名称没有传达足够的信息。对于布尔值,status太含糊,无法说明真和假各代表什么。blink同样含糊,因为它没有说明究竟是什么在闪烁。下面是更好的选择:// Controls cursor blinking: true means the cursor is visible, // false means the cursor is not displayed. private boolean cursorVisible = true;名称
cursorVisible传达了更多信息,例如,读者可以据此猜出真值的含义(通常,布尔变量应采用谓词式命名)。名称中不再包含blink,所以读者要了解为什么光标有时不可见,就必须查阅文档;这是相对次要的信息。一个实现共识协议的项目包含以下代码:
// Value representing that the server has not voted (yet) for // anyone for the current election term. private static final String VOTED_FOR_SENTINEL_VALUE = "null";此值的名称表示它是特殊的,但没有说明特殊含义是什么。使用更具体的名称(例如
NOT_YET_VOTED)会更好。一个没有返回值的方法使用了名为
result的变量。这个名称有多个问题。首先,它让人误以为变量将作为方法的返回值。其次,除了暗示它保存某种计算结果,名称没有说明变量里究竟是什么。名称应体现result实际代表的内容,例如mergedLine或totalChars。如果方法确实有返回值,使用result就是合理的。虽然名称仍然有些笼统,但读者可以查阅方法文档了解其含义,而且这个名称能让读者知道哪个值最终会被返回。Linux 内核包含两个描述网络套接字结构的结构:
struct socket和struct sock。struct sock包含一个struct socket作为其第一个元素,它实际上是struct socket的子类。这些名称如此相似,以至于很难记住哪个是哪个。选择易于区分的并阐明了这两个类型之间的关系的名称会更好,例如struct sock_base和struct inet_sock。
像所有规则一样,有关选择精确名称的规则也有一些例外。例如,如果循环仅包含几行代码,也可以将通用名称(如 i 和 j)用作循环迭代变量。如果你可以直接看到一个变量的完整使用范围,那么该变量的含义可能在代码中就很明显了,因此你不需要长名称。例如以下代码:
for (i = 0; i < numLines; i++) {
...
}
这段代码清楚地表明,i 用来遍历某个实体中的每一行。如果循环太长,无法一次看全,或者很难从代码中判断迭代变量的含义,就应该使用更具描述性的名称。
名称也可能过于具体,例如下面这个删除一段文本的方法声明:
void delete(Range selection) {...}
selection 这个参数名过于具体,暗示要删除的是用户界面中当前选中的文本。但这个方法可以删除任意范围的文本,无论是否选中。因此,参数名应更通用,例如 range。
如果很难为某个变量想出精确、直观又不太长的名称,这就是一个危险信号:变量的定义或用途可能不够清楚。此时应考虑其他拆分方式。例如,也许你正试图用一个变量表示多个事物;若拆成多个变量,每个变量的定义可能会更简单。为变量命名的过程能帮助发现设计中的薄弱之处,从而改进设计。
危险信号:难以选取名称
如果很难用一个简单的名称清楚描述某个变量或方法,就说明其设计可能不够整洁。
译者图解名称的范围要与概念的范围吻合
14.4 命名要确保一致性
好名称的第二个重要属性是一致性。程序中某些变量的用途会反复出现,例如文件系统经常要操作块号。对于每种常见用途,应选定一个名称,并在各处一致使用。例如,文件系统可以始终用 fileBlock 保存文件中的块索引。命名一致与复用同一个类一样,都能减轻认知负荷:读者在一处了解名称的含义后,在其他上下文中遇到它时,就能利用已有知识立即作出判断。
一致性有三个要求:首先,对给定用途始终使用统一约定的名称;其次,不把这个名称用于其他用途;第三,该用途必须限定在足够小的范围内,让所有采用该名称的变量都具有相同的行为。本章开头的文件系统缺陷违反了第三项要求:文件系统用 block 表示两种行为不同的变量(文件块和磁盘块),让人误判变量的含义,进而导致代码缺陷。
有时需要多个变量表示同一类事物。例如,复制文件数据的方法需要两个块号,一个表示来源,另一个表示目标。这时,各变量仍应使用统一约定的名称,但要添加前缀加以区分,例如 srcFileBlock 和 dstFileBlock。
一致命名在循环中也很有帮助。如果用 i 和 j 这样的名称表示循环变量,就始终将 i 用于最外层循环,将 j 用于嵌套循环。这样,读者一看到名称,就能立即对代码的行为作出可靠的判断。
14.5 避免多余的单词
名称中的每个单词都应提供有用的信息。无助于说明变量含义的词只会增加混乱,例如让更多代码行需要换行。一个常见的错误是添加笼统的名词,如 field 或 object,fileObject 就是一个例子。其中的 Object 可能没有提供有用信息(还有不是对象的文件吗?),因此应当省略。
一些编码风格会在名称中包含类型信息,例如 filePtr 表示一个指向文件对象的指针变量。一个极端的例子是微软 C 编程中使用的匈牙利命名法:每个变量名都有表示完整类型的前缀。例如,arru8NumberList 表示由无符号 8 位整数组成的数组。虽然我过去也在变量名中包含类型信息,但现在不再推荐这样做。现代 IDE 可以方便地从变量名跳转到声明(IDE 甚至可以自动显示类型),因此不必在名称中重复这些信息。
另一个例子是实例变量名重复了类名,例如 File 类中有一个名为 fileBlock 的实例变量。上下文已经表明该变量属于 File 类,变量名中再包含类名并没有提供额外信息,直接命名为 block 即可(除非该类包含多种不同类型的块)。
14.6 不同的观点:Go 样式指南
并非所有人都同意我对命名的看法。参与开发 Go 语言的一些人员认为,名称应该非常简短,通常只用一个字符。在一场关于 Go 语言命名的演示中,Andrew Gerrand 指出:“长名称模糊了代码的作用。” [1] 他展示了下面这段使用单字母变量名的代码:
func RuneCount(b []byte) int {
i, n := 0, 0
for i < len(b) {
if b[i] < RuneSelf {
i++
} else {
_, size := DecodeRune(b[i:])
i += size
}
n++
}
return n
}
并认为它比以下使用更长名称的版本更具可读性:
func RuneCount(buffer []byte) int {
index, count := 0, 0
for index < len(buffer) {
if buffer[index] < RuneSelf {
index++
} else {
_, size := DecodeRune(buffer[index:])
index += size
}
count++
}
return count
}
就个人而言,我不觉得第二个版本更难读。例如,与 n 相比,count 更能提示变量的含义。阅读第一个版本时,我得通读代码才能弄清 n 代表什么,第二个版本就不需要。不过,如果整个系统始终用 n 表示计数,不用于任何其他含义,那么其他开发人员很可能也能清楚理解这个短名称。
Go 文化鼓励在多个不同的事物上使用相同的短名称:ch 用于字符或通道,d 用于数据、差异或距离,等等。对我来说,像这样的模棱两可的名称很可能导致混乱和错误,就像在文件块的示例中一样。
总的来说,可读性应该由读者而不是作者来判断。如果你使用简短的变量名,而读者很容易理解,那就没有问题。如果读者开始抱怨代码含糊不清,你就应该考虑更长的名称(在网络上搜索 go language short names 会发现一些这样的抱怨)。同样,如果读者抱怨长变量名让我的代码难以阅读,我也会考虑使用更短的名称。
Gerrand 的一条评论我很赞同:“名称声明与其使用之间的距离越大,名称就应该越长。”前面关于循环变量 i 和 j 的讨论,就是这条规则的例子。
14.7 结论
精心选取的名称能让代码更容易理解:读者第一次遇到变量时,不必多想,就能正确猜到它的行为。选好名称体现了第 3 章讨论的投资思维:现在多花一点时间,将来处理代码就会更容易,引入代码缺陷的可能性也会更小。培养命名能力本身也是一项投资。刚决定不再满足于平庸名称时,你可能会觉得想出好名称既耗时又令人沮丧。但随着经验积累,命名会越来越容易。最终,你几乎不需要多花时间就能选出好名称,获得这些好处也就几乎不必付出额外成本。
[1] https://talks.golang.org/2014/names.slide#1 ↩