小马的世界

读书笔记-软件设计的哲学【8】向下拉取复杂度

2026-05-31 · 8 min read

本章介绍了一种关于如何构建“更深层(deeper)类”的思考方式。

假设正在开发一个新的模块,并发现其中存在某些无法避免的复杂性。那么,更好的做法是什么?是让模块的使用者去处理这些复杂性,还是由模块在内部自行消化这些复杂性?如果这些复杂性与模块所提供的功能密切相关,那么通常第二种做法才是正确的选择。

大多数模块的用户数量都远远多于模块开发者,因此,与其让大量用户承受复杂性,不如让少数开发者承受复杂性。作为模块开发者,你应该努力让模块的使用者尽可能轻松,即使这意味着你自己需要付出更多工作。

换一种说法: 对于一个模块而言,拥有简单的接口,比拥有简单的实现更重要。

作为开发者,人们往往容易采取相反的做法:把简单的问题自己解决,把困难的问题丢给别人。

例如:

  • 如果遇到一个自己不确定该如何处理的情况,最简单的方法就是抛出异常(exception),让调用者去解决。
  • 如果不知道应该采用什么策略,那么可以增加几个配置参数,把策略选择权交给系统管理员,让他们自己摸索最佳配置。

这些做法在短期内确实能让开发者轻松一些,但它们会放大系统复杂度,使得很多人都必须面对同一个问题,而不是仅由一个人解决。

例如:

  • 如果一个类抛出了异常,那么它的每一个调用者都必须处理该异常。
  • 如果一个类暴露了配置参数,那么每个部署环境中的系统管理员都必须学习如何配置这些参数。

示例:文本编辑器中的 Text 类

考虑一个用于 GUI 文本编辑器的类,它负责管理文件中的文本内容。这个类已经在第 6 章和第 7 章中讨论过。

它提供以下功能:

  • 从磁盘读取文件到内存
  • 查询和修改内存中的文件内容
  • 将修改后的内容写回磁盘

当学生们实现这个类时,很多人选择了面向行(line-oriented)的接口

提供以下方法:

  • 读取整行
  • 插入整行
  • 删除整行

这种设计使 Text 类本身的实现非常简单,但却给上层软件带来了复杂性。因为在用户界面层面,操作很少以“整行”为单位。

例如:

  • 用户敲击键盘时,通常是在一行中的某个位置插入字符;
  • 用户复制或删除选中的内容时,往往会跨越多行,并且只涉及每行的一部分内容。

因此,为了实现用户界面功能,上层软件不得不频繁地:

  • 拆分(split)文本行;
  • 拼接(join)文本行。

而第 6.3 节介绍的那种 面向字符(character-oriented)的接口 则把复杂性向下拉取了。这样一来:

  • 用户界面软件可以直接插入任意字符;
  • 可以删除任意文本范围;

而无需自己拆分或合并文本行。因此,上层软件变得更简单。

当然,Text 类自身的实现会变得更复杂。如果内部仍然以“行集合”的形式存储文本,那么为了支持字符级操作,它必须自行完成:

  • 行拆分
  • 行合并

等工作。

但这种做法更好,因为:它把“拆分与合并”的复杂性封装在 Text 类内部,从而降低了整个系统的总复杂度。

##示例:配置参数

配置参数是一个典型的例子:它们通常是在把复杂性向上传递,而不是向下吸收。

本来,一个类完全可以在内部决定自己的行为。但有时开发者会暴露一些配置参数,例如:

  • 缓存大小(cache size)
  • 请求失败后的重试次数(retry count)

于是,用户必须自己决定这些参数应该设置成什么值。

如今配置参数已经非常流行。有些系统甚至拥有数百个配置项。

支持者认为,配置参数能够让用户根据自己的需求和工作负载来调整系统。在某些场景下确实如此,低层基础设施代码未必了解最佳策略,而用户可能更了解自己的业务领域。

例如,某些请求比其他请求更加关键,因此用户希望为这些请求配置更高优先级。在这种情况下,配置参数确实可能帮助系统适应更多场景,并获得更好的性能表现。然而,配置参数也给开发者提供了一种逃避问题的捷径:遇到困难,不解决,而是转交给别人。
很多情况下:用户或管理员其实很难,甚至根本不可能,确定这些参数应该设置成什么值。
而另一些情况下:系统完全可以稍微多做一点工作,在内部自动推导出合适的参数值。

例如,考虑一个需要处理丢包问题的网络协议。它发送请求后,如果在规定时间内没有收到响应,就会重新发送请求。为了决定重试间隔(retry interval),一种做法是增加配置参数,让用户自行设定。但实际上,协议完全可以自动计算这个值。

方法是:

  1. 测量成功请求的响应时间;
  2. 计算其合理倍数;
  3. 将该值作为重试间隔。

这种做法把复杂性向下拉取到了协议内部。用户不再需要思考:“重试时间到底应该设多少?”除此之外,它还有额外好处:重试时间能够动态调整。当运行环境发生变化时:系统会自动适应。相比之下,配置参数往往会随着环境变化而逐渐失效。

因此:

应当尽可能避免配置参数。 在暴露一个配置参数之前,先问自己:

“用户(或者更高层模块)真的能比我们更准确地确定这个值吗?”

如果必须提供配置参数,那么应该尽量提供合理默认值(reasonable defaults)。这样用户只有在特殊情况下才需要修改配置。理想情况下:每个模块都应该彻底解决它负责的问题。而配置参数本质上意味着:这个问题并没有被完全解决。它只是把一部分复杂性转嫁给了别人,从而增加了系统复杂度。

做得过头

在向下拉取复杂性时,需要保持克制。因为这个思想很容易被滥用。如果走向极端,那么你可能会试图把整个应用程序的所有功能都塞进一个类中。显然这是荒谬的。

只有满足以下条件时,把复杂性向下拉取才是合理的:

  • 被拉下来的复杂性,与该类已有职责紧密相关。
  • 把复杂性拉下来后,能够简化系统其他部分。
  • 把复杂性拉下来后,能够简化该类的接口。

需要始终记住: 目标是降低整个系统的复杂度,而不是单纯地让某个类承担更多责任。

第 6 章曾提到,一些学生在 Text 类中定义了直接反映用户界面行为的方法。例如,专门实现 Backspace 键行为的方法。乍看之下,这似乎是在向下拉取复杂性。但实际上并不是。因为,把用户界面的知识塞进 Text 类中,并没有显著简化上层代码。同时,这些用户界面相关知识,与 Text 类的核心职责毫无关系。

因此在这个例子里:所谓的“向下拉取复杂性”实际上只是造成了信息泄漏(information leakage)

总结

开发模块时,应当主动寻找机会 让自己多承担一点痛苦,从而减少用户的痛苦。

换句话说:

开发者承担复杂性,
用户获得简单性。

这正是“向下拉取复杂度(Pull Complexity Downwards)”的核心思想,也是构建深层模块(Deep Module)的关键原则之一。