小马的世界

读书笔记-软件设计的哲学【14】命名

2026-06-22 · 18 min read

为变量、方法以及其他实体选择名称,是软件设计中最被低估的方面之一。好的名字本身就是一种文档:它能让代码更容易理解,减少对额外文档的依赖,也更容易发现错误。相反,糟糕的命名会增加代码复杂度,制造歧义和误解,最终导致 Bug。

命名是“复杂度是逐步累积的”这一原则的典型体现。对于某个变量来说,选择一个平庸的名字而不是最好的名字,或许不会对整个系统的复杂度产生太大影响。然而,一个软件系统往往拥有成千上万个变量;如果所有这些地方都采用好的命名,那么对系统复杂度和可维护性的影响将是巨大的。

示例:糟糕的命名会导致 Bug

有时,仅仅一个命名不当的变量就可能造成严重后果。我曾修复过的最棘手的 Bug,就是因为一个糟糕的名字引起的。

20 世纪 80 年代末到 90 年代初,我和我的研究生们开发了一个名为 Sprite 的分布式操作系统。某个时期,我们发现文件偶尔会丢失数据:文件中的某个数据块会突然全部变成零,即使用户从未修改过该文件。

这个问题出现得并不频繁,因此极难定位。几位研究生曾尝试寻找原因,但始终没有取得进展,最终放弃了。不过,我把任何未解决的 Bug 都视为对个人能力的严重挑战,因此决定亲自追查。

我花了六个月时间,最终找到了并修复了这个 Bug。问题实际上非常简单(大多数 Bug 在被找出来之后都会显得简单)。文件系统中的代码使用了变量名 block 来表示两种不同的概念。

在某些情况下,block 表示磁盘上的物理块号(physical block number);而在另外一些情况下,block 表示文件内部的逻辑块号(logical block number)。不幸的是,在某段代码中,一个保存逻辑块号的 block 变量被误用到了需要物理块号的地方,结果导致磁盘上一个毫不相关的数据块被零值覆盖。

在排查这个 Bug 的过程中,包括我自己在内的许多人都曾阅读过这段有问题的代码,但始终没有发现问题。当我们看到变量 block 被当作物理块号使用时,会下意识地认为它确实保存的是物理块号。

经过漫长的插桩调试(instrumentation)过程后,我们最终确认数据损坏一定发生在某条特定语句中。在此之后,我才得以突破由变量名造成的心理暗示,去检查这个变量的值究竟来自哪里。

如果当时针对两种不同的块使用不同的变量名,例如 fileBlockdiskBlock,那么这个错误很可能根本不会发生;程序员会立刻意识到 fileBlock 不应该出现在那个位置。更好的做法是,为这两种块定义不同的类型,使它们根本无法被混用。

遗憾的是,大多数开发者并不会花太多时间思考命名。他们往往使用脑海中第一个浮现出来的名字,只要这个名字和所表示的事物“大致对应”即可。

例如,block 对于磁盘块和文件逻辑块来说都算是一个比较接近的名称;它当然不算特别糟糕。但即便如此,我们仍然为定位这个微妙的 Bug 付出了巨大的时间成本。

因此,不要满足于“差不多就行”的名字。多花一点时间去选择真正优秀的名称——准确、无歧义且直观。这样的额外投入很快就会得到回报,而且随着经验积累,你也会越来越快地想出好的名字。

创建心智图像(Create an image)

在选择名称时,目标是在读者脑海中建立一个关于被命名对象的清晰形象。

一个好的名字能够传递大量信息:不仅说明底层实体是什么,同样重要的是,也说明它不是什么。

当考虑某个名字时,可以问自己:

“如果有人单独看到这个名字,而没有看到它的声明、文档或任何使用它的代码,那么他能够多准确地猜出它所代表的东西?是否存在另一个名字,能够让他形成更清晰的理解?”

当然,单个名称能够承载的信息量是有限的。如果一个名字包含超过两三个单词,它往往会变得笨重难用。

因此,命名的挑战在于:用尽可能少的几个词,表达实体最重要的特征。

名称本身是一种抽象(abstraction):它提供了一种简化的方式,让人们理解背后更加复杂的实体。

与其他形式的抽象一样,最好的名称会聚焦于底层实体最重要的部分,同时省略那些不那么重要的细节。

名称应该精确(Names should be precise)

好的名称具有两个特性:精确性(precision)一致性(consistency)

先来看精确性。

命名中最常见的问题是名称过于宽泛或模糊。结果就是,读者很难判断这个名字到底指什么;甚至可能像前面 block 的例子那样,错误地理解它所表示的对象。

考虑下面的方法声明:

/**
 * 返回该对象管理的 indexlet 总数。
 */
int IndexletManager::getCount() {...}

count 这个词过于笼统。

到底是什么的数量(count)?

如果有人看到这个方法调用,除非阅读文档,否则很难知道它的作用。更精确的名称,例如 numActiveIndexlets,会更好。很多读者无需查看文档,就能大致猜出该方法返回什么内容。

