3重构(第2版)

代码的坏味道

识别需要重构的信号

神秘命名重复代码过长函数

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

第3章:代码的坏味道(Bad Smells in Code)

一、导读

1.1 本章学习目标

第3章是《重构》中最具实践价值的章节之一。Martin Fowler 在本章中系统地列举了各种"代码的坏味道"——那些暗示代码可能需要重构的信号。学会识别这些坏味道,是每位程序员提升代码审查能力的关键技能。

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

  • 掌握常见代码坏味道的识别方法:学会从代码表面发现深层问题
  • 理解每种坏味道背后的设计问题:不只是知道"是什么",更要知道"为什么"
  • 建立坏味道与重构手法的映射关系:看到坏味道就知道该用什么手法
  • 培养代码审查的直觉:通过练习提高发现问题的能力
  • 区分坏味道与合理的代码风格差异:不是所有不寻常的代码都是坏味道

1.2 为什么本章如此重要

Kent Beck 说过:"代码应该像婴儿的屁股——又软又嫩,而且闻起来很香。"遗憾的是,大多数代码并非如此。坏味道是代码在告诉你:"我需要改变。"学会听懂代码的"语言",是区分初级程序员和高级程序员的关键能力。

本章之所以重要,是因为它提供了一套可操作的诊断工具。你不需要等到代码完全不可维护才去重构——当你发现某个坏味道时,就是重构的信号。这种"嗅觉"需要通过实践来培养,而本章就是培养这种嗅觉的起点。

1.3 核心问题引导

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

什么是"代码的坏味道"?它与 bug 有什么区别?

最常见的坏味道有哪些?它们各自暗示了什么设计问题?

如何判断一段代码是"坏味道"还是"合理的代码风格"?

坏味道之间是否有关联?一个坏味道是否会导致其他坏味道?

如何在团队中建立识别和消除坏味道的文化?

重构手法与坏味道之间是什么关系?


二、核心概念详解

2.1 神秘命名(Mysterious Name)

定义:函数、变量或类的名称没有清晰地表达其用途或含义。

为什么是坏味道:名称是代码的文档。好的名称让读者不需要阅读实现就能理解代码的意图。差的名称迫使读者花时间阅读代码来猜测其含义,极大地增加了理解成本。

识别方法:

  • 名称过于笼统:data、info、temp、handle
  • 名称与功能不匹配:getData() 实际上在做计算
  • 名称包含实现细节:calculateUsingBubbleSort()
  • 缩写和拼写错误:usrCnt、calclate
javascript
// 坏味道:神秘命名
function calc(x, y) {
  return x * y * 0.08;
}

function process(data) {
  let t = 0;
  for (let i = 0; i < data.length; i++) {
    t += data[i].v;
  }
  return t;
}

// 重构后:清晰的命名
function calculateSalesTax(price, quantity) {
  const taxRate = 0.08;
  return price * quantity * taxRate;
}

function calculateTotalRevenue(transactions) {
  let totalRevenue = 0;
  for (const transaction of transactions) {
    totalRevenue += transaction.value;
  }
  return totalRevenue;
}

对应的重构手法:Change Function Declaration(改变函数声明)、Rename Variable(重命名变量)、Rename Field(重命名字段)

2.2 重复代码(Duplicated Code)

定义:相同的代码结构出现在多个地方。

为什么是坏味道:重复代码是维护的噩梦。当你需要修改逻辑时,必须找到并修改所有重复的地方。遗漏任何一处都会导致不一致的 bug。正如 Andy Hunt 和 Dave Thomas 在《程序员修炼之道》中所说:"Every piece of knowledge must have a single, unambiguous, authoritative representation within a system."(DRY 原则)

识别方法:

  • 完全相同的代码块出现在不同函数中
  • 结构相似但细节不同的代码
  • 复制粘贴的代码,只有变量名不同
javascript
// 坏味道:重复代码
function renderUserCard(user) {
  let html = '<div class="card">';
  html += `<h2>${user.name}</h2>`;
  html += `<p>${user.email}</p>`;
  html += `<span class="badge">${user.role}</span>`;
  html += '</div>';
  return html;
}

