1重构(第2版)

重构,第一个示例

从一个简单的程序开始

重构测试小步前进

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

第1章:重构,第一个示例(Refactoring, First Example)

一、导读

1.1 本章学习目标

第1章是《重构:改善既有代码的设计(第2版)》的开篇之作,Martin Fowler 通过一个完整的戏剧演出计费程序,带领读者体验重构的全过程。本章不急于引入抽象概念,而是让你在实践中感受重构的节奏和思维方式。

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

  • 理解重构的定义:在不改变代码外在行为的前提下,改善其内部结构
  • 掌握重构的基本节奏:小步前进、频繁测试、每次提交都保持可工作状态
  • 体验重构的全过程:从一个混乱的函数逐步演化为结构清晰的代码
  • 理解测试在重构中的核心地位:没有测试就不敢重构
  • 区分重构与重写:渐进式改善 vs 推倒重来
  • 建立重构的信心:通过小步修改和测试验证来积累信心

1.2 为什么第一章如此重要

很多技术书籍的第一章都是概念性的铺垫,但 Fowler 选择了截然不同的方式——直接上手实践。这种教学设计背后有深刻的考量:重构不是一种你可以"理解"然后"应用"的技术,而是一种需要在实践中体会的技能。就像学游泳一样,你不能只靠读书学会,必须下水。

本章选择的示例程序看似简单——为戏剧演出生成账单——但它暴露了真实代码中最常见的问题:过长的函数、重复的代码、不清晰的命名、纠缠在一起的逻辑。通过一步步重构这个程序,你会看到代码如何从"能工作"变成"优雅",更重要的是,你会感受到重构的节奏:修改-测试-提交的循环。

1.3 核心问题引导

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

什么是"重构"?它与"重写"或"优化"有什么本质区别?

为什么 Fowler 强调"小步前进"?大步修改有什么问题?

测试在重构中扮演什么角色?没有测试能重构吗?

如何判断一段代码需要重构?什么样的代码是"坏代码"?

重构的过程中如何保证程序的行为不变?

重构完成后,代码在哪些方面得到了改善?


二、核心概念详解

2.1 重构的定义

