读前:这一章要回答什么

一个模块牵涉的因素那么多,哪些应该放在设计的中心、哪些应该藏起来?拿不准的时候怎么办?

这是全书最短的几章之一,但它的位置很特别:它不提出新技巧,而是把前面的很多章收拢到同一个动作上,就是把重要的和不重要的分开。接口只放使用者在意的东西(第 4、5 章),名字挑信息量最大的几个词(第 14 章),性能要紧时就围绕关键路径组织代码(第 20 章),这些都是这个动作的具体形式。

所以读这一章的最好方式是"回头看":每读到一个观点,就想想前面哪一章其实在讲同一件事。作者最后给这种能力起了一个名字:好品味(good taste)。

  1. 开头两段与 21.1 节:作者举了抽象、命名、性能三个例子,说明前面各章都在"分开重要与不重要"。读完试着自己再补两个(提示:第 8 章的配置参数、第 10 章的异常)。21.1 节的关键词是杠杆(leverage),留意作者举的两种杠杆有什么不同。
  2. 21.1 节后半:作者给经验不足的开发者一个具体建议:先立一个假设。注意作者强调的不是"猜对",而是事后怎么复盘。
  3. 21.3~21.4 节:三种突出方式之后有一句"反过来也成立",它把"突出"变成了一个诊断工具。21.4 节的两种错误,各自对应前面哪些红旗?

核心观点

围绕重要的东西组织系统

重要的东西要突出、要显眼;不重要的东西尽量藏起来,把它对系统其余部分的影响降到最小。

找杠杆

一个问题的解法能顺带解决许多问题,或者知道一条信息就能理解许多事(例如一个不变量),那里通常就是重要的东西。

拿不准就先立假设

说出"我认为这个最重要",按它去建,然后复盘它为什么对、为什么错,下一次会判断得更好。

让重要的东西尽量少

能给默认值就给,能自动算出来的配置就别让人填,能在底层处理掉的异常就别往上抛。确实重要的,也要减少它起作用的地方。

突出的三种方式

显眼(prominence):放在接口文档、名字、常用方法的参数里;重复(repetition):关键概念反复出现;居中(centrality):放在系统核心,决定周围的结构。

两种错误

把太多东西当成重要的(杂乱、认知负担、浅类);没认出真正重要的东西(信息被藏、功能缺失、未知的未知)。

The phrase "good taste" describes the ability to distinguish what is important from what isn't important.— 第 21.5 节

心智模型:两条轴、四个格子

把"这件事实际上重不重要"和"设计里有没有突出它"当成两条独立的轴,就得到四种情况。两条对角线分别是做对了和做错了;第 21.4 节的两种错误正好是两个错的格子。

设计里怎么对待它 突出(接口、名字、核心结构) 隐藏(实现内部、默认值) 它实际上 重要 不重要 ✓ 突出它 必填参数、写进接口注释 起进名字里 让周围的结构围着它转 ✗ 错误二:没认出来 虚假的抽象(第 4 章) 使用者被迫反复重造功能 未知的未知(第 2 章) ✗ 错误一:当成重要 多数调用者不关心的参数 过度暴露、浅类(第 4、5 章) 认知负担上升 ✓ 藏起来 信息隐藏(第 5 章) 默认值、自动计算(第 8 章) 在底层处理掉(第 10 章) 右侧箭头:先让重要的东西尽量少,把能变得不重要的挪到下一行(21.2 节)
判断"实际重不重要"需要品味;判断之后怎么做,前面各章已经给了工具。本站自绘示意。

练一下手感:一个上传客户端

假设你要给团队写一个"把本地文件上传到对象存储"的客户端库。下面是设计时会碰到的十个因素。先替每一个选一个位置,再点"对照"看本站的看法。没有唯一正确答案,重要的是理由。

交互

重要还是不重要?