function renderAdminCard(admin) {
  let html = '<div class="card">';
  html += `<h2>${admin.name}</h2>`;
  html += `<p>${admin.email}</p>`;
  html += `<span class="badge">${admin.role}</span>`;
  html += `<p>Department: ${admin.department}</p>`;
  html += '</div>';
  return html;
}

// 重构后:提取公共逻辑
function renderCard(person, extraContent = '') {
  return `
    <div class="card">
      <h2>${person.name}</h2>
      <p>${person.email}</p>
      <span class="badge">${person.role}</span>
      ${extraContent}
    </div>
  `;
}

function renderUserCard(user) {
  return renderCard(user);
}

function renderAdminCard(admin) {
  return renderCard(admin, `<p>Department: ${admin.department}</p>`);
}

对应的重构手法:Extract Function(提炼函数)、Slide Statements(搬移语句)、Pull Up Method(上移方法)

2.3 过长函数(Long Function)

定义:函数体过长,做了太多事情。

为什么是坏味道:过长的函数难以理解、难以测试、难以修改。函数的长度与其认知复杂度成正比——当函数超过 20-30 行时,大多数程序员就很难在脑中保持完整的上下文。

识别方法:

  • 函数超过 30 行(经验值,不是绝对标准)
  • 函数内有多个注释来分隔不同的逻辑段
  • 函数的缩进层次超过 3 层
  • 函数名需要用"和"来描述(如"calculateAndValidateAndSave")
javascript
// 坏味道:过长函数
function processOrder(order) {
  // 验证订单
  if (!order.customer) throw new Error("No customer");
  if (!order.items || order.items.length === 0) throw new Error("No items");
  for (const item of order.items) {
    if (item.quantity <= 0) throw new Error("Invalid quantity");
    if (item.price < 0) throw new Error("Invalid price");
  }
  
  // 计算价格
  let subtotal = 0;
  for (const item of order.items) {
    subtotal += item.price * item.quantity;
  }
  const tax = subtotal * 0.08;
  const shipping = subtotal > 100 ? 0 : 10;
  const total = subtotal + tax + shipping;
  
  // 检查库存
  for (const item of order.items) {
    const stock = inventoryService.getStock(item.productId);
    if (stock < item.quantity) {
      throw new Error(`Insufficient stock for ${item.name}`);
    }
  }
  
  // 扣减库存
  for (const item of order.items) {
    inventoryService.decreaseStock(item.productId, item.quantity);
  }
  
  // 处理支付
  const payment = paymentService.charge(order.customer.paymentMethod, total);
  if (!payment.success) {
    throw new Error("Payment failed");
  }
  
  // 创建订单记录
  const orderRecord = {
    id: generateOrderId(),
    customer: order.customer.id,
    items: order.items,
    total: total,
    status: 'CONFIRMED',
    createdAt: new Date()
  };
  database.save('orders', orderRecord);
  
  // 发送通知
  emailService.sendOrderConfirmation(order.customer.email, orderRecord);
  
  return orderRecord;
}

// 重构后:短小而清晰的函数
function processOrder(order) {
  validateOrder(order);
  const pricing = calculatePricing(order.items);
  checkAndReserveInventory(order.items);
  const payment = chargePayment(order.customer, pricing.total);
  const orderRecord = createOrderRecord(order, pricing, payment);
  sendConfirmation(order.customer, orderRecord);
  return orderRecord;
}

function validateOrder(order) {
  if (!order.customer) throw new Error("No customer");
  if (!order.items?.length) throw new Error("No items");
  order.items.forEach(validateItem);
}

function validateItem(item) {
  if (item.quantity <= 0) throw new Error("Invalid quantity");
  if (item.price < 0) throw new Error("Invalid price");
}

function calculatePricing(items) {
  const subtotal = items.reduce((sum, item) => sum + item.price * item.quantity, 0);
  const tax = subtotal * TAX_RATE;
  const shipping = subtotal > FREE_SHIPPING_THRESHOLD ? 0 : SHIPPING_COST;
  return { subtotal, tax, shipping, total: subtotal + tax + shipping };
}