下面是一些来自学生项目中的命名示例,它们同样不够精确:

  • 某个开发 GUI 文本编辑器的项目中,使用 xy 来表示文件中字符的位置。这些名称过于泛化。它们可能表示很多不同的东西。

⚑ 危险信号:模糊的名称(Red Flag: Vague Name)

如果一个变量名或方法名宽泛到能够指代很多不同的事物,那么它向开发者传递的信息就非常有限,而对应的实体也更容易被误用。

  • 某个开发 GUI 文本编辑器的项目中,使用 xy 来表示文件中字符的位置。这些名称过于泛化,因为它们可能表示很多不同的含义;例如,也可能表示字符在屏幕上的像素坐标。单独看到变量名 x 时,人们很难联想到它表示的是字符在文本行中的位置。如果改用 charIndexlineIndex 这样的名称,代码会更加清晰,因为这些名字反映了代码实现中的具体抽象概念。

  • 另一个编辑器项目中包含如下代码:

// Blink state: true when cursor visible.
private boolean blinkStatus = true;

blinkStatus 这个名字没有传达足够的信息。对于布尔值来说,status 过于模糊,它无法说明 truefalse 分别意味着什么。而 blink 这个词也同样含糊,因为它没有说明究竟是什么东西在闪烁。

下面这种写法会更好:

// Controls cursor blinking: true means the cursor is visible,
// false means the cursor is not displayed.
private boolean cursorVisible = true;

cursorVisible 这个名字传递了更多信息。例如,它让读者能够直接推断出 true 的含义(通常来说,布尔变量应该使用谓词式命名)。blink 一词已经不再出现在变量名中,因此如果读者想了解光标为什么不是一直显示,就需要查阅文档;而这部分信息的重要性相对较低。

  • 某个实现共识协议(consensus protocol)的项目中包含如下代码:
// 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 的变量出现在一个没有返回值的方法中。

这个名称存在多个问题。首先,它会让人误以为该变量最终会作为方法的返回值。其次,除了说明这是某种计算结果之外,它几乎没有提供任何关于实际内容的信息。

名称应当描述结果本身,例如 mergedLinetotalChars

当然,在确实有返回值的方法中,使用 result 是可以接受的。虽然这个名称依然略显宽泛,但读者可以通过方法文档了解其具体含义,而且知道该值最终会成为返回值也是有帮助的。

  • Linux 内核中存在两个用于描述网络套接字的结构体:struct socketstruct sock

struct sock 的第一个成员就是一个 struct socket;从某种意义上说,它相当于 struct socket 的子类。这两个名字过于相似,以至于很难记住谁是谁。

更好的做法是选择更容易区分、同时能够体现两者关系的名称,例如 struct sock_basestruct inet_sock

和所有规则一样,精确命名原则也存在少量例外。

例如,对于循环迭代变量,使用 ij 这样的通用名称是可以接受的,前提是循环只跨越少量代码行。如果能够一眼看到变量的全部使用范围,那么变量的含义通常已经足够明显,无需使用冗长的名字。例如:

for (i = 0; i < numLines; i++) {
    ...
}

从这段代码中可以清楚地看出,i 用来遍历某个对象中的每一行。

如果循环过长,以至于无法一次看完整个循环,或者迭代变量的含义无法轻易从代码中推断出来,那么就应该使用更具描述性的名称。

命名也有可能过于具体。例如下面这个用于删除一段文本的方法声明:

void delete(Range selection) {...}

参数名 selection 过于具体,因为它暗示被删除的文本一定是当前用户界面中选中的文本。

然而,该方法实际上可以用于删除任意文本范围,而不仅仅是已选中的文本。因此,参数名称应该更通用,例如 range

如果你发现自己很难为某个变量找到一个既精确、又直观、同时长度适中的名字,那么这通常是一个危险信号(red flag)。

⚑ 危险信号:难以命名(Red Flag: Hard to Pick Name)

如果很难为某个变量或方法找到一个简单的名字,并且这个名字能够在读者脑海中形成清晰的对象形象,那么这往往说明其背后的对象本身可能缺乏清晰的设计。

当出现这种情况时,可以考虑重新组织设计。例如,也许你正试图用一个变量来表示多个不同的概念;如果是这样,将其拆分成多个变量,分别表示不同内容,往往能够让每个变量拥有更加简单明确的定义。

选择好名字的过程,本身也能够帮助发现设计中的弱点,并推动设计改进。

一致地使用名称(Use names consistently)

好名字的第二个重要特性是一致性(consistency)。

在任何程序中,总会存在一些被反复使用的概念。例如,文件系统会不断处理块号(block numbers)。对于这些常见用途,应当选择一个统一的名称,并且在所有地方都使用同样的名称。

例如,文件系统可以规定始终使用 fileBlock 表示文件内部块的索引。

一致的命名能够降低认知负担,其作用类似于复用一个通用类:当读者已经在某个上下文中理解了一个名称,那么当它在另一个地方再次出现时,读者可以立即复用已有知识,并迅速做出合理推断。

一致性包含三个要求:

第一,针对某种用途始终使用同一个名称;

第二,不要把这个名称用于其他用途;

