读前:这一章要回答什么
读者扫一眼代码时会做出一个猜测。怎样写,才能让这个第一眼的猜测恰好是对的?
第 2 章说复杂性有两个根源:依赖和模糊性(obscurity)。第 4~11 章主要对付依赖;第 12~17 章从注释、命名、一致性入手对付模糊性。这一章把这条线收尾:给"不模糊"一个可操作的定义,即代码一目了然(obvious),读得快,而且第一眼的猜测是对的。
这一章提出了全书最后一个红旗"代码不明显",和第 14 条设计原则"为易读而设计,而不是为易写而设计"。后者是一个很实用的裁判标准:几乎每一个"写起来方便"的捷径(返回 std::pair、加一个 bool 参数、在构造函数里顺手启动点什么)都是把成本从写的人转嫁给了以后所有读的人。
- 本章开头关于"明显是在读者心里"的那一段:当你觉得自己的代码很清楚、评审者却说看不懂时,作者站在哪一边?这个立场对你写代码有什么实际影响?
- 18.2 节列了几种让代码不明显的写法。注意作者对事件驱动编程的态度:书中没有说"别用",而是说了什么?
- 18.3 节给出了三种让读者拿到所需信息的方式,并且排了序。读完想一想:给一段难懂的代码加注释,属于第几种?为什么它不是最好的?
核心观点
"一目了然"的定义
读者不用费多少力气就能读懂,而且第一眼对行为和含义的猜测是对的。明显的代码需要的注释也更少。
读者说了算
明显与否只存在于读者心里。评审者说看不懂,那就是不明显,不管你自己觉得多清楚。
两大手段
好名字(第 14 章)和一致性(第 17 章)。再加上恰当的空白,以及在无法避免时用注释补上缺失的信息。
让代码变模糊的写法
事件驱动、通用容器(std::pair 之类)、声明类型和实际类型不一致、违反读者预期的行为。
为读而设计
写的人多花几分钟,换来所有后来读的人少花时间、少出错。
三种给信息的方式
最好是减少读者需要的信息;其次是利用读者已有的知识;最后才是把信息写进代码(名字、注释)。
红旗:代码不明显
快速读一遍弄不懂代码的含义和行为,通常说明有重要的信息读者拿不到。
心智模型:读者缺了什么信息
作者在 18.3 节换了一个角度:代码不明显,通常意味着读者缺少某条重要信息。于是"让代码明显"就变成"让读者拿到信息",而拿到信息有三种方式,代价和效果完全不同:
这个排序和全书一脉相承:第 ① 种就是第 4~10 章的深模块、信息隐藏和"把错误定义为不存在";第 ② 种是第 17 章的一致性;第 ③ 种是第 12~15 章的注释和命名。注释是兜底手段,不是首选。
试一试:你的第一眼猜对了吗?
下面的小工具只给你看调用处的一行代码,就像你在评审一个 diff 时看到的那样。先凭第一眼选一个猜测,再看真相和改法。
第一眼猜测
你猜错了不说明你粗心:调用处本来就没有给出足够的信息。每个例子的改法都是把信息搬到读者第一眼能看到的地方,或者让它变得不必要。
让代码更明显:名字、一致性、空白、注释
名字和一致性前两章已经讲过,这里不再展开。书中另外讲了空白的作用(用 Javadoc 参数说明和一段内存分配代码举例,见 18.1 节)。空白有三个层次:注释里用缩进区分条目、函数里用空行分块、语句里用空格分出结构。下面是一个日志恢复函数,逻辑完全相同:
int LogReader::recover(){
uint64_t off=0;Header h;
while(readHeader(off,&h)==0){
if(h.magic!=kMagic||h.len>kMaxRecord)break;
if(crc32(payload(off,h.len))!=h.crc)break;
index_.put(h.key,off);off+=sizeof(h)+h.len;
}
truncateAt(off);return 0;
}
能看懂,但要逐个字符去找结构:两个 break 的条件分别在说什么?为什么最后要截断?
// 从头扫描日志,重建内存索引。遇到第一条不完整或损坏的记录就停下,
// 把它和之后的内容截掉:它们来自崩溃时没写完的追加。
int LogReader::recover() {
uint64_t off = 0;
Header h;
while (readHeader(off, &h) == 0) {
// 头部不合法:到了上次崩溃时写了一半的位置。
if (h.magic != kMagic || h.len > kMaxRecord) break;
// 头部合法但内容校验失败:同样视为写了一半。
if (crc32(payload(off, h.len)) != h.crc) break;
index_.put(h.key, off);
off += sizeof(h) + h.len;
}
// off 之后的内容都不可信。
truncateAt(off);
return 0;
}
空行把循环体切成"检查头部、检查内容、登记"三块,每块第一行是一句说明为什么的注释;空行让这些注释更显眼。语句内部的空格让 || 两侧的条件一眼可分。
当不明显的写法无法避免时(例如为了性能),作者要求用注释补偿:站在读者的位置,想清楚对方会在哪里困惑、哪条信息能消除困惑,然后只写那条信息。
让代码不明显的四类写法(C++ 版)
1. 事件驱动:谁在什么时候调用我?
事件驱动的代码里,处理函数从不被直接调用,而是由事件模块通过函数指针、std::function 或虚函数间接调用。你在处理函数里找不到调用者,在事件模块里找到调用点也看不出会调到哪个函数,因为那取决于运行时注册了什么。
void Server::start() {
loop_.watch(listenFd_, Event::Readable, [this] { onAccept(); });
loop_.every(std::chrono::seconds(1), [this] { reapIdle(); });
}
// …… 四百行之后 ……
// 关闭空闲超过 idleTimeout_ 的连接。
// 由事件循环每秒调用一次,运行在 I/O 线程里,和 onAccept/onReadable
// 不会并发执行,所以这里可以不加锁地修改 conns_。
void Server::reapIdle() {
auto now = Clock::now();
std::erase_if(conns_, [&](auto& kv) { return now - kv.second.lastActive > idleTimeout_; });
}
第 9~10 行就是作者建议的补偿:在每个处理函数的接口注释里写明它在什么时候、被谁、在哪个线程调用。没有这两行,读者读到 reapIdle 时最想问的"这里不加锁安全吗?"就没有答案。作者并没有说事件驱动不好,它在网络服务里几乎无法替代;作者说的是用了就要补上文档。
OSTEP 第 33 章基于事件的并发讲了事件循环为什么能避免锁(单线程里处理函数不会并发),也讲了它的难处:阻塞调用会卡住整个循环、状态要手动在回调之间传递。那一章关心的是正确性和性能,这一章关心的是可读性:控制流被拆散到了注册点和处理函数两处,读者需要注释把它们重新连起来。
2. 通用容器:.first 是什么?
std::pair、std::tuple 写起来很方便,一个返回值就能带回好几样东西。问题是元素的名字是通用的:first、second、std::get<2>,读者从名字里得不到任何含义。作者的结论是:最好不要用通用容器,需要组合值就定义一个专门的类或结构体。
// 查找 key 在日志里的最新记录。
std::optional<std::tuple<uint64_t, uint32_t, bool>>
Index::locate(const Key& key) const;
// 调用者(另一个文件里):
auto r = index_.locate(key);
if (!r || std::get<2>(*r)) return -ENOENT;
return readAt(std::get<0>(*r), std::get<1>(*r), out);
std::get<2> 是什么,为什么它为真就返回"不存在"?第二个元素是长度还是结束偏移?每个调用者都要跳到声明处,而声明处也没写。
// 一个键在日志里最新一条记录的位置。
struct Location {
uint64_t offset; // 记录在日志文件中的起始偏移
uint32_t length; // 记录总长度,含头部
bool tombstone; // 这条记录是删除标记:键已被删除
};
// key 从未写入过时返回 std::nullopt。
std::optional<Location> Index::locate(const Key& key) const;
// 调用者:
auto loc = index_.locate(key);
if (!loc || loc->tombstone) return -ENOENT;
return readAt(loc->offset, loc->length, out);
调用处自己就说清楚了;声明处有地方写每个字段的含义和单位。代价是多一个类型、多几行声明,写的人多花两分钟。
software should be designed for ease of reading, not ease of writing.— 第 18.2 节
这就是第 14 条原则的来历:通用容器对写的人很方便,却让之后所有读的人困惑。同样的道理,本站补充一个 C/C++ 里比 pair 还常见的例子:bool 参数。
void Store::flush(bool sync, bool compact);
// 调用处:
store.flush(true, false);
只看调用处,没人知道 true 和 false 分别是什么;把两个参数的顺序写反,编译器也不会报错。
enum class Durability { Buffered, Fsync };
void Store::flush(Durability d);
void Store::compact(); // 原来的第二个 bool 本来就是另一件事
// 调用处:
store.flush(Durability::Fsync);
枚举让调用处可读,也让参数顺序错不了。顺带发现:compact 根本不该是 flush 的一个开关,它是一个独立的操作。
3. 声明的类型和实际的类型不一致
书中的例子是 Java 里把变量声明成接口类型 List、实际分配 ArrayList。C++ 里对应的情形是:成员声明成抽象基类的指针,实际总是某一个具体实现,而代码依赖了那个具体实现的特性。
class Dispatcher {
// ……
std::unique_ptr<TaskQueue> queue_;
};
Dispatcher::Dispatcher() : queue_(std::make_unique<MpscLockFreeQueue>()) {}
// 在多个工作线程里并发调用:
void Dispatcher::submit(Task t) {
queue_->push(std::move(t)); // 没加锁:依赖 MpscLockFreeQueue 是多生产者安全的
}
读者看到第 3 行的声明,会以为这里只依赖 TaskQueue 的接口;但 TaskQueue 的接口并不保证线程安全,第 10 行能工作,全靠构造函数里那个具体类型。作者的建议是让声明和分配一致:直接把成员声明成 std::unique_ptr<MpscLockFreeQueue>(或者干脆是一个值成员)。本站补充另一种同样有效的改法:把"多生产者安全"写进 TaskQueue 的接口约定,让所有实现都必须满足。两种改法的共同点是:读者在声明处就能看到代码真正依赖的东西。
4. 违反读者预期的代码
书中的例子是一个 main 函数返回后程序却不退出,因为某个构造函数悄悄起了线程。C++ 读者对构造函数也有一个普遍的预期:它初始化这个对象,不去改动进程级别的状态。下面的构造函数打破了这个预期:
int main(int argc, char** argv) {
Config cfg = Config::load(argv[1]);
Logger log(cfg.logPath());
ConfigWatcher watcher(argv[1], &cfg); // 构造函数里调用了 sigaction(SIGHUP, …)
// 半年后,另一位同事为了支持日志轮转加了这一行:
std::signal(SIGHUP, [](int) { Logger::reopenAll(); });
return Server(cfg).run();
}
第 7 行的作者看不到第 4 行在构造时装了信号处理器,于是悄悄覆盖了它,配置热加载从此失效,而且没有任何报错。作者对这类代码的建议是:在构造函数的接口注释里写明这种行为;如果它足够出人意料,在调用处(这里是 main)也加一行简短的注释。作者的原则是:代码遵守读者预期的约定时最明显;不遵守时,就必须把这种行为写清楚。本站补充:能做到的话,更好的办法是让副作用变成显式调用,例如 watcher.reloadOn(SIGHUP);,让 main 里所有信号处理都看得见,这就从"把信息写在读者眼前"升级成了"符合读者预期"。
边界与反方
作者明确说,事件驱动这类写法在某些场合很有用,你可能还是会用它;这时要用额外的文档减少读者的困惑。同理,为了性能写的位运算技巧、无锁算法,本身可能无法写得一目了然。红旗"代码不明显"的意思是"这里需要补信息",不是"这里必须重写"。不过要先问一句:真的无法避免吗?很多"必要的技巧"包进一个命名清楚的函数(如 isPowerOfTwo(n),或 C++20 的 std::has_single_bit)就不再是问题。
第 19 章作者会称赞接口继承:一个接口有多种实现,接口就更深。而本章又说声明类型要和实际类型一致。本站补充一个调和的办法:如果代码只依赖接口承诺的东西,声明成接口类型完全没问题,这正是抽象的意义;问题出在代码悄悄依赖了具体实现的性质(性能、线程安全、迭代顺序),而声明处没有体现。所以判断标准不是"用不用基类指针",而是"读者看到声明时,会不会对行为做出错误的推断"。
一段位运算对内核开发者是常识,对业务开发者可能是天书。作者的立场很明确:评审者说不明显,那就是不明显,你该做的是弄清楚是什么让对方困惑,而不是在评审回复里解释一遍(解释在回复里,下一个读者还是看不到)。本站补充:这也意味着"明显"要以这份代码实际的读者群为准;团队里新人多,门槛就要放低。
std::map::insert、try_emplace 返回 pair<iterator, bool>,你改不了它。本站补充:这类接口是 C++ 程序员普遍熟悉的约定(18.3 节说的"利用读者已有的信息"),用 C++17 的结构化绑定立刻给元素起名,auto [it, inserted] = m.try_emplace(k);,可读性的问题就基本解决了。所以本站认为,这条建议真正要管的是你自己设计的接口:别让它返回通用容器,把起名字的成本推给每一个调用者。
以下不在书里,来自书中给出的读者讨论组。18.2 节说事件驱动的写法让控制流难以跟踪。2018 年有读者把这条路走到了头:对象之间不直接调用,一律广播消息,谁认识这条消息谁就处理;并贴了一个小游戏的两种写法,认为消息版"没有依赖、没有接口",代码还更少。Ousterhout 的反驳(2018-08):
- 依赖没有消失:发消息的一方仍然得确切知道对方支持什么、怎么请求;对方改了,这边可能也得改。只是这层依赖在代码里看不出来了。
- 想知道某条消息由谁处理、具体做什么,只能去搜所有会响应消息的类;找到第一个还不能停,因为可能有好几处都在处理,而且都会被调用。
- 接口也没有消失,只是没有地方可以写文档了,多半也就不会有文档。
- 省下的只是一行声明;而那行声明的作用恰恰是把依赖写明白,值得多打这几个字。
Ousterhout 的结论是:复杂性主要不在于你得打多少字,而在于你得知道多少事。小例子里看不出差别,几百个类的系统里差别就大了。几年后回答一位做前端的读者时,对"发布—订阅"的评价是同一个方向(2023-07):很灵活,任何部分都能对别处的变化作出反应,但管不住复杂性,因为很难预料一次状态变更会引出什么;有适用的场合,建议有节制地用。
18.2 节有一条:变量声明成 List、实际分配的是 ArrayList,会误导读者,声明应当和分配一致。2019 年有读者反对,并指出 Java 里 List 是接口,根本不能直接实例化。Ousterhout 承认没注意到这一点,同时把立场改成了有条件的(2019-02):如果你确实不在乎它是哪一种列表,声明成 List 是合理的;如果具体类型要紧(比如读者需要知道哪些操作是高效的),声明就该写出具体类型。Ousterhout 也说这不是自己最在意的问题。本站核对过,2021 年的第 2 版里这一条仍是原来的写法。上面"面向接口编程"那一条说的是同一件事;现在可以说,作者本人的立场也是"看具体类型要不要紧"。
有读者问:一门语言有一堆初学者看不懂的高级特性,算不算复杂?Ousterhout 的回答是算,充分使用它的人认知负担更重;但如果换来的能力足够大,这份复杂可以是值得的。判据有两条(2019-01):往系统里放难懂的特性,它们最好提供很大的价值;能力相同的两个系统,好学的那个更好。举的例子是 C++ 的模板(Ousterhout 说自己至今没全弄懂其中的细节,但很看重它带来的能力),以及自己设计的 Tcl 语言里的一个特性:承认它增加了复杂性,当时是为了达到某个目标想不出别的办法。
这给本章"不明显的写法有时值得用"提供了一把尺子:不是能不能看懂,而是它换来的东西配不配得上读者要付的学习成本。
练一练
下面三道题来自训练场。本章的题大多是"识红旗":先只凭第一眼读代码,再看自己漏掉了什么。
再加一道,取材于读者讨论组(对应上面"边界与反方"里的"讨论组"一栏):
动手:项目任务
minikv 是一个故意按"战术式"风格写成的迷你键值存储。任务 m08 对应本章和第 20 章:在 minikv 里找出让人第一眼就猜错的代码,例如 bulk(s, true, true) 这种看不出含义的 bool 参数、用 std::pair<bool, std::string> 带回来的返回值、一个"只是查询"却会悄悄改统计数字的方法,把它们改得一目了然;性能相关的部分留到第 20 章再做。改之前先写下你第一眼的猜测,改完请一位没看过这段代码的人读一遍,看对方的第一眼猜测是否正确。
任务 m08:找出 minikv 中读者第一眼会猜错的代码,把信息挪到调用处或让它变得不必要。详见 任务页。
cd project/minikv && make test自测题
std::pair<int, int> 表示一个范围。最符合本章建议的改法是:.first 仍然要跳过去查。std::unique_ptr<Cache>,构造时总是创建 ShardedConcurrentCache,多个线程不加锁地调用它。下面哪种说法最准确?小结与读后一句话
- 代码一目了然 = 读得快,而且第一眼的猜测正确;明显与否由读者(评审者)判断。
- 让代码更明显:好名字、一致性、恰当的空白;无法避免不明显时,用注释补上缺失的信息。
- 让代码不明显:事件驱动、通用容器、声明类型与实际类型不一致、违反读者预期的行为。
- 为易读而设计,而不是为易写而设计:写的人多花几分钟,换来所有读者少花时间。
- 三种给读者信息的方式:减少所需信息(最好)、利用已有信息、在代码里呈现信息。
到这里,全书的 14 个红旗都已出现。下一章(软件开发趋势)换一个视角:拿前面学到的原则去评价面向对象、敏捷、测试驱动开发、设计模式等流行做法,看它们到底是减少还是增加了复杂性。
读后一句话:找一段你最近在评审里被问过"这是什么意思"的代码,写下当时读者缺了什么信息,以及三种方式里哪一种最适合补上它。