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 三种格式。每种格式的导出流程都是:

  1. 查询数据
  2. 格式化数据
  3. 写入文件
  4. 发送通知

流程骨架一样,但「格式化数据」和「写入文件」的具体实现完全不同。如果每个导出类都写一遍完整流程,那就是结构重复。

模板方法模式就是干这个的。它把不变的流程放在父类,把变化的部分留给子类去实现。

// 抽象基类
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');
  }
}
核心思想:模板方法把「做什么」和「怎么做」分开了。父类说「我们要做 A→B→C」,子类说「A 我这样做,B 我那样做」。这样新增一种导出格式时,只需要写差异部分,不用重复整个流程。

什么时候用提炼函数,什么时候用模板方法?

我有个简单的判断标准:

  • 重复的是「一段计算逻辑」 → 提炼函数。比如金额计算、字符串处理、校验规则。
  • 重复的是「一个流程结构」 → 模板方法。比如数据导入导出、批处理任务、多步骤校验。

说白了,函数解决的是「怎么做」的重复,模板方法解决的是「做什么」的重复。

避坑指南

我曾经踩过的坑:有一次我把一个 200 行的函数提炼成 5 个小函数,结果每个函数只有 3 行代码。过度提炼反而让代码变得支离破碎,阅读时要跳来跳去。后来我给自己定了个规矩:如果一个函数提炼出来后,调用处反而更难理解了,那就别提炼。

另一个坑是模板方法的「抽象类膨胀」。我见过一个项目,模板父类里有十几个抽象方法,子类实现起来痛苦不堪。这时候其实应该考虑把大模板拆成多个小模板,或者用策略模式替代。

知识体系总览

下面这张图总结了本章的核心逻辑:

重复代码重构决策树 识别重复代码 字面重复(代码相同) 结构重复(流程相同) 提炼函数 提取公共逻辑,一处定义多处调用 模板方法 固定骨架,子类实现变化部分 目标:消除重复,提升可维护性

总结

重复代码不是洪水猛兽,但放任不管就会变成技术债务。我的建议是:每次看到重复代码,先问自己三个问题

  1. 这段逻辑以后会不会变?如果会,重复的代价就更大。
  2. 重复的代码是「恰好长得像」还是「本质相同」?前者可能是巧合,后者才是重构目标。
  3. 提炼后会不会让代码更难理解?如果会,考虑用更渐进的方式,比如先加注释说明重复点。

提炼函数和模板方法,一个管微观,一个管宏观。两者结合,基本能覆盖 90% 的重复代码场景。剩下的 10%……嗯,那就需要更高级的模式了,比如策略模式、组合模式,那是后面章节的内容。

最后分享一个习惯:我每次写完一段逻辑,都会刻意回头看一眼——这段代码是不是在别的地方见过?如果「见过」的感觉超过两次,我就会停下来做提炼。这个习惯帮我避免了很多后期的大规模重构。

公众号:蓝海资料掘金营,微信deep3321