对应的重构手法:Extract Function(提炼函数)、Replace Temp with Query(以查询取代临时变量)、Introduce Parameter Object(引入参数对象)

2.4 过长参数列(Long Parameter List)

定义:函数的参数过多,通常超过 3-4 个。

为什么是坏味道:过长的参数列难以理解、容易传错参数、且暗示函数可能做了太多事情。参数越多,函数的可能调用组合就呈指数增长,测试的复杂度也随之增加。

识别方法:

  • 参数超过 4 个
  • 参数中有多个是相关的(可以组合成一个对象)
  • 参数中包含标志位(boolean flag)
  • 不同调用场景使用不同的参数子集
javascript
// 坏味道:过长参数列
function createUser(firstName, lastName, email, phone, 
                      street, city, state, zip, country,
                      role, department, startDate) {
  // ...
}

// 重构后:引入参数对象
function createUser(name, contact, address, employment) {
  // ...
}

// 更具体的实现
class Name {
  constructor(firstName, lastName) {
    this.firstName = firstName;
    this.lastName = lastName;
  }
  
  get fullName() {
    return `${this.firstName} ${this.lastName}`;
  }
}

class Contact {
  constructor(email, phone) {
    this.email = email;
    this.phone = phone;
  }
}

class Address {
  constructor(street, city, state, zip, country) {
    this.street = street;
    this.city = city;
    this.state = state;
    this.zip = zip;
    this.country = country;
  }
}

function createUser(name, contact, address, employment) {
  return {
    name: name.fullName,
    email: contact.email,
    address: address,
    role: employment.role,
    department: employment.department,
    startDate: employment.startDate
  };
}

对应的重构手法:Introduce Parameter Object(引入参数对象)、Remove Flag Argument(移除标记参数)、Preserve Whole Object(保持完整对象)

2.5 发散式变化(Divergent Change)

定义:一个类或模块因为多种不同的原因而被修改。

为什么是坏味道:发散式变化违反单一职责原则。当一个类需要因为不同的原因被修改时,修改之间可能会相互干扰。更糟糕的是,相关的逻辑散落在多个类中,使得理解和修改都变得困难。

识别方法:

  • 一个类频繁因为不同的原因被修改
  • 同一个类中的代码服务于不同的业务场景
  • 修改一个功能时需要同时修改类中多个不相关的方法
javascript
// 坏味道:发散式变化
class Employee {
  // 以下方法因为薪酬计算规则变化而修改
  calculateSalary() { /* ... */ }
  calculateBonus() { /* ... */ }
  calculateOvertime() { /* ... */ }
  
  // 以下方法因为报告格式变化而修改
  generatePayStub() { /* ... */ }
  generateTaxForm() { /* ... */ }
  
  // 以下方法因为权限规则变化而修改
  canAccessReport() { /* ... */ }
  canModifySalary() { /* ... */ }
}

// 重构后:每个类只有一个变化的原因
class SalaryCalculator {
  calculateSalary(employee) { /* ... */ }
  calculateBonus(employee) { /* ... */ }
  calculateOvertime(employee) { /* ... */ }
}

class ReportGenerator {
  generatePayStub(employee) { /* ... */ }
  generateTaxForm(employee) { /* ... */ }
}

class PermissionChecker {
  canAccessReport(user) { /* ... */ }
  canModifySalary(user) { /* ... */ }
}

对应的重构手法:Extract Class(提炼类)、Split Phase(分离阶段)、Move Function(搬移函数)

2.6 霰弹式修改(Shotgun Surgery)

定义:一个变化需要修改多个不同的类。

为什么是坏味道:与发散式变化相反,霰弹式修改是指一个变更需要跨越多个文件进行修改。这使得修改变得困难且容易遗漏——你必须记住所有需要修改的地方。

识别方法:

  • 每次添加新功能都需要修改很多文件
  • 修改一个业务规则需要同时改动多个类
  • 代码变更的"影响范围"总是超出预期