重构(Refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码内部结构进行调整,以改善其可理解性和可修改性。

这个定义中有两个关键短语需要仔细理解:

"不改变代码外在行为" 意味着重构不是修复 bug,也不是添加新功能。重构前后,程序对外表现出的行为应该完全一致。如果你输入相同的数据,应该得到相同的结果。这是重构与重写的根本区别——重写可能会改变行为,而重构不会。

"改善其内部结构" 意味着重构关注的是代码的"内部质量":命名是否清晰、函数是否短小、职责是否单一、重复是否消除。这些改善不会直接影响程序的运行速度或输出结果,但会极大地影响代码的可读性、可维护性和可扩展性。

javascript
// 重构前:行为正确但结构混乱
function statement(invoice, plays) {
  let totalAmount = 0;
  let volumeCredits = 0;
  let result = `Statement for ${invoice.customer}\n`;
  
  for (let perf of invoice.performances) {
    volumeCredits += Math.max(perf.audience - 30, 0);
    let thisAmount = 0;
    switch (playFor(perf).type) {
      case "tragedy":
        thisAmount = 40000;
        if (perf.audience > 30) {
          thisAmount += 1000 * (perf.audience - 30);
        }
        break;
      case "comedy":
        thisAmount = 30000;
        if (perf.audience > 20) {
          thisAmount += 10000 + 500 * (perf.audience - 20);
        }
        volumeCredits += Math.floor(perf.audience / 5);
        break;
    }
    result += ` ${playFor(perf).name}: $${thisAmount/100} (${perf.audience} seats)\n`;
    totalAmount += thisAmount;
  }
  result += `Amount owed is $${totalAmount/100}\n`;
  result += `You earned ${volumeCredits} credits\n`;
  return result;
}

这段代码能正确工作,但它有多个问题:函数过长、职责不单一、命名不清晰、计算逻辑与格式化逻辑纠缠在一起。重构的目标就是改善这些内部问题,同时保持输出不变。

2.2 小步前进的原则

Fowler 反复强调的一个核心原则是:小步前进,频繁测试。这不是因为程序员能力不够,而是因为人类在处理变化时的认知局限。

当你一次做了大量修改时,如果测试失败了,你可能需要花很长时间才能定位问题。但如果你每次只做一小步修改,测试失败时你就能立即知道问题出在哪里。这就是小步前进的价值——它将调试的成本降低到接近零。

重构的节奏应该是这样的:

选择一个小步骤:比如重命名一个变量

执行修改:只修改这一个变量

运行测试:确认所有测试仍然通过

提交代码:将这个小改动提交到版本控制

重复:选择下一个小步骤

这个节奏看起来可能很慢,但实际上它比大步修改更快。因为大步修改一旦出错,你需要花费大量时间调试,而小步修改几乎不会出错。

javascript
// 第一步:提取一个函数
// 重构前
function statement(invoice, plays) {
  let totalAmount = 0;
  // ... 50行代码 ...
  return result;
}

// 重构后:提取了 calculateTotalAmount 函数
function statement(invoice, plays) {
  const totalAmount = calculateTotalAmount(invoice, plays);
  // ... 其余代码 ...
  return result;
}

function calculateTotalAmount(invoice, plays) {
  let totalAmount = 0;
  // ... 计算逻辑 ...
  return totalAmount;
}

2.3 测试是重构的安全网

如果没有测试,你就不敢重构。因为每次修改都可能引入 bug,而你没有手段来验证修改是否正确。测试就像攀岩时的安全绳——它不能防止你犯错,但它能在你犯错时接住你。

Fowler 强调的测试策略是:

  • 自动化测试:测试必须能够一键运行,不需要人工干预
  • 快速反馈:测试应该在几秒到几分钟内完成,否则你会不愿意运行它们
  • 高覆盖率:关键路径必须被测试覆盖
  • 测试也是代码:测试代码也需要维护,也需要整洁
javascript
// 测试示例
function testStatement() {
  const invoice = {
    customer: "BigCo",
    performances: [
      { playID: "hamlet", audience: 55 },
      { playID: "as-like", audience: 35 },
      { playID: "othello", audience: 40 }
    ]
  };
  
  const plays = {
    hamlet: { name: "Hamlet", type: "tragedy" },
    "as-like": { name: "As You Like It", type: "comedy" },
    othello: { name: "Othello", type: "tragedy" }
  };
  
  const result = statement(invoice, plays);
  
  // 验证输出
  assert(result.includes("Statement for BigCo"));
  assert(result.includes("Hamlet: $650.00"));
  assert(result.includes("Amount owed is $1,920.00"));
  assert(result.includes("You earned 47 credits"));
}

2.4 重构的节奏:修改-测试-提交

Fowler 描述的重构节奏可以概括为三个词的循环:修改-测试-提交。

修改:每次只做一个小的修改。这个"小"是相对的——对于经验丰富的重构者,一步可能包含多个手法;对于初学者,一步可能只是重命名一个变量。关键是每一步都应该足够小,使得测试能够立即发现问题。

测试:修改完成后立即运行测试。如果测试通过,继续下一步;如果测试失败,立即回退到上一个可工作状态,找出问题所在,然后用更小的步骤重新尝试。

提交:测试通过后立即提交代码。这样你就有了一个可以回退的安全点。频繁提交还有一个好处:它让你的提交历史变得清晰,每次提交都是一个有意义的、可理解的改动。

2.5 重构与重写的区别

很多开发者在面对"坏代码"时的第一反应是:"这段代码太烂了,我要重写它。"这种冲动是可以理解的,但往往是危险的。

重写的问题在于:

  • 重写期间无法交付:重写通常需要数周甚至数月,期间无法添加新功能
  • 重写的风险极高:你可能在重写过程中引入新的 bug
  • 重写容易范围蔓延:"既然都重写了,不如把那个也改了"
  • 重写可能永远不会完成:很多重写项目最终被取消

重构的优势在于:

  • 渐进式改善:每次只改一小部分,风险可控
  • 持续交付:重构期间仍然可以添加新功能
  • 可回退:任何一步出问题都可以回退
  • 累积效应:多次小重构的累积效果可以媲美重写
javascript
// 重写的方式:完全重新实现
// 风险高,周期长,可能引入新 bug

// 重构的方式:逐步改善
// 第1步:提取函数
function statement(invoice, plays) {
  const statementData = createStatementData(invoice, plays);
  return renderPlainText(statementData);
}

// 第2步:进一步分解
function createStatementData(invoice, plays) {
  return {
    customer: invoice.customer,
    performances: invoice.performances.map(enrichPerformance),
    totalAmount: totalAmount(statementData),
    totalVolumeCredits: totalVolumeCredits(statementData)
  };
}

// 第3步:继续细化...

2.6 重构的完整示例

让我们完整地走一遍本章示例的重构过程。起始代码是一个 statement 函数,它同时负责计算和格式化。我们的目标是将其分解为清晰的、职责单一的模块。

第一步:提取计算逻辑

首先,我们注意到 statement 函数中混合了计算逻辑(金额计算、积分计算)和格式化逻辑(生成文本报告)。这是第一个需要解决的问题——职责不单一。

javascript
// 提取 createStatementData 函数
function statement(invoice, plays) {
  return renderPlainText(createStatementData(invoice, plays));
}

function createStatementData(invoice, plays) {
  const result = {};
  result.customer = invoice.customer;
  result.performances = invoice.performances.map(enrichPerformance);
  result.totalAmount = totalAmount(result);
  result.totalVolumeCredits = totalVolumeCredits(result);
  return result;
  
  function enrichPerformance(aPerformance) {
    const calculator = createPerformanceCalculator(aPerformance, playFor(aPerformance));
    const result = Object.assign({}, calculator);
    result.play = calculator.play;
    return result;
  }
  
  function playFor(aPerformance) {
    return plays[aPerformance.playID];
  }
  
  function totalAmount(data) {
    return data.performances.reduce((total, p) => total + p.amount, 0);
  }
  
  function totalVolumeCredits(data) {
    return data.performances.reduce((total, p) => total + p.volumeCredits, 0);
  }
}

第二步:提取格式化逻辑

将文本格式化逻辑提取到独立的函数中:

javascript
function renderPlainText(data) {
  let result = `Statement for ${data.customer}\n`;
  for (let perf of data.performances) {
    result += ` ${perf.play.name}: $${perf.amount/100} (${perf.audience} seats)\n`;
  }
  result += `Amount owed is $${data.totalAmount/100}\n`;
  result += `You earned ${data.totalVolumeCredits} credits\n`;
  return result;
}

第三步:引入多态

最终的改进是引入多态来替代 switch 语句,使得添加新的演出类型变得容易:

javascript
function createPerformanceCalculator(aPerformance, aPlay) {
  switch (aPlay.type) {
    case "tragedy": return new TragedyCalculator(aPerformance, aPlay);
    case "comedy": return new ComedyCalculator(aPerformance, aPlay);
    default:
      throw new Error(`unknown type: ${aPlay.type}`);
  }
}

class PerformanceCalculator {
  constructor(aPerformance, aPlay) {
    this.performance = aPerformance;
    this.play = aPlay;
  }
  
  get amount() {
    throw new Error('subclass responsibility');
  }
  
  get volumeCredits() {
    return Math.max(this.performance.audience - 30, 0);
  }
}

class TragedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 40000;
    if (this.performance.audience > 30) {
      result += 1000 * (this.performance.audience - 30);
    }
    return result;
  }
}

