读前:这一章要回答什么

读者扫一眼代码时会做出一个猜测。怎样写,才能让这个第一眼的猜测恰好是对的?

第 2 章说复杂性有两个根源:依赖和模糊性(obscurity)。第 4~11 章主要对付依赖;第 12~17 章从注释、命名、一致性入手对付模糊性。这一章把这条线收尾:给"不模糊"一个可操作的定义,即代码一目了然(obvious),读得快,而且第一眼的猜测是对的。

这一章提出了全书最后一个红旗"代码不明显",和第 14 条设计原则"为易读而设计,而不是为易写而设计"。后者是一个很实用的裁判标准:几乎每一个"写起来方便"的捷径(返回 std::pair、加一个 bool 参数、在构造函数里顺手启动点什么)都是把成本从写的人转嫁给了以后所有读的人。

  1. 本章开头关于"明显是在读者心里"的那一段:当你觉得自己的代码很清楚、评审者却说看不懂时,作者站在哪一边?这个立场对你写代码有什么实际影响?
  2. 18.2 节列了几种让代码不明显的写法。注意作者对事件驱动编程的态度:书中没有说"别用",而是说了什么?
  3. 18.3 节给出了三种让读者拿到所需信息的方式,并且排了序。读完想一想:给一段难懂的代码加注释,属于第几种?为什么它不是最好的?

核心观点

"一目了然"的定义

读者不用费多少力气就能读懂,而且第一眼对行为和含义的猜测是对的。明显的代码需要的注释也更少。

读者说了算

明显与否只存在于读者心里。评审者说看不懂,那就是不明显,不管你自己觉得多清楚。

两大手段

好名字(第 14 章)和一致性(第 17 章)。再加上恰当的空白,以及在无法避免时用注释补上缺失的信息。

让代码变模糊的写法

事件驱动、通用容器(std::pair 之类)、声明类型和实际类型不一致、违反读者预期的行为。

为读而设计

写的人多花几分钟,换来所有后来读的人少花时间、少出错。

三种给信息的方式

最好是减少读者需要的信息;其次是利用读者已有的知识;最后才是把信息写进代码(名字、注释)。

红旗:代码不明显

快速读一遍弄不懂代码的含义和行为,通常说明有重要的信息读者拿不到。

心智模型:读者缺了什么信息

作者在 18.3 节换了一个角度:代码不明显,通常意味着读者缺少某条重要信息。于是"让代码明显"就变成"让读者拿到信息",而拿到信息有三种方式,代价和效果完全不同:

读者需要的信息从哪里来?(由好到次) ① 让读者根本不需要它 抽象、消除特殊情况:读者不用知道的东西,就不会弄错 peek() 永不返回空 → 调用者不用判空 最好 ② 用读者已经有的 遵守约定、符合预期:读者在别处学过的东西,在这里依然成立 begin()/end() 是半开区间;构造函数不做 I/O ③ 把它写在读者眼前 精确的名字、有针对性的注释:信息仍然要读者去读、去记 flush(Durability::Fsync);// 由事件循环在 I/O 线程调用 兜底
三种方式不是互斥的,但顺序很重要:先问"能不能让这条信息变得不必要",再问"能不能用读者已知的约定",最后才写名字和注释。本站自绘示意。

这个排序和全书一脉相承:第 ① 种就是第 4~10 章的深模块、信息隐藏和"把错误定义为不存在";第 ② 种是第 17 章的一致性;第 ③ 种是第 12~15 章的注释和命名。注释是兜底手段,不是首选。

试一试:你的第一眼猜对了吗?

下面的小工具只给你看调用处的一行代码,就像你在评审一个 diff 时看到的那样。先凭第一眼选一个猜测,再看真相和改法。

交互

第一眼猜测

每个例子只显示调用处。选出你第一眼认为它在做什么,然后看声明处的真相,以及怎样改写才能让调用处自己说清楚。
0 / 4
已作答
0
第一眼猜对

你猜错了不说明你粗心:调用处本来就没有给出足够的信息。每个例子的改法都是把信息搬到读者第一眼能看到的地方,或者让它变得不必要。

让代码更明显:名字、一致性、空白、注释

名字和一致性前两章已经讲过,这里不再展开。书中另外讲了空白的作用(用 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

自测题

按本章的定义,"代码一目了然"指的是什么?
"一目了然"有两层:读得快,而且第一眼的猜测正确。短代码可能很难懂;注释多也不等于明显,作者说明显的代码需要的注释反而更少。
你觉得自己的代码很清楚,评审者却说"这段我看不懂"。按作者的观点:
作者说"明显"存在于读者心里,看别人的代码比看自己的更容易发现不明显之处,所以评审是判断明显与否的最好方法。在评审回复里解释,下一个读者仍然看不到。
18.3 节给出的三种让读者拿到信息的方式中,作者认为最好的是哪一种?
读者不需要知道的信息,就不会被误解,也不用去维护。其次才是利用读者已有的知识,最后是把信息呈现在代码里。
关于事件驱动编程,作者的态度是:
作者承认事件驱动在某些场合有用,所以给的建议是补偿:在处理函数的接口注释里说明它在什么情况下被调用。
函数返回 std::pair<int, int> 表示一个范围。最符合本章建议的改法是:
作者的建议是不用通用容器,定义专门的类或结构体:元素有了有意义的名字,声明处还能写文档(如区间是否半开)。A 只把信息放在声明处,调用处读到 .first 仍然要跳过去查。
"软件应该为易读而设计,而不是为易写而设计",下列哪种做法违背了这条原则?
返回 tuple 对写的人最省事,却把理解成本留给了所有调用者。其他三项都是写的人多花一点功夫,让读的人更轻松。
一个成员声明为 std::unique_ptr<Cache>,构造时总是创建 ShardedConcurrentCache,多个线程不加锁地调用它。下面哪种说法最准确?
问题不在用了基类指针,而在代码依赖的性质(并发安全)没有体现在读者看到的声明里。让声明与实际一致,或者把这条性质提升为接口约定,读者就不会做出错误的推断。

小结与读后一句话

到这里,全书的 14 个红旗都已出现。下一章(软件开发趋势)换一个视角:拿前面学到的原则去评价面向对象、敏捷、测试驱动开发、设计模式等流行做法,看它们到底是减少还是增加了复杂性。

读后一句话:找一段你最近在评审里被问过"这是什么意思"的代码,写下当时读者缺了什么信息,以及三种方式里哪一种最适合补上它。