javascript
// 坏味道:霰弹式修改
// 添加新的订单状态需要修改以下所有文件:
// Order.js - 添加状态常量
// OrderValidator.js - 添加状态转换规则
// OrderService.js - 添加状态处理逻辑
// OrderRepository.js - 添加状态查询
// OrderController.js - 添加状态 API
// OrderNotification.js - 添加状态通知

// 重构后:将相关逻辑集中在一起
class OrderStateMachine {
  constructor() {
    this.transitions = {
      'DRAFT': ['SUBMITTED', 'CANCELLED'],
      'SUBMITTED': ['CONFIRMED', 'CANCELLED'],
      'CONFIRMED': ['SHIPPED'],
      'SHIPPED': ['DELIVERED'],
      'DELIVERED': [],
      'CANCELLED': []
    };
  }
  
  canTransition(from, to) {
    return this.transitions[from]?.includes(to) || false;
  }
  
  transition(order, newState) {
    if (!this.canTransition(order.status, newState)) {
      throw new Error(`Invalid transition: ${order.status} -> ${newState}`);
    }
    order.status = newState;
    return order;
  }
}

对应的重构手法:Move Function(搬移函数)、Move Field(搬移字段)、Inline Class(内联类)

2.7 其他常见坏味道

2.7.1 过大的类(Large Class)

一个类试图做太多事情,拥有过多的字段和方法。识别方法:类文件超过 500 行,或者需要滚动才能看完所有方法。

2.7.2 依恋情结(Feature Envy)

一个函数对另一个类的兴趣超过了对自己所在类的兴趣。它频繁访问其他类的数据,而不是调用其行为。

javascript
// 坏味道:依恋情结
class Report {
  generateSummary(transactions) {
    let total = 0;
    for (const t of transactions) {
      total += t.amount; // 频繁访问 Transaction 的数据
    }
    return `Total: ${total}`;
  }
}

// 重构后:将逻辑移到数据所在的类
class Transaction {
  getTotal(transactions) {
    return transactions.reduce((sum, t) => sum + t.amount, 0);
  }
}

class Report {
  generateSummary(transactions) {
    const total = new Transaction().getTotal(transactions);
    return `Total: ${total}`;
  }
}

2.7.3 数据泥团(Data Clumps)

多个数据项总是同时出现,比如三个参数总是成对传递。这暗示它们应该被封装成一个对象。

2.7.4 基本类型偏执(Primitive Obsession)

使用基本类型(字符串、数字)来表示领域概念,而不是创建专门的类。比如用字符串表示电话号码、用数字表示金额。

javascript
// 坏味道:基本类型偏执
const phoneNumber = "123-456-7890";
const money = 100.50;

// 重构后:用领域对象表达
class PhoneNumber {
  constructor(value) {
    if (!this.isValid(value)) throw new Error("Invalid phone number");
    this.value = value;
  }
  
  isValid(value) {
    return /^\d{3}-\d{3}-\d{4}$/.test(value);
  }
  
  toString() { return this.value; }
}

class Money {
  constructor(amount, currency = 'USD') {
    this.amount = amount;
    this.currency = currency;
  }
  
  add(other) {
    if (this.currency !== other.currency) throw new Error("Currency mismatch");
    return new Money(this.amount + other.amount, this.currency);
  }
}

三、实战案例

3.1 案例一:识别电商系统中的坏味道

以下是一个电商系统中的订单处理代码,其中包含多种坏味道。请尝试识别它们。

java
public class OrderManager {
  // 坏味道1:过大的类 - 这个类做了太多事情
  
