引言:一个让所有工程师沉默的问题
场景还原
代码评审会上,有人问你:
"如果现在要加一个'用户积分'模块,你这个系统需要改多少?"
你愣了一下。你写的代码,你每天维护的系统,但这个问题你答不上来。你隐约知道大概要动几个地方,但不确定。你需要打开 IDE,搜索,追踪,才能给出一个大概的答案。
紧接着第二个问题:
"如果用户在下单时,积分刚好不够,但优惠券可以抵扣,系统会怎么处理?"
你又愣了一下。这个 Case 你没有想过。你只知道"正常流程"能跑通,但边界情况呢?你写的代码,在遇到这个 Case 时,会给出什么结果?
你写的代码,不等于你懂的代码。
这不是能力问题。这是可推演性问题。
什么是可推演性
可推演性(Reasoning Traceability)是一个很具体的能力:
当有人问你"如果......会怎样"时,你能不能在不运行代码的情况下,给出确定的答案?
- 新增一个模块,改动幅度是 3 个文件还是 30 个?
- 遇到 Case X,程序会走到哪个分支、返回什么、副作用是什么?
- 如果并发量翻倍,哪个环节先崩?
- 如果删掉这段代码,会怎样?
能答上来,说明你对代码有完整的心理模型。答不上来,说明代码已经超出了你的认知边界------哪怕它每一行都是你写的。
核心论点
这篇文章要论证的是:
可推演性是代码质量的第一性原理。它比"优雅"更根本,比"简洁"更重要。
一段优雅但无法推演的代码,是负债。一段朴素但完全可推演的代码,是资产。
过度设计摧毁可推演性------每一个"为未来预留"的抽象,都是一个推演盲区。而可推演性,是过度设计的照妖镜。
第一章 什么是可推演性
1.1 定义:从"我写的"到"我懂它"
可推演性 = 心理模型与代码结构的对齐程度。
你脑子里有一个关于代码如何工作的模型。代码本身有一个实际的结构。当这两个东西一致时,你能推演。当它们不一致时,你不能。
不一致的来源有很多:
- 代码里有你不知道的隐式行为(AOP、拦截器、中间件)
- 代码的结构太复杂,超出了工作记忆
- 代码是你三个月前写的,你已经忘了
- 代码经过了太多人的手,没有人有完整模型
可推演性不是"代码简单",而是"代码在你脑子里是完整的"。
1.2 可推演性的三个层次
第一层:局部可推演
你能预测一个函数的行为。给它输入,你知道输出是什么。给它边界条件,你知道它会怎么处理。
这是最低要求。如果连一个函数都推演不了,代码就不可维护了。
第二层:路径可推演
你能预测一个请求经过的所有分支。从入口到出口,你知道它会经过哪些函数、哪些条件、哪些副作用。
这是中等要求。大多数工程师在正常流程上能做到,但在边界情况上做不到。
第三层:变更可推演
你能预测一个改动的影响范围。改这里,会影响到哪些模块、哪些功能、哪些测试。
这是最高要求。能做到这一层的人,才真正"拥有"代码。
1.3 可推演性 vs 可读性 vs 可维护性
这三个概念经常被混用,但它们不同:
可读性是"别人能看懂"。它关注的是代码的表面形式:命名、注释、格式。
可推演性是"你能预测"。它关注的是代码的行为模型:输入、输出、路径、副作用。
可维护性是"能改"。它关注的是代码的变更成本:改这里要动多少、风险多大。
三者的关系是:
- 可读性是可推演性的前提------看不懂,就推演不了。
- 可推演性是可维护性的前提------推演不了,就改不动。
- 可读性不等于可推演性------一段代码可以很好读,但你依然不知道它在边界情况下会怎样。
可推演性是三者的核心。它连接了"看懂"和"改动"。
1.4 为什么可推演性比"优雅"更重要
工程师文化里有一种对"优雅"的崇拜。
优雅的抽象、优雅的设计模式、优雅的架构。代码评审时,一个复杂的抽象方案往往比一个简单的直接方案获得更多赞誉。
但优雅是审美,可推演性是工程。
一段优雅但无法推演的代码,是负债。 它看起来很聪明,但没人敢改。改一行,不知道会触发什么。加一个功能,不知道要动多少地方。最终,它变成了一个只能加、不能改、更不能删的黑箱。
一段朴素但完全可推演的代码,是资产。 它看起来平平无奇,但你能预测它的所有行为。改起来放心,加功能清晰,删代码也敢。
优雅是给外人看的,可推演性是给自己用的。
好的代码,是你能在脑子里运行一遍的代码。
第二章 可推演性是如何丧失的
2.1 抽象层:每多一层,就多一个盲区
你调用 userService.isDeleted(user)。
这个调用看起来清晰。但问题是:你不知道里面做了什么。
它可能只是 return user.getStatus() == DELETED。但它也可能查了缓存、调了远程服务、触发了事件、修改了审计日志。你看不到,你只能假设。
每一个抽象层,都是一个推演盲区。调用者失去了对行为的直接感知,只能依赖抽象的名字来猜测。
案例:一个字段的七层之旅
一个用户昵称字段,从前端到数据库,经过:
- Controller 接收 DTO
- Service 转换为 BO
- Manager 转换为 Entity
- Repository 转换为 PO
- 数据库存储
- 返回时反向转换
- 前端展示
七层转换,每一层都有自己的规则、校验、默认值。你改一个字段名,需要改七个地方。你能推演这个改动的影响吗?
抽象层的悖论:它让代码看起来更清晰,但让行为变得更模糊。
2.2 隐式行为:看不见的副作用
AOP、拦截器、装饰器、中间件------代码在你看不见的地方运行。
你写了一个方法:
java
less
@Transactional
@Cacheable("users")
@AuditLog(action = "DELETE_USER")
public void deleteUser(Long userId) {
userRepository.deleteById(userId);
}
这个方法看起来只有一行。但实际上:
@Transactional开启了事务,异常时回滚@Cacheable在调用前查缓存,调用后写缓存@AuditLog通过 AOP 记录了操作日志
这些行为全部是隐式的。你从方法签名上看不到,从方法体上也看不到。你只能通过注解来猜测。
如果 @Cacheable 的 key 生成逻辑有 bug,导致删除了用户但缓存没更新,你能推演出来吗?
隐式行为是可推演性的头号杀手。 它让代码的实际行为与表面行为脱节。
2.3 分布式:当调用跨越网络
在单体应用里,你能推演一个请求的完整路径。
拆成微服务后,路径变成了概率。
- 服务 A 调用服务 B,B 可能超时
- B 超时后,A 可能重试,重试可能重复下单
- B 可能返回成功,但消息队列没收到
- 消息队列收到了,但消费者处理失败
案例:一个下单请求的不可推演性
用户下单,经过:
- 订单服务创建订单
- 库存服务扣减库存
- 支付服务发起支付
- 积分服务增加积分
- 通知服务发送短信
这五个服务之间,有同步调用、有异步消息、有重试、有补偿。
如果支付成功但积分服务失败,系统会怎样?如果库存扣了但订单创建失败,会怎样?如果通知发了但支付其实失败了,会怎样?
在分布式系统里,"如果......会怎样"的答案不再是确定的,而是概率的。
2.4 动态性:运行时才知道的事
反射、依赖注入、动态代理、配置驱动------代码在写的时候是一个样,跑起来是另一个样。
java
java
@Autowired
private UserService userService;
你注入的到底是谁?是 UserServiceImpl?还是被 AOP 代理过的 UserServiceImpl?还是某个 @Primary 的实现?还是根据配置动态选择的实现?
你写代码时看到的是一个东西,运行时是另一个东西。
配置驱动更糟。逻辑从代码移到了配置文件,看似灵活,实则不可推演。代码里的 if 你能看懂,配置里的 if 你看不到。
案例:规则引擎的黑箱
一个促销规则引擎,所有规则都在数据库里配置。运营人员可以随时添加、修改规则。
某天用户投诉:"为什么我的订单没有打折?"
你查代码,代码说:"根据规则引擎的结果执行。" 你查规则,规则有 200 条,互相嵌套,优先级复杂。你查日志,日志说:"规则 R237 匹配失败。"
最终行为不可推演,因为逻辑不在代码里。
2.5 状态蔓延:全局变量、单例、缓存
状态越多,可能的组合越多,推演越难。
java
arduino
public class Config {
public static String ENV = "prod";
public static boolean FEATURE_X_ENABLED = true;
public static int MAX_RETRY = 3;
}
一个全局配置,在三个地方被修改。你改了一个地方,另外两个地方的行为也会变。你能推演所有影响吗?
单例更糟。单例的状态在请求之间共享,一个请求修改了状态,另一个请求会受影响。并发环境下,单例的状态是不可推演的。
缓存是另一个状态源。缓存里的数据可能过期,可能不一致,可能被其他请求修改。你推演的是"正常情况",但缓存让"正常情况"变得不确定。
2.6 认知边界:你自己也不记得了
三个月前的代码,你已经忘了为什么这样写。
那个 if (status == 3) 里的 3 是什么?那个 Thread.sleep(500) 是为了解决什么问题?那个 try-catch 吞掉的异常,当初是为了绕过什么 bug?
没有文档、没有注释、没有测试------模型彻底丢失。
你写的代码,变成了别人的代码。你成了自己代码的陌生人。
第三章 可推演性的敌人:过度设计
3.1 过度设计如何摧毁可推演性
过度设计与可推演性是零和博弈。
每一个"为未来预留"的抽象,都是一个推演盲区。
每一个"可能用得上"的参数,都是一条未知路径。
每一个"灵活可配置"的设计,都是一个不可推演的黑箱。
当你为了"扩展性"而增加抽象层时,你牺牲的是调用者对行为的直接感知。
当你为了"灵活性"而引入配置驱动时,你牺牲的是代码逻辑的可见性。
当你为了"解耦"而拆分服务时,你牺牲的是路径的可追踪性。
过度设计的每一步,都在用可推演性换取想象中的好处。
3.2 抽象层的通货膨胀
案例:一个 CRUD 的七层之旅
一个简单的"查询用户"功能,经过:
UserController.getUser(id)UserService.getUser(id)UserManager.getUser(id)UserHelper.getUser(id)UserRepository.getUser(id)UserDAO.getUser(id)UserMapper.selectById(id)
每一层都只做一件事:转发。
改一个字段,需要改七个文件。加一个参数,需要改七个签名。
问:这个设计的可推演性在哪里?
答案:每一层都需要单独推演,但每一层的信息都是不完整的。Controller 不知道 Service 里做了什么,Service 不知道 Manager 里做了什么。推演被切碎在七层里,没有人有完整的模型。
3.3 配置驱动的陷阱
把逻辑从代码移到配置,看似灵活,实则不可推演。
yaml
less
rules:
- name: "vip_discount"
condition: "user.level >= 3 && order.amount > 100"
action: "discount(0.9)"
- name: "new_user_coupon"
condition: "user.registerDays < 7"
action: "coupon(10)"
代码里的 if 你能看懂,配置里的 if 你看不到。
配置越多,代码的静态结构越简单,但运行时行为越复杂。你推演的不再是代码,而是代码 + 配置 + 它们之间的交互。
3.4 微服务的粒度陷阱
拆分的成本是即时的,收益是远期的,可推演性是牺牲品。
案例:一个查询跨三个服务
用户查询自己的订单列表,需要:
- 订单服务:查订单
- 用户服务:查用户信息
- 商品服务:查商品信息
三个服务,三次网络调用,三种失败可能。
如果用户服务超时,订单列表还返回吗?如果商品服务挂了,显示什么?如果三个服务的数据不一致,以谁为准?
在单体里,这些问题的答案是确定的。在微服务里,它们是概率的。
3.5 本章小结:可推演性是过度设计的照妖镜
问"如果......会怎样",答不上来,就是过度设计。
- 答不上"改一个字段要动几个文件" → 抽象层太多
- 答不上"这个 Case 会怎么处理" → 隐式行为太多
- 答不上"这个请求经过哪些服务" → 分布式太碎
- 答不上"这个配置最终会怎样" → 配置驱动太深
可推演性不是过度设计的唯一代价,但它是最容易被感知的代价。
第四章 如何度量可推演性
4.1 问题一:新增一个模块,改动幅度是多少?
能立刻回答 → 高可推演性。你知道系统的边界在哪里,知道新模块会接入哪些点。
需要翻代码才能回答 → 中可推演性。你对系统有大致了解,但细节不清晰。
答不上来 → 低可推演性。系统已经超出了你的认知边界。
这个问题的本质:变更的影响范围是否可预测。
4.2 问题二:遇到 Case X,程序会给出什么结果?
能立刻回答 → 路径清晰。你知道所有分支,知道边界条件。
需要加日志才能回答 → 路径模糊。你知道正常流程,但边界情况不确定。
答不上来 → 路径不可知。代码的行为对你来说是一个黑箱。
这个问题的本质:执行路径是否可追踪。
4.3 问题三:如果并发翻倍,哪个环节先崩?
能指出具体环节 → 系统模型完整。你知道瓶颈在哪里,知道资源限制。
只能笼统说"数据库" → 模型粗糙。你知道大概方向,但不确定。
答不上来 → 模型缺失。你对系统的运行时行为没有概念。
这个问题的本质:系统行为是否可预测。
4.4 问题四:如果删掉这段代码,会怎样?
能立刻回答 → 依赖关系清晰。你知道谁调用了它,知道它的副作用。
需要全局搜索 → 依赖关系模糊。你需要工具来帮你找。
答不上来 → 依赖关系不可知。代码已经嵌入了一个你看不见的网络。
这个问题的本质:依赖关系是否透明。
4.5 可推演性自测清单
- 你能说出系统里所有的入口吗?
- 你能画出一个请求的完整路径吗?
- 你能说出每个模块的职责边界吗?
- 你能预测一个字段改名的全部影响吗?
- 你能说出所有的定时任务和它们的作用吗?
- 你能说出所有的异步消息和它们的消费者吗?
- 你能说出所有的缓存和它们的失效策略吗?
- 你能说出所有的配置项和它们的影响吗?
- 你能预测边界条件下的行为吗?
- 你能不运行代码就回答"如果......会怎样"吗?
如果超过三个答不上来,你的代码已经失去了可推演性。
第五章 如何写出可推演的代码
5.1 原则一:控制抽象层数
三层足够:入口层、逻辑层、数据层。
- 入口层:Controller、Handler、API
- 逻辑层:Service、UseCase、Domain
- 数据层:Repository、DAO、Mapper
超过三层,每一层都是转发,没有实际价值。
实践:能直接写的,不要封装。
如果一个方法只是转发,删掉它。如果一个类只是包装,删掉它。如果一个抽象只有一个实现,删掉它。
5.2 原则二:显式优于隐式
拒绝魔法:AOP、反射、动态代理能不用就不用。
- 副作用要显式:方法名体现行为,返回值体现结果。
- 不要用注解隐藏行为:
@Transactional可以,但要知道它做了什么。 - 不要用配置隐藏逻辑:配置可以,但逻辑要在代码里可见。
实践:如果一个行为你看不见,它就不该存在。
如果你无法从方法签名和方法体推演行为,那这个方法就是不可推演的。
5.3 原则三:限制状态
状态越少,组合越少,推演越易。
- 不可变优先:能用 final 就用 final。
- 无状态优先:能用参数传递,就不要用成员变量。
- 局部优先:能用局部变量,就不要用全局变量。
实践:能传参的,不要用全局;能局部的,不要用成员。
状态是推演的敌人。每一个状态变量,都是一个新的维度。
5.4 原则四:保持路径唯一
一个请求,只有一条路径。
- 拒绝配置驱动的分支:代码里的 if 你看得懂,配置里的 if 你看不到。
- 拒绝运行时的动态决策:运行时才知道走哪条路,等于不可推演。
- 拒绝多重条件嵌套:if 可以,但 if 的条件要能一眼看懂。
实践:if 可以,但 if 的条件要能一眼看懂。
如果 if 的条件需要三行才能写完,它就已经不可推演了。
5.5 原则五:边界清晰
模块之间,接口明确,职责单一。
- 一个模块的行为,不依赖另一个模块的内部。
- 一个模块的改动,不影响另一个模块的行为。
- 一个模块的推演,不需要理解另一个模块。
实践:改一个模块,不需要理解另一个模块。
如果你改 A 模块,需要先看懂 B 模块,那它们的边界就模糊了。
5.6 原则六:测试即文档
测试用例是可推演性的证明。
- 每个 Case 都有测试,推演就有依据。
- 测试不仅验证行为,还记录行为。
- 测试是唯一不会过时的文档。
实践:测试不仅验证行为,还记录行为。
如果一个行为没有测试,那它的推演就没有依据。你只能靠记忆,而记忆会消失。
5.7 原则七:保持小
文件小、函数小、类小、服务小。
- 小到你能在脑子里装下。
- 小到你能一口气读完。
- 小到你能在评审时全部看完。
实践:如果一个文件超过 500 行,它已经超出你的工作记忆。
工作记忆的容量是有限的。超过这个容量,推演就变成了猜测。
第六章 如何保持可推演性
6.1 定期做"推演练习"
随机选一段代码,问自己:
- 如果改这里,会怎样?
- 如果这个输入是空,会怎样?
- 如果这个服务挂了,会怎样?
- 如果并发翻倍,会怎样?
答不上来,就是盲区。
把推演练习变成习惯。每次改代码前,先做一遍推演。每次评审代码时,问一遍"如果......会怎样"。
6.2 写"变更影响分析"
每次改动前,先写:
- 改什么
- 影响什么
- 怎么验证
写不出来,说明你还没理解。
这个文档本身就是可推演性的证明。如果你能写出来,说明你有完整的模型。如果你写不出来,说明模型有洞。
6.3 拒绝"我不确定"
"我不确定"是可推演性的警报。
- 遇到不确定,要么搞清楚,要么重构到清楚。
- 不要带着不确定交付。
- 不确定的代码,就是不可推演的代码。
"我不确定"不是谦虚,是危险信号。
6.4 保持代码的新鲜度
- 定期重构:防止代码腐烂。
- 定期删除:防止抽象堆积。
- 定期阅读:防止模型过时。
代码会腐烂。三个月不看的代码,模型就开始模糊。一年不看的代码,模型就彻底丢失。
保持新鲜度,就是保持可推演性。
6.5 团队层面的可推演性
- 代码评审时问:如果......会怎样?
- 架构评审时问:改动影响范围是什么?
- 把可推演性作为代码质量的核心指标。
可推演性不是个人的事。团队里只要有一个人不推演,整个系统的可推演性就会下降。
在团队里,可推演性是一种共同责任。
结语:从作者到主人
你写的代码,不等于你懂的代码。
可推演性,是工程师对代码的掌控力。它比优雅更重要,比简洁更根本。
好的代码,是你能在脑子里运行一遍的代码。
- 你能预测它的行为。
- 你能追踪它的路径。
- 你能推演它的变更。
- 你能回答"如果......会怎样"。
从今天起,问自己:
- 如果加一个模块,要改多少?
- 如果遇到 Case X,会怎样?
- 如果并发翻倍,哪里先崩?
- 如果删掉这段代码,会怎样?
答不上来,就是过度设计在作祟。答得上来,你才是代码真正的主人。