3200 字
约 10 分钟
15
S03-A08 · 抽象的成本:什么时候该多一层封装

项目启动时有人提了一句"以后可能要换掉这个存储",于是先抽了三层接口:Repository、RepositoryAdapter、RepositoryPort。第一年换了零次实现,每个 bug 的定位路径却多跳了两次。

抽象是用当下确定的成本,换未来不确定的收益。这笔交换有时极划算(隔离外部依赖),有时纯亏损(只有一个实现的接口)。区别不在品味,而在几个能数出来的判据。


1. 抽象到底花掉了什么

成本项 具体形态 什么时候最痛
阅读跳转 读一处逻辑要跳 2–4 个文件 新人接手、代码评审
调试断点 栈里一半帧是包装层 线上问题复现
参数透传 每加一个参数,改 3 层签名 需求变化
变更扩散 改一处行为,动 5 个类 小改动变大 PR
隐式约定 谁管事务、谁管重试,看不出来 并发与一致性
收益项 具体形态 成立条件
隔离变化 换实现只改一个地方 变化真的会发生
复用 同一份逻辑服务多处 有 ≥ 2 个真实调用点
可测 可以在边界上替换成替身 抽象位于正确的接缝
表达领域 代码里出现业务词汇 术语在团队内有共识

关键的不对称:成本确定且立刻发生,收益有概率且延迟兑现。所以不该问"抽象好不好",该问"这个收益的兑现概率有多高"。


2. "以后可能要换"是最贵的假设

判据一:现在有几个实现?
  0 个(连替代方案都没有)  → 不抽,等第二个实现出现再抽
  1 个(只有当前实现)        → 不抽;抽了也只是把"没有第二个实现"写成接口
  2 个(已有真实替代)        → 抽,且此时抽象的形状已经被现实约束住了
判据二:上一个"以后可能要换"的假设,兑现了没有?
  记录兑现率;0/5 兑现的团队,第六次预测的权重应当接近零

这类假设的问题不是它错,而是无法验证,因而无法被否证。不能被证伪的假设会永久占据代码结构,且没有机制清理它。

做法:把假设写进 ADR 并带触发条件——"若需要第二种存储,则在 X 处引入接口"。触发条件没到的抽象不做。


3. 三种动机与各自的成立条件

动机 典型例子 成立条件 不成立的表现
隔离外部依赖 支付网关、对象存储 SDK 外部 API 会变、且难以在测试中启动 包了一层仍然到处 import SDK 的类型
消除重复 三个服务里同样的重试逻辑 重复且确定同向演化 抽完之后各处加 if 开关,最后比复制还长
表达领域概念 Order、Refund 术语稳定、团队共识 概念是新造的,业务方不用这个词

检验方式各不相同:隔离外部依赖看接缝是否只有一个入口;消除重复看抽出来的东西是否还在变;表达领域看非技术同事能否看懂这个名字。最常见的错配,是拿消除重复的动机做了隔离变化的抽象——两段本就该分化的代码被合并成带 mode 参数的类。


4. 重复三次再抽象,以及它的反例

规则:第一次直接写,第二次复制并标注,第三次才抽象。

情形 该不该抽 理由
三次重复,且三次的演化方向一致 抽 变化是"同一件事的不同数据"
三次重复,但每次都在往不同方向长 不抽 抽象会把分叉固化成 if 分支
两次重复,但代码是纯计算且极短 抽 合并成本低于阅读成本
两次重复,但涉及不同的失败语义 不抽 错误处理不同意味着不是同一概念

反例的核心:重复是事实,共性只是猜测。第三次出现前,样本不足以判断哪些部分稳定、哪些各自演化。

# 判据可部分机械化:同一段逻辑的调用点数量
$ grep -rn "calculateRetryDelay" --include=*.java | wc -l
3   # ≥3 且演化同向 → 考虑抽象;=1 → 先别动

5. 抽象层级与调用深度

深度 读者的体验 可接受条件
1 层(直接调用实现) 一眼到底 默认形态
2 层(接口 + 实现) 需要跳一次 有第二个实现或需要替身
3 层(接口 + 抽象基类 + 实现) 开始需要工具和耐心 必须有明确的分层理由(如框架回调边界)
≥ 4 层 没人读完,改的时候靠猜 几乎总是设计事故

深度不是罪,深度乘分叉数才是。3 层但每层一条路,读起来仍线性;2 层但每层 5 个实现加条件装配,路径数已爆炸。评估要数"从入口到执行经过的条件分支数",不是数类。


6. 接口的宽度

取向 特征 优点 代价
窄接口(1–3 个方法) 每个接口一件事 易组合、易替换、易测 调用方要拼装多个接口
宽接口(10+ 方法) 一个接口包一切 调用方简单 任何实现都要实现全部方法;替换成本极高
// 窄:调用方按需组合,替身只需实现用到的那一个
interface PriceLookup { Money priceOf(Sku sku); }
interface StockLookup { int available(Sku sku); }

