看到一个抽象腐烂的系统,通常不是因为它当初设计得不够好。是因为设计它的时候,信息根本不够。

信息不够的抽象,比没有抽象更昂贵。它不解决问题——它创造了一个需要持续修补的东西。而我们这个行业里,"看到重复就抽出来"的本能太强了,强到很少有人在动手之前停下来问一句:我现在知道得够多吗?

一个典型模式

几个模块,处理流程看起来高度相似。任何一个工程师看到三四百行结构雷同的代码,第一反应都是"抽出来"。定义一个统一的 interface,把流程引擎化,每个模块只实现自己的差异部分。

这在当下的信息量里,挑不出毛病。

然后业务开始变化。

第一个新需求:某类模块需要额外的处理步骤。往 interface 里塞一个新方法,不需要的模块返回空实现。不太好看,但能忍。一个小裂缝。

第二个:来了一个只有部分模块才关心的校验逻辑。interface 又长一截。现在七八个方法,每个实现只用得到六七个。裂缝在扩大,但还没人觉得需要推倒重来——加一个方法而已,多大的事。

第三个:另一种差异化逻辑,只跟一种类型有关。这时候你再看那个 interface——它已经不需要更多方法了,它已经烂了。

一堆"可能存在也可能不存在"的方法。每次加功能,要么再加一个可选方法,要么在外面写绕过它的兼容层。两样都有人干过。兼容层越堆越厚,代码在 interface 外围长出一个比 interface 本身更复杂的生态。

这个抽象没有降低复杂度。它只是创造了一个需要持续打补丁的结构。而补丁的成本是递增的——每加一个,下一个就更难加。最终这个 interface 变成系统的最大耦合点:所有调用方都依赖它,但没有一个调用方真的需要它的全部。

这个模式在任何语言、任何团队、任何业务领域都会出现。它和工程师的水平无关,和时机有关。在信息不足的时候把一个抽象钉进去,之后业务每变一次,那个抽象就被拉扯一次。扯多了,它就变形了。

错在哪

两个根因。

第一,把偶然相似当成了本质相同。

几个模块的处理流程表面上长得像——因为业务刚开始、需求简单,所有人都只用到最基础的能力。一旦业务复杂度上来,它们往不同的方向变。一个关心格式转换,一个关心合规校验,一个关心版权审核。它们的相似是那个时间点的巧合,不是本质。

判断两个东西是否"本质上相同",不能只看它们现在长什么样。要看什么会驱动它们变化。如果模块 A 因为甲方 A 的需求变,模块 B 因为监管政策变,模块 C 因为内容源的数据格式变——它们的变更驱动力完全不同,就不该被塞进同一个抽象。一个模块是一个变更维度的封装单元。把不同变更维度的东西强行统一,等于把所有维度的变化都耦合到了一起。

这在软件设计中有一个更正式的名字,叫 Common Closure Principle:一个包里的类应该对同一种变化原因封闭。反过来也成立——如果两个东西会因为不同的原因变化,它们就不该在一个包里。抽象同理。一个 interface 的多个实现,如果各自的变化驱动力不同,interface 就是在逆着 CCP 工作。

第二,在信息不足的时候把抽象锁死了。

只有一个业务场景的时候,你看不到变化方向。你看到的是"几个东西看起来一样",看不到的是"它们未来会往不同的方向变"。

第一个用例告诉你有哪些步骤。第二个用例才告诉你哪些步骤是公共的、哪些是可变的。只基于第一个用例做抽象,抽象的形态不是设计的——是猜的。是你拿着一个样本点拟合了一条曲线,然后期待未来的所有点都落在这条曲线上。它们大概率不会。

猜错的代价是什么?不是回到原点重新来。是更贵——你已经有代码依赖这个抽象了,interface 已经在十几个地方被引用了,测试已经基于它写了。推翻重来意味着所有这些都要动。而如果不推翻、继续在这个抽象上打补丁,每个新需求都比上一个更难加。两条路都更贵,因为你已经把错误的结构焊进了系统。

等第二个用例

至少两个真实用例,你清楚它们的共性和差异之后,抽象才有立足点。

第一个模块,一把梭。代码该写写,不为了"以后可能有更多"提前抽象。

第二个模块出现,认真看两者的差异。它们的处理步骤有哪些是真正一样的?哪些只是碰巧一样但业务语义不同?如果大部分相同、只有个别步骤不同——只在那个步骤引入差异。如果整个流程结构都不同——它们就不该共享同一个抽象。

第三个出现,你已经有数据判断了。前两个变化方向一致,可以抽象。方向不同,你就知道不该统一。

"至少两个"不是死规矩,是信息门槛。只有一个?你连差异在哪都看不全。信息不够的时候,不抽象是更好的设计决策。

这和 TDD 里"写刚好能让测试通过的代码"是同一个逻辑:不为还没出现的需求写代码。只不过在这里,是不为还没出现的用例做抽象。YAGNI 不仅适用于功能,也适用于结构。关于"为了以后扩展"这个理由为什么经常失效,我在另一篇文章里专门展开过。

"一把梭"为什么是合理的架构选择

有人会不舒服——DRY 呢?代码质量呢?不抽象不就是复制粘贴?

Sandi Metz 在 RailsConf 2014 的演讲里说过一句话:复制粘贴远比错误的抽象更便宜。

你得看成本结构。

一段复制粘贴的代码是局部的。改 A 不影响 B,删 A 不影响 B。它在一个文件里臭,不传染。如果要改三处相似的代码——是的,要改三次。但每次改动都是独立的、可测试的、不影响其他调用方的。这是一种可控的、线性的成本。

错误抽象是全局的。改 interface 签名,所有实现都跟着动。加一个兼容方法,所有调用方都看见。一个错误的结构决策扩散到整个系统之后,修复成本不是加法,是乘法。每个依赖方都增加一层阻力。当初为了省"改三个地方"的麻烦而引入的抽象,后来变成了"为了一个地方的小改动而评估所有实现和所有调用方"的负担。

见过太多这样的系统了:起初像一个干净统一的设计,后来变成一个什么都经过但什么都不做的中间层。所有调用链都穿过它,但它对每个调用链的贡献为零。如果当初不抽象,后来改起来会快得多。

这不是反对抽象。是反对在信息不足时,用猜测替代等待。

什么时候可以提前抽象

有,但条件苛刻。

在同一个领域做过三次以上类似系统——你对变化方向的判断不是猜测,是模式识别。这类判断有经验支撑,不是拍脑袋。

在写基础设施或公共库,API 向后兼容的代价需要提前评估。这种场景下,"猜错了再改"的成本本身就很高,提前投入设计是合理的。

变化的驱动力被领域本身锁死了。比如 HTTP 的请求/响应模型,再怎么演进也不可能变成单向通知。比如一个财务系统的借贷记账法,复式记账的基本结构不会被业务需求改变。这些是领域已经帮你做完了抽象设计,你只需要实现它。

对大部分业务代码,这三个条件不成立。你面对的是一个还在探索中的业务,一个你没完全理解的领域。在这种条件下猜抽象形态,猜错的代价不值得冒。

一个可以带走的问题

下次考虑抽象的时候,问自己:

目前有几个真实用例?它们的共性和差异分别是什么?

如果"差异"那部分你答不上来——因为你只有一个用例,因为你还没见过这段代码在不同业务压力下的表现——就先别抽象。

延迟抽象不是拖延。是在等待让设计决策成立的信息。一条好的设计决策,和一个好的抽象一样,都是在信息足够之后自然浮现的,不是在信息不足时强行构造的。

参考资料