读前:这一章要回答什么

怎样才能得到一个好的设计,而不只是第一个想到的设计?

第 4~10 章给了很多判断设计好坏的工具:深度、信息隐藏、通用性、分层、把复杂性往下拉、把错误定义掉。但这些工具都是用来评价一个方案的。如果你手里只有一个方案,评价的结果最多是"还行"或者"有问题",并不能告诉你更好的方案长什么样。

这一章很短,只讲一个过程上的习惯:对每个重要的设计决定,先勾勒几个差别很大的方案,比较之后再选择或组合。它是全书第 12 条原则,也是本站项目任务 m02 的设计步骤。所以这一页不打算多讲道理,而是带你真的做一次。

  1. 本章不分小节。开头的文本编辑器例子里有三种接口:按行、按字符、按任意区间。注意作者说的不是哪个"更优雅",而是每种方案会让上层代码多做什么事。
  2. 中段"列出优缺点"那部分:作者给比较标准排了优先级。读的时候留意接口的标准和实现的标准有什么不同。
  3. 结尾关于"聪明人"的那几段:问问自己,上一次你认真考虑第二个方案是什么时候?

核心观点

第一个想法很少是最好的

设计软件很难,第一个想法未必就是好设计。对每个重要的决定,至少考虑两个方案。

方案之间要差别很大

只改名字或参数的两个方案没什么可比的。差别越大,学到的越多。即使确信只有一种合理做法,也勾勒一个第二方案。

草图就够了

每个方案只需写出最重要的几个方法,不必把所有细节定下来。

接口先看"上层好不好用"

其次才是简单性、通用性、能否高效实现。到了实现阶段,标准换成简单和性能。

选择、组合或重新设计

最好的结果可能是几个方案的组合;如果都不满意,就用已经发现的问题驱动新的方案。

代价很小,回报很大

一个类花一两个小时比较方案,对比几天到几周的实现时间不算什么,长期还能提升设计能力。

心智模型:先发散,再收敛

第一个想法 方案 A 方案 B 方案 C哪怕你觉得它很糟 刻意发散 按标准比较 ① 上层好不好用 ② 接口简单吗 ③ 够通用吗 ④ 能高效实现吗 选一个 组合优点 缺点驱动新方案 接口定了,实现再来一遍
设计两次是一次刻意的发散和收敛。同一个过程可以用在接口、实现、系统分解等各个层次。本站自绘示意。

书中的例子是文本编辑器里管理文本的类。按行的接口让上层在剪切、粘贴跨行内容时自己拆行、并行;按字符的接口让上层写循环,而且每个字符调用一次,很慢。作者指出,两个方案都逼着上层代码做额外的文本处理,这本身就是一个信号:既然有一个文本类,文本处理就该由它负责。顺着这个缺点想下去,就得到了按区间操作的接口。这正是"用已发现的问题驱动新方案"。

怎样逼出"差别很大"的方案

"再想一个方案"说起来容易,真坐下来,第二个方案常常只是第一个换了个名字。书里没有给出具体的方法,下面是本站整理的几个维度。把第一个方案放在每个维度上定位,然后故意走到另一端:

维度一端另一端
操作的粒度单个元素(一个字符、一条记录)任意范围、整批
信息流向调用者来"拉"(查询、轮询)模块去"推"(回调、事件)
状态可变对象,原地修改不可变快照,整体替换
同步方式阻塞直到完成立即返回,结果稍后取
谁负责调用者自己组装、自己处理特殊情况模块包办,调用者一行搞定
做不做做这个功能不做,用别的机制替代(重启、离线批处理、人工流程)

常见误区:① 把第二个方案写成一个明显很差的"稻草人",只为了衬托第一个。作者说要认真分析它的弱点,稻草人教不了你什么。② 按"哪个我更快写完"来比较。接口的首要标准是上层用起来是否方便,实现的难易排在后面。③ 比较完只做"选择",忘了"组合":很多时候最好的设计是从两三个方案里各借一点。

练习:自己设计两次

下面是一个真实服务里常见的需求。请按步骤写下你的想法,写的内容只保存在你的浏览器里。先写完再展开后面的参考比较,否则这个练习就失去了意义。

需求:配置热更新。一个 C++ 写的 HTTP 服务,有几十个组件会读配置:限流阈值、超时时间、功能开关、下游地址、线程池大小、监听端口……运维希望修改配置文件后,不重启进程就能生效。请设计"配置模块"对其他组件提供的接口。

提示:想想会有哪些调用者。有的在每个请求里读一个值;有的在启动时根据配置建好一个长期存在的资源(线程池、连接池);有的一次要读好几个相互关联的值(例如连接池的 min 和 max)。

1写下你的第一个想法

不用犹豫,写出你脑子里第一个冒出来的接口:两三个最重要的方法声明,加一句话说明调用者怎么用。

