代码重构专题文章
@[TOC]
0. 前言
在软件开发中,我们常常追求优雅、高效且易于维护的代码。但代码的生命周期很长,需求不断变化,最初的“完美”设计也会随着时间流逝而变得臃肿、复杂。这时,“重构”就成了我们手中的瑞士军刀,帮助我们持续改进代码内部结构,而不改变其外部行为。
本篇博客将带你深入了解重构的第一组核心手法,它们如同武者的基本功,虽看似简单,却是构建高质量代码的基石。掌握了它们,你就能更好地理解和驾驭代码,让你的程序更健壮、更灵活、更具可读性。
1. 提炼函数(Extract Function)——“将意图与实现分开”
当你在审视一段代码时,如果需要花费几秒钟甚至更久才能弄明白它到底在做什么,那么恭喜你,你发现了一个绝佳的重构机会!提炼函数的核心思想是:将一段具有单一、明确意图的代码块独立出来,封装成一个新函数,并赋予它一个能够清晰表达其意图的名称。
- 描述: 将一段可识别的、有明确目的的代码片段移动到一个新函数中。
- 目的:
- 提升可读性: 就像为书本章节命名,函数名能让你一眼洞悉其职责。
- 减少重复: 相同的逻辑不必写两遍,一个函数搞定。
- 简化调试与测试: 小函数更容易测试,出问题也更容易定位。
- 促进代码复用: 提炼出的函数可以在代码库的其他地方被复用。
- 如何操作: 识别出一段完成特定任务的代码,将其剪切到一个新函数,并在原位置留下新函数的调用。
void printOwing() {
printBanner();
// 需要提炼的逻辑:计算未结金额
double outstanding = 0;
for (Order o : orders) {
outstanding += o.getAmount();
}
printDetails(outstanding);
}
// 提炼后,意图更清晰
void printOwing() {
printBanner();
double outstanding = calculateOutstanding(); // 一目了然:计算未结金额
printDetails(outstanding);
}
double calculateOutstanding() { // 新函数,职责明确
double result = 0;
for (Order o : orders) {
result += o.getAmount();
}
return result;
}
2. 内联函数(Inline Function)——去除无用的间接层
如果说提炼函数是“化繁为简”,那么内联函数就是“化简为繁”(仅限于不必要的间接层)。当一个函数的函数体非常简单,甚至比其名字本身更能直接表达意图,或者它只被调用一次且没有其他复用价值时,就可以考虑将其内容直接合并到调用它的地方。
- 描述: 将函数的体直接替换掉所有对该函数的调用。
- 目的:
- 消除不必要的间接性: 减少函数调用层级,有时能让代码流更直接。
- 提高可读性(在特定情况下): 对于极简单的函数,内联可能使其更易于理解。
- 为其他重构铺路: 有时,一个简单的函数可能阻碍了更大范围的重构。
- 何时使用: 函数体和其名称一样清晰;函数仅被调用一次且无其他好处;间接层徒增复杂性。
int getRating() {
return (moreThanFiveLateDeliveries()) ? 2 : 1; // 需要深入函数才能理解条件
}
boolean moreThanFiveLateDeliveries() { // 简单到名字就是描述了
return numberOfLateDeliveries > 5;
}
// 内联后,逻辑直接呈现
int getRating() {
return (numberOfLateDeliveries > 5) ? 2 : 1; // 条件直接可见
}
3. 提炼变量(Extract Variable)/引入解释性变量(Introduce Explaining Variable)——给复杂表达式命名
冗长复杂的表达式是代码可读性的杀手。当一个表达式包含多个操作、条件或函数调用,让人难以一眼看懂其含义时,提炼变量就派上用场了。通过引入一个有意义的局部变量来承载表达式的一部分或全部结果,我们能给这部分逻辑一个“名字”,从而大大提升理解效率。
- 描述: 将复杂表达式的结果或其子表达式赋值给一个具有描述性名称的局部变量。
- 目的:
- 提高可读性: 用“名称”解释“意图”,特别是表达式冗长时。
- 辅助调试: 变量能让你在调试时轻松检查中间结果。
- 减少重复计算(潜在): 如果同一复杂子表达式被多次使用,变量可以避免重复计算。
- 如何操作: 找到复杂表达式中的某个片段,将其计算结果赋值给一个新的局部变量,并在原位置使用这个变量。
if (platform.toUpperCase().contains("MAC") &&
browser.toUpperCase().contains("IE") &&
wasInitialized() && resize > 0) {
// do something
}
// 提炼变量后,条件判断清晰明了
final boolean isMacOs = platform.toUpperCase().contains("MAC");
final boolean isIEBrowser = browser.toUpperCase().contains("IE");
final boolean wasResized = resize > 0; // 假设 resize > 0 意味着被调整过大小
if (isMacOs && isIEBrowser && wasInitialized() && wasResized) {
// do something
}
4. 内联变量(Inline Variable)/内联临时变量(Inline Temp)——移除无用的中间商
并非所有变量都是好东西。如果一个局部变量的名称并没有比它所代表的表达式更具表现力,或者它只是简单地传递一个值,反而可能妨碍代码的清晰性或后续重构。这时,内联变量可以帮助我们去除这些不必要的“中间商”。
- 描述: 将一个局部变量的所有引用替换为其初始化的表达式。
- 目的:
- 消除冗余: 移除不必要的变量声明和赋值。
- 简化代码: 让代码更紧凑,避免阅读时跳来跳去。
- 移除重构障碍: 有时,一个临时变量可能会阻止其他更有价值的重构。
- 何时使用: 变量名没有比其表达式提供更多信息;变量仅被赋值一次且无副作用;变量阻碍了其他重构。
double basePrice = order.quantity * order.itemPrice; // basePrice 只是表达式的别名
if (basePrice > 1000) {
return basePrice * 0.95;
}
// 内联后,逻辑更直接
if (order.quantity * order.itemPrice > 1000) {
return order.quantity * order.itemPrice * 0.95;
}
5. 改变函数声明(Change Function Declaration)——重塑函数接口
函数声明是函数与外部世界沟通的桥梁。无论是函数名不够清晰、参数列表过于臃肿,还是需要调整返回类型,改变函数声明都是一项常用且重要的重构手法。一个好的函数声明能让函数更容易被理解和正确使用。
- 描述: 更改函数的名称、参数列表(增、删、改、序)或返回类型。
- 目的:
- 提升清晰度: 通过更具表达力的名称,精准传达函数意图。
- 优化接口: 使函数更易用,减少误用。
- 降低耦合: 调整参数可以减少依赖,增强模块独立性。
- 增加灵活性: 适当的参数能让函数处理更多场景。
- 如何操作: 使用IDE的重构工具,安全地修改函数声明,并更新所有调用点。
范例:函数改名(迁移式做法)——稳健的 API 演进
修改已发布的API函数名时,直接改动会破坏现有客户端。迁移式做法提供了一种平滑过渡的策略:
- 原函数:
function circum(radius) { return 2 * Math.PI * radius; } - 提炼新函数: 创建一个名字更清晰的新函数,让旧函数调用它。
function circum(radius) { return circumference(radius); // 旧函数作为转发器 } function circumference(radius) { // 新函数,意图明确 return 2 * Math.PI * radius; } - 测试: 确保改动无误。
- 逐步替换调用: 找到所有
circum的调用点,逐步改为circumference。每改一处,就测试一次。 - 弃用与删除: 当所有调用者都迁移到新函数后,旧函数即可安全删除(或者在API场景下,标记为deprecated,等待客户端逐步更新)。这种方式即便无法彻底删除旧函数,也让新代码有了更好的接口。
6. 封装变量(Encapsulate Variable)——守护数据,控制访问
直接暴露可变数据(如全局变量、公共字段)是引发混乱的根源。封装变量意味着将数据隐藏起来,通过受控的公共访问器(getter)和修改器(setter)来对其进行操作。这不仅能保护数据,还能在读写时加入验证、日志等附加逻辑。
- 描述: 将一个可变数据成员设为私有,并通过公共的 getter 和 setter 方法进行访问和修改。
- 目的:
- 控制访问: 限制数据被直接修改,确保数据完整性。
- 维护一致性: 在 setter 中加入验证逻辑,防止无效数据。
- 解耦: 允许在不影响外部调用的情况下,改变数据内部实现。
- 追踪变化: 在 getter/setter 中添加日志或通知机制。
- 处理复杂数据: 更好地控制复杂对象内部内容的修改。
public static String defaultOwner = "Unassigned"; // 直接暴露的变量
// 封装后,通过方法控制访问
private static String defaultOwner = "Unassigned";
public static String getDefaultOwner() { // 访问器
return defaultOwner;
}
public static void setDefaultOwner(String owner) { // 修改器,可加入验证
// 例如:if (owner == null || owner.trim().isEmpty()) throw new IllegalArgumentException("Owner cannot be empty");
defaultOwner = owner;
}
7. 变量改名(Rename Variable)——清晰即力量
一个清晰、准确的变量名是代码自解释的基础。如果一个变量的名称模糊不清、含义不明,或者与团队命名规范不符,那么就应该果断进行变量改名。这虽然看似小事,却能极大地提升代码的可读性和可维护性。
- 描述: 更改局部变量、字段或常量的名称。
- 目的:
- 提高可读性: 用更精确的名称表达变量的真实意图。
- 消除歧义: 避免混淆或误解。
- 统一规范: 使代码符合约定,易于团队协作。
- 如何操作: 强烈建议使用IDE的重构功能,它能安全地全局修改所有引用。
int d; // days since creation (注释才能解释含义)
// 改名后,变量名本身就是解释
int daysSinceCreation;
8. 引入参数对象(Introduce Parameter Object)——告别数据泥团
当一个函数的参数列表变得过长,且其中的一些参数总是成组地出现、共同传递时,我们就遇到了“数据泥团”的问题。引入参数对象的重构手法,就是将这些总是结伴同行的参数封装成一个独立的类(或数据结构),用一个对象来代替多个散乱的参数。
- 描述: 将一组相关联的参数封装到一个新的数据结构(对象)中,并用这个新对象替换原函数签名中的多个参数。
- 目的:
- 简化函数签名: 减少参数数量,使函数更简洁易读。
- 提高内聚性: 将相关数据组织成一个有意义的整体。
- 封装变化: 增加新参数时,只需修改参数对象,而不必改动所有调用函数。
- 更好的类型支持: 在强类型语言中提供更强的类型安全。
- 为后续重构准备: 参数对象本身可能演化为一个功能丰富的类。
// 重构前:参数列表冗长
public void printStatement(String startDate, String endDate) { ... }
// 重构后:引入参数对象,签名更简洁
class DateRange { // 新的数据结构
private String start;
private String end;
// 构造函数,getter等
}
public void printStatement(DateRange range) { ... } // 一个对象搞定
9. 函数组合成类(Combine Functions into Class)——数据与行为的内聚
当你发现一组函数总是形影不离地操作同一块数据(通常是将这块数据作为参数传递给它们),这往往是一个强烈的信号:是时候将这些函数和数据共同封装到一个新的类中了。函数组合成类是面向对象设计的基础,它能极大地提升代码的内聚性和可维护性。
- 描述: 将共享相同数据的一组独立函数和该数据结构一起封装到一个新类中。
- 目的:
- 提高内聚性: 将相关数据和行为紧密绑定,形成职责单一的单元。
- 降低耦合性: 外部代码通过类接口交互,而非直接操作数据。
- 封装变化: 数据和操作的实现细节对外部隐藏。
- 促进复用: 类可以被实例化,处理不同的数据实例。
- 面向对象化: 将过程式代码转化为更具结构的对象。
// 重构前:函数独立,数据作为参数传递
double baseCharge(int usage) { /* ... */ }
double taxableCharge(int usage) { /* ... */ }
// 重构后:将函数和数据封装到类中
class UsageCalculator {
private int usage; // 数据成为类的成员
public UsageCalculator(int usage) { this.usage = usage; }
double baseCharge() { /* ... 访问 this.usage ... */ return this.usage * 1.5; }
double taxableCharge() { /* ... 访问 this.usage ... */ return baseCharge() * 0.1; }
}
// 调用示例
UsageCalculator calculator = new UsageCalculator(100);
double charge = calculator.baseCharge();
10. 函数组合成变换(Combine Functions into Transform)——派生数据的统一管理
在处理数据时,我们经常需要基于原始数据计算出各种派生信息。如果这些派生信息的计算逻辑散落在代码的各个角落,不仅容易重复,而且一旦计算规则改变,修改起来将是噩梦。函数组合成变换,就是将所有计算派生数据的逻辑集中到一个“变换”函数或对象中。它接收原始数据,然后返回一个包含了所有派生数据的新结构。
- 描述: 将一组接收原始数据并计算出派生数据的函数集中到一个单一的“变换”单元。这个单元接收原始数据,返回一个包含所有派生信息的新数据结构。
- 目的:
- 消除重复计算: 所有派生数据从一处生成。
- 确保一致性: 统一计算逻辑,避免不同地方产生不同结果。
- 提高可维护性: 修改计算规则只需改一处。
- 清晰的责任划分: 明确原始数据与派生数据及其计算过程。
- 潜在性能优化: 可以在变换中实现缓存。
// 重构前:派生数据(base, discount)的计算散落在各处
// client 1
double base = order.quantity * order.itemPrice;
double discount = base > 1000 ? base * 0.1 : 0;
// client 2
double anotherBase = anotherOrder.quantity * anotherOrder.itemPrice;
// ...
// 重构后:创建变换对象/函数,集中计算派生数据
class OrderCalculations { // 这是一个“变换”对象,持有原始数据并计算派生数据
private Order order;
public OrderCalculations(Order order) {
this.order = order;
}
public double getBasePrice() { return order.quantity * order.itemPrice; }
public double getDiscount() {
double base = getBasePrice(); // 内部复用
return base > 1000 ? base * 0.1 : 0;
}
public double getFinalPrice() {
return getBasePrice() - getDiscount();
}
}
// 调用示例
Order myOrder = new Order(5, 250.0);
OrderCalculations calc = new OrderCalculations(myOrder);
double finalPrice = calc.getFinalPrice(); // 所有派生数据都通过 calc 访问
11. 拆分阶段(Split Phase)——各司其职,化解复杂
当一段代码或一个函数身兼数职,同时处理着两个或更多相互独立(或至少是逻辑上可分离)的任务时,代码会变得难以理解和修改。拆分阶段的重构思想是将这些职责明确分离,将一个复杂任务分解为一系列独立的、按序执行的阶段,每个阶段只专注于一个主题。
- 描述: 将一个同时处理多个独立任务的代码或函数拆分成两个或更多独立的阶段或函数,每个阶段负责一个单一的逻辑任务。
- 目的:
- 降低复杂度: 将大问题分解为小问题。
- 提高内聚性: 每个阶段只做一件事。
- 提升可维护性: 修改一个职责,只需关注对应的阶段。
- 促进复用: 拆分出的阶段可能在其他地方独立复用。
- 简化测试: 可以独立测试每个阶段。
- 常见场景:
- 数据解析与业务逻辑。
- 配置读取与配置应用。
- 编译器前端(解析)与后端(生成)。
- 如何操作: 识别代码中的第一个主要职责,将其结果封装成一个中间数据结构。然后将第二个职责提炼成另一个函数,接收这个中间数据作为输入。
// 重构前:一个函数处理“解析原始数据”和“计算订单总价”两个阶段
String rawOrderString = "10 25.5"; // 原始字符串:数量 价格
String[] data = rawOrderString.split(" ");
int quantity = Integer.parseInt(data[0]);
double price = Double.parseDouble(data[1]);
double orderValue = quantity * price; // 紧接着计算
// 重构后:分为“解析阶段”和“计算阶段”
class OrderData { // 中间数据结构
int quantity;
double price;
public OrderData(int quantity, double price) {
this.quantity = quantity;
this.price = price;
}
}
// 阶段一:解析原始数据
OrderData parseOrder(String raw) {
String[] data = raw.split(" ");
return new OrderData(Integer.parseInt(data[0]), Double.parseDouble(data[1]));
}
// 阶段二:计算订单价值
double calculateOrderValue(OrderData order) {
return order.quantity * order.price;
}
// 调用示例
String rawOrder = "10 25.5";
OrderData parsedOrder = parseOrder(rawOrder);
double finalOrderValue = calculateOrderValue(parsedOrder);
总结
这一组重构手法覆盖了 函数命名、参数管理、变量使用、类组织 等最基础的方面。
掌握了这第一组重构手法,你就能像一位经验丰富的工匠,对代码进行精细的打磨。重构并非一蹴而就,它是一个持续学习和实践的过程。从小处着手,持续改进,你的代码将日臻完善,成为更易读、易维护、高质量的艺术品。