第三,要确保该用途足够具体,使得所有使用该名称的变量都具有相同的行为。

本章开头提到的文件系统 Bug 正是违反了第三条原则。文件系统把 block 同时用于两种具有不同含义的变量(文件块和磁盘块),结果导致人们对变量含义产生错误假设,并最终引发 Bug。

有时,你需要多个变量来表示同一类事物。

例如,一个复制文件数据的方法需要两个块号:一个表示源块,一个表示目标块。

遇到这种情况时,应当保留共同名称,同时增加区分前缀,例如:

srcFileBlock
dstFileBlock

循环变量也是一致命名能够发挥作用的地方。

如果使用 ij 作为循环变量,那么应当始终在最外层循环使用 i,在嵌套循环中使用 j

这样一来,当读者看到这些名称时,就能够立即(而且通常是正确地)推断出代码当前的执行结构和逻辑。

避免多余的词(Avoid extra words)

名称中的每一个词都应该提供有价值的信息;那些无助于理解变量含义的词只会增加噪音(例如,它们可能导致代码换行,从而占用更多行数)。

一个常见错误是在名称中加入通用名词,例如 fieldobject,形成类似 fileObject 这样的名称。

在这种情况下,Object 很可能没有提供任何有价值的信息(难道还存在不是对象的文件吗?),因此应该从名称中删除。

有些编码风格会在名称中包含类型信息。例如,对于一个指向文件对象的指针变量,可能会命名为 filePtr

这一做法的极端形式是匈牙利命名法(Hungarian Notation),它曾多年被微软用于 C 语言开发。

在匈牙利命名法中,每个变量名前面都有一个前缀,用来表示其完整类型。例如:

arru8NumberList

表示该变量是一个由无符号 8 位整数构成的数组。

虽然过去我也曾在变量名中加入类型信息,但现在已经不再推荐这种做法。现代 IDE 可以轻松从变量名跳转到其声明位置(甚至直接显示类型信息),因此没有必要把这些信息再写进变量名中。

另一个多余词汇的例子,是类的实例变量重复了类名本身。例如,在名为 File 的类中存在一个实例变量 fileBlock

由于上下文已经明确说明该变量属于 File 类,因此在变量名中再次包含 File 并不会提供任何额外信息。

直接将变量命名为 block 即可(除非该类中同时存在多种不同类型的 block)。

不同的观点:Go 风格指南(A different opinion: Go style guide)

并不是所有人都认同我关于命名的观点。

Go 语言的一些开发者认为,名称应该非常短,很多时候甚至只需要一个字符。

在一次关于 Go 命名的演讲中,Andrew Gerrand 提出:

“长名字会掩盖代码真正的行为。”

他给出了下面这段代码,并认为它比后面那个使用较长变量名的版本更容易阅读:

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
}

就我个人而言,我并不觉得第二个版本比第一个更难阅读。

如果一定要说区别的话,count 至少比 n 更能提示变量的用途。

阅读第一个版本时,我最终还是需要仔细阅读代码来弄清楚 n 的含义;而阅读第二个版本时则不需要这样做。

不过,如果在整个系统中始终使用 n 来表示“计数值”(并且只表示这一种含义),那么这个简短的名称对于其他开发者来说可能同样清晰。

Go 社区鼓励使用相同的短名称来表示多种不同事物:

  • ch 可以表示 character(字符)或 channel(通道)
  • d 可以表示 data(数据)、difference(差值)或 distance(距离)
  • 等等

在我看来,这类模糊名称很容易导致混淆和错误,就像前面提到的 block 示例一样。

总体而言,我认为代码的可读性应该由读者而不是作者来决定。

如果你使用短变量名,而阅读代码的人也觉得容易理解,那当然没有问题。

如果开始有人抱怨你的代码晦涩难懂,那么你就应该考虑使用更长、更具描述性的名称(搜索一下 “go language short names”,你会发现不少类似抱怨)。

同样地,如果我开始收到大量反馈说过长的变量名让代码更难阅读,那么我也会考虑采用更短的名称。

Gerrand 提出过一个观点,我非常认同:

“一个名称的声明位置与使用位置之间的距离越远,这个名称就应该越长。”

前面关于循环变量 ij 的讨论,就是这一原则的典型例子。

总结(Conclusion)

精心选择的名称能够让代码更加直观。

当读者第一次看到某个变量时,他们几乎不需要额外思考,就能够正确猜测它的行为和含义。

选择好名称,是第 3 章中提到的“投资思维(investment mindset)”的一个典型例子。

如果你愿意在前期多花一点时间来挑选好的名称,那么未来维护和修改代码时就会轻松许多。

此外,引入 Bug 的概率也会降低。

培养命名能力本身也是一种投资。

当你刚开始要求自己不再满足于平庸的名字时,你可能会发现想出好名字既耗时又令人沮丧。

然而,随着经验不断积累,这件事会越来越容易。

最终,你会达到这样一种状态:几乎不需要额外花费时间,就能够自然地想到优秀的名称。

到了那时,你将几乎免费地获得好命名带来的全部收益。