(四)优雅重构:洞悉“搬移特性”的艺术与实践
文章目录
- 八、优雅重构:洞悉“搬移特性”的艺术与实践
- 0.1 前言
- 0.2 前序笔记
- 1. 搬移函数(Move Function)/搬移方法(Move Method)
- 2. 搬移字段(Move Field)
- 3. 搬移语句到函数(Move Statements into Function)
- 4. 搬移语句到调用者(Move Statements to Callers)
- 5. 以函数调用取代内联代码(Replace Inline Code with Function Call)
- 6. 移动语句(Slide Statements)/合并重复的代码片段(Consolidate Duplicate Conditional Fragments)
- 7. 拆分循环(Split Loop)
- 8. 以管道取代循环(Replace Loop with Pipeline)
- 9. 移除死代码(Remove Dead Code)
- 总结
- 参考
- 后续文章
八、优雅重构:洞悉“搬移特性”的艺术与实践
0.1 前言
在软件开发的漫长旅程中,代码的演进与维护是永恒的主题。随着业务逻辑的增长和需求的变迁,我们经常会发现最初精心设计的代码结构变得臃肿、耦合,甚至难以理解。此时,重构便成为我们手中一把锋利的剑,而“搬移特性”系列手法,则是这把剑上最常用、也最关键的招式之一。
本文将深入探讨“搬移特性”这一重构主题下的九种具体手法,通过理论阐述、场景分析和 Java 代码示例,帮助你更好地理解和运用这些技巧,让你的代码库焕发出新的活力。
“搬移特性”是重构中最常用的手法之一,旨在通过调整代码元素(函数、字段、语句等)的位置,来改善代码的封装性、降低耦合度、提高可读性和可维护性。其核心思想是让相关的代码更靠近,不相关的代码更远离,从而构建出更健壮、更灵活的软件系统。
0.2 前序笔记
(一)代码匠心:重构之道,化腐朽为神奇
(二)重构的艺术:精进代码的第一组基本功
(三)封装与结构优化:让代码更优雅
1. 搬移函数(Move Function)/搬移方法(Move Method)
搬移函数最直接的一个动因是,它频繁引用其他上下文中的元素,而对自身上下文中的元素却关心甚少。此时,让它去与那些更亲密的元素相会,通常能取得更好的封装效果,因为系统别处就可以减少对当前模块的依赖。
场景描述: 当一个方法大量使用另一个类的数据,而非自己类的数据时,就应该考虑将该方法搬移到数据所在的类中。
Java 代码示例:
// 搬移前
class Account {private int daysOverdrawn;// ... 其他Account相关方法 ...// 这个方法实际上更关心Customer的数据public double bankCharge(Customer customer) {if (daysOverdrawn > 0) {return customer.getChargeAmount() + 1.75; // 假设customer有自己的收费逻辑}return 0.0;}
}class Customer {private double chargeAmount;public double getChargeAmount() {return chargeAmount;}public void setChargeAmount(double chargeAmount) {this.chargeAmount = chargeAmount;}// ... 其他Customer相关方法 ...
}// 搬移后
class Account {private int daysOverdrawn;// ... 其他Account相关方法 ...// bankCharge 方法被搬移到 Customer 类中,Account 只需提供必要的数据public int getDaysOverdrawn() {return daysOverdrawn;}
}class Customer {private double chargeAmount;public double getChargeAmount() {return chargeAmount;}public void setChargeAmount(double chargeAmount) {this.chargeAmount = chargeAmount;}// 搬移过来的方法,现在它更亲密地与其数据在一起public double bankCharge(Account account) {if (account.getDaysOverdrawn() > 0) {return this.getChargeAmount() + 1.75;}return 0.0;}// ... 其他Customer相关方法 ...
}
解释: 在搬移前,Account.bankCharge
方法虽然定义在 Account
类中,但其内部逻辑却强烈依赖 Customer
的 chargeAmount
。搬移后,bankCharge
方法被移动到 Customer
类中,使其与其操作的数据 chargeAmount
更加紧密。Account
类现在只需要提供 daysOverdrawn
的值。这样,Customer
类封装了所有与客户收费相关的逻辑,提高了模块的内聚性。
2. 搬移字段(Move Field)
数据结构才是一个健壮程序的根基。即便经验再丰富,技能再熟练,我仍然发现我在进行初版设计时往往还是会犯错。如果我发现数据结构已经不适应于需求,就应该马上修缮它。我开始寻思搬移数据。
场景描述: 当一个字段在语义上属于另一个类,或者被另一个类更频繁地使用时,应该将其搬移到对应的类中。
Java 代码示例:
// 搬移前
class Order {private double orderAmount;private double discountRate; // 这个字段可能更适合在Customer类中定义// ...public double calculateTotal() {return orderAmount * (1 - discountRate);}
}class Customer {private String name;// ...
}// 搬移后
class Order {private double orderAmount;// ...public double calculateTotal(Customer customer) {return orderAmount * (1 - customer.getDiscountRate());}
}class Customer {private String name;private double discountRate; // 字段被搬移到Customer类public double getDiscountRate() {return discountRate;}public void setDiscountRate(double discountRate) {this.discountRate = discountRate;}// ...
}
解释: 在搬移前,Order
类包含了 discountRate
字段。但通常情况下,折扣率是与客户相关的属性,而不是订单本身的属性。搬移后,discountRate
字段被移动到 Customer
类中,这样更符合业务语义。Order
在计算总价时,通过 Customer
对象获取折扣率。
3. 搬移语句到函数(Move Statements into Function)
要维护代码库的健康发展,需要遵守几条黄金守则,其中最重要的一条当属“消除重复”。如果我发现调用某个函数时,总有一些相同的代码也需要每次执行,那么我会考虑将此段代码合并到函数里头。这样,日后对这段代码的修改只需改一处地方,还能对所有调用者同时生效。如果将来代码对不同的调用者需有不同的行为,那时再通过搬移语句到调用者(217)将它(或其一部分)搬移出来也十分简单。
如果某些语句与一个函数放在一起更像一个整体,并且更有助于理解,那我就会毫不犹豫地将语句搬移到函数里去。如果它们与函数不像一个整体,但仍应与函数一起执行,那我可以用提炼函数(106)将语句和函数一并提炼出去。
场景描述: 当多个调用点都包含相同的预处理或后处理逻辑,或者某些语句与一个函数紧密相关时,可以将这些语句合并到函数内部。
Java 代码示例:
// 搬移前
class PaymentProcessor {public void processOrder(Order order, Customer customer) {// 预处理逻辑,可能在多个地方重复System.out.println("Processing order: " + order.getId());if (!customer.isActive()) {System.out.println("Customer is inactive, order cannot be processed.");return;}// 核心处理逻辑System.out.println("Order processed for customer: " + customer.getName());// ... 更多处理 ...}public void processRefund(Refund refund, Customer customer) {// 类似的预处理逻辑System.out.println("Processing refund: " + refund.getId());if (!customer.isActive()) {System.out.println("Customer is inactive, refund cannot be processed.");return;}// 核心退款逻辑System.out.println("Refund processed for customer: " + customer.getName());// ... 更多处理 ...}
}// 搬移后
class PaymentProcessor {// 提炼出公共的客户活跃性检查逻辑private boolean checkCustomerActive(Customer customer, String operation) {if (!customer.isActive()) {System.out.println("Customer is inactive, " + operation + " cannot be processed.");return false;}return true;}public void processOrder(Order order, Customer customer) {System.out.println("Processing order: " + order.getId());if (!checkCustomerActive(customer, "order")) { // 调用提炼出的函数return;}// 核心处理逻辑System.out.println("Order processed for customer: " + customer.getName());// ... 更多处理 ...}public void processRefund(Refund refund, Customer customer) {System.out.println("Processing refund: " + refund.getId());if (!checkCustomerActive(customer, "refund")) { // 调用提炼出的函数return;}// 核心退款逻辑System.out.println("Refund processed for customer: " + customer.getName());// ... 更多处理 ...}
}// 辅助类
class Order { String getId() { return "123"; } }
class Refund { String getId() { return "456"; } }
class Customer {private String name = "John Doe";private boolean active = true;public String getName() { return name; }public boolean isActive() { return active; }
}
解释: 在搬移前,processOrder
和 processRefund
方法都包含重复的客户活跃性检查逻辑。搬移后,这段重复的逻辑被提炼到一个新的私有方法 checkCustomerActive
中。这样,当需要修改客户活跃性检查的逻辑时,只需要修改一处即可,降低了维护成本并消除了重复代码。
4. 搬移语句到调用者(Move Statements to Callers)
函数边界发生偏移的一个征兆是,以往在多个地方共用的行为,如今需要在某些调用点面前表现出不同的行为。于是,我们得把表现不同的行为从函数里挪出,并搬移到其调用处。
场景描述: 当一个函数中的部分逻辑不再适用于所有调用者,或者在某些调用者处需要不同的行为时,将这部分逻辑从函数中搬移出来,让调用者自行处理。
Java 代码示例:
// 搬移前
class ReportGenerator {public String generateReport(String data) {// 通用的报告生成逻辑String header = "--- Report Header ---\n";String formattedData = "Formatted Data: " + data.toUpperCase() + "\n"; // 这部分可能在某些地方需要定制String footer = "--- Report Footer ---";return header + formattedData + footer;}
}// 调用方
class Application {public void printSimpleReport() {ReportGenerator generator = new ReportGenerator();System.out.println(generator.generateReport("simple data"));}public void printDetailedReport() {ReportGenerator generator = new ReportGenerator();System.out.println(generator.generateReport("detailed data")); // 这里可能需要不同的格式化}
}// 搬移后
class ReportGenerator {// 移除不通用的格式化逻辑public String generateReportBody(String data) {// 只保留核心的、通用的报告体生成逻辑return "Raw Data: " + data + "\n";}public String getReportHeader() {return "--- Report Header ---\n";}public String getReportFooter() {return "--- Report Footer ---";}
}// 调用方
class Application {public void printSimpleReport() {ReportGenerator generator = new ReportGenerator();String formattedData = "Formatted Data: " + "simple data".toUpperCase() + "\n"; // 调用者自行处理格式化System.out.println(generator.getReportHeader() + formattedData + generator.getReportBody("simple data") + generator.getReportFooter());}public void printDetailedReport() {ReportGenerator generator = new ReportGenerator();String customFormattedData = "CUSTOM FORMATTED DATA: [" + "detailed data" + "]\n"; // 不同的格式化System.out.println(generator.getReportHeader() + customFormattedData + generator.getReportBody("detailed data") + generator.getReportFooter());}
}
解释: 在搬移前,generateReport
方法包含了将数据转换为大写的格式化逻辑。如果 printSimpleReport
和 printDetailedReport
对数据格式化有不同的需求,那么这种统一的处理方式就不再适用。搬移后,generateReport
方法被拆分为更细粒度的方法(generateReportBody
, getReportHeader
, getReportFooter
),并且将数据格式化的逻辑移到了调用者 Application
中。现在,每个调用者可以根据自己的需求对数据进行不同的格式化处理。
5. 以函数调用取代内联代码(Replace Inline Code with Function Call)
善用函数可以帮助我将相关的行为打包起来,这对于提升代码的表达力大有裨益—— 一个命名良好的函数,本身就能极好地解释代码的用途,使读者不必了解其细节。配合一些库函数使用,会使本手法效果更佳,因为我甚至连函数体都不需要自己编写了,库已经提供了相应的函数。
场景描述: 当一段代码逻辑可以被一个清晰命名的方法(无论是自定义方法还是库函数)所替代时,用方法调用来取代内联代码。这有助于提高代码的抽象级别,使其更易读和维护。
Java 代码示例:
// 搬移前
class Calculator {public double calculateArea(double radius) {// 内联的圆面积计算逻辑return 3.14159 * radius * radius;}public int calculateSum(int[] numbers) {int sum = 0;for (int number : numbers) {sum += number; // 内联的数组求和逻辑}return sum;}
}// 搬移后
class Calculator {// 使用常量和Math.pow()取代内联计算private static final double PI = Math.PI; // 使用Math库中的PIpublic double calculateArea(double radius) {return PI * Math.pow(radius, 2); // 使用Math库函数}public int calculateSum(int[] numbers) {// 使用Java 8 Stream API取代内联循环求和return java.util.Arrays.stream(numbers).sum();}
}
解释: 在搬移前,calculateArea
方法手动编写了圆面积计算的公式,而 calculateSum
方法则使用了传统的循环来求和。搬移后,calculateArea
方法利用 Math.PI
和 Math.pow
方法,使代码更简洁,且不易出错。calculateSum
方法则利用 Java 8 的 Stream API,将循环求和的逻辑抽象为一个函数调用,大大提高了代码的可读性和简洁性。
6. 移动语句(Slide Statements)/合并重复的代码片段(Consolidate Duplicate Conditional Fragments)
让存在关联的东西一起出现,可以使代码更容易理解。如果有几行代码取用了同一个数据结构,那么最好是让它们在一起出现,而不是夹杂在取用其他数据结构的代码中间。
场景描述: 当代码中存在重复的条件判断逻辑,或者需要将相关的语句移动到一起以提高可读性时,可以使用此手法。
Java 代码示例:
// 搬移前
class OrderProcessor {public void processOrder(Order order) {double totalAmount = order.getBaseAmount();System.out.println("Order ID: " + order.getId());// 优惠券检查if (order.hasCoupon()) {totalAmount -= order.getCouponDiscount();}// 税费计算double tax = totalAmount * 0.08; // 8% 的税totalAmount += tax;// 额外服务费检查if (order.hasPremiumService()) {totalAmount += 10.0;}System.out.println("Final amount: " + totalAmount);}
}// 搬移后 - 移动语句以使相关逻辑更靠近
class OrderProcessor {public void processOrder(Order order) {double totalAmount = order.getBaseAmount();System.out.println("Order ID: " + order.getId());// 将所有影响 totalAmount 的逻辑集中处理if (order.hasCoupon()) {totalAmount -= order.getCouponDiscount();}if (order.hasPremiumService()) {totalAmount += 10.0;}// 税费计算double tax = totalAmount * 0.08;totalAmount += tax;System.out.println("Final amount: " + totalAmount);}
}// 辅助类
class Order {String id = "ABC123";double baseAmount = 100.0;boolean hasCoupon = true;double couponDiscount = 20.0;boolean hasPremiumService = false;String getId() { return id; }double getBaseAmount() { return baseAmount; }boolean hasCoupon() { return hasCoupon; }double getCouponDiscount() { return couponDiscount; }boolean hasPremiumService() { return hasPremiumService; }
}
解释: 在搬移前,OrderProcessor.processOrder
方法中,影响 totalAmount
的优惠券逻辑和额外服务费逻辑被税费计算逻辑隔开。搬移后,所有直接影响 totalAmount
的逻辑(优惠券和额外服务费)被集中在一起,然后才进行税费的计算。这使得代码的逻辑流更加清晰,读者可以更容易地理解 totalAmount
是如何一步步被计算出来的。
7. 拆分循环(Split Loop)
你常常能见到一些身兼多职的循环,它们一次做了两三件事情,不为别的,就因为这样可以只循环一次。但如果你在一次循环中做了两件不同的事,那么每当需要修改循环时,你都得同时理解这两件事情。如果能够将循环拆分,让一个循环只做一件事情,那就能确保每次修改时你只需要理解要修改的那块代码的行为就可以了。本手法的意义不仅在于拆分出循环本身,而且在于它为进一步优化提供了良好的起点——下一步我通常会寻求将每个循环提炼到独立的函数中。
场景描述: 当一个循环内部执行了多项不相关的任务时,将其拆分为多个独立的循环,每个循环只负责一项任务。这有助于提高代码的内聚性和可维护性。
Java 代码示例:
// 拆分前
class DataProcessor {public void processData(double[] data) {double sum = 0;double max = Double.MIN_VALUE;// 一个循环同时计算总和和最大值for (double value : data) {sum += value;if (value > max) {max = value;}}System.out.println("Sum: " + sum);System.out.println("Max: " + max);}
}// 拆分后
class DataProcessor {public void processData(double[] data) {// 第一次循环:计算总和double sum = 0;for (double value : data) {sum += value;}System.out.println("Sum: " + sum);// 第二次循环:计算最大值double max = Double.MIN_VALUE;for (double value : data) {if (value > max) {max = value;}}System.out.println("Max: " + max);}
}
解释: 在拆分前,processData
方法中的循环同时计算了数组的总和和最大值。虽然这样只遍历了一次数据,但如果将来需要修改总和的计算方式,或者最大值的计算方式,都需要理解整个循环的逻辑。拆分后,两个任务被分到两个独立的循环中。现在,每个循环只做一件事,修改时只需要关注相关的逻辑,降低了理解和修改的复杂性。
8. 以管道取代循环(Replace Loop with Pipeline)
与大多数程序员一样,我入行的时候也有人告诉我,迭代一组集合时得使用循环。不过时代在发展,如今越来越多的编程语言都提供了更好的语言结构来处理迭代过程,这种结构就叫作集合管道(collection pipeline)。集合管道[mf-cp]是这样一种技术,它允许我使用一组运算来描述集合的迭代过程,其中每种运算接收的入参和返回值都是一个集合。这类运算有很多种,最常见的则非 map 和 filter 莫属:map 运算是指用一个函数作用于输入集合的每一个元素上,将集合变换成另外一个集合的过程;filter 运算是指用一个函数从输入集合中筛选出符合条件的元素子集的过程。运算得到的集合可以供管道的后续流程使用。我发现一些逻辑如果采用集合管道来编写,代码的可读性会更强——我只消从头到尾阅读一遍代码,就能弄清对象在管道中间的变换过程。
场景描述: 当对集合进行筛选、转换或聚合等操作时,使用函数式编程的集合管道(如 Java Stream API)取代传统的 for
循环,可以使代码更声明式、更简洁、更易读。
Java 代码示例:
import java.util.Arrays;
import java.util.List;
import java.util.stream.Collectors;// 取代前
class OldStyleProcessor {public List<String> getUpperCaseNamesOfAdults(List<Person> people) {List<String> result = new java.util.ArrayList<>();for (Person person : people) {if (person.getAge() >= 18) {result.add(person.getName().toUpperCase());}}return result;}
}// 取代后
class NewStyleProcessor {public List<String> getUpperCaseNamesOfAdults(List<Person> people) {return people.stream() // 创建流.filter(person -> person.getAge() >= 18) // 筛选出成年人.map(person -> person.getName().toUpperCase()) // 将名字转换为大写.collect(Collectors.toList()); // 收集结果到List}
}// 辅助类
class Person {private String name;private int age;public Person(String name, int age) {this.name = name;this.age = age;}public String getName() { return name; }public int getAge() { return age; }
}class Main {public static void main(String[] args) {List<Person> people = Arrays.asList(new Person("Alice", 25),new Person("Bob", 16),new Person("Charlie", 30),new Person("David", 19));OldStyleProcessor oldProcessor = new OldStyleProcessor();System.out.println("Old style: " + oldProcessor.getUpperCaseNamesOfAdults(people));NewStyleProcessor newProcessor = new NewStyleProcessor();System.out.println("New style: " + newProcessor.getUpperCaseNamesOfAdults(people));}
}
解释: 在取代前,getUpperCaseNamesOfAdults
方法使用传统的 for
循环来遍历列表,并在循环内部进行筛选和转换。这种方式将所有逻辑混杂在一起。取代后,通过 Java 8 的 Stream API,逻辑被清晰地分解为一系列链式操作:stream()
创建流,filter()
筛选出符合条件的对象,map()
对对象进行转换,collect()
将结果收集起来。这种“管道式”的写法使得数据处理流程一目了然,更具表达力。
9. 移除死代码(Remove Dead Code)
场景描述: 当代码中存在永远不会被执行的、不再使用的、或者逻辑上已经废弃的代码时,应该将其移除。死代码会增加代码库的体积,降低可读性,并可能引入未来的维护困扰。
Java 代码示例:
// 移除前
class OldCalculator {public int add(int a, int b) {// 这是最新的加法实现return a + b;}// 曾经用于旧的加法逻辑,但现在已经不再被调用,也没有实际作用private int oldAdd(int a, int b) {System.out.println("This old add method is never called.");return a + b + 1; // 假设这是一个过时的、错误的逻辑}public void unusedMethod() {System.out.println("This method is completely unused.");}public int calculate(int x, int y, boolean useNewLogic) {if (useNewLogic) {return add(x, y);} else {// 这段else分支的条件永远不会为true,或者对应的oldAdd方法已废弃// 假设useNewLogic在所有调用点都为true,或者这个分支对应的逻辑已经被淘汰// return oldAdd(x, y); // 这就是死代码return -1; // 为了编译通过暂时这样写,实际应该移除整个分支}}
}// 移除后
class NewCalculator {public int add(int a, int b) {return a + b;}// `oldAdd` 和 `unusedMethod` 方法,以及 `calculate` 方法中的死分支已被移除public int calculate(int x, int y) { // 移除boolean参数,因为旧逻辑已不复存在return add(x, y);}
}
解释: 在移除前,OldCalculator
类中包含 oldAdd
和 unusedMethod
这两个方法,它们不再被任何地方调用,是典型的死代码。此外,calculate
方法中的 else
分支如果 useNewLogic
永远为 true
,那么 else
分支也是死代码。移除后,NewCalculator
变得更加简洁,只包含实际被使用的代码。这不仅减小了代码库的体积,还降低了阅读和理解代码的负担,避免了因维护废弃代码而产生的潜在错误。
总结
“搬移特性”系列重构手法是软件开发中的基本功。通过熟练运用这些技巧,我们可以有效地:
- 提高封装性: 将数据和操作数据的方法紧密关联,减少外部对内部实现的依赖。
- 降低耦合度: 减少不同模块之间的相互依赖,使模块更加独立,更易于理解和修改。
- 消除重复: 将重复的代码逻辑抽象出来,减少代码冗余,提高代码的一致性。
- 改善可读性: 使相关的代码更靠近,不相关的代码更远离,从而让代码的逻辑流更加清晰。
- 增强可维护性: 更清晰、更模块化的代码更容易被理解、测试和修改。
重构是一个持续的过程,它要求我们不断审视和优化代码。掌握并实践“搬移特性”这些重构手法,将有助于你写出更高质量、更具弹性的软件系统。记住,好的代码不是一次写出来的,而是不断迭代和重构出来的。
参考
《重构:改善既有代码的设计(第二版)》
后续文章
(五)数据重构的艺术:优化你的代码结构与可读性