2重构(第2版)

重构原则

重构的定义与原则

两顶帽子重构理由

🎯 AI 时代的学习建议:不要只读理论。理解这一章的设计动机(为什么需要这个概念?)和边界条件(什么情况下会失效?)。当 AI 生成的代码涉及这些概念时,你能判断它是否用对了场景。AI 能帮你总结内容,但无法替代你亲手阅读、思考、建立直觉的过程。

第2章:重构原则(Principles in Refactoring)

一、导读

1.1 本章学习目标

第2章是《重构》的理论基础,Martin Fowler 在本章中系统阐述了重构的定义、原则、理由和挑战。如果说第1章让你"动手做",那么第2章则让你"理解为什么"。理解这些原则,你才能在实践中做出正确的判断。

本章的核心学习目标包括:

  • 准确定义重构:区分重构与其他代码修改活动
  • 理解"两顶帽子":添加功能时不重构,重构时不添加功能
  • 掌握重构的理由:为什么要花时间重构?收益在哪里?
  • 认识重构的挑战:数据库迁移、接口变更、遗留代码等难题
  • 把握重构的时机:三次法则、添加功能时、代码审查时
  • 理解重构与性能优化的关系:何时重构,何时优化

1.2 为什么本章如此重要

很多开发者对重构的态度是:"我知道应该重构,但没时间。"这种态度源于对重构价值的误解。本章将帮助你建立正确的认知:重构不是额外的负担,而是让开发更快的手段。

Fowler 用了一个精妙的类比:重构就像保持花园整洁。你可以让花园自然生长,但杂草会越来越多,最终你找不到想要的植物。定期除草看起来花了时间,但实际上它让你更快地找到和种植想要的植物。软件也是如此——不重构的代码库会逐渐变得难以修改,开发速度越来越慢。

1.3 核心问题引导

在阅读本章时,请带着以下问题思考:

重构的准确定义是什么?它与"清理代码"有什么区别?

"两顶帽子"原则为什么如此重要?违反它会带来什么问题?

重构的三大理由是什么?哪个理由最重要?

什么时候应该重构?什么时候不应该重构?

如何处理重构中的数据库迁移问题?

重构与性能优化应该如何协调?


二、核心概念详解

2.1 重构的准确定义

Fowler 对重构给出了两个略有不同的定义,分别用于名词和动词:

名词形式:对软件内部结构的一种修改,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。

动词形式:使用一系列重构手法,在不改变软件可观察行为的前提下,调整软件的结构。

这两个定义都强调了三个关键点:

内部结构的修改:重构改变的是代码的组织方式,而不是它的功能

不改变可观察行为:重构前后的程序在功能上必须完全一致

提高可理解性,降低修改成本:这是重构的目的

javascript
// 这不是重构:改变了函数的行为
function calculateTotal(items) {
  // 原来返回整数,现在返回浮点数
  return items.reduce((sum, item) => sum + item.price, 0) / 100;
}

// 这是重构:只改变内部结构,行为不变
function calculateTotal(items) {
  const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
  return totalPrice; // 行为与原来完全一致
}

2.2 两顶帽子

Fowler 提出了一个极其重要的原则:两顶帽子。

  • 添加功能时:不改变现有代码的结构。只关注让新功能工作,用测试来验证。
  • 重构时:不添加新功能。只关注改善代码结构,用测试来验证行为不变。

这两顶帽子不能同时戴。如果你发现自己在添加功能的同时也在重构,就应该停下来,选择一顶帽子。

这个原则的重要性在于:它让你在任何时刻都有一个明确的目标。当你添加功能时,不需要担心代码是否整洁;当你重构时,不需要担心是否遗漏了功能。这种分离极大地降低了认知负担。

javascript
// 错误做法:同时添加功能和重构
function processOrder(order) {
  // 在重构代码结构的同时添加了新的折扣逻辑
  const discount = calculateDiscount(order); // 新功能
  const total = order.items.reduce((sum, item) => {
    // 同时在重构计算逻辑
    return sum + item.price * item.quantity;
  }, 0) - discount;
  return total;
}

