第1章:重构,第一个示例(Refactoring, First Example)
一、导读
1.1 本章学习目标
第1章是《重构:改善既有代码的设计(第2版)》的开篇之作,Martin Fowler 通过一个完整的戏剧演出计费程序,带领读者体验重构的全过程。本章不急于引入抽象概念,而是让你在实践中感受重构的节奏和思维方式。
本章的核心学习目标包括:
- 理解重构的定义:在不改变代码外在行为的前提下,改善其内部结构
- 掌握重构的基本节奏:小步前进、频繁测试、每次提交都保持可工作状态
- 体验重构的全过程:从一个混乱的函数逐步演化为结构清晰的代码
- 理解测试在重构中的核心地位:没有测试就不敢重构
- 区分重构与重写:渐进式改善 vs 推倒重来
- 建立重构的信心:通过小步修改和测试验证来积累信心
1.2 为什么第一章如此重要
很多技术书籍的第一章都是概念性的铺垫,但 Fowler 选择了截然不同的方式——直接上手实践。这种教学设计背后有深刻的考量:重构不是一种你可以"理解"然后"应用"的技术,而是一种需要在实践中体会的技能。就像学游泳一样,你不能只靠读书学会,必须下水。
本章选择的示例程序看似简单——为戏剧演出生成账单——但它暴露了真实代码中最常见的问题:过长的函数、重复的代码、不清晰的命名、纠缠在一起的逻辑。通过一步步重构这个程序,你会看到代码如何从"能工作"变成"优雅",更重要的是,你会感受到重构的节奏:修改-测试-提交的循环。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
什么是"重构"?它与"重写"或"优化"有什么本质区别?
为什么 Fowler 强调"小步前进"?大步修改有什么问题?
测试在重构中扮演什么角色?没有测试能重构吗?
如何判断一段代码需要重构?什么样的代码是"坏代码"?
重构的过程中如何保证程序的行为不变?
重构完成后,代码在哪些方面得到了改善?
二、核心概念详解
2.1 重构的定义
重构(Refactoring)是这样一个过程:在不改变代码外在行为的前提下,对代码内部结构进行调整,以改善其可理解性和可修改性。
这个定义中有两个关键短语需要仔细理解:
"不改变代码外在行为" 意味着重构不是修复 bug,也不是添加新功能。重构前后,程序对外表现出的行为应该完全一致。如果你输入相同的数据,应该得到相同的结果。这是重构与重写的根本区别——重写可能会改变行为,而重构不会。
"改善其内部结构" 意味着重构关注的是代码的"内部质量":命名是否清晰、函数是否短小、职责是否单一、重复是否消除。这些改善不会直接影响程序的运行速度或输出结果,但会极大地影响代码的可读性、可维护性和可扩展性。
// 重构前:行为正确但结构混乱
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 反复强调的一个核心原则是:小步前进,频繁测试。这不是因为程序员能力不够,而是因为人类在处理变化时的认知局限。
当你一次做了大量修改时,如果测试失败了,你可能需要花很长时间才能定位问题。但如果你每次只做一小步修改,测试失败时你就能立即知道问题出在哪里。这就是小步前进的价值——它将调试的成本降低到接近零。
重构的节奏应该是这样的:
选择一个小步骤:比如重命名一个变量
执行修改:只修改这一个变量
运行测试:确认所有测试仍然通过
提交代码:将这个小改动提交到版本控制
重复:选择下一个小步骤
这个节奏看起来可能很慢,但实际上它比大步修改更快。因为大步修改一旦出错,你需要花费大量时间调试,而小步修改几乎不会出错。
// 第一步:提取一个函数
// 重构前
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 强调的测试策略是:
- 自动化测试:测试必须能够一键运行,不需要人工干预
- 快速反馈:测试应该在几秒到几分钟内完成,否则你会不愿意运行它们
- 高覆盖率:关键路径必须被测试覆盖
- 测试也是代码:测试代码也需要维护,也需要整洁
// 测试示例
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
- 重写容易范围蔓延:"既然都重写了,不如把那个也改了"
- 重写可能永远不会完成:很多重写项目最终被取消
重构的优势在于:
- 渐进式改善:每次只改一小部分,风险可控
- 持续交付:重构期间仍然可以添加新功能
- 可回退:任何一步出问题都可以回退
- 累积效应:多次小重构的累积效果可以媲美重写
// 重写的方式:完全重新实现
// 风险高,周期长,可能引入新 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 函数中混合了计算逻辑(金额计算、积分计算)和格式化逻辑(生成文本报告)。这是第一个需要解决的问题——职责不单一。
// 提取 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);
}
}第二步:提取格式化逻辑
将文本格式化逻辑提取到独立的函数中:
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 语句,使得添加新的演出类型变得容易:
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 案例一:电商订单处理系统
以下是一个电商订单处理系统的重构案例,展示了如何从混乱的代码逐步演化为清晰的设计。
重构前的代码:
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;
}
}重构后的代码:
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 案例二:用户认证模块重构
重构前:
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" };
}
}重构后:
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 语句的函数重构为使用多态的版本,比较两者的可读性和可扩展性。