  public String processOrder(String customerId, String[] productIds, 
                             int[] quantities, String shippingAddress,
                             String paymentMethod, String cardNumber,
                             String expiryDate, String cvv, boolean isGift,
                             String giftMessage, String couponCode) {
    // 坏味道2:过长参数列
    // 坏味道3:过长函数
    
    // 验证客户
    String customerData = db.query("SELECT * FROM customers WHERE id = '" + customerId + "'");
    if (customerData == null) return "ERROR: Customer not found";
    
    // 验证产品
    double total = 0;
    for (int i = 0; i < productIds.length; i++) {
      String product = db.query("SELECT * FROM products WHERE id = '" + productIds[i] + "'");
      if (product == null) return "ERROR: Product not found: " + productIds[i];
      double price = Double.parseDouble(product.split(",")[2]);
      total += price * quantities[i];
    }
    
    // 应用优惠券
    if (couponCode != null && !couponCode.isEmpty()) {
      String coupon = db.query("SELECT * FROM coupons WHERE code = '" + couponCode + "'");
      if (coupon != null) {
        double discount = Double.parseDouble(coupon.split(",")[1]);
        total -= total * discount / 100;
      }
    }
    
    // 计算运费
    double shipping = 0;
    if (total < 50) shipping = 10;
    else if (total < 100) shipping = 5;
    
    total += shipping;
    
    // 处理支付
    // 坏味道4:重复代码 - 支付逻辑在多处重复
    if (paymentMethod.equals("CREDIT_CARD")) {
      // 验证信用卡...
      // 处理支付...
    } else if (paymentMethod.equals("PAYPAL")) {
      // 处理PayPal支付...
    }
    
    // 保存订单
    String orderId = UUID.randomUUID().toString();
    db.execute("INSERT INTO orders VALUES ('" + orderId + "', '" + customerId + "', " + total + ")");
    
    // 发送邮件
    // 坏味道5:神秘命名
    sendMail(customerData, orderId, total);
    
    return orderId;
  }
  
  // 坏味道6:霰弹式修改 - 订单相关逻辑分散在多个类中
  public void cancelOrder(String orderId) { /* ... */ }
  public void shipOrder(String orderId) { /* ... */ }
  public void refundOrder(String orderId) { /* ... */ }
}

3.2 案例二:代码审查中的坏味道检查清单

在实际工作中,可以使用以下检查清单来系统地识别坏味道:

markdown
## 代码审查检查清单

### 命名
- [ ] 函数名是否清晰表达了其用途?
- [ ] 变量名是否反映了数据的含义?
- [ ] 是否有缩写或拼写错误?

### 函数
- [ ] 函数是否超过 30 行?
- [ ] 函数是否做了多于一件事?
- [ ] 参数是否超过 4 个?
- [ ] 是否有 boolean 参数?
- [ ] 缩进层次是否超过 3 层?

### 类
- [ ] 类文件是否超过 500 行?
- [ ] 类是否有超过 20 个方法?
- [ ] 类是否因为多种原因被修改?

### 重复
- [ ] 是否有完全相同的代码块?
- [ ] 是否有结构相似但细节不同的代码?
- [ ] 是否有复制粘贴的代码?

### 依赖
- [ ] 一个修改是否需要改动多个文件?
- [ ] 一个类是否过于频繁地访问其他类的数据?

四、本章小结

第3章系统地介绍了代码的坏味道:

神秘命名:名称没有揭示意图,增加理解成本

重复代码:相同的代码出现在多处,增加维护成本

过长函数:函数做了太多事,难以理解和测试

过长参数列:参数过多,容易传错,暗示职责过多

发散式变化:一个类因多种原因被修改,违反单一职责

霰弹式修改:一个变化需要修改多个类,增加遗漏风险

其他坏味道:过大的类、依恋情结、数据泥团、基本类型偏执等

本章的核心思想是:坏味道是代码在呼唤重构。学会识别这些信号,你就能在代码变得不可维护之前及时改善它。正如 Fowler 所说:"记住,编译器不会抱怨代码的坏味道,但下一个阅读代码的程序员会。"


五、思考与练习

思考题

坏味道与代码风格有什么区别?如何区分主观偏好和客观问题?

是否所有坏味道都需要立即修复?如何确定重构的优先级?

在敏捷开发中,如何平衡"快速交付"和"消除坏味道"?

如何在团队中建立识别坏味道的共同语言?

编程练习

在你当前的项目中,找出 3 个不同类型的坏味道,记录它们的特征和位置。

选择其中一个坏味道,使用对应的重构手法进行修复,并验证行为不变。

为团队编写一份"坏味道检查清单",用于代码审查时参考。


3/12代码的坏
0%