// 正确做法:先重构,再添加功能
// 第一步:重构(不添加新功能)
function processOrder(order) {
  const subtotal = calculateSubtotal(order);
  return subtotal;
}

function calculateSubtotal(order) {
  return order.items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// 第二步:添加新功能(不重构)
function processOrder(order) {
  const subtotal = calculateSubtotal(order);
  const discount = calculateDiscount(order); // 新功能
  return subtotal - discount;
}

2.3 为什么要重构

Fowler 列出了重构的三大理由:

2.3.1 重构改善软件的设计

重构就像整理房间——它让一切各归其位。当你发现一段代码做了太多事情时,你会把它拆分成多个职责单一的函数。当你发现两个函数有大量重复代码时,你会提取公共逻辑。这些改善累积起来,使软件的设计质量持续提升。

好的设计意味着可预测性。当设计良好时,你可以准确地预测修改一段代码会带来什么影响。当设计糟糕时,修改总是带来意想不到的副作用。

2.3.2 重构使软件更容易理解

代码是写给人看的,只是偶尔让机器执行。重构让代码的意图更加清晰——好的命名、短的函数、清晰的抽象,这些都让阅读代码的人(包括未来的你自己)能够更快地理解代码在做什么。

javascript
// 难以理解
const x = items.filter(i => i.s > 100 && i.t === 'A').map(i => i.p * 0.9);

// 重构后:意图清晰
const highValueItems = items.filter(isHighValueItem);
const discountedPrices = highValueItems.map(applyDiscount);

function isHighValueItem(item) {
  return item.sales > 100 && item.type === 'PREMIUM';
}

function applyDiscount(item) {
  return item.price * 0.9; // 10% discount for premium items
}

2.3.3 重构帮助找到 bug

当你改善代码结构时,你实际上在加深对代码的理解。这种理解往往能帮助你发现隐藏的 bug。正如 Fowler 所说:"我不是一个很会写 bug 的程序员,但我是一个很好的找 bug 的程序员——因为我经常重构。"

2.4 何时应该重构

Fowler 提出了几个关键的重构时机:

2.4.1 三次法则(Rule of Three)

Don Roberts 提出的一条经验法则:第一次做某件事时,直接做。第二次做类似的事时,可以再做一次,但会觉得有些别扭。第三次做类似的事时,就必须重构——提取公共逻辑。

javascript
// 第一次:直接写
function renderUser() {
  let result = "<div>";
  result += "<h1>" + user.name + "</h1>";
  result += "<p>" + user.email + "</p>";
  result += "</div>";
  return result;
}

// 第二次:类似但不同
function renderProduct() {
  let result = "<div>";
  result += "<h1>" + product.name + "</h1>";
  result += "<p>$" + product.price + "</p>";
  result += "</div>";
  return result;
}

// 第三次:必须重构
function renderCard(title, content) {
  return `<div><h1>${title}</h1><p>${content}</p></div>`;
}

function renderUser(user) {
  return renderCard(user.name, user.email);
}

function renderProduct(product) {
  return renderCard(product.name, `$${product.price}`);
}

2.4.2 添加功能时

当你需要添加新功能时,如果发现现有代码难以修改,就应该先重构它,然后再添加功能。这就是"两顶帽子"原则的实践——先戴重构的帽子,再戴添加功能的帽子。

2.4.3 代码审查时

代码审查是发现重构机会的最佳时机。审查者可以从旁观者的角度发现代码中的问题,而作者往往因为太熟悉自己的代码而忽视这些问题。

2.5 重构的挑战

2.5.1 数据库迁移

数据库是最难重构的部分之一。因为数据库的模式变更通常需要停机,而且数据的迁移是单向的——你很难回退。

Fowler 的建议是:将数据库模式与代码分离。使用"扩展-收缩"模式:先扩展数据库模式(添加新字段但不删除旧字段),然后逐步迁移代码到新字段,最后收缩(删除旧字段)。

sql
-- 扩展阶段:添加新列,保留旧列
ALTER TABLE users ADD COLUMN full_name VARCHAR(255);
-- 旧列 first_name, last_name 暂时保留

-- 迁移阶段:代码同时写入新旧列
UPDATE users SET full_name = first_name || ' ' || last_name;

-- 收缩阶段:代码完全迁移到新列后,删除旧列
ALTER TABLE users DROP COLUMN first_name;
ALTER TABLE users DROP COLUMN last_name;

2.5.2 接口变更

如果你修改的是一个公共 API 的接口,那么所有使用这个 API 的代码都需要修改。这种情况下,Fowler 建议使用"废弃-迁移"策略:

添加新接口,保留旧接口

将旧接口标记为废弃(deprecated)

让旧接口内部调用新接口

逐步迁移调用者到新接口

最终删除旧接口

javascript
// 旧接口
function getUser(id) {
  return db.query(`SELECT * FROM users WHERE id = ${id}`);
}

// 新接口
async function getUserAsync(id) {
  return await db.queryAsync(`SELECT * FROM users WHERE id = ${id}`);
}

// 过渡期:旧接口内部调用新接口
function getUser(id) {
  console.warn('getUser is deprecated, use getUserAsync instead');
  return getUserAsync(id); // 注意:这里需要处理异步
}

2.5.3 遗留代码

面对没有测试的遗留代码,重构的第一步是添加测试。但遗留代码往往难以测试——这就是"遗留代码的困境":你需要测试来重构,但你需要重构才能添加测试。

Fowler 的建议是:

先给遗留代码加上"测试接缝"(test seam)——即使不写完整的测试

从小处开始重构,逐步建立信心

使用"接枝法"——在旧代码旁边写新代码,逐步替代

2.6 重构与性能优化

很多开发者担心重构会降低性能。Fowler 的回应是:

大多数时候,性能不是问题:不要过早优化

重构通常对性能的影响微乎其微:好的结构不会显著降低性能

如果确实需要优化,先重构再优化:在整洁的代码上优化比在混乱的代码上优化容易得多

优化和重构要分开做:遵循"两顶帽子"原则

javascript
// 重构后性能可能略有变化,但通常可以忽略
// 如果确实需要优化,在重构后的代码上优化

// 重构后的清晰代码
function calculateTotal(items) {
  return items.reduce((sum, item) => sum + item.price * item.quantity, 0);
}

// 如果需要优化,可以在清晰代码的基础上进行
function calculateTotal(items) {
  // 缓存计算结果
  if (this._cachedTotal && this._cacheVersion === items.version) {
    return this._cachedTotal;
  }
  this._cachedTotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  this._cacheVersion = items.version;
  return this._cachedTotal;
}

三、实战案例

3.1 案例一:渐进式重构一个支付模块

以下是一个支付模块的渐进式重构过程,展示了如何在保持功能不变的前提下改善代码结构。

初始代码:

java
public class PaymentService {
  public boolean processPayment(Payment payment) {
    if (payment.getAmount() <= 0) return false;
    if (payment.getCurrency() == null) return false;
    
    // 验证用户
    User user = userDao.findById(payment.getUserId());
    if (user == null) return false;
    if (!user.isActive()) return false;
    
    // 计算手续费
    double fee = 0;
    if (payment.getMethod().equals("CREDIT_CARD")) {
      fee = payment.getAmount() * 0.029 + 0.30;
    } else if (payment.getMethod().equals("PAYPAL")) {
      fee = payment.getAmount() * 0.034 + 0.30;
    } else if (payment.getMethod().equals("BANK_TRANSFER")) {
      fee = payment.getAmount() > 10000 ? 25.0 : 15.0;
    }
    
    double total = payment.getAmount() + fee;
    
    // 检查余额
    if (payment.getMethod().equals("WALLET")) {
      double balance = walletService.getBalance(payment.getUserId());
      if (balance < total) return false;
      walletService.debit(payment.getUserId(), total);
    }
    
    // 记录交易
    Transaction tx = new Transaction();
    tx.setAmount(payment.getAmount());
    tx.setFee(fee);
    tx.setTotal(total);
    tx.setStatus("COMPLETED");
    tx.setTimestamp(new Date());
    transactionDao.save(tx);
    
    // 发送通知
    if (user.getEmail() != null) {
      emailService.sendPaymentConfirmation(user.getEmail(), payment, total);
    }
    
    return true;
  }
}

重构过程:

java
// 第一步:提取验证逻辑
public class PaymentService {
  public boolean processPayment(Payment payment) {
    if (!validatePayment(payment)) return false;
    
    User user = validateUser(payment.getUserId());
    if (user == null) return false;
    
    double fee = calculateFee(payment);
    double total = payment.getAmount() + fee;
    
    if (!checkBalance(payment, total)) return false;
    
    recordTransaction(payment, fee, total);
    sendNotification(user, payment, total);
    
    return true;
  }
  
  private boolean validatePayment(Payment payment) {
    return payment.getAmount() > 0 && payment.getCurrency() != null;
  }
  
  private User validateUser(String userId) {
    User user = userDao.findById(userId);
    return (user != null && user.isActive()) ? user : null;
  }
  
  private double calculateFee(Payment payment) {
    return feeCalculator.calculate(payment);
  }
  
  private boolean checkBalance(Payment payment, double total) {
    if (!payment.getMethod().equals("WALLET")) return true;
    return walletService.getBalance(payment.getUserId()) >= total;
  }
  
  private void recordTransaction(Payment payment, double fee, double total) {
    Transaction tx = new Transaction(payment.getAmount(), fee, total);
    transactionDao.save(tx);
  }
  
  private void sendNotification(User user, Payment payment, double total) {
    if (user.getEmail() != null) {
      emailService.sendPaymentConfirmation(user.getEmail(), payment, total);
    }
  }
}

// 第二步:提取手续费计算为策略模式
public class FeeCalculator {
  private final Map<String, FeeStrategy> strategies;
  
  public FeeCalculator() {
    this.strategies = Map.of(
      "CREDIT_CARD", new CreditCardFeeStrategy(),
      "PAYPAL", new PayPalFeeStrategy(),
      "BANK_TRANSFER", new BankTransferFeeStrategy(),
      "WALLET", new WalletFeeStrategy()
    );
  }
  
  public double calculate(Payment payment) {
    FeeStrategy strategy = strategies.getOrDefault(
      payment.getMethod(), 
      new DefaultFeeStrategy()
    );
    return strategy.calculate(payment.getAmount());
  }
}

interface FeeStrategy {
  double calculate(double amount);
}

class CreditCardFeeStrategy implements FeeStrategy {
  public double calculate(double amount) {
    return amount * 0.029 + 0.30;
  }
}

3.2 案例二:重构一个报表生成器

javascript
// 重构前:一个巨大的函数
function generateReport(data, format, options) {
  let result = '';
  
  if (format === 'html') {
    result += '<html><head><title>Report</title></head><body>';
    result += '<h1>' + data.title + '</h1>';
    result += '<table>';
    result += '<tr>';
    for (let col of data.columns) {
      result += '<th>' + col.label + '</th>';
    }
    result += '</tr>';
    for (let row of data.rows) {
      result += '<tr>';
      for (let col of data.columns) {
        result += '<td>' + row[col.key] + '</td>';
      }
      result += '</tr>';
    }
    result += '</table>';
    
    if (options.showSummary) {
      result += '<div class="summary">';
      result += '<p>Total rows: ' + data.rows.length + '</p>';
      for (let col of data.columns.filter(c => c.numeric)) {
        const sum = data.rows.reduce((s, r) => s + Number(r[col.key]), 0);
        result += `<p>${col.label} total: ${sum}</p>`;
      }
      result += '</div>';
    }
    
    result += '</body></html>';
  } else if (format === 'csv') {
    result += data.columns.map(c => c.label).join(',') + '\n';
    for (let row of data.rows) {
      result += data.columns.map(c => row[c.key]).join(',') + '\n';
    }
    if (options.showSummary) {
      result += '\n';
      result += 'Total rows,' + data.rows.length + '\n';
      for (let col of data.columns.filter(c => c.numeric)) {
        const sum = data.rows.reduce((s, r) => s + Number(r[col.key]), 0);
        result += col.label + ' total,' + sum + '\n';
      }
    }
  }
  
  return result;
}

// 重构后:职责分离
class ReportGenerator {
  constructor(formatter, summarizer) {
    this.formatter = formatter;
    this.summarizer = summarizer;
  }
  
  generate(reportData, options = {}) {
    const sections = [
      this.formatter.formatHeader(reportData),
      this.formatter.formatTable(reportData),
    ];
    
    if (options.showSummary) {
      sections.push(this.summarizer.formatSummary(reportData));
    }
    
    return this.formatter.wrapDocument(sections);
  }
}

class HtmlFormatter {
  formatHeader(data) {
    return `<h1>${data.title}</h1>`;
  }
  
  formatTable(data) {
    const header = this.formatTableHeader(data.columns);
    const body = this.formatTableBody(data.rows, data.columns);
    return `<table>${header}${body}</table>`;
  }
  
  formatTableHeader(columns) {
    const cells = columns.map(c => `<th>${c.label}</th>`).join('');
    return `<tr>${cells}</tr>`;
  }
  
  formatTableBody(rows, columns) {
    return rows.map(row => {
      const cells = columns.map(c => `<td>${row[c.key]}</td>`).join('');
      return `<tr>${cells}</tr>`;
    }).join('');
  }
  
  wrapDocument(sections) {
    return `<html><head><title>Report</title></head><body>${sections.join('')}</body></html>`;
  }
}

class SummaryGenerator {
  formatSummary(data) {
    const parts = [
      `<p>Total rows: ${data.rows.length}</p>`,
      ...this.formatNumericSummaries(data)
    ];
    return `<div class="summary">${parts.join('')}</div>`;
  }
  
  formatNumericSummaries(data) {
    return data.columns
      .filter(c => c.numeric)
      .map(c => {
        const sum = data.rows.reduce((s, r) => s + Number(r[c.key]), 0);
        return `<p>${c.label} total: ${sum}</p>`;
      });
  }
}

四、本章小结

第2章建立了重构的理论基础:

重构的定义:在不改变软件可观察行为的前提下,改善其内部结构

两顶帽子:添加功能和重构是两个独立的活动,不能同时进行

重构的理由:改善设计、提高可理解性、帮助发现 bug

重构的时机:三次法则、添加功能时、代码审查时

重构的挑战:数据库迁移、接口变更、遗留代码都有应对策略

重构与性能:先重构再优化,两者分开进行

本章的核心思想是:重构不是可选的,而是持续开发过程中不可或缺的一部分。它不是额外的负担,而是让开发更快、更安全的手段。正如 Fowler 所说:"重构的目的不是让代码完美,而是让代码更容易变得完美。"


五、思考与练习

思考题

"两顶帽子"原则在实际工作中可行吗?如何向团队推广这个理念?

重构的三大理由中,你认为哪个最重要?为什么?

如何处理"老板不给时间重构"的问题?如何量化重构的收益?

数据库重构为什么特别困难?有哪些策略可以降低风险?

编程练习

在一个你熟悉的项目中,找到一段需要重构的代码。先写测试,然后按照"两顶帽子"原则进行重构。

尝试对一个公共 API 进行接口重构,使用"废弃-迁移"策略,确保向后兼容。

对比重构前后的代码,用具体的指标(如函数长度、圈复杂度、重复率)量化改善程度。


2/12重构原则
0%