读前:这一章要回答什么
一个名字要怎样起,读者才能第一眼就猜对它的含义?名字怎么也起不好的时候,又说明了什么?
第 12、13 章讲注释,这一章讲名字。两者是一回事的两面:名字也是文档,而且是最短、出现次数最多的文档。每个变量名都会在声明处之外被读到很多次,读者往往只看名字就做出判断,不会每次都回去查注释。
作者把起名字当作"复杂性是一点点累积起来的"的典型例子:一个平庸的名字无伤大雅,但一个系统有成千上万个名字,它们加起来决定了代码好不好读。这一章还藏着一个更深的观点:起名字的难度是设计质量的读数。这和下一章"注释难写说明设计有问题"是一对。到第 18 章讨论"代码一目了然"时,好名字和一致性会被列为最重要的手段中的两种。
- 14.1 节是作者亲历的一个 bug。留意两件事:为什么好几个人读过出错的那行代码都没看出问题?作者最后给出的修法分了两级,第二级是什么?
- 14.3 节除了两个红旗,还有两处"例外"和一个反方向的错误(名字太具体)。读完试着各举一个你自己代码里的例子。
- 14.6 节别读成"作者反对短名字"。弄清楚作者不同意什么、同意什么,以及最后用什么标准来裁决。
核心观点
名字是一种抽象
和其他抽象一样,好名字突出被命名对象最重要的方面,省略次要的。名字通常只容得下两三个词,所以要挑。
孤立测试
假设读者只看到这个名字,看不到声明、文档和用法,读者能多准确地猜出它指什么?好名字也告诉读者它不是什么。
精确:别太泛
最常见的问题是名字太泛:count、data、status、x。读者可能猜成别的意思,进而用错。
也别太具体
名字的具体程度要和抽象一致:一个对任何文本范围都适用的方法,参数名不该暗示"只能是当前选中的文本"。
布尔值用谓词
布尔变量的名字应该让人一看就知道 true 表示什么。"status""mode"之类的词说不清哪边是 true。
一致性的三条要求
同一用途永远用同一个名字;这个名字不用于别的用途;用途窄到所有同名变量的行为都一样。
去掉多余的词
每个词都要提供信息。Object、类型前缀、重复类名这类词只会让名字变长。
名字难起是设计信号
起不出简单又清晰的名字,往往说明被命名的东西本身定义不清,比如一个变量身兼数职。
心智模型:名字在读者脑中圈出一个范围
读者看到一个名字时,脑中会浮现出它可能指的几种东西。名字越泛,这个候选集合越大,读者"按最常见的理解"猜错的机会也越大。好名字的作用是把候选集合缩到只剩一个。
下面的小实验把书中 14.2 节的"孤立测试"变成练习:你只能看到一个变量的声明,先猜它的含义,再看它在代码里实际怎么用。注意观察:一个含糊的名字,是怎样让写下一行代码的人"自然而然"地写出 bug 的。
名字实验室:只看声明,你能猜对吗?
这些案例都很小,放在一屏里你也许能猜对。真实代码里,声明和使用之间往往隔着几百行、甚至在不同文件里,写下使用那一行的人只能依赖名字。
一个名字两种含义:从名字到类型
书中 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 / newSegment | seg1 / 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 章"通用模块更深"是同一个道理:抽象是通用的,名字也该是通用的;抽象是专用的,名字就该说出它专用在哪里。
遇到"名字难起",最常见的反应是继续琢磨词汇,最后用一个又长又拗口的名字凑合。作者的建议是换个方向:怀疑被命名的东西。它是不是同时代表了两件事?是不是一个函数做了几件不相干的事?拆开以后,名字往往自己就出来了。当然,也有确实是词汇量问题的时候(领域术语不熟),这时去问懂业务的人,比自己硬造一个词更好。
以下不在书里,来自书中给出的读者讨论组。
- 用类型区分的代价。第 2 版 14.1 节末尾已经有一句提到可以给两种块号定义不同的类型。2022 年有读者建议用两个只包一个整数的类型来做,让编译器拦住混用。Ousterhout 表示大体赞成,并说下次修订时会把它写成这个问题的另一种、也许更好的解法(2022-08)。同时说了代价:读者看到这样的类型,可能得点进定义才知道它只是个整数;做运算时还得取出里面的值。如果全项目有统一的包装写法,这点代价很小。
- 用类型区分,但别泛滥。针对 14.1 节那个因为同一个名字指两种东西而出的 bug,有读者说给两种块号各定义一个类型,编译器就能拦住。Ousterhout 同意那样 bug 会好找得多,同时有保留:担心造出一大堆本质上都只是整数的类型,而且很难事先判断哪些整数值得单独成为类型(2023-08)。
- 名字会引出它没回答的问题。有读者主张用一组小函数为领域"造词汇",例子是把"把某个属性设为空"包成一个叫"添加空值"的函数。Ousterhout 认为这个名字并不比原来的写法更清楚,反而让人疑惑:如果这个属性已经存在,"添加"是什么意思,会多出一个,还是报错?至于"造词汇"本身,每个接口都在引入词汇,词汇多不等于好;量它的尺子和深浅模块是同一把:好学、又能干净地表达大量任务的词汇才是好词汇(2026-01)。
- 尽量短,但不能再短。有读者嫌长名字让代码行折行、难读。Ousterhout 的回答是:不损失清晰度时就用短的,一两个词的名字几乎总是够用;但不值得为了避免折行而用一个会引出 bug 的含糊名字(2023-04)。
以下不在书里,来自 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自测题
if (claimed) 读起来就是"如果已被领走"。status、flag、mode 只说明它和什么有关,说不清 true 是哪一边。pConn、strName),作者的态度是?i、j 的例外正是它的一个例子。作者也不认为长名字更难读,并且认为用同一个短名字指几种东西容易引发和 14.1 节类似的错误。小结与读后一句话
- 名字是一种抽象,也是最常被读到的文档。用"孤立测试"检验它:只看名字能不能猜对。
- 精确:避免泛泛的词,布尔值用谓词;但也别比抽象本身更具体。
- 一致:同一用途同一个名字,这个名字不做他用,用途窄到所有同名变量行为一致。
- 删掉不提供信息的词;名字长度随声明和使用的距离增长;可读性由读者裁决。
- 名字难起,往往是设计不干净的信号;容易混淆的数值,用不同的类型比用不同的名字更可靠。
第 15 章把同样的思路用到注释上:如果一个方法或变量的注释怎么也写不短,那也是设计在报警。两章合起来,就是在写实现之前,用名字和注释来检验设计。
读后一句话:用你自己的话写下这一章最重要的一个观点,最好附上一个你最近改过(或想改)的名字。