class ComedyCalculator extends PerformanceCalculator {
  get amount() {
    let result = 30000;
    if (this.performance.audience > 20) {
      result += 10000 + 500 * (this.performance.audience - 20);
    }
    result += 500 * this.performance.audience;
    return result;
  }
  
  get volumeCredits() {
    return super.volumeCredits + Math.floor(this.performance.audience / 5);
  }
}

三、实战案例

3.1 案例一:电商订单处理系统

以下是一个电商订单处理系统的重构案例,展示了如何从混乱的代码逐步演化为清晰的设计。

重构前的代码:

java
public class OrderProcessor {
  public String processOrder(Order order) {
    double total = 0;
    String result = "Order #" + order.getId() + "\n";
    
    for (Item item : order.getItems()) {
      double itemPrice = 0;
      if (item.getType().equals("ELECTRONICS")) {
        itemPrice = item.getPrice() * 1.13; // 含税
        if (item.getPrice() > 1000) {
          itemPrice *= 0.95; // 大额折扣
        }
      } else if (item.getType().equals("BOOK")) {
        itemPrice = item.getPrice();
        if (item.getQuantity() > 5) {
          itemPrice *= 0.9; // 批量折扣
        }
      } else if (item.getType().equals("FOOD")) {
        itemPrice = item.getPrice() * 1.05; // 食品税
        if (item.isExpired()) {
          itemPrice = 0; // 过期免费
        }
      }
      
      total += itemPrice * item.getQuantity();
      result += "  " + item.getName() + ": $" + itemPrice + "\n";
    }
    
    result += "Total: $" + total + "\n";
    
    if (order.getCustomer().isVIP()) {
      total *= 0.9; // VIP 折扣
      result += "VIP Discount Applied\n";
    }
    
    return result;
  }
}

重构后的代码:

java
public class OrderProcessor {
  private final Map<String, PricingStrategy> pricingStrategies;
  
  public OrderProcessor() {
    this.pricingStrategies = Map.of(
      "ELECTRONICS", new ElectronicsPricing(),
      "BOOK", new BookPricing(),
      "FOOD", new FoodPricing()
    );
  }
  
  public Receipt processOrder(Order order) {
    List<ReceiptLine> lines = order.getItems().stream()
      .map(this::calculateLine)
      .collect(Collectors.toList());
    
    double subtotal = lines.stream()
      .mapToDouble(ReceiptLine::getLineTotal)
      .sum();
    
    double discount = order.getCustomer().isVIP() ? subtotal * 0.1 : 0;
    double total = subtotal - discount;
    
    return new Receipt(order.getId(), lines, subtotal, discount, total);
  }
  