突出 = 必填参数、名字或接口注释里写明;可选 = 提供,但不挡在常用路径上;藏起来 = 库自己决定,使用者不需要知道。
    还没有选择。

    原创例子:同一个上传接口,两种重点

    struct UploadOptions {
        size_t chunkSize;          // 分块大小
        int threads;               // 并发数
        int retryCount;
        int backoffMs;
        std::string checksumAlgo;  // "md5" / "crc32c"
        std::string tmpDir;
        bool verifyAfterUpload;
        bool overwrite;
    };
    
    Status upload(const std::string& localPath, const std::string& bucket,
                  const std::string& key, const UploadOptions& opt);

    调用者必须给每个字段都填一个值,于是大家从别处复制一份配置过来。真正危险的 overwrite(第 9 行)混在一堆调优参数中间,和"分块大小"看起来一样重要;"返回成功是否意味着对象已完整可读"这一点则根本没写。两种错误同时出现了:调优参数被当成了重要的东西,覆盖语义和完成保证却没被突出。

    // 把本地文件上传为 dst。返回 OK 时,对象已完整写入并通过校验,可以立即读取。
    // 目标已存在时怎么办由 ifExists 决定:这是唯一需要调用者想清楚的选项。
    enum class IfExists { Fail, Replace };
    
    Status upload(const std::string& localPath, const ObjectName& dst,
                  IfExists ifExists);
    
    // 分块、并发、重试和校验由库根据文件大小和网络状况自动决定。
    // 极少数需要干预的场景(例如压测)才用这个重载。
    Status upload(const std::string& localPath, const ObjectName& dst,
                  IfExists ifExists, const UploadTuning& tuning);

    重要的东西被突出了:覆盖语义成了没有默认值的必填参数(显眼),完成保证写在接口注释的第一句。不重要的东西被藏了起来,或者自动决定(让重要的东西尽量少)。代价是库的实现变复杂了:它要自己根据文件大小选分块、自己处理重试。这正是第 8 章说的"把复杂性往下拉"。

    边界与反方:什么时候别生搬硬套

    同一个细节,对一类使用者不重要,对另一类可能至关重要。上面的 UploadTuning 对普通业务方无关紧要,对做压测的团队却是核心。所以"藏起来"不等于"删掉":罕见但真实的需求要有入口,只是不挡在常用路径上。藏过了头就会落到第二种错误,第 10 章"做过头"那一节说的也是这件事:真正重要的东西必须暴露出来。

    作者说,一个想法如果到处都看得见、反复出现、影响系统结构,那它就是重要的。本站补充:这句话也可以用来查问题。如果某个你认为无关紧要的细节(某个内部格式、某个魔法常量)出现在几十个文件里,那么不管你怎么想,它已经变重要了,因为改它的代价很大。这时该做的是把它收回一个模块里(第 5 章的信息隐藏),而不是给它补更多文档。

    很多团队把"可配置"当成美德,觉得参数越多越灵活。按这一章的标准,每多一个必须由人决定的参数,就多一件"重要的事"。作者在 21.2 节的建议正相反:能自动算出来的配置,就不要再让管理员去管。本站补充:判断标准不是"有没有人可能想调",而是"大多数使用者需不需要想这件事"。

    以下不在书里,来自书中给出的读者讨论组。本章的两句话,Ousterhout 2018 年就在讨论组里用过,而且各有一个具体的场合:

    两处合起来就是本章的做法:先判断什么重要,再让重要的东西尽量少、尽量显眼。

    以下不在书里,来自 Ousterhout 在 X(原 Twitter)上的回帖。2021 年有人说写作和编程之间有联系,Ousterhout 表示同意,并说自己会这样告诉学生(2021-03):能写出好的代码内文档、能设计出干净的类,多半也就能写好论文;两件事用的是同一套办法,找出最重要的东西,然后把力气集中在它上面。本章 21.5 节把"分清什么重要"推广到了软件之外,点名提到技术写作;这条回帖是同一个意思的一个具体场合。

    练一练

    下面三道题来自训练场。先自己判断再看解析,答题记录会进入间隔复习。

    自测题

    作者说,判断什么重要时可以找"杠杆"。下列哪一项最符合作者说的杠杆?
    杠杆有两种:一个解法能顺带解决许多问题(例如通用的文本区间插入、删除接口),或者一条信息能让人理解许多事(例如不变量)。
    一位新人不确定新模块里哪件事最重要。作者的建议是?
    作者认为猜错也没关系,关键是事后思考:当初有哪些线索本可以让你判断得更好。这样一次次积累,判断会越来越准。
    "操作系统的设备驱动接口有成百上千个驱动依赖它",这是在说明哪种突出方式?
    驱动接口处在系统中心,大量代码的结构由它决定。这也说明了为什么这类接口一旦定下就很难改,值得投入最多的设计精力。
    书中讨论的 Java I/O 接口要求使用者自己区分并显式加上缓冲。按本章的说法,这属于?
    使用者几乎总是想要缓冲,"带不带缓冲"这个区分对他们几乎从不重要,却被强加给每个人。作者还指出,浅类常常就是把太多东西当成重要的结果。
    一个库没有说明写入何时真正落盘,结果每个团队都自己包一层"写完立刻全部刷盘"的函数。这是哪种错误?
    第二种错误的典型后果就是:重要信息被隐藏,或者缺少重要的功能,开发者只好一遍遍自己重造,还会带来未知的未知。

    小结与读后一句话

    第 22 章是全书的结语。本站把它做成了一张全书复习页:用一张图把前面所有章节串起来,并指向速查、训练和项目。

    读后一句话:用你自己的话写下这一章最重要的一个观点,最好结合你正在做的某个模块:它最重要的是什么?