读前:这一章要回答什么
怎样才能得到一个好的设计,而不只是第一个想到的设计?
第 4~10 章给了很多判断设计好坏的工具:深度、信息隐藏、通用性、分层、把复杂性往下拉、把错误定义掉。但这些工具都是用来评价一个方案的。如果你手里只有一个方案,评价的结果最多是"还行"或者"有问题",并不能告诉你更好的方案长什么样。
这一章很短,只讲一个过程上的习惯:对每个重要的设计决定,先勾勒几个差别很大的方案,比较之后再选择或组合。它是全书第 12 条原则,也是本站项目任务 m02 的设计步骤。所以这一页不打算多讲道理,而是带你真的做一次。
- 本章不分小节。开头的文本编辑器例子里有三种接口:按行、按字符、按任意区间。注意作者说的不是哪个"更优雅",而是每种方案会让上层代码多做什么事。
- 中段"列出优缺点"那部分:作者给比较标准排了优先级。读的时候留意接口的标准和实现的标准有什么不同。
- 结尾关于"聪明人"的那几段:问问自己,上一次你认真考虑第二个方案是什么时候?
核心观点
第一个想法很少是最好的
设计软件很难,第一个想法未必就是好设计。对每个重要的决定,至少考虑两个方案。
方案之间要差别很大
只改名字或参数的两个方案没什么可比的。差别越大,学到的越多。即使确信只有一种合理做法,也勾勒一个第二方案。
草图就够了
每个方案只需写出最重要的几个方法,不必把所有细节定下来。
接口先看"上层好不好用"
其次才是简单性、通用性、能否高效实现。到了实现阶段,标准换成简单和性能。
选择、组合或重新设计
最好的结果可能是几个方案的组合;如果都不满意,就用已经发现的问题驱动新的方案。
代价很小,回报很大
一个类花一两个小时比较方案,对比几天到几周的实现时间不算什么,长期还能提升设计能力。
心智模型:先发散,再收敛
书中的例子是文本编辑器里管理文本的类。按行的接口让上层在剪切、粘贴跨行内容时自己拆行、并行;按字符的接口让上层写循环,而且每个字符调用一次,很慢。作者指出,两个方案都逼着上层代码做额外的文本处理,这本身就是一个信号:既然有一个文本类,文本处理就该由它负责。顺着这个缺点想下去,就得到了按区间操作的接口。这正是"用已发现的问题驱动新方案"。
怎样逼出"差别很大"的方案
"再想一个方案"说起来容易,真坐下来,第二个方案常常只是第一个换了个名字。书里没有给出具体的方法,下面是本站整理的几个维度。把第一个方案放在每个维度上定位,然后故意走到另一端:
| 维度 | 一端 | 另一端 |
|---|---|---|
| 操作的粒度 | 单个元素(一个字符、一条记录) | 任意范围、整批 |
| 信息流向 | 调用者来"拉"(查询、轮询) | 模块去"推"(回调、事件) |
| 状态 | 可变对象,原地修改 | 不可变快照,整体替换 |
| 同步方式 | 阻塞直到完成 | 立即返回,结果稍后取 |
| 谁负责 | 调用者自己组装、自己处理特殊情况 | 模块包办,调用者一行搞定 |
| 做不做 | 做这个功能 | 不做,用别的机制替代(重启、离线批处理、人工流程) |
常见误区:① 把第二个方案写成一个明显很差的"稻草人",只为了衬托第一个。作者说要认真分析它的弱点,稻草人教不了你什么。② 按"哪个我更快写完"来比较。接口的首要标准是上层用起来是否方便,实现的难易排在后面。③ 比较完只做"选择",忘了"组合":很多时候最好的设计是从两三个方案里各借一点。
练习:自己设计两次
下面是一个真实服务里常见的需求。请按步骤写下你的想法,写的内容只保存在你的浏览器里。先写完再展开后面的参考比较,否则这个练习就失去了意义。
需求:配置热更新。一个 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自测题
小结与读后一句话
- 对每个重要的设计决定,勾勒几个差别很大的方案(草图即可),哪怕你确信只有一种合理做法。
- 比较接口时,最重要的是上层代码用起来是否方便;其次是简单、通用、能否高效实现。比较实现时,看简单和性能。
- 结果可以是选一个、组合几个,或者用已发现的问题驱动一个新方案。
- 代价很小(一个类一两个小时),回报很大,而且会持续提升你的设计能力。
- "聪明人一次就该做对"是个陷阱:不是你不聪明,而是问题真的难。
从第 12 章开始,全书转向注释。第 15 章会把注释也变成设计工具:先写接口注释,写不清楚往往说明设计本身有问题。这和"设计两次"一样,都是在动手实现之前发现问题的手段。
读后一句话:回想一个你只考虑过一个方案的设计决定,现在你会想到的第二个方案是什么?