读前:这一章要回答什么

一个名字要怎样起,读者才能第一眼就猜对它的含义?名字怎么也起不好的时候,又说明了什么?

第 12、13 章讲注释,这一章讲名字。两者是一回事的两面:名字也是文档,而且是最短、出现次数最多的文档。每个变量名都会在声明处之外被读到很多次,读者往往只看名字就做出判断,不会每次都回去查注释。

作者把起名字当作"复杂性是一点点累积起来的"的典型例子:一个平庸的名字无伤大雅,但一个系统有成千上万个名字,它们加起来决定了代码好不好读。这一章还藏着一个更深的观点:起名字的难度是设计质量的读数。这和下一章"注释难写说明设计有问题"是一对。到第 18 章讨论"代码一目了然"时,好名字和一致性会被列为最重要的手段中的两种。

  1. 14.1 节是作者亲历的一个 bug。留意两件事:为什么好几个人读过出错的那行代码都没看出问题?作者最后给出的修法分了两级,第二级是什么?
  2. 14.3 节除了两个红旗,还有两处"例外"和一个反方向的错误(名字太具体)。读完试着各举一个你自己代码里的例子。
  3. 14.6 节别读成"作者反对短名字"。弄清楚作者不同意什么、同意什么,以及最后用什么标准来裁决。

核心观点

名字是一种抽象

和其他抽象一样,好名字突出被命名对象最重要的方面,省略次要的。名字通常只容得下两三个词,所以要挑。

孤立测试

假设读者只看到这个名字,看不到声明、文档和用法,读者能多准确地猜出它指什么?好名字也告诉读者它不是什么。

精确:别太泛

最常见的问题是名字太泛:count、data、status、x。读者可能猜成别的意思,进而用错。

也别太具体

名字的具体程度要和抽象一致:一个对任何文本范围都适用的方法,参数名不该暗示"只能是当前选中的文本"。

布尔值用谓词

布尔变量的名字应该让人一看就知道 true 表示什么。"status""mode"之类的词说不清哪边是 true。

一致性的三条要求

同一用途永远用同一个名字;这个名字不用于别的用途;用途窄到所有同名变量的行为都一样。

去掉多余的词

每个词都要提供信息。Object、类型前缀、重复类名这类词只会让名字变长。

名字难起是设计信号

起不出简单又清晰的名字,往往说明被命名的东西本身定义不清,比如一个变量身兼数职。

心智模型:名字在读者脑中圈出一个范围

读者看到一个名字时,脑中会浮现出它可能指的几种东西。名字越泛,这个候选集合越大,读者"按最常见的理解"猜错的机会也越大。好名字的作用是把候选集合缩到只剩一个。

size 字节数? 元素个数? 已分配的容量? 屏幕上的像素尺寸? 候选多:读者按自己最熟悉的那种去理解 payloadBytes 元素个数 消息体的字节数 已分配的容量 候选只剩一个,也排除了它"不是什么"
名字的好坏不在长短,而在它让读者脑中的候选集合有多小。本站自绘示意。

下面的小实验把书中 14.2 节的"孤立测试"变成练习:你只能看到一个变量的声明,先猜它的含义,再看它在代码里实际怎么用。注意观察:一个含糊的名字,是怎样让写下一行代码的人"自然而然"地写出 bug 的。

交互

名字实验室:只看声明,你能猜对吗?

选一个案例,先凭第一印象选出你的猜测,再看实际用法和更好的名字。最后一个案例是对照组。
0 / 0
已揭晓的案例
0
第一印象猜对

这些案例都很小,放在一屏里你也许能猜对。真实代码里,声明和使用之间往往隔着几百行、甚至在不同文件里,写下使用那一行的人只能依赖名字。

一个名字两种含义:从名字到类型

书中 14.1 节讲的是作者职业生涯里最难查的一个 bug:一个分布式操作系统的文件系统里,同一个变量名有时指文件内的块号,有时指磁盘上的块号,一处用混了,就把磁盘上一块不相干的数据清零了。前后花了六个月才找到。下面是同一类问题在一个环形消息日志里的样子:

// 消息按递增的序号写入,存放在 slots_[序号 % capacity_]。
class RingLog {
public:
    const Msg& get(uint64_t index) const { return slots_[index % capacity_]; }
    uint64_t head() const;   // 下一条消息的序号
};
// 另一个文件里,缓冲区快照的遍历代码:
for (uint64_t index = 0; index < snapshot.size(); ++index)
    process(log.get(index));   // 这里的 index 是数组下标,不是序号!