// 宽:任何替身都得实现 12 个方法,其中 11 个是 UnsupportedOperationException
interface CatalogService { /* 12 methods */ }

判断方法:写出替身时要实现几个方法。出现 throw new UnsupportedOperationException(),说明接口太宽,把不相关能力绑在了一起。


7. 配置化与抽象的界限

配置是运行期抽象:同一份代码,不同参数走不同路径。它比代码抽象便宜,代价是静态检查失效——错误从编译期推迟到运行期。

# 配置化:分支存在但不可见,读代码时看不到有哪些取值
retry:
  strategy: exponential   # 合法值?谁校验?默认值错了会怎样?
手段 变化发生在 静态可检查 适合
代码抽象(接口/策略) 编译期 是 实现差异大、数量少
配置开关 运行期 否 同一实现的参数差异
特性开关 运行期 否 灰度与回滚,有生命周期

界限:两个分支的差异超过几行,配置化就是用 if 隐藏两个类;差异只是一个数字,写两个类就是浪费。判定线是"分支里的代码能否独立测试"——能则是类,不能则是参数。


8. 过度抽象的识别信号

信号 检出方式 说明
只有一个实现的接口 grep -r "implements XxxPort" 只命中一处 抽象没有第二实现,也没有测试替身需求
只被调用一次的类 调用点计数 拆出来没有复用收益
参数原样透传的包装 方法体只有一行 return delegate.x(...) 纯厚度,无信息
抽象基类只有一个子类 继承树宽度 为"未来扩展"预留,实际从未扩展
每次改动都要改三层签名 变更历史 抽象的接缝位置错了

注意:测试替身也算第二个实现。如果一个接口唯一的用途是被 mock,那不是抽象失败,而是抽象成功了——但前提是这个接缝确实需要被替换。


9. 抽象撤销的成本

抽错后的三条路都不便宜:保留(每次改动多一层,无人敢删)、内联回调用方(一次中等重构,撞并行开发)、改接缝位置(语义漂移,测试重写)。

抽错的代价高于不抽,因为删除接口比添加接口难:删要确认所有调用方、所有替身、所有装配配置。所以"先抽了再说"在组织上是不对称的坏决定——加的人收益归自己,删的人承担全部成本。


10. 测试对抽象的约束

抽象应当让测试更容易。要 4 个以上替身才测得到一个纯函数,或替身必须模拟调用顺序、断言依赖调用次数,都说明接缝切在了业务逻辑中间:

// 抽象让测试变难:为了测一个纯函数,必须启动 5 个替身
@Test void testFee() {
    var svc = new FeeService(mockRepo(), mockClock(), mockTax(), mockFx(), mockAudit());
    // 真正的逻辑只有一行加法
}

11. 抽象泄漏

泄漏不可避免:非平凡抽象迟早会暴露下层细节(性能、事务边界、异常类型)。关键不是消灭,而是泄漏有没有被文档化、有没有逃生口。

泄漏类型 例子 缓解
性能特征 "批量接口比循环单条快 50 倍" 文档写明批量语义,提供批量入口
异常语义 包装后丢失原始错误码 保留 cause 与错误码映射表
事务边界 抽象内部各自开事务 在接口契约上写明事务归属
资源生命周期 连接何时归还不可见 显式 close 或 try-with-resources

有逃生口的意思是:保留一个"绕过抽象直接操作"的合法途径,并写清何时该用。没有逃生口的抽象,会在第一个性能问题上被推倒重来。


12. 排错:抽象上线后出现的问题

现象 原因 处理
改一个字段要动 5 个文件 分层过深或 DTO 层层转换 合并相邻层,去掉纯透传的映射
线上栈里一半是框架帧 包装层过多 按用例收拢接缝,减少转发
换实现时发现接口不匹配 抽象按当前实现的形状写 接口按调用方需求定义
测试替身越写越复杂 接口太宽或接缝太细 收窄接口,改按用例切分
新人读不懂调用链 深度 ≥ 4 或不必要的中间层 删层,而不是补文档

13. 边界:这些地方不要抽象

场景 理由
一次性代码(脚本、迁移工具、demo) 生命周期短于学习成本
探索期原型 需求形状未定,抽象会把错误固化
性能极敏感的热路径 每次调用多一次间接跳转,JIT 未必能内联掉
语言/框架已有稳定抽象 再包一层只是方言
团队没有维护约定的能力 抽象要求"改接口的人负责全部实现"

14. 自测

  1. 抽象的成本和收益在时间上的不对称,如何影响判断顺序?
  2. "现在有几个实现"作为判据,为什么比"以后可能要换"可靠?
  3. 重复三次才抽象,这条经验法则在什么情况下应当被推翻?
  4. 评估抽象深度时,为什么数条件分支比数类更准?
  5. 写出替身需要 UnsupportedOperationException,说明接口有什么问题?
  6. 抽象泄漏无法消灭,那么"可接受"的标准是什么?
S03-A08 · 抽象的成本:什么时候该多一层封装
https://www.clxhxhhr.top/posts/6226/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。