2强迫自己写出一个差别很大的方案

不是给第一个方案换个名字。试着换一个根本的思路:信息是调用者"拉"还是配置模块"推"?值是可变的还是不可变的?甚至,真的需要热更新吗?

3再来一个(哪怕你觉得它很糟)

作者说,即使你认为只有一种合理做法,也值得想一想别的方案,分析它的弱点本身就很有启发。

4按标准比较

先回答最重要的问题:对上面三类调用者(每个请求读一个值 / 用配置建长期资源 / 一次读几个相关的值),哪个方案用起来最省事、最不容易出错?然后再看简单性、通用性、效率。

5选择、组合,或者重新设计

写下最终的接口草图,并说明它从哪个方案借了什么、避开了哪个方案的什么问题。

写完了?展开本站的参考比较

下面是本站想到的四个方案。它们的差别足够大:拉还是推、可变还是不可变,最后一个干脆问"要不要热更新"。

int Config::getInt(std::string_view key, int def) const;   // 内部加锁读 map
// 文件变化时,后台线程逐项更新 map

int timeout = cfg.getInt("upstream.timeout_ms", 500);

每个请求读一个值:最省事。读相关的值:先读 pool.min,再读 pool.max,中间如果刚好发生了更新,就会拿到一半旧、一半新的组合。建长期资源:线程池在启动时读了一次大小,之后永远不知道它变了。每次读取还要加锁。

Subscription Config::onChange(std::string_view key,
                              std::function<void(const Value&)> fn);

auto sub = cfg.onChange("pool.max", [&](const Value& v) {
    pool_.resize(v.asInt());
});

建长期资源:正合适。每个请求读一个值:很别扭,每个组件都得写回调、自己存一份副本。回调在更新线程上执行,组件要自己处理并发;min 和 max 两个回调先后触发,中间状态可能不合法。

struct Settings { int timeoutMs; PoolLimits pool; bool newCheckout; /* … */ };

// 返回当前配置的快照;快照永远不会被修改。
std::shared_ptr<const Settings> Config::current() const;
// 更新时:解析并校验整份新文件,校验通过才原子地替换指针;
// 校验失败就保留旧配置并报警,不存在"更新了一半"的状态。

auto s = cfg.current();          // 一个请求开始时取一次
use(s->pool.min, s->pool.max);   // 同一快照里的值必然一致

每个请求:取一次快照,之后随便读,没有锁,值也一致。读相关值:天然一致。建长期资源:仍然不知道配置变了,这是它唯一的短板。注意它顺便把"配置只更新了一半"这个错误定义掉了(第 10 章)。

// 配置只在启动时读一次。修改配置 = 滚动重启各个实例。
const Settings& settings();

最简单,所有调用者都不用考虑"值会变"。代价在系统层面:重启会断开连接、丢掉本地缓存、启动慢的服务要等很久。对监听端口这类本来就无法在运行中修改的配置项,它反而是唯一合理的答案。

本站的组合方案:以 C 为主,因为绝大多数调用者属于"每个请求读几个值";给少数建长期资源的组件加一个很小的订阅接口,但推送的是整份新旧快照,而不是单个键,这样就避开了 B 的中间状态问题;监听端口这类字段明确标注"修改需要重启",也就是对它们采用 D。

class Config {
public:
    std::shared_ptr<const Settings> current() const;
    // 每次成功替换配置后调用 fn(旧快照, 新快照),组件自己比较关心的字段。
    // fn 在配置更新线程上执行。
    Subscription subscribe(std::function<void(const Settings& before, const Settings& after)> fn);
};

回头看第一个想法:如果你写的是 A(大多数人都会这么写),只比较一次就能发现它在"相关值一致性"上有一个很难排查的问题。如果你写下的第一个方案就是 C,也不要紧,B 和 D 的比较让你知道了 C 缺什么,从而补上订阅和"需要重启"的标注。这就是作者说的:比较能让你更好地找到最好的设计。

边界与反方

作者说的是"每个重要的设计决定",并且强调对一个类来说,比较方案可能只需要一两个小时,相比几天到几周的实现时间很划算;模块越大,前期探索的时间越多,回报也越大。本站补充:反过来,一个私有辅助函数、一个随时能改的内部细节,不值得郑重其事地写三个方案。值得投入的,是那些接口会被很多人依赖、以后很难改的决定:公共 API、存储格式、网络协议、模块边界。

敏捷开发和 YAGNI(你不会需要它)常被理解为"先实现第一个能跑的方案,以后再重构"。本站补充:设计两次说的是动手实现之前花一点时间比较方案,比较的结果完全可以是"选最简单的那个",上面的方案 D 就是例子。真正冲突的是"不比较就动手"。作者对敏捷开发的看法在第 19 章有更完整的讨论。

