代码重构专题文章
(一)代码匠心:重构之道,化腐朽为神奇
(二)重构的艺术:精进代码的第一组基本功
(三)封装与结构优化:让代码更优雅
(四)优雅重构:洞悉"搬移特性"的艺术与实践
(五)数据重构的艺术:优化你的代码结构与可读性
(六)重构的艺术:简化复杂条件逻辑的秘诀
(七)API 重构的艺术:打造优雅、可维护的 API
文章目录
- 代码重构专题文章
- [第 11 章 API 重构的艺术:打造优雅、可维护的 API](#第 11 章 API 重构的艺术:打造优雅、可维护的 API)
-
- [11.0 前言](#11.0 前言)
- [11.1 将查询函数和修改函数分离(Separate Query from Modifier)](#11.1 将查询函数和修改函数分离(Separate Query from Modifier))
- [11.2 函数参数化(Parameterize Function)](#11.2 函数参数化(Parameterize Function))
- [11.3 移除标记参数(Remove Flag Argument)](#11.3 移除标记参数(Remove Flag Argument))
- [11.4 保持对象完整(Preserve Whole Object)](#11.4 保持对象完整(Preserve Whole Object))
- [11.5 以查询取代参数(Replace Parameter with Query)](#11.5 以查询取代参数(Replace Parameter with Query))
- [11.6 以参数取代查询(Replace Query with Parameter)](#11.6 以参数取代查询(Replace Query with Parameter))
- [11.7 移除设值函数(Remove Setting Method)](#11.7 移除设值函数(Remove Setting Method))
- [11.8 以工厂函数取代构造函数(Replace Constructor with Factory Function)](#11.8 以工厂函数取代构造函数(Replace Constructor with Factory Function))
- [11.9 以命令取代函数(Replace Function with Command)](#11.9 以命令取代函数(Replace Function with Command))
- [11.10 以函数取代命令(Replace Command with Function)](#11.10 以函数取代命令(Replace Command with Function))
- 参考
第 11 章 API 重构的艺术:打造优雅、可维护的 API
11.0 前言
模块和函数是软件的骨肉,而 API 则是将骨肉连接起来的关节。一个易于理解和使用的 API 对软件的成功至关重要,但设计一个好的 API 绝非易事。随着我们对软件理解的加深,总会发现改进 API 的方法,这时,重构 API 便成为不可避免的需求。
本章将深入探讨一系列重构手法,帮助你将 API 设计得更清晰、更灵活、更易于维护。我们将学习如何分离查询和修改、参数化函数、移除标记参数、保持对象完整、以及在命令和函数之间进行转换等。
11.1 将查询函数和修改函数分离(Separate Query from Modifier)
动机
一个纯粹的查询函数,即只提供值而不产生任何可见副作用的函数,是极其宝贵的。它可以在程序的任何地方被安全地调用,其测试也更为简单。换句话说,它减少了我们需要关注的复杂度。
明确区分"有副作用"和"无副作用"的函数是一种良好的实践。一个普遍的良好规则是:任何有返回值的函数,都不应该有可见的副作用------这被称为"命令与查询分离"(Command-Query Separation)原则 [mf-cqs]。虽然我们不必严格遵守所有规则,但在大多数情况下,遵循这一原则能带来显著的好处。
当遇到一个既有返回值又有副作用的函数时,我们应该尝试将其查询(获取值)和修改(改变状态)的职责分离。
值得注意的是,"可见的副作用"一词。一些常见的优化手段,如将查询结果缓存到字段中,虽然改变了对象的内部状态,但这通常不被认为是可见的副作用,因为无论如何查询,结果始终保持一致。
做法
- 复制整个原始函数。
- 将复制出的函数重命名,使其名称清晰地表达其查询功能。如果难以命名,可以考虑函数返回值的用途。
- 从新建的查询函数中移除所有导致副作用的语句。
- 执行静态检查,确保代码语法正确。
- 查找所有调用原始函数的地方。如果调用处使用了该函数的返回值,将其修改为先调用新建的查询函数获取返回值,然后紧接着再调用原始函数(此时原始函数不再返回结果,只执行副作用)。每次修改后都要进行测试。
- 从原始函数中移除返回值。
- 测试。
- 重构完成后,查询函数和原始函数之间可能会有重复代码,此时可以进行必要的清理,例如使用替换算法(Replace Algorithm)。
范例
考虑一个在用户列表中查找"捣乱者"(miscreant)并发出警报的函数。如果找到,它会返回捣乱者的名字并触发警报。
java
public class MiscreantDetector {
private AlarmService alarmService;
public MiscreantDetector(AlarmService alarmService) {
this.alarmService = alarmService;
}
// 原始函数:既有查询(返回捣乱者名字)又有修改(拉响警报)
public String alertForMiscreant(String[] people) {
for (String p : people) {
if (p.equals("Don")) {
alarmService.setOffAlarms(); // 副作用
return "Don";
}
if (p.equals("John")) {
alarmService.setOffAlarms(); // 副作用
return "John";
}
}
return "";
}
}
// 调用示例
// String found = detector.alertForMiscreant(new String[]{"Alice", "Don", "Bob"});
// System.out.println("Found miscreant: " + found);
重构步骤:
-
复制函数并命名查询部分:
javapublic class MiscreantDetector { private AlarmService alarmService; public MiscreantDetector(AlarmService alarmService) { this.alarmService = alarmService; } public String alertForMiscreant(String[] people) { /* ... 原始实现 ... */ } // 新建查询函数 public String findMiscreant(String[] people) { for (String p : people) { if (p.equals("Don")) { alarmService.setOffAlarms(); // 暂时保留,下一步移除 return "Don"; } if (p.equals("John")) { alarmService.setOffAlarms(); // 暂时保留,下一步移除 return "John"; } } return ""; } }
-
从查询函数中移除副作用:
javapublic class MiscreantDetector { private AlarmService alarmService; public MiscreantDetector(AlarmService alarmService) { this.alarmService = alarmService; } public String alertForMiscreant(String[] people) { /* ... 原始实现 ... */ } // 查询函数,无副作用 public String findMiscreant(String[] people) { for (String p : people) { if (p.equals("Don")) { return "Don"; } if (p.equals("John")) { return "John"; } } return ""; } }
-
修改所有调用者: 假设原先的调用方式是
const found = detector.alertForMiscreant(people);
java// 原始调用 // String found = detector.alertForMiscreant(new String[]{"Alice", "Don", "Bob"}); // 修改为: String found = detector.findMiscreant(new String[]{"Alice", "Don", "Bob"}); if (!found.isEmpty()) { // 如果找到了,再调用修改函数 detector.alertOnlyForMiscreant(new String[]{"Alice", "Don", "Bob"}); }
这里我们还需要一个只负责修改的函数,先来修改原始函数。
-
从原始函数中移除返回值并改名(使其只负责修改):
javapublic class MiscreantDetector { private AlarmService alarmService; public MiscreantDetector(AlarmService alarmService) { this.alarmService = alarmService; } // 修改函数,无返回值 public void alertOnlyForMiscreant(String[] people) { for (String p : people) { if (p.equals("Don")) { alarmService.setOffAlarms(); return; // 不再返回 } if (p.equals("John")) { alarmService.setOffAlarms(); return; // 不再返回 } } return; // 不再返回 } public String findMiscreant(String[] people) { for (String p : people) { if (p.equals("Don")) { return "Don"; } if (p.equals("John")) { return "John"; } } return ""; } }
-
清理重复代码(使修改函数调用查询函数):
javapublic class MiscreantDetector { private AlarmService alarmService; public MiscreantDetector(AlarmService alarmService) { this.alarmService = alarmService; } public void alertOnlyForMiscreant(String[] people) { if (!findMiscreant(people).isEmpty()) { // 调用查询函数 alarmService.setOffAlarms(); } } public String findMiscreant(String[] people) { for (String p : people) { if (p.equals("Don")) { return "Don"; } if (p.equals("John")) { return "John"; } } return ""; } }
AlarmService.java
辅助类:
java
public class AlarmService {
public void setOffAlarms() {
System.out.println("ALARM! Miscreant detected!");
}
}
最终调用示例:
java
public class Application {
public static void main(String[] args) {
AlarmService alarmService = new AlarmService();
MiscreantDetector detector = new MiscreantDetector(alarmService);
String[] group1 = {"Alice", "Bob", "Charlie"};
String found1 = detector.findMiscreant(group1);
System.out.println("Group 1 found miscreant: " + found1); // 输出:Group 1 found miscreant:
detector.alertOnlyForMiscreant(group1); // 不会触发警报
String[] group2 = {"Alice", "Don", "Bob"};
String found2 = detector.findMiscreant(group2);
System.out.println("Group 2 found miscreant: " + found2); // 输出:Group 2 found miscreant: Don
detector.alertOnlyForMiscreant(group2); // 触发警报:ALARM! Miscreant detected!
}
}
11.2 函数参数化(Parameterize Function)
曾用名:令函数携带参数(Parameterize Method)
动机
如果发现两个或多个函数在逻辑上非常相似,仅仅是一些字面量值不同,那么可以将它们合并为一个函数,通过参数传入这些不同的值来消除重复。这种重构不仅能减少代码量,还能使函数更加通用和灵活,因为它现在可以处理更广泛的输入值。
做法
- 从一组相似的函数中选择一个作为参数化的起点。
- 使用"改变函数声明"(Change Function Declaration)重构手法,将需要作为参数传入的字面量添加到函数的参数列表中。
- 修改该函数的所有调用处,使其在调用时传入对应的字面量值。
- 执行测试。
- 修改函数体,令其使用新传入的参数替换掉硬编码的字面量。每替换一个参数,都要执行测试。
- 对于其他与之相似的函数,逐一将其调用处改为调用已经参数化的新函数。每次修改后都要进行测试。
- 如果第一个参数化的函数不能直接替代所有相似函数,可能需要对参数化后的函数进行必要的调整,以适应所有场景。
范例
显而易见的例子:
java
public class Employee {
private double salary;
public Employee(double salary) {
this.salary = salary;
}
public double getSalary() {
return salary;
}
// 原始函数1
public void tenPercentRaise() {
this.salary = this.salary * 1.1;
}
// 原始函数2
public void fivePercentRaise() {
this.salary = this.salary * 1.05;
}
}
// 调用示例
// Employee emp = new Employee(1000);
// emp.tenPercentRaise(); // salary becomes 1100
// emp.fivePercentRaise(); // salary becomes 1155
重构步骤:
-
选择一个函数(tenPercentRaise)并添加参数:
javapublic class Employee { private double salary; public Employee(double salary) { this.salary = salary; } public double getSalary() { return salary; } // 参数化后的函数 public void raise(double factor) { this.salary = this.salary * (1 + factor); // 暂时保留硬编码值,下一步修改 } public void tenPercentRaise() { /* ... 原始实现 ... */ } public void fivePercentRaise() { /* ... 原始实现 ... */ } }
-
修改 tenPercentRaise 的调用处为新的 raise 函数:
java// 原始调用 // emp.tenPercentRaise(); // 修改为: // emp.raise(0.1);
-
修改函数体使用参数:
javapublic class Employee { // ... public void raise(double factor) { this.salary = this.salary * (1 + factor); } // ... }
-
修改 fivePercentRaise 的调用处为新的 raise 函数:
java// 原始调用 // emp.fivePercentRaise(); // 修改为: // emp.raise(0.05);
最终重构结果:
java
public class Employee {
private double salary;
public Employee(double salary) {
this.salary = salary;
}
public double getSalary() {
return salary;
}
public void raise(double factor) {
this.salary = this.salary * (1 + factor);
}
}
// 调用示例
// Employee emp = new Employee(1000);
// emp.raise(0.1); // salary becomes 1100
// emp.raise(0.05); // salary becomes 1155
更复杂的例子:计费分档
考虑一个根据使用量计算基础费用的函数,它有不同的计费档次:
java
import java.math.BigDecimal;
import java.math.RoundingMode;
public class BillingService {
public BigDecimal baseCharge(int usage) {
if (usage < 0) return BigDecimal.ZERO;
BigDecimal amount = BigDecimal.ZERO;
amount = amount.add(bottomBand(usage).multiply(new BigDecimal("0.03")));
amount = amount.add(middleBand(usage).multiply(new BigDecimal("0.05")));
amount = amount.add(topBand(usage).multiply(new BigDecimal("0.07")));
return amount.setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal bottomBand(int usage) {
return BigDecimal.valueOf(Math.min(usage, 100));
}
private BigDecimal middleBand(int usage) {
return BigDecimal.valueOf(usage > 100 ? Math.min(usage, 200) - 100 : 0);
}
private BigDecimal topBand(int usage) {
return BigDecimal.valueOf(usage > 200 ? usage - 200 : 0);
}
}
// 调用示例
// BillingService service = new BillingService();
// System.out.println(service.baseCharge(50)); // 1.50 (50 * 0.03)
// System.out.println(service.baseCharge(150)); // 100*0.03 + 50*0.05 = 3 + 2.5 = 5.50
// System.out.println(service.baseCharge(250)); // 100*0.03 + 100*0.05 + 50*0.07 = 3 + 5 + 3.5 = 11.50
BigDecimal
Helper Class (for brevity):
java
// Just a placeholder for the example, assume BigDecimal usage as in original
// No need to provide a full BigDecimal class, just for illustration.
// You might use actual Java's BigDecimal for a real app.
重构步骤:
-
选择
middleBand
进行参数化,并改名calculateBand
:javapublic class BillingService { // ... private BigDecimal bottomBand(int usage) { /* ... */ } private BigDecimal middleBand(int usage) { /* ... */ } private BigDecimal topBand(int usage) { /* ... */ } // 新建参数化函数 private BigDecimal calculateBand(int usage, int bottom, int top) { return BigDecimal.valueOf(usage > 100 ? Math.min(usage, 200) - 100 : 0); // 暂时保留硬编码 } }
-
修改
baseCharge
中对middleBand
的调用为calculateBand
:javapublic class BillingService { public BigDecimal baseCharge(int usage) { if (usage < 0) return BigDecimal.ZERO; BigDecimal amount = BigDecimal.ZERO; amount = amount.add(bottomBand(usage).multiply(new BigDecimal("0.03"))); amount = amount.add(calculateBand(usage, 100, 200).multiply(new BigDecimal("0.05"))); // 修改此处 amount = amount.add(topBand(usage).multiply(new BigDecimal("0.07"))); return amount.setScale(2, RoundingMode.HALF_UP); } // ... }
-
修改
calculateBand
函数体使用新参数:javapublic class BillingService { // ... private BigDecimal calculateBand(int usage, int bottom, int top) { return BigDecimal.valueOf(usage > bottom ? Math.min(usage, top) - bottom : 0); } // ... }
-
修改
baseCharge
中对bottomBand
的调用为calculateBand
:javapublic class BillingService { public BigDecimal baseCharge(int usage) { if (usage < 0) return BigDecimal.ZERO; BigDecimal amount = BigDecimal.ZERO; amount = amount.add(calculateBand(usage, 0, 100).multiply(new BigDecimal("0.03"))); // 修改此处 amount = amount.add(calculateBand(usage, 100, 200).multiply(new BigDecimal("0.05"))); amount = amount.add(topBand(usage).multiply(new BigDecimal("0.07"))); return amount.setScale(2, RoundingMode.HALF_UP); } // ... }
-
修改
baseCharge
中对topBand
的调用为calculateBand
(使用Integer.MAX_VALUE
代表无穷大):javapublic class BillingService { public BigDecimal baseCharge(int usage) { if (usage < 0) return BigDecimal.ZERO; BigDecimal amount = BigDecimal.ZERO; amount = amount.add(calculateBand(usage, 0, 100).multiply(new BigDecimal("0.03"))); amount = amount.add(calculateBand(usage, 100, 200).multiply(new BigDecimal("0.05"))); amount = amount.add(calculateBand(usage, 200, Integer.MAX_VALUE).multiply(new BigDecimal("0.07"))); // 修改此处 return amount.setScale(2, RoundingMode.HALF_UP); } // ... }
最终重构结果:
java
import java.math.BigDecimal;
import java.math.RoundingMode;
public class BillingService {
public BigDecimal baseCharge(int usage) {
if (usage < 0) return BigDecimal.ZERO;
BigDecimal amount = BigDecimal.ZERO;
amount = amount.add(calculateBand(usage, 0, 100).multiply(new BigDecimal("0.03")));
amount = amount.add(calculateBand(usage, 100, 200).multiply(new BigDecimal("0.05")));
amount = amount.add(calculateBand(usage, 200, Integer.MAX_VALUE).multiply(new BigDecimal("0.07")));
return amount.setScale(2, RoundingMode.HALF_UP);
}
private BigDecimal calculateBand(int usage, int bottom, int top) {
if (usage <= bottom) return BigDecimal.ZERO; // 如果使用量低于下限,则为0
return BigDecimal.valueOf(Math.min(usage, top) - bottom);
}
}
11.3 移除标记参数(Remove Flag Argument)
曾用名:以明确函数取代参数(Replace Parameter with Explicit Methods)
动机
"标记参数"是指调用者用来指示被调函数应执行哪部分逻辑的参数。例如,一个布尔参数 isPremium
,它决定了函数内部是执行"高级预订"逻辑还是"普通预订"逻辑。
我不喜欢标记参数,因为它们降低了 API 的可读性和可用性。当看到一个函数签名时,标记参数隐藏了不同调用路径的存在。调用者必须深入了解参数的含义及其可用值。布尔型标记尤其糟糕,因为 true
和 false
往往无法清晰地表达其意图。使用独立的、明确命名的函数来完成不同的任务,其含义会清晰得多。
标记参数的识别标准:
- 调用者直接传入字面量值(如
true
,false
,"premium"
)。 - 参数值影响了函数内部的控制流(
if/else
,switch
语句)。
移除标记参数不仅能使代码更整洁,还能帮助开发工具更好地分析代码。例如,IDE 可以更容易地识别出"高级"和"普通"预订是两种不同的操作。
如果一个函数有多个标记参数,可能会导致需要为所有参数组合创建显式函数,这会使函数数量爆炸。这通常也是一个信号,表明这个函数可能承担了过多的职责,应该考虑用更简单的函数组合出完整的逻辑。
做法
- 针对标记参数的每一种可能值,新建一个明确的函数。
- 如果主函数有清晰的条件分发逻辑(
if/else
或switch
),可以使用"分解条件表达式"(Decompose Conditional)来创建这些明确函数。 - 否则,可以在原始函数之上创建包装函数。
- 将所有"使用字面量值作为标记参数"的函数调用处,修改为调用新建的明确函数。
- 在所有调用处修改完成后,可以考虑删除原始函数(如果它只被作为标记参数的入口)。
范例
我们有一个计算物流到货日期的函数 deliveryDate
,它有一个布尔型标记参数 isRush
来区分加急订单和普通订单。
java
import java.time.LocalDate;
import java.util.Arrays;
import java.util.Set;
import java.util.HashSet;
public class OrderService {
public static class Order {
private String deliveryState;
private LocalDate placedOn;
public Order(String deliveryState, LocalDate placedOn) {
this.deliveryState = deliveryState;
this.placedOn = placedOn;
}
public String getDeliveryState() { return deliveryState; }
public LocalDate getPlacedOn() { return placedOn; }
public LocalDate plusDays(int days) {
return placedOn.plusDays(days);
}
}
// 原始函数:使用标记参数 isRush
public LocalDate deliveryDate(Order anOrder, boolean isRush) {
if (isRush) {
int deliveryTime;
Set<String> maCt = new HashSet<>(Arrays.asList("MA", "CT"));
Set<String> nyNh = new HashSet<>(Arrays.asList("NY", "NH"));
if (maCt.contains(anOrder.getDeliveryState())) deliveryTime = 1;
else if (nyNh.contains(anOrder.getDeliveryState())) deliveryTime = 2;
else deliveryTime = 3;
return anOrder.plusDays(1 + deliveryTime);
} else {
int deliveryTime;
Set<String> maCtNy = new HashSet<>(Arrays.asList("MA", "CT", "NY"));
Set<String> meNh = new HashSet<>(Arrays.asList("ME", "NH"));
if (maCtNy.contains(anOrder.getDeliveryState())) deliveryTime = 2;
else if (meNh.contains(anOrder.getDeliveryState())) deliveryTime = 3;
else deliveryTime = 4;
return anOrder.plusDays(2 + deliveryTime);
}
}
}
// 调用示例
// Order rushOrder = new Order("MA", LocalDate.now());
// LocalDate rushDelivery = service.deliveryDate(rushOrder, true);
// Order regularOrder = new Order("NY", LocalDate.now());
// LocalDate regularDelivery = service.deliveryDate(regularOrder, false);
重构步骤:
-
根据标记参数的值,分解条件表达式,创建明确函数:
javapublic class OrderService { // ... Order 内部类 ... // 原始函数,现在作为分发器 public LocalDate deliveryDate(Order anOrder, boolean isRush) { if (isRush) return rushDeliveryDate(anOrder); else return regularDeliveryDate(anOrder); } // 明确函数:处理加急订单 public LocalDate rushDeliveryDate(Order anOrder) { int deliveryTime; Set<String> maCt = new HashSet<>(Arrays.asList("MA", "CT")); Set<String> nyNh = new HashSet<>(Arrays.asList("NY", "NH")); if (maCt.contains(anOrder.getDeliveryState())) deliveryTime = 1; else if (nyNh.contains(anOrder.getDeliveryState())) deliveryTime = 2; else deliveryTime = 3; return anOrder.plusDays(1 + deliveryTime); } // 明确函数:处理普通订单 public LocalDate regularDeliveryDate(Order anOrder) { int deliveryTime; Set<String> maCtNy = new HashSet<>(Arrays.asList("MA", "CT", "NY")); Set<String> meNh = new HashSet<>(Arrays.asList("ME", "NH")); if (maCtNy.contains(anOrder.getDeliveryState())) deliveryTime = 2; else if (meNh.contains(anOrder.getDeliveryState())) deliveryTime = 3; else deliveryTime = 4; return anOrder.plusDays(2 + deliveryTime); } }
-
修改所有调用者,直接调用明确函数:
java// 原始调用 // LocalDate rushDelivery = service.deliveryDate(rushOrder, true); // LocalDate regularDelivery = service.deliveryDate(regularOrder, false); // 修改为: OrderService service = new OrderService(); Order rushOrder = new OrderService.Order("MA", LocalDate.now()); LocalDate rushDelivery = service.rushDeliveryDate(rushOrder); // 直接调用 Order regularOrder = new OrderService.Order("NY", LocalDate.now()); LocalDate regularDelivery = service.regularDeliveryDate(regularOrder); // 直接调用
-
(可选)删除原始
deliveryDate
函数: 如果没有其他代码以非字面量形式传入isRush
参数,或者旧函数不再需要,可以删除它。
复杂情况下的包装函数策略:
如果 deliveryDate
函数的 isRush
逻辑不是简单地在顶层进行 if/else
分发,而是散布在函数内部,使得分解条件表达式变得困难,那么可以采用创建包装函数的方式。
java
public class OrderService {
// ... Order 内部类 ...
// ... 原始 deliveryDate 复杂实现 ...
// 创建包装函数
public LocalDate rushDeliveryDate(Order anOrder) {
return deliveryDate(anOrder, true); // 调用原始函数,传入字面量标记
}
public LocalDate regularDeliveryDate(Order anOrder) {
return deliveryDate(anOrder, false); // 调用原始函数,传入字面量标记
}
}
然后同样修改调用者,并考虑将原始 deliveryDate
函数的可见性限制为 private
或改名(如 deliveryDateInternal
),以表明不应直接调用它。
11.4 保持对象完整(Preserve Whole Object)
动机
如果代码从一个记录结构中导出几个值,然后又把这几个值一起传递给一个函数,我们更愿意把整个记录传给这个函数,在函数体内部导出所需的值。
"传递整个记录"的方式能更好地应对变化:如果将来被调的函数需要从记录中导出更多的数据,就不用为此修改参数列表。并且传递整个记录也能缩短参数列表,让函数调用更容易看懂。如果有很多函数都在使用记录中的同一组数据,处理这部分数据的逻辑常会重复,此时可以把这些处理逻辑搬移到完整对象中去。
有时我们不想采用本重构手法,因为不想让被调函数依赖完整对象,尤其是在两者不在同一个模块中的时候。
从一个对象中抽取出几个值,单独对这几个值做某些逻辑操作,这是一种代码坏味道(依恋情结),通常标志着这段逻辑应该被搬移到对象中。保持对象完整经常发生在引入参数对象之后,我们会搜寻使用原来的数据泥团的代码,代之以使用新的对象。
如果几处代码都在使用对象的一部分功能,可能意味着应该用提炼类把这一部分功能单独提炼出来。
还有一种常被忽视的情况:调用者将自己的若干数据作为参数,传递给被调用函数。这种情况下,可以将调用者的自我引用(在 JavaScript 中就是 this)作为参数,直接传递给目标函数。
做法
- 新建一个空函数,给它以期望中的参数列表(即传入完整对象作为参数)。
- 给这个函数起一个容易搜索的名字,这样到重构结束时方便替换。
- 在新函数体内调用旧函数,并把新的参数(即完整对象)映射到旧的参数列表(即来源于完整对象的各项数据)。
- 执行静态检查。
- 逐一修改旧函数的调用者,令其使用新函数,每次修改之后执行测试。
- 修改之后,调用处用于"从完整对象中导出参数值"的代码可能就没用了,可以用移除死代码去掉。
- 所有调用处都修改过来之后,使用内联函数把旧函数内联到新函数体内。
- 给新函数改名,从重构开始时的容易搜索的临时名字,改为使用旧函数的名字,同时修改所有调用处。
范例
我们想象一个室温监控系统,它负责记录房间一天中的最高温度和最低温度,然后将实际的温度范围与预先规定的温度控制计划(heating plan)相比较,如果当天温度不符合计划要求,就发出警告。
原始代码:
java
// 温度范围类
class NumberRange {
private int low;
private int high;
public NumberRange(int low, int high) {
this.low = low;
this.high = high;
}
public int getLow() {
return low;
}
public int getHigh() {
return high;
}
}
// 供暖计划类
class HeatingPlan {
private NumberRange temperatureRange;
public HeatingPlan(NumberRange temperatureRange) {
this.temperatureRange = temperatureRange;
}
// 检查温度是否在计划范围内
public boolean withinRange(int bottom, int top) {
return (bottom >= temperatureRange.getLow()) && (top <= temperatureRange.getHigh());
}
}
// 房间类
class Room {
private NumberRange daysTempRange;
public Room(NumberRange daysTempRange) {
this.daysTempRange = daysTempRange;
}
public NumberRange getDaysTempRange() {
return daysTempRange;
}
}
// 调用方
public class Client {
public void checkTemperature(Room aRoom, HeatingPlan aPlan) {
int low = aRoom.getDaysTempRange().getLow();
int high = aRoom.getDaysTempRange().getHigh();
if (!aPlan.withinRange(low, high)) {
System.out.println("Room temperature went outside range");
}
}
}
重构后:
java
class NumberRange {
private int low;
private int high;
public NumberRange(int low, int high) {
this.low = low;
this.high = high;
}
public int getLow() {
return low;
}
public int getHigh() {
return high;
}
}
class HeatingPlan {
private NumberRange temperatureRange;
public HeatingPlan(NumberRange temperatureRange) {
this.temperatureRange = temperatureRange;
}
// 新增的重构函数,将整个NumberRange对象作为参数传入
public boolean withinRange(NumberRange aNumberRange) {
return (aNumberRange.getLow() >= temperatureRange.getLow()) &&
(aNumberRange.getHigh() <= temperatureRange.getHigh());
}
}
class Room {
private NumberRange daysTempRange;
public Room(NumberRange daysTempRange) {
this.daysTempRange = daysTempRange;
}
public NumberRange getDaysTempRange() {
return daysTempRange;
}
}
public class Client {
public void checkTemperature(Room aRoom, HeatingPlan aPlan) {
// 直接传入整个daysTempRange对象
if (!aPlan.withinRange(aRoom.getDaysTempRange())) {
System.out.println("Room temperature went outside range");
}
}
}
11.5 以查询取代参数(Replace Parameter with Query)
动机
函数的参数列表应该总结该函数的可变性,标示出函数可能体现出行为差异的主要方式。和任何代码中的语句一样,参数列表应该尽量避免重复,并且参数列表越短就越容易理解。
如果调用函数时传入了一个值,而这个值由函数自己来获得也是同样容易,这就是重复。这个本不必要的参数会增加调用者的难度,因为它不得不找出正确的参数值,其实原本调用者是不需要费这个力气的。
"同样容易"四个字,划出了一条判断的界限。去除参数也就意味着"获得正确的参数值"的责任被转移:有参数传入时,调用者需要负责获得正确的参数值;参数去除后,责任就被转移给了函数本身。一般而言,我们习惯于简化调用方,因此愿意把责任移交给函数本身,但如果函数难以承担这份责任,就另当别论了。
不使用以查询取代参数最常见的原因是,移除参数可能会给函数体增加不必要的依赖关系------迫使函数访问某个程序元素,而我原本不想让函数了解这个元素的存在。这种"不必要的依赖关系"除了新增的以外,也可能是我想要稍后去除的,例如为了去除一个参数,我可能会在函数体内调用一个有问题的函数,或是从一个对象中获取某些原本想要剥离出去的数据。在这些情况下,都应该慎重考虑使用以查询取代参数。
如果想要去除的参数值只需要向另一个参数查询就能得到,这是使用以查询取代参数最安全的场景。如果可以从一个参数推导出另一个参数,那么几乎没有任何理由要同时传递这两个参数。
另外有一件事需要留意:如果在处理的函数具有引用透明性(referential transparency,即,不论任何时候,只要传入相同的参数值,该函数的行为永远一致),这样的函数既容易理解又容易测试,我们不想使其失去这种优秀品质。我们不会去掉它的参数,让它去访问一个可变的全局变量。
做法
- 如果有必要,使用提炼函数将参数的计算过程提炼到一个独立的函数中。
- 将函数体内引用该参数的地方改为调用新建的函数。每次修改后执行测试。
- 全部替换完成后,使用改变函数声明将该参数去掉。
范例
某些重构会使参数不再被需要,这是我们最常用到以查询取代参数的场合。考虑下列代码。
原始代码:
java
class Order {
private int quantity;
private double itemPrice;
public Order(int quantity, double itemPrice) {
this.quantity = quantity;
this.itemPrice = itemPrice;
}
public double finalPrice() {
double basePrice = quantity * itemPrice;
int discountLevel;
if (quantity > 100) {
discountLevel = 2;
} else {
discountLevel = 1;
}
return discountedPrice(basePrice, discountLevel);
}
// 计算折扣后的价格
private double discountedPrice(double basePrice, int discountLevel) {
switch (discountLevel) {
case 1:
return basePrice * 0.95;
case 2:
return basePrice * 0.9;
default:
return basePrice;
}
}
}
重构后:
java
class Order {
private int quantity;
private double itemPrice;
public Order(int quantity, double itemPrice) {
this.quantity = quantity;
this.itemPrice = itemPrice;
}
public double finalPrice() {
double basePrice = quantity * itemPrice;
// 不再传递discountLevel,而是直接调用getDiscountLevel()
return discountedPrice(basePrice);
}
// 提炼出计算折扣等级的方法
private int getDiscountLevel() {
return (quantity > 100) ? 2 : 1;
}
// discountedPrice方法不再需要discountLevel参数
private double discountedPrice(double basePrice) {
switch (getDiscountLevel()) { // 直接在方法内部查询折扣等级
case 1:
return basePrice * 0.95;
case 2:
return basePrice * 0.9;
default:
return basePrice;
}
}
}
11.6 以参数取代查询(Replace Query with Parameter)
动机
在浏览函数实现时,有时会发现一些令人不快的引用关系,例如,引用一个全局变量,或者引用另一个我们想要移除的元素。为了解决这些令人不快的引用,我们需要将其替换为函数参数,从而将处理引用关系的责任转交给函数的调用者。
需要使用本重构的情况大多源于我们想要改变代码的依赖关系------为了让目标函数不再依赖于某个元素,我们把这个元素的值以参数形式传递给该函数。这里需要注意权衡:如果把所有依赖关系都变成参数,会导致参数列表冗长重复;如果作用域之间的共享太多,又会导致函数间依赖过度。我们一向不善于微妙的权衡,所以"能够可靠地改变决定"就显得尤为重要,这样随着我们的理解加深,程序也能从中受益。
如果一个函数用同样的参数调用总是给出同样的结果,我们就说这个函数具有"引用透明性"(referential transparency),这样的函数理解起来更容易。如果一个函数使用了另一个元素,而后者不具引用透明性,那么包含该元素的函数也就失去了引用透明性。只要把"不具引用透明性的元素"变成参数传入,函数就能重获引用透明性。虽然这样就把责任转移给了函数的调用者,但是具有引用透明性的模块能带来很多益处。有一个常见的模式:在负责逻辑处理的模块中只有纯函数,其外再包裹处理 I/O 和其他可变元素的逻辑代码。借助以参数取代查询,我们可以提纯程序的某些组成部分,使其更容易测试、更容易理解。
不过以参数取代查询并非只有好处。把查询变成参数以后,就迫使调用者必须弄清如何提供正确的参数值,这会增加函数调用者的复杂度,而我们在设计接口时通常更愿意让接口的消费者更容易使用。归根到底,这是关于程序中责任分配的问题,而这方面的决策既不容易,也不会一劳永逸------这就是我们需要非常熟悉本重构(及其反向重构)的原因。
做法
- 对执行查询操作的代码使用提炼变量,将其从函数体中分离出来。
- 现在函数体代码已经不再执行查询操作(而是使用前一步提炼出的变量),对这部分代码使用提炼函数。
- 给提炼出的新函数起一个容易搜索的名字,以便稍后改名。
- 使用内联变量,消除刚才提炼出来的变量。
- 对原来的函数使用内联函数。
- 对新函数改名,改回原来函数的名字。
范例
我们想象一个简单却又烦人的温度控制系统。用户可以从一个温控终端(thermostat)指定温度,但指定的目标温度必须在温度控制计划(heating plan)允许的范围内。
原始代码:
java
// 温控器类(模拟全局变量)
class Thermostat {
private static int selectedTemperature; // 用户选择的温度
private static int currentTemperature; // 当前温度
public static int getSelectedTemperature() {
return selectedTemperature;
}
public static void setSelectedTemperature(int temp) {
selectedTemperature = temp;
}
public static int getCurrentTemperature() {
return currentTemperature;
}
public static void setCurrentTemperature(int temp) {
currentTemperature = temp;
}
}
// 供暖计划类
class HeatingPlan {
private int min;
private int max;
public HeatingPlan(int min, int max) {
this.min = min;
this.max = max;
}
// 目标温度依赖于全局的Thermostat
public int targetTemperature() {
if (Thermostat.getSelectedTemperature() > max) return max;
else if (Thermostat.getSelectedTemperature() < min) return min;
else return Thermostat.getSelectedTemperature();
}
}
// 调用方
public class Client {
public void runControl(HeatingPlan thePlan) {
Thermostat.setSelectedTemperature(22); // 模拟用户设置
Thermostat.setCurrentTemperature(20); // 模拟当前温度
if (thePlan.targetTemperature() > Thermostat.getCurrentTemperature()) {
System.out.println("Set to heat");
} else if (thePlan.targetTemperature() < Thermostat.getCurrentTemperature()) {
System.out.println("Set to cool");
} else {
System.out.println("Set off");
}
}
}
重构后:
java
class Thermostat {
private static int selectedTemperature;
private static int currentTemperature;
public static int getSelectedTemperature() {
return selectedTemperature;
}
public static void setSelectedTemperature(int temp) {
selectedTemperature = temp;
}
public static int getCurrentTemperature() {
return currentTemperature;
}
public static void setCurrentTemperature(int temp) {
currentTemperature = temp;
}
}
class HeatingPlan {
private int min;
private int max;
public HeatingPlan(int min, int max) {
this.min = min;
this.max = max;
}
// 目标温度不再直接依赖Thermostat,而是将所需值作为参数传入
public int targetTemperature(int selectedTemperature) {
if (selectedTemperature > max) return max;
else if (selectedTemperature < min) return min;
else return selectedTemperature;
}
}
public class Client {
public void runControl(HeatingPlan thePlan) {
Thermostat.setSelectedTemperature(22);
Thermostat.setCurrentTemperature(20);
// 调用方负责从Thermostat获取值并传入
if (thePlan.targetTemperature(Thermostat.getSelectedTemperature()) > Thermostat.getCurrentTemperature()) {
System.out.println("Set to heat");
} else if (thePlan.targetTemperature(Thermostat.getSelectedTemperature()) < Thermostat.getCurrentTemperature()) {
System.out.println("Set to cool");
} else {
System.out.println("Set off");
}
}
}
11.7 移除设值函数(Remove Setting Method)
动机
如果为某个字段提供了设值函数,这就暗示这个字段可以被改变。如果不希望在对象创建之后此字段还有机会被改变,那就不要为它提供设值函数(同时将该字段声明为不可变)。这样一来,该字段就只能在构造函数中赋值,我们"不想让它被修改"的意图会更加清晰,并且可以排除其值被修改的可能性------这种可能性往往是非常大的。
有两种常见的情况需要讨论。一种情况是,有些人喜欢始终通过访问函数来读写字段值,包括在构造函数内也是如此。这会导致构造函数成为设值函数的唯一使用者。若果真如此,我们更愿意去除设值函数,清晰地表达"构造之后不应该再更新字段值"的意图。
另一种情况是,对象是由客户端通过创建脚本构造出来,而不是只有一次简单的构造函数调用。所谓"创建脚本",首先是调用构造函数,然后就是一系列设值函数的调用,共同完成新对象的构造。创建脚本执行完以后,这个新生对象的部分(乃至全部)字段就不应该再被修改。设值函数只应该在起初的对象创建过程中调用。对于这种情况,我们也会想办法去除设值函数,更清晰地表达我们的意图。
做法
- 如果构造函数尚无法得到想要设入字段的值,就使用改变函数声明将这个值以参数的形式传入构造函数。在构造函数中调用设值函数,对字段设值。
- 如果想移除多个设值函数,可以一次性把它们的值都传入构造函数,这能简化后续步骤。
- 移除所有在构造函数之外对设值函数的调用,改为使用新的构造函数。每次修改之后都要测试。
- 如果不能把"调用设值函数"替换为"创建一个新对象"(例如你需要更新一个多处共享引用的对象),请放弃本重构。
- 使用内联函数消去设值函数。如果可能的话,把字段声明为不可变。
- 测试。
范例
我们有一个很简单的 Person 类。
原始代码:
java
class Person {
private String name;
private String id;
// 默认构造函数
public Person() {}
public String getName() {
return name;
}
public void setName(String name) { // 可设置姓名
this.name = name;
}
public String getId() {
return id;
}
public void setId(String id) { // 可设置ID
this.id = id;
}
}
// 调用方
public class Client {
public static void main(String[] args) {
Person martin = new Person();
martin.setName("Martin");
martin.setId("1234"); // ID在对象创建后被设置
System.out.println("Name: " + martin.getName() + ", ID: " + martin.getId());
}
}
重构后:
java
class Person {
private String name;
private final String id; // 将id声明为final,使其不可变
// 构造函数接收id,在创建时初始化
public Person(String id) {
this.id = id;
}
public String getName() {
return name;
}
public void setName(String name) { // 姓名仍然可变
this.name = name;
}
public String getId() {
return id;
}
// 移除了setId方法,因为id在构造后不应改变
// public void setId(String id) {
// this.id = id;
// }
}
public class Client {
public static void main(String[] args) {
// 在构造时就传入id
Person martin = new Person("1234");
martin.setName("Martin");
// martin.setId("5678"); // 编译错误:无法调用已移除的setId方法
System.out.println("Name: " + martin.getName() + ", ID: " + martin.getId());
}
}
11.8 以工厂函数取代构造函数(Replace Constructor with Factory Function)
动机
很多面向对象语言都有特别的构造函数,专门用于对象的初始化。需要新建一个对象时,客户端通常会调用构造函数。但与一般的函数相比,构造函数又常有一些丑陋的局限性。例如,Java 的构造函数只能返回当前所调用类的实例,也就是说,我无法根据环境或参数信息返回子类实例或代理对象;构造函数的名字是固定的,因此无法使用比默认名字更清晰的函数名;构造函数需要通过特殊的操作符来调用(在很多语言中是 new 关键字),所以在要求普通函数的场合就难以使用。
工厂函数就不受这些限制。工厂函数的实现内部可以调用构造函数,但也可以换成别的方式实现。
做法
- 新建一个工厂函数,让它调用现有的构造函数。
- 将调用构造函数的代码改为调用工厂函数。
- 每修改一处,就执行测试。
- 尽量缩小构造函数的可见范围。
范例
又是那个单调乏味的例子:员工薪资系统。我们还是以 Employee 类表示"员工"。
原始代码:
java
class Employee {
public static final int ENGINEER = 0;
public static final int MANAGER = 1;
public static final int SALESMAN = 2;
private String name;
private int typeCode;
public Employee(String name, int typeCode) {
this.name = name;
this.typeCode = typeCode;
}
public String getName() {
return name;
}
public int getTypeCode() {
return typeCode;
}
// ... 其他方法
}
// 调用方
public class Client {
public static void main(String[] args) {
// 创建普通员工
Employee candidate = new Employee("Alice", Employee.ENGINEER);
System.out.println("Candidate: " + candidate.getName() + ", Type: " + candidate.getTypeCode());
// 创建首席工程师
Employee leadEngineer = new Employee("Bob", Employee.ENGINEER);
System.out.println("Lead Engineer: " + leadEngineer.getName() + ", Type: " + leadEngineer.getTypeCode());
}
}
重构后:
java
class Employee {
public static final int ENGINEER = 0;
public static final int MANAGER = 1;
public static final int SALESMAN = 2;
private String name;
private int typeCode;
// 构造函数可以保持为public,或者设为protected/private以强制使用工厂方法
public Employee(String name, int typeCode) {
this.name = name;
this.typeCode = typeCode;
}
public String getName() {
return name;
}
public int getTypeCode() {
return typeCode;
}
// 工厂函数:创建通用员工
public static Employee createEmployee(String name, int typeCode) {
return new Employee(name, typeCode);
}
// 工厂函数:创建工程师
public static Employee createEngineer(String name) {
return new Employee(name, ENGINEER);
}
// 工厂函数:创建经理
public static Employee createManager(String name) {
return new Employee(name, MANAGER);
}
// ... 其他方法
}
// 调用方
public class Client {
public static void main(String[] args) {
// 使用工厂函数创建普通员工
Employee candidate = Employee.createEmployee("Alice", Employee.ENGINEER);
System.out.println("Candidate: " + candidate.getName() + ", Type: " + candidate.getTypeCode());
// 使用更具表达力的工厂函数创建首席工程师
Employee leadEngineer = Employee.createEngineer("Bob");
System.out.println("Lead Engineer: " + leadEngineer.getName() + ", Type: " + leadEngineer.getTypeCode());
}
}
11.9 以命令取代函数(Replace Function with Command)
动机
函数,不管是独立函数,还是以方法(method)形式附着在对象上的函数,是程序设计的基本构造块。不过,将函数封装成自己的对象,有时也是一种有用的办法。这样的对象我们称之为"命令对象"(command object),或者简称"命令"(command)。这种对象大多只服务于单一函数,获得对该函数的请求,执行该函数,就是这种对象存在的意义。
与普通的函数相比,命令对象提供了更大的控制灵活性和更强的表达能力。除了函数调用本身,命令对象还可以支持附加的操作,例如撤销操作。我们可以通过命令对象提供的方法来设值命令的参数值,从而支持更丰富的生命周期管理能力。我们可以借助继承和钩子对函数行为加以定制。如果我所使用的编程语言支持对象但不支持函数作为一等公民,通过命令对象就可以给函数提供大部分相当于一等公民的能力。同样,即便编程语言本身并不支持嵌套函数,我们也可以借助命令对象的方法和字段把复杂的函数拆解开,而且在测试和调试过程中可以直接调用这些方法。
所有这些都是使用命令对象的好理由,所以我们要做好准备,一旦有需要,就能把函数重构成命令。不过我们不能忘记,命令对象的灵活性也是以复杂性作为代价的。所以,如果要在作为一等公民的函数和命令对象之间做个选择,95%的时候我都会选函数。只有当我特别需要命令对象提供的某种能力而普通的函数无法提供这种能力时,我才会考虑使用命令对象。
跟软件开发中的很多词汇一样,"命令"这个词承载了太多含义。在这里,"命令"是指一个对象,其中封装了一个函数调用请求。这是遵循《设计模式》[gof]一书中的命令模式(command pattern)。在这个意义上,使用"命令"一词时,我会先用完整的"命令对象"一词设定上下文,然后视情况使用简略的"命令"一词。在命令与查询分离原则(command-query separation principle)中也用到了"命令"一词,此时"命令"是一个对象所拥有的函数,调用该函数可以改变对象可观察的状态。我尽量避免使用这个意义上的"命令"一词,而更愿意称其为"修改函数"(modifier)或者"改变函数"(mutator)。
做法
- 为想要包装的函数创建一个空的类,根据该函数的名字为其命名。
- 使用搬移函数把函数移到空的类里。
- 保持原来的函数作为转发函数,至少保留到重构结束之前才删除。
- 遵循编程语言的命名规范来给命令对象起名。如果没有合适的命名规范,就给命令对象中负责实际执行命令的函数起一个通用的名字,例如"execute"或者"call"。
- 可以考虑给每个参数创建一个字段,并在构造函数中添加对应的参数。
范例
一个典型的应用场景就是拆解复杂的函数,以便理解和修改。下面的函数用于给一份保险申请评分。
原始代码:
java
class Candidate {
String originState;
boolean hasCriminalRecord; // 假设用于更多复杂的评分逻辑
// ...其他属性
}
class MedicalExam {
boolean isSmoker;
// ...其他属性
}
class ScoringGuide {
boolean stateWithLowCertification(String state) {
return "MA".equals(state) || "CT".equals(state);
}
// ...其他评分规则
}
public class InsuranceScorer {
// 原始的复杂评分函数
public int score(Candidate candidate, MedicalExam medicalExam, ScoringGuide scoringGuide) {
int result = 0;
int healthLevel = 0;
boolean highMedicalRiskFlag = false;
if (medicalExam.isSmoker) {
healthLevel += 10;
highMedicalRiskFlag = true;
}
String certificationGrade = "regular";
if (scoringGuide.stateWithLowCertification(candidate.originState)) {
certificationGrade = "low";
result -= 5;
}
// ... 更多复杂的评分逻辑
result -= Math.max(healthLevel - 5, 0);
// ... 更多代码
return result;
}
}
// 调用方
public class Client {
public static void main(String[] args) {
Candidate c = new Candidate();
c.originState = "NY";
MedicalExam m = new MedicalExam();
m.isSmoker = true;
ScoringGuide sg = new ScoringGuide();
InsuranceScorer scorer = new InsuranceScorer();
int finalScore = scorer.score(c, m, sg);
System.out.println("Final Score: " + finalScore);
}
}
重构后:
java
class Candidate {
String originState;
boolean hasCriminalRecord;
// ...其他属性
}
class MedicalExam {
boolean isSmoker;
// ...其他属性
}
class ScoringGuide {
boolean stateWithLowCertification(String state) {
return "MA".equals(state) || "CT".equals(state);
}
// ...其他评分规则
}
// 将原函数封装成命令对象
class ScorerCommand {
private Candidate candidate;
private MedicalExam medicalExam;
private ScoringGuide scoringGuide;
// 内部状态,原函数的局部变量
private int result;
private int healthLevel;
private boolean highMedicalRiskFlag;
private String certificationGrade;
// 构造函数接收所有必要参数
public ScorerCommand(Candidate candidate, MedicalExam medicalExam, ScoringGuide scoringGuide) {
this.candidate = candidate;
this.medicalExam = medicalExam;
this.scoringGuide = scoringGuide;
}
// 执行命令的方法
public int execute() {
result = 0;
healthLevel = 0;
highMedicalRiskFlag = false;
certificationGrade = "regular"; // 初始化为字段
scoreSmoking(); // 提炼子方法
scoreCertification(); // 提炼子方法
// ... 更多复杂的评分逻辑,现在可以进一步提炼为方法
result -= Math.max(healthLevel - 5, 0);
// ... 更多代码
return result;
}
// 提炼后的子方法:处理吸烟相关的评分
private void scoreSmoking() {
if (medicalExam.isSmoker) {
healthLevel += 10;
highMedicalRiskFlag = true;
}
}
// 提炼后的子方法:处理认证等级相关的评分
private void scoreCertification() {
if (scoringGuide.stateWithLowCertification(candidate.originState)) {
certificationGrade = "low";
result -= 5;
}
}
// ... 可以继续提炼更多子方法
}
public class InsuranceScorer {
// 原始函数现在作为一个转发函数
public int score(Candidate candidate, MedicalExam medicalExam, ScoringGuide scoringGuide) {
return new ScorerCommand(candidate, medicalExam, scoringGuide).execute();
}
}
// 调用方
public class Client {
public static void main(String[] args) {
Candidate c = new Candidate();
c.originState = "NY";
MedicalExam m = new MedicalExam();
m.isSmoker = true;
ScoringGuide sg = new ScoringGuide();
InsuranceScorer scorer = new InsuranceScorer();
int finalScore = scorer.score(c, m, sg); // 仍然通过原始接口调用
System.out.println("Final Score: " + finalScore);
}
}
11.10 以函数取代命令(Replace Command with Function)
反向重构:以命令取代函数(337)
动机
命令对象为处理复杂计算提供了强大的机制。借助命令对象,可以轻松地将原本复杂的函数拆解为多个方法,彼此之间通过字段共享状态;拆解后的方法可以分别调用;开始调用之前的数据状态也可以逐步构建。但这种强大是有代价的。大多数时候,我只是想调用一个函数,让它完成自己的工作就好。如果这个函数不是太复杂,那么命令对象可能显得费而不惠,我就应该考虑将其变回普通的函数。
做法
- 运用提炼函数(Extract Function) ,把"创建并执行命令对象"的代码单独提炼到一个函数中。
- 这一步会新建一个函数,最终这个函数会取代现在的命令对象。
- 对命令对象在执行阶段用到的函数,逐一使用内联函数(Inline Function) 。
- 如果被调用的函数有返回值,请先对调用处使用提炼变量(Extract Variable),然后再使用内联函数。
- 使用改变函数声明(Change Function Declaration),把构造函数的参数转移到执行函数。
- 对于所有的字段,在执行函数中找到引用它们的地方,并改为使用参数。每次修改后都要测试。
- 把"调用构造函数"和"调用执行函数"两步都内联到调用方(也就是最终要替换命令对象的那个函数)。
- 测试。
- 用移除死代码(Remove Dead Code) 把命令类消去。
范例
假设我有一个很小的命令对象,用于计算客户的费用。
ChargeCalculator
类(重构前)
java
public class ChargeCalculator {
private Customer customer;
private double usage;
private Provider provider;
public ChargeCalculator(Customer customer, double usage, Provider provider) {
this.customer = customer;
this.usage = usage;
this.provider = provider;
}
// 私有辅助方法,用于计算基础费用
private double getBaseCharge() {
return customer.getBaseRate() * usage;
}
// 主要的费用计算方法
public double calculateCharge() {
return getBaseCharge() + provider.getConnectionCharge();
}
}
配套的 Customer
和 Provider
类(简化版):
java
// Customer.java
public class Customer {
private double baseRate; // 基础费率
public Customer(double baseRate) {
this.baseRate = baseRate;
}
public double getBaseRate() {
return baseRate;
}
}
// Provider.java
public class Provider {
private double connectionCharge; // 连接费
public Provider(double connectionCharge) {
this.connectionCharge = connectionCharge;
}
public double getConnectionCharge() {
return connectionCharge;
}
}
使用方的代码如下。
调用方(重构前)
java
public class Client {
public static void main(String[] args) {
Customer customer = new Customer(0.10); // 费率 0.10
Provider provider = new Provider(5.0); // 连接费 5.0
double usage = 100.0; // 用量 100
// 创建并执行命令对象来计算月费用
double monthCharge = new ChargeCalculator(customer, usage, provider).calculateCharge();
System.out.println("Monthly Charge: " + monthCharge); // 预期: 0.10 * 100 + 5.0 = 15.0
}
}
这个命令类足够小、足够简单,变成函数更合适。
步骤 1: 提炼函数,包装命令对象的创建与调用
首先,我们创建一个新的静态函数 charge
来封装 ChargeCalculator
的实例化和方法调用。
java
// 新增的静态函数
public class Client {
public static void main(String[] args) {
Customer customer = new Customer(0.10);
Provider provider = new Provider(5.0);
double usage = 100.0;
double monthCharge = charge(customer, usage, provider); // 调用新函数
System.out.println("Monthly Charge: " + monthCharge);
}
// 提炼出来的函数,包装了命令对象的创建和执行
private static double charge(Customer customer, double usage, Provider provider) {
return new ChargeCalculator(customer, usage, provider).calculateCharge();
}
}
步骤 2: 对命令对象在执行阶段用到的函数,逐一使用内联函数
ChargeCalculator
中的 calculateCharge
方法使用了 getBaseCharge
辅助方法。我们将其内联。
首先,在 calculateCharge
中提炼 getBaseCharge
的返回值到一个变量。
java
public class ChargeCalculator {
private Customer customer;
private double usage;
private Provider provider;
public ChargeCalculator(Customer customer, double usage, Provider provider) {
this.customer = customer;
this.usage = usage;
this.provider = provider;
}
private double getBaseCharge() {
return customer.getBaseRate() * usage;
}
public double calculateCharge() {
// 提炼变量
double baseChargeValue = getBaseCharge();
return baseChargeValue + provider.getConnectionCharge();
}
}
然后将 getBaseCharge()
方法的调用直接替换为其实现:
java
public class ChargeCalculator {
private Customer customer;
private double usage;
private Provider provider;
public ChargeCalculator(Customer customer, double usage, Provider provider) {
this.customer = customer;
this.usage = usage;
this.provider = provider;
}
// getBaseCharge 方法被内联,可以删除(或保留待定)
// private double getBaseCharge() {
// return customer.getBaseRate() * usage;
// }
public double calculateCharge() {
// 直接内联 getBaseCharge 的逻辑
double baseChargeValue = customer.getBaseRate() * usage;
return baseChargeValue + provider.getConnectionCharge();
}
}
步骤 3: 使用改变函数声明,把构造函数的参数转移到执行函数
现在 calculateCharge
是 ChargeCalculator
中唯一的公共方法,且包含了所有逻辑。我们将 ChargeCalculator
构造函数中的参数(customer
, usage
, provider
)直接作为参数传递给 calculateCharge
方法。
java
public class ChargeCalculator {
// 字段将逐渐被移除
// private Customer customer;
// private double usage;
// private Provider provider;
// 构造函数可以保持不变,但最终会被删除
public ChargeCalculator(Customer customer, double usage, Provider provider) {
// this.customer = customer;
// this.usage = usage;
// this.provider = provider;
}
// 修改 calculateCharge 的签名,使其接收所有必要的参数
public double calculateCharge(Customer customer, double usage, Provider provider) {
double baseChargeValue = customer.getBaseRate() * usage;
return baseChargeValue + provider.getConnectionCharge();
}
}
同时更新 charge
转发函数中的调用:
java
public class Client {
// ... 其他代码 ...
private static double charge(Customer customer, double usage, Provider provider) {
// 现在直接将参数传递给 calculateCharge 方法
return new ChargeCalculator(customer, usage, provider).calculateCharge(customer, usage, provider);
}
}
步骤 4: 对于所有的字段,在执行函数中找到引用它们的地方,并改为使用参数
在上一步中,我们已经直接使用了传入的参数,而不是类字段。
java
public class ChargeCalculator {
// 字段不再被使用,可以考虑移除
// private Customer customer;
// private double usage;
// private Provider provider;
public ChargeCalculator(Customer customer, double usage, Provider provider) {
// 构造函数现在是空的,因为字段不再用于 calculateCharge
}
public double calculateCharge(Customer customer, double usage, Provider provider) {
// 直接使用参数,而不是 this._customer, this._usage, this._provider
double baseChargeValue = customer.getBaseRate() * usage;
return baseChargeValue + provider.getConnectionCharge();
}
}
步骤 5: 把"调用构造函数"和"调用执行函数"两步都内联到调用方
现在 ChargeCalculator
类中的 calculateCharge
方法已经是一个纯函数,不依赖于类的状态。我们可以将其逻辑直接内联到 charge
转发函数中。
java
public class Client {
// ... 其他代码 ...
private static double charge(Customer customer, double usage, Provider provider) {
// 直接内联 ChargeCalculator 类的 calculateCharge 逻辑
double baseChargeValue = customer.getBaseRate() * usage;
return baseChargeValue + provider.getConnectionCharge();
}
}
步骤 6: 测试
运行所有测试,确保重构后的行为与重构前一致。
步骤 7: 用移除死代码把命令类消去
ChargeCalculator
类现在已经没有任何被调用的方法或被引用的字段,可以安全地删除了。
最终的函数版本
java
// Client.java
public class Client {
public static void main(String[] args) {
Customer customer = new Customer(0.10);
Provider provider = new Provider(5.0);
double usage = 100.0;
double monthCharge = charge(customer, usage, provider);
System.out.println("Monthly Charge: " + monthCharge); // 预期: 15.0
}
// 简洁的函数,直接计算费用
private static double charge(Customer customer, double usage, Provider provider) {
double baseChargeValue = customer.getBaseRate() * usage;
return baseChargeValue + provider.getConnectionCharge();
}
}