index 在 RingLog 里表示消息序号,在调用方表示数组下标。两处单独看都很自然,所以评审时谁也不会多看一眼第 9 行。程序不会崩溃,只会安静地处理错误的消息。

const Msg& get(uint64_t seq) const { return slots_[seq % capacity_]; }

for (size_t i = 0; i < snapshot.size(); ++i)
    process(log.get(i));       // get(seq) 却传了 i:一眼就觉得不对

只要全项目一致地用 seq 表示序号,第 4 行读起来就会别扭。这是书中给出的第一级修法:给不同的东西起不同的名字。

struct Seq { uint64_t value; };      // 消息序号,只增不减
const Msg& get(Seq seq) const { return slots_[seq.value % capacity_]; }
Seq head() const;

for (size_t i = 0; i < snapshot.size(); ++i)
    process(log.get(i));       // 编译错误:size_t 不能隐式转成 Seq

作者说更好的做法是给两种东西定义不同的类型,让它们根本没法互换。在 C++ 里这只需要一个单成员结构体。代价是每处构造、取值都要多写一点,所以本站建议只对容易混淆、而且混了不会报错的东西这样做:各种 id、偏移量、带单位的数值。

作者那个 bug 里混淆的两种块号,正是 OSTEP 第 40 章文件系统实现里 inode 负责翻译的两种地址:文件内第几块,以及它在磁盘上的哪一块。文件系统代码里这两种数字到处都是、类型都是整数,这正是名字和类型最该帮忙的地方。

一致性:给项目定一张命名词汇表

精确解决的是"一个名字说没说清楚",一致性解决的是"读者能不能把在一处学到的东西带到另一处"。书中把它比作复用一个类:读者在一个地方理解了 seq,到了别的文件看到 seq 就能立刻做出正确的假设。三条要求缺一不可,第三条最容易被忽视:用途要定得够窄,窄到所有叫这个名字的变量行为都一样。上面那个 bug 正是第三条被违反了。

在实际项目里,最省事的做法是把高频概念的名字写下来。下面是一个日志存储项目可能有的词汇表:

概念统一叫法这个名字不用来表示
记录的序号seq字节位置、数组下标
记录在文件中的位置byteOffset记录序号、段内第几条
时长std::chrono 类型;不得已用整数时带单位后缀:timeoutMs没有单位的 int timeout
同时出现两个同类对象通用名字加区分前缀:oldSegment / newSegmentseg1 / seg2
循环变量最外层 i,内层 j外层用 j 内层用 i

多余的词

书中 14.5 节列出了三种多余的词,每种在 C++ 代码里都很常见:

std::vector<User> userListVector;   // → users        :泛名词和类型信息
Connection* pConnObj;               // → conn         :匈牙利式前缀 + "Obj"
class Segment { size_t segmentSize_; };   // → size_  :重复类名

作者说自己以前也在名字里放过类型信息,现在不再推荐:IDE 点一下就能看到类型。重复类名有一个例外:如果类里有好几个同类的东西(比如 Segment 既有数据大小又有索引大小),区分它们的词就不是多余的。

边界与反方

Go 社区一些开发者主张名字要很短,常常只用一个字母。作者在 14.6 节不同意这种普遍偏好,更反对用同一个短名字表示几种不同的东西(这正是 14.1 节那个 bug 的成因)。但作者的态度并不绝对:如果一个短名字在全系统里一贯只表示一种东西,别人多半能看懂;书中还赞同 Go 社区那条"声明和使用离得越远,名字越长"的规则,i、j 的例外就是它的特例。最终裁决交给读者:

readability must be determined by readers, not writers.— 第 14.6 节

本站补充:这对团队很实用。名字是否清楚,不要靠作者自我感觉,而要看评审者和新同事的反应。有人问"这个 d 是什么",就是该改名的信号。

名字只容得下两三个词,必然要省略次要信息。书中光标闪烁那个例子里,改名之后"为什么光标不总是显示"这一信息就不在名字里了,作者的态度是:这一点不那么重要,需要时去看注释。反过来,第 12.6 节批评了用超长方法名代替注释的做法:名字长得像一句话,仍然比一段好注释说得少,而且每次调用都要把这段"文档"重敲一遍。名字负责给出清晰的第一印象,注释负责补上精确的细节。两者的分歧在本站的专题:APOSD 与 Clean Code 里有完整对照。

