第2章:重构原则(Principles in Refactoring)
一、导读
1.1 本章学习目标
第2章是《重构》的理论基础,Martin Fowler 在本章中系统阐述了重构的定义、原则、理由和挑战。如果说第1章让你"动手做",那么第2章则让你"理解为什么"。理解这些原则,你才能在实践中做出正确的判断。
本章的核心学习目标包括:
- 准确定义重构:区分重构与其他代码修改活动
- 理解"两顶帽子":添加功能时不重构,重构时不添加功能
- 掌握重构的理由:为什么要花时间重构?收益在哪里?
- 认识重构的挑战:数据库迁移、接口变更、遗留代码等难题
- 把握重构的时机:三次法则、添加功能时、代码审查时
- 理解重构与性能优化的关系:何时重构,何时优化
1.2 为什么本章如此重要
很多开发者对重构的态度是:"我知道应该重构,但没时间。"这种态度源于对重构价值的误解。本章将帮助你建立正确的认知:重构不是额外的负担,而是让开发更快的手段。
Fowler 用了一个精妙的类比:重构就像保持花园整洁。你可以让花园自然生长,但杂草会越来越多,最终你找不到想要的植物。定期除草看起来花了时间,但实际上它让你更快地找到和种植想要的植物。软件也是如此——不重构的代码库会逐渐变得难以修改,开发速度越来越慢。
1.3 核心问题引导
在阅读本章时,请带着以下问题思考:
重构的准确定义是什么?它与"清理代码"有什么区别?
"两顶帽子"原则为什么如此重要?违反它会带来什么问题?
重构的三大理由是什么?哪个理由最重要?
什么时候应该重构?什么时候不应该重构?
如何处理重构中的数据库迁移问题?
重构与性能优化应该如何协调?
二、核心概念详解
2.1 重构的准确定义
Fowler 对重构给出了两个略有不同的定义,分别用于名词和动词:
名词形式:对软件内部结构的一种修改,目的是在不改变软件可观察行为的前提下,提高其可理解性,降低其修改成本。
动词形式:使用一系列重构手法,在不改变软件可观察行为的前提下,调整软件的结构。
这两个定义都强调了三个关键点:
内部结构的修改:重构改变的是代码的组织方式,而不是它的功能
不改变可观察行为:重构前后的程序在功能上必须完全一致
提高可理解性,降低修改成本:这是重构的目的
// 这不是重构:改变了函数的行为
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 提出了一个极其重要的原则:两顶帽子。
- 添加功能时:不改变现有代码的结构。只关注让新功能工作,用测试来验证。
- 重构时:不添加新功能。只关注改善代码结构,用测试来验证行为不变。
这两顶帽子不能同时戴。如果你发现自己在添加功能的同时也在重构,就应该停下来,选择一顶帽子。
这个原则的重要性在于:它让你在任何时刻都有一个明确的目标。当你添加功能时,不需要担心代码是否整洁;当你重构时,不需要担心是否遗漏了功能。这种分离极大地降低了认知负担。
// 错误做法:同时添加功能和重构
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 重构使软件更容易理解
代码是写给人看的,只是偶尔让机器执行。重构让代码的意图更加清晰——好的命名、短的函数、清晰的抽象,这些都让阅读代码的人(包括未来的你自己)能够更快地理解代码在做什么。
// 难以理解
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 提出的一条经验法则:第一次做某件事时,直接做。第二次做类似的事时,可以再做一次,但会觉得有些别扭。第三次做类似的事时,就必须重构——提取公共逻辑。
// 第一次:直接写
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 的建议是:将数据库模式与代码分离。使用"扩展-收缩"模式:先扩展数据库模式(添加新字段但不删除旧字段),然后逐步迁移代码到新字段,最后收缩(删除旧字段)。
-- 扩展阶段:添加新列,保留旧列
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)
让旧接口内部调用新接口
逐步迁移调用者到新接口
最终删除旧接口
// 旧接口
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 的回应是:
大多数时候,性能不是问题:不要过早优化
重构通常对性能的影响微乎其微:好的结构不会显著降低性能
如果确实需要优化,先重构再优化:在整洁的代码上优化比在混乱的代码上优化容易得多
优化和重构要分开做:遵循"两顶帽子"原则
// 重构后性能可能略有变化,但通常可以忽略
// 如果确实需要优化,在重构后的代码上优化
// 重构后的清晰代码
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 案例一:渐进式重构一个支付模块
以下是一个支付模块的渐进式重构过程,展示了如何在保持功能不变的前提下改善代码结构。
初始代码:
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;
}
}重构过程:
// 第一步:提取验证逻辑
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 案例二:重构一个报表生成器
// 重构前:一个巨大的函数
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 进行接口重构,使用"废弃-迁移"策略,确保向后兼容。
对比重构前后的代码,用具体的指标(如函数长度、圈复杂度、重复率)量化改善程度。