  private ReceiptLine calculateLine(Item item) {
    PricingStrategy strategy = pricingStrategies.get(item.getType());
    double unitPrice = strategy.calculatePrice(item);
    double lineTotal = unitPrice * item.getQuantity();
    return new ReceiptLine(item.getName(), unitPrice, item.getQuantity(), lineTotal);
  }
}

interface PricingStrategy {
  double calculatePrice(Item item);
}

class ElectronicsPricing implements PricingStrategy {
  public double calculatePrice(Item item) {
    double price = item.getPrice() * 1.13;
    if (item.getPrice() > 1000) {
      price *= 0.95;
    }
    return price;
  }
}

class BookPricing implements PricingStrategy {
  public double calculatePrice(Item item) {
    double price = item.getPrice();
    if (item.getQuantity() > 5) {
      price *= 0.9;
    }
    return price;
  }
}

class FoodPricing implements PricingStrategy {
  public double calculatePrice(Item item) {
    if (item.isExpired()) return 0;
    return item.getPrice() * 1.05;
  }
}

3.2 案例二:用户认证模块重构

重构前:

javascript
function authenticateUser(username, password, db) {
  const user = db.query(`SELECT * FROM users WHERE username = '${username}'`);
  
  if (user) {
    const hashedPassword = crypto.createHash('md5').update(password).digest('hex');
    if (user.password === hashedPassword) {
      if (user.isLocked) {
        if (user.lockExpiry < Date.now()) {
          user.isLocked = false;
          db.update(user);
        } else {
          return { success: false, message: "Account locked" };
        }
      }
      
      user.lastLogin = Date.now();
      user.loginCount++;
      db.update(user);
      
      const token = jwt.sign({ userId: user.id, role: user.role }, 'secret');
      return { success: true, token: token, user: user };
    } else {
      user.failedAttempts++;
      if (user.failedAttempts >= 5) {
        user.isLocked = true;
        user.lockExpiry = Date.now() + 30 * 60 * 1000;
      }
      db.update(user);
      return { success: false, message: "Invalid password" };
    }
  } else {
    return { success: false, message: "User not found" };
  }
}

重构后:

javascript
class AuthenticationService {
  constructor(userRepository, tokenService, lockService) {
    this.userRepository = userRepository;
    this.tokenService = tokenService;
    this.lockService = lockService;
  }
  
  async authenticate(credentials) {
    const user = await this.userRepository.findByUsername(credentials.username);
    if (!user) {
      throw new AuthenticationError("Invalid credentials");
    }
    
    if (this.lockService.isLocked(user)) {
      throw new AccountLockedError("Account is temporarily locked");
    }
    
    if (!this.verifyPassword(credentials.password, user.passwordHash)) {
      await this.handleFailedLogin(user);
      throw new AuthenticationError("Invalid credentials");
    }
    
    await this.handleSuccessfulLogin(user);
    const token = this.tokenService.generateToken(user);
    
    return new AuthenticationResult(user, token);
  }
  
  verifyPassword(plainPassword, hashedPassword) {
    return bcrypt.compareSync(plainPassword, hashedPassword);
  }
  
  async handleFailedLogin(user) {
    user.recordFailedAttempt();
    await this.userRepository.save(user);
    
    if (user.failedAttempts >= 5) {
      this.lockService.lock(user);
    }
  }
  
  async handleSuccessfulLogin(user) {
    user.recordSuccessfulLogin();
    await this.userRepository.save(user);
  }
}

四、本章小结

第1章通过一个完整的戏剧演出计费程序,展示了重构的全过程:

重构的定义:在不改变代码外在行为的前提下改善其内部结构

小步前进:每次只做一小步修改,频繁测试,确保每步都正确

测试是安全网:没有测试就不敢重构,自动化测试是重构的前提

重构的节奏:修改-测试-提交的循环

重构 vs 重写:渐进式改善比推倒重来更安全、更可控

重构的收益:代码变得更清晰、更易理解、更易修改

本章最重要的启示是:重构不是一次性的活动,而是一种持续的习惯。好的代码不是第一次就写出来的,而是在不断的重构中演化出来的。正如 Fowler 所说:"任何一个傻瓜都能写出计算机可以理解的代码,唯有优秀的程序员才能写出人类可以理解的代码。"


五、思考与练习

思考题

重构与优化(性能优化)有什么区别?它们可以同时进行吗?

如果你接手一个完全没有测试的遗留代码,你敢重构吗?应该怎么做?

"小步前进"的"小"是多大?如何判断一步是否足够小?

重构是否会增加项目的时间成本?如何向项目经理证明重构的价值?

编程练习

拿你项目中的一段代码,尝试用本章学到的重构手法进行改善。记录每一步修改和测试结果。

为一个没有测试的函数编写测试,然后尝试重构它。体会测试给重构带来的信心。

将一个包含 switch 语句的函数重构为使用多态的版本,比较两者的可读性和可扩展性。


1/12重构,第
0%