43、代码坏味道:重复代码 - 实战提炼函数与模板方法
重复代码,这大概是所有程序员最早遇到的坏味道之一。我记得刚入行那会儿,项目经理指着屏幕上三块几乎一模一样的代码块问我:「你觉得这段代码有什么问题?」我当时愣了半天,觉得能跑就行啊。后来才明白,重复代码就像房间里慢慢滋生的霉菌——一开始只是一个小点,等你想清理的时候,整个结构都已经被腐蚀了。
今天我们就来聊聊怎么对付它。我会用两个实战手法:提炼函数和模板方法模式。这两种方式,一个偏微观,一个偏宏观,结合起来用效果最好。
重复代码的三种常见形态
先看看重复代码长什么样。我个人习惯把它们分成三类:
| 类型 | 特征 | 典型场景 |
|---|---|---|
| 字面重复 | 完全相同的代码块出现多次 | 多处校验逻辑、重复的SQL拼接 |
| 结构重复 | 逻辑流程相同,细节不同 | 多种支付方式的处理流程 |
| 语义重复 | 功能相同,实现方式不同 | 不同团队写的日期格式化工具 |
字面重复最好处理,提炼函数就搞定了。结构重复稍微麻烦点,需要模板方法。语义重复嘛……嗯,这往往需要团队层面的规范,今天先不展开。
实战一:提炼函数——从「复制粘贴」到「一处定义」
先看一个我最近在项目中重构的真实案例。一个订单系统里,有三处地方需要计算「订单的最终金额」:
// 场景A:结算页
function calculateSettlement(order) {
let total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (order.coupon) total -= order.coupon.discount;
if (total < 0) total = 0;
return total;
}
// 场景B:退款页
function calculateRefund(order) {
let total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (order.coupon) total -= order.coupon.discount;
if (total < 0) total = 0;
return total * 0.9; // 退款打九折
}
// 场景C:报表页
function calculateReport(order) {
let total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (order.coupon) total -= order.coupon.discount;
if (total < 0) total = 0;
return total;
}
看出来了吧?三处都有完全相同的「计算原始金额→扣减优惠券→保底为0」的逻辑。这就是典型的字面重复。
重构很简单,提炼一个公共函数:
function calculateBaseTotal(order) {
let total = order.items.reduce((sum, item) => sum + item.price * item.qty, 0);
if (order.coupon) total -= order.coupon.discount;
return Math.max(0, total);
}
// 改造后
function calculateSettlement(order) {
return calculateBaseTotal(order);
}
function calculateRefund(order) {
return calculateBaseTotal(order) * 0.9;
}
function calculateReport(order) {
return calculateBaseTotal(order);
}
calculateBaseTotal 就比 calculateTotalWithCoupon 更清晰。你想想看,调用者只关心「给我基础总额」,不关心里面有没有优惠券逻辑。
实战二:模板方法——把「骨架」和「血肉」分开
提炼函数能解决字面重复,但结构重复就没那么简单了。举个例子,一个数据导出系统,支持导出 CSV、Excel、PDF 三种格式。每种格式的导出流程都是:
- 查询数据
- 格式化数据
- 写入文件
- 发送通知
流程骨架一样,但「格式化数据」和「写入文件」的具体实现完全不同。如果每个导出类都写一遍完整流程,那就是结构重复。
模板方法模式就是干这个的。它把不变的流程放在父类,把变化的部分留给子类去实现。
// 抽象基类
class DataExporter {
export() {
const data = this.fetchData();
const formatted = this.formatData(data);
this.writeFile(formatted);
this.sendNotification();
}
fetchData() {
// 公共实现:从数据库查询
return db.query('SELECT * FROM reports');
}
sendNotification() {
// 公共实现:发送邮件
email.send('导出完成');
}
// 抽象方法,子类必须实现
formatData(data) { throw new Error('子类必须实现 formatData'); }
writeFile(data) { throw new Error('子类必须实现 writeFile'); }
}
// CSV 导出
class CsvExporter extends DataExporter {
formatData(data) {
return data.map(row => row.join(',')).join('\n');
}
writeFile(content) {
fs.writeFile('export.csv', content);
}
}
// Excel 导出
class ExcelExporter extends DataExporter {
formatData(data) {
return xlsx.build(data);
}
writeFile(workbook) {
xlsx.writeFile(workbook, 'export.xlsx');
}
}
什么时候用提炼函数,什么时候用模板方法?
我有个简单的判断标准:
- 重复的是「一段计算逻辑」 → 提炼函数。比如金额计算、字符串处理、校验规则。
- 重复的是「一个流程结构」 → 模板方法。比如数据导入导出、批处理任务、多步骤校验。
说白了,函数解决的是「怎么做」的重复,模板方法解决的是「做什么」的重复。
避坑指南
另一个坑是模板方法的「抽象类膨胀」。我见过一个项目,模板父类里有十几个抽象方法,子类实现起来痛苦不堪。这时候其实应该考虑把大模板拆成多个小模板,或者用策略模式替代。
知识体系总览
下面这张图总结了本章的核心逻辑:
总结
重复代码不是洪水猛兽,但放任不管就会变成技术债务。我的建议是:每次看到重复代码,先问自己三个问题:
- 这段逻辑以后会不会变?如果会,重复的代价就更大。
- 重复的代码是「恰好长得像」还是「本质相同」?前者可能是巧合,后者才是重构目标。
- 提炼后会不会让代码更难理解?如果会,考虑用更渐进的方式,比如先加注释说明重复点。
提炼函数和模板方法,一个管微观,一个管宏观。两者结合,基本能覆盖 90% 的重复代码场景。剩下的 10%……嗯,那就需要更高级的模式了,比如策略模式、组合模式,那是后面章节的内容。
公众号:蓝海资料掘金营,微信deep3321