抽一个公共方法之前:同一条知识,还是只是长得像
税率从 6% 调到 9%,开票模块改了,退款模块里还有一处
1.06没人记得。用户退款时发现退回的钱比付的少。另一个团队吸取了教训,把所有长得一样的校验都合并成了公共方法;三个月后,产品要求昵称支持中文,改完公共方法,优惠码也开始接受中文了。
「不要重复自己」是最广为人知的设计原则,也是最容易被用偏的一条。它说的不是「不要有两段一样的代码」,而是同一条知识只应该有一个权威来源。两段代码一样,可能是同一条知识写了两遍,也可能只是两条不同的规则今天恰好一样。前一种要合并,后一种合并了反而有害。
决定合并之后,还有第二个问题:把重复的代码提取成方法时,有几种语义会悄悄改变。本文把这两件事都放进实验里跑一遍。实验见文末。
一、同一条知识写在两处
开票与退款各写一遍税率 6%:开票 106.00,退款 106.00
税率改成 9%,只改到了开票:开票 109.00,退款仍是 106.00
两处都调用同一个 TaxPolicy,改一处:开票 109.00,退款 109.00税率是一条业务规则,开票和退款都在使用它。规则写在两处,改的时候就要两处都记得改。这类重复的特征是:它们会因为同一个原因、在同一时间改变。税率调整,两处必须同时改;改了一处没改另一处,系统就处于一个业务上不存在的状态。
这类重复往往不是逐字相同的。可能一处写的是 * 1.06,另一处是 + amount * 0.06,还有一处在 SQL 里。搜索 1.06 找不全。识别它们要从业务问题出发:「税率在哪些地方被用到」,而不是「哪些代码长得一样」。
合并的方式是给这条知识一个名字和一个位置:TaxPolicy、ShippingRules、RefundWindow。调用方表达「按税率计算」,而不是自己算一遍。
二、长得像,但不是同一条规则
昵称与优惠码今天的规则一样(4 到 20 位字母数字):昵称 "bob2026" true,优惠码 "七折优惠" false
合并成 isValidShortText 之后按昵称的新需求修改:昵称 "小明" true,优惠码 "七折优惠" 也变成 true昵称的长度规则来自用户体验,优惠码的格式规则来自营销系统和打印的限制。它们今天碰巧都是「4 到 20 位字母数字」,但改变它们的人、原因和时间都不同。合并成 isValidShortText 之后,两条规则被绑在了一起:昵称的需求一变,优惠码的规则也跟着变了,而改代码的人只想改昵称。
判断方法是问一句:如果需求变了,这两处是否一定要一起改? 一定要,就是同一条知识;不一定,就是偶然的相似。偶然相似的代码保持分开,各自用业务名称命名(isValidNickname、isValidCouponCode),即使今天内容一样。它们可以共享更底层的、真正通用的部分(例如一个字符集判断),但规则本身要分开。
这类错误合并在跨上下文时尤其常见:订单服务和库存服务里都有一个「商品」,字段也差不多,于是合并成一个公共的 Product 类。之后任何一方的需求变化都要两边协调。同一个词在不同上下文里是不同的概念,见从统一语言到限界上下文。
三、提取时会改变的三种语义
确认是同一条知识、决定提取之后,还要保证提取前后的行为一致。下面三种变化,代码看起来几乎一样,编译器不会提醒。
失败语义
两处都有一段「执行任务,失败时通知」的代码。原来的写法里,通知本身失败会被忽略,原异常照常抛出;提取公共方法时少写了一层 try:
static void runAndNotify(Runnable task) {
try { task.run(); } catch (RuntimeException e) { notifier.fail(e); throw e; }
}原写法:调用方收到 DbError(写入失败)
提取成 runAndNotify 之后:调用方收到 NotifierDown(通知服务不可用)
提取时把通知失败挂到原异常上:调用方收到 DbError(写入失败),suppressed 里有 NotifierDown通知服务一出问题,所有调用方看到的错误都变成了「通知服务不可用」,真正的写入失败被盖住了,排查的方向完全错误。正确的提取要保留原来的语义:通知失败不能取代原异常,挂到它的 suppressed 上:
try { task.run(); } catch (RuntimeException e) {
try { notifier.fail(e); } catch (RuntimeException n) { e.addSuppressed(n); }
throw e;
}异常信息在传播路径上的其他丢失方式,见出了错,日志里却没有可用的信息。
求值次数
if (debug) log(buildExpensiveDump()); // 原写法
logIf(debug, buildExpensiveDump()); // 提取后日志关闭时循环 1000 次:内联的 if 里 expensive() 调用 0 次;提取成 logIf(on, expensive()) 后 1000 次;参数改成 Supplier 后 0 次Java 的方法参数在调用前就求值了。原来 if 挡住了昂贵的构造,提取成方法之后,不管日志开没开,参数都先算好再传进去。如果这个「昂贵的构造」还有副作用(读数据库、计数),行为就变了。参数改成 Supplier,由方法内部决定要不要求值。
短路
cheapCheck() && consumeQuota() 执行 100 次,额度被扣 0 次;提取成 both(cheapCheck(), consumeQuota()) 后被扣 100 次&& 在左边为假时不会计算右边。提取成 both(a, b) 之后,两个参数都会先求值,右边那个扣额度的调用每次都执行了。条件里带副作用的调用,提取时要特别小心;更好的做法是让条件本身没有副作用。
三种变化的共同点是:提取改变了代码的执行时机。原来在某个条件之内或之后执行的东西,变成了在方法调用之前执行。检查一个提取是否保行为,要逐项比较:返回值、抛出的异常、副作用的次数和顺序。怎样系统地做这种比较,见把长方法拆开,哪些行为最容易被改掉。
四、提取的时机
两段代码一样不必立刻合并。几个实用的判断:
- 第一次出现,写下来;第二次出现,注意到;第三次出现,再考虑提取。 两个例子往往还看不出哪部分是共同的、哪部分是变化的,过早提取容易提取错。
- 提取的是规则,不是行数。 把三行相同的代码提取成方法,如果这三行没有一个业务名称(只能叫
helper1),通常说明它不是一个独立的概念。 - 参数越来越多是信号。 公共方法为了适应每个调用方,加了一个又一个参数和布尔开关,说明这些调用方其实不是同一条知识,应该拆回去。
- 提取之后读调用处,应该更清楚。 如果读者为了理解调用处,必须点进公共方法看它对这组参数做了什么,提取就没有帮上忙。
五、两份同构的流程:把差别收敛成一个参数
另一种常见的重复不是几行代码,而是两份结构相同的流程。HTTP 框架里的根资源和子资源就是一个例子:两者都要「用剩余路径匹配资源方法,匹配不上就尝试子资源定位器,再递归」,唯一的区别是资源实例从哪里来:根资源由容器创建,子资源是定位器方法返回的对象。
两份流程各写一遍,以后修改匹配规则(例如增加 HTTP 方法和媒体类型的选择)就要改两处。把差别收敛成一个参数,流程只保留一份:
// 之前:RootResource 和 SubResource 各自实现 match(path, context)
// 之后:一个 ResourceHandler,差别由「实例来源」这个参数表达
record ResourceHandler(Class<?> type, Function<ResourceContext, Object> instance) {
Optional<Result> match(String remaining, ResourceContext ctx) { ... }
}
ResourceHandler root = new ResourceHandler(Orders.class, ctx -> ctx.create(Orders.class));
ResourceHandler sub = new ResourceHandler(item.getClass(), ctx -> item); // 复用定位器返回的同一个对象这次提取要注意两点:
- 共享的是流程,不是生命周期。 根资源每个请求新建,子资源复用定位器返回的实例。参数化之后,测试仍要分别证明两条来源各自正确:根资源每次都是新对象,子资源就是定位器返回的那一个,而不是按声明类型重新构造。
- 通用的匹配函数要把完整的匹配结果交回调用方。 一个只返回「选中了哪个处理器」的通用
match,丢掉了剩余路径和路径参数,调用方只好再匹配一次,或者干脆拿不到这些信息。抽象的返回值要按调用方后续需要什么来设计,而不是按「这几个调用方共同需要的最少信息」来设计。
instance 参数之后,匹配规则只改一处;但根资源每个请求新建、子资源复用定位器返回的对象,两条来源仍要分别测试。匹配结果要把剩余路径和路径参数交回调用方参数化的扩展点怎样设计、什么时候应该改用策略对象,见多个产品共用一套流程。
六、检查清单
- 判断重复时问:需求变了,这几处是否一定要一起改。
- 同一条知识给一个名字和一个位置;偶然相似保持分开,各自用业务名称。
- 跨上下文的「同名概念」不合并。
- 提取前后比较返回值、异常、副作用的次数与顺序。
- 失败处理里的二次失败不能盖住原异常;带副作用或昂贵的参数用
Supplier;条件里不放副作用。 - 公共方法的参数和开关越加越多时,考虑拆回去。
配套实验
- codesphere-labs/refactoring/duplication-and-extraction:JDK 25.0.4.1:税率写在两处、昵称与优惠码合并规则后修改、提取公共方法后的失败语义、参数的求值次数与短路(验证记录)
参考资料