名字也可能太具体:一个通用的工具函数,参数名却带着第一个调用方的业务词汇,会让其他人以为它只适用于那种场景。名字的具体程度要和它所命名的抽象一致。这和第 6 章"通用模块更深"是同一个道理:抽象是通用的,名字也该是通用的;抽象是专用的,名字就该说出它专用在哪里。

遇到"名字难起",最常见的反应是继续琢磨词汇,最后用一个又长又拗口的名字凑合。作者的建议是换个方向:怀疑被命名的东西。它是不是同时代表了两件事?是不是一个函数做了几件不相干的事?拆开以后,名字往往自己就出来了。当然,也有确实是词汇量问题的时候(领域术语不熟),这时去问懂业务的人,比自己硬造一个词更好。

以下不在书里,来自书中给出的读者讨论组。

以下不在书里,来自 Ousterhout 在 X(原 Twitter)上的回帖。2024 年一位开发者讲了自己的经历:拿一段难懂的代码,结构和操作一点不动,只把几个变量改了名,别人就说它"简单多了";这位开发者由此认为,人们说的"复杂"其实只是"不清楚"。Ousterhout 从同一件事里读出相反的结论(2024-01):这说明名字的选择对代码的复杂度有显著影响。按第 2 章的定义,复杂度就是让代码难懂、难改的东西,名字当然算在里面。整场争论见第 2 章"是复杂,还是只是不熟悉"。

练一练

下面三道题来自训练场。本章题库里有 4 道"先写后对照"的改名题,建议都做一遍:起名字这件事,只有动手写才练得出来。

再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):

动手:项目任务

任务 m07 覆盖第 12~15 章。和本章相关的部分:minikv 的存储层里有几个典型的含糊名字,比如 total_、dead_、count()、handle()。先对每个名字做一次"孤立测试":只看名字,你能猜出它是什么的总量、什么东西死了、数的是什么、处理的又是什么吗?读懂实际用法后再改名,测试保持全绿。改名时如果发现某个名字怎么也起不好,记下来:那很可能是一处值得重构的设计。

任务 m07:给 store.h / store.cpp / index.h 里含糊的名字改名、重写复述代码和写满实现步骤的注释,再先写接口注释、后实现新命令 RENAME src dst。详见 任务页。

cd project/minikv && make test

自测题

书中建议用来检验一个名字的问题是什么?
这是 14.2 节的"孤立测试":好名字要在读者脑中建立一个清晰的印象,既告诉读者它是什么,也告诉读者它不是什么。大小写规范、长短都是次要的;类型信息则是作者明确不再推荐放进名字里的。
下列哪一项不是书中提出的命名一致性的要求?
A、C、D 是 14.4 节的三条要求。名字长度应该随声明和使用的距离而变化,没有"统一长度"一说。
一个变量为 true 时表示"这个任务已经被别的线程领走了"。下列哪个名字最好?
布尔变量的名字应该是谓词:if (claimed) 读起来就是"如果已被领走"。status、flag、mode 只说明它和什么有关,说不清 true 是哪一边。
你想给一个变量起名,试了好几个都觉得不准确。按本章的观点,最应该先做什么?
"名字难起"是红旗:它暗示被命名的东西本身定义不清。书中给出的建议是考虑换一种拆分,例如把一个表示多种东西的变量拆成多个,每个的定义都会更简单,名字也就好起了。
关于在名字里加入类型信息(如 pConn、strName),作者的态度是?
作者在 14.5 节说得很坦白:自己过去也在名字里放过类型信息,但现在不推荐了。现代 IDE 一点就能跳到声明,甚至会自动显示类型,名字里不需要再重复。
作者在 14.6 节同意 Go 社区的哪一个观点?
作者只同意距离规则,并指出 i、j 的例外正是它的一个例子。作者也不认为长名字更难读,并且认为用同一个短名字指几种东西容易引发和 14.1 节类似的错误。

小结与读后一句话

第 15 章把同样的思路用到注释上:如果一个方法或变量的注释怎么也写不短,那也是设计在报警。两章合起来,就是在写实现之前,用名字和注释来检验设计。

读后一句话:用你自己的话写下这一章最重要的一个观点,最好附上一个你最近改过(或想改)的名字。