作者观察到,很多聪明人从小靠第一个想法就能拿好成绩,于是不习惯考虑第二种可能;可大型软件设计难到没有人能一次做对。坚持只实现第一个想法,会让他们发挥不出真正的水平,也让人很难和他们合作。本站补充:现在让 AI 生成三个备选方案只要几秒钟,发散变得很便宜,比较却依然要你自己来:哪个方案让上层代码多做了事、哪个把错误推给了调用者,这些判断正是前面几章训练的东西。参见专题:AI 时代读这本书。

以下不在书里,来自书中给出的读者讨论组。有读者问怎样才能一步步得到好的模块划分。Ousterhout 的回答比本章多了"之后"的部分(2023-05):自己做了这么多年,几乎从来没有一次做对。建议是:动手前尽量想清楚,但别陷进分析瘫痪,当不再冒出会影响设计的新想法时,就该开始实现;实现过程中一定会发现问题,要准备好重新设计,一个接口通常要经过两三轮(一轮大改,加一两轮小改)才到位;最初的设计阶段照本章说的,拿出两个差别很大的方案来比。Ousterhout 强调其中迭代是关键。

这和"设计两次"并不冲突:设计两次减少的是第一轮就选错方向的概率,它不保证第一轮的细节是对的。

这个话题书里没有。2020 年 Ousterhout 在讨论组里先说自己刚形成一个看法:自顶向下更容易得到好设计,因为从整个应用出发,知道最终需要什么,可以直接设计出提供大块功能的深模块;自底向上最省事的走法是小步前进,容易得到浅类。举的例子是课上的一次作业:学生要做一个 RPC 系统,很多组先写了一套打开套接字、监听连接之类的底层工具函数,这些工具都很浅;一问,几乎所有人都是自底向上做的(2020-03)。

经过两天讨论,Ousterhout 把结论改成了对称的:两种方式和它们的组合都可以,自底向上通常更容易,尤其是在不熟悉的环境里;各有各的风险,自底向上诱人做出浅类,自顶向下诱人按时间顺序分解,或者把接口做成只贴合眼前这个应用。用哪一种,就提防哪一种的风险。Ousterhout 当时还说可能为此在书里加一章;本站核对过 2021 年的第 2 版,没有这一章。

练一练

本章题目以书写题和判断题为主,共 10 题。下面三道来自训练场。

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

动手:项目任务

项目任务 m02(与第 6 章对应)要求把 minikv 里一串专用命令方法改造成一个更通用的核心。动手改代码之前,先用本章的方法给那个通用核心设计两次:至少写出两种差别很大的接口草图,按"上层命令处理代码用起来是否方便"比较它们,再动手实现。留着你的草图和比较结果,做完任务后回头看看当初的判断是否站得住。

任务 m02:从专用方法中提炼通用核心;动手前先为核心接口设计两次。详见 任务页。

cd project/minikv && make test

自测题

关于备选方案之间的差别,作者的建议是:
作者建议挑选彼此差别很大的方案,并且只勾勒最重要的几个方法即可。即使确信只有一种合理做法,也要考虑第二种。
比较几个实现方案(比如文本类内部用行链表、固定大小的块,还是 gap buffer)时,最重要的标准是:
"上层是否好用"是比较接口时最重要的标准。接口确定后,实现隐藏在其后,上层看不到,此时作者说最重要的是简单和性能。
在文本类的例子里,作者是怎样从"按行"和"按字符"两个方案走到"按区间"方案的?
两个方案共同的缺点(上层要自己拆行、并行或者写循环)就是线索:文本处理本该由文本类负责。用已发现的问题驱动新的方案,是"设计两次"里最有价值的一步。
"设计两次"可以用在哪些层次?
作者说这个原则可以用在系统的许多层次:先选接口,再选实现,更高层次的功能选择、系统分解也一样。每次只要能比较几个方案,就更容易找出最好的。
按作者的说法,很多聪明人难以接受"设计两次"的原因是:
作者认为问题不在于不够聪明,而在于大型软件设计这类问题真的很难,没有人能一次做对。坚持只用第一个想法,反而会让人发挥不出真正的水平。
你画出了三个接口方案,比较后发现都不太满意。按本章的建议,下一步最好是:
作者说,如果没有一个方案特别吸引人,就再想更多方案,并用原方案暴露出的问题驱动新设计,文本类从"按行 / 按字符"走到"按区间"就是这样。把所有方法堆进一个接口不是组合优点,只会得到一个更浅的模块。

小结与读后一句话

从第 12 章开始,全书转向注释。第 15 章会把注释也变成设计工具:先写接口注释,写不清楚往往说明设计本身有问题。这和"设计两次"一样,都是在动手实现之前发现问题的手段。

读后一句话:回想一个你只考虑过一个方案的设计决定,现在你会想到的第二个方案是什么?