后六式大多来自《重构 改善既有代码的设计(第二版)》在此当做是读书笔记,也当做是给自己的约束吧
目录
7、分解条件表达式(Decompose Conditional):
8、合并条件表达式(Consolidate Conditional Expression):
9、以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)(提前return,去除不必要的else)
10、以多态取代条件表达式(Replace Conditional with Polymorphism)
11、引入特例(Introduce Special Case):
7、分解条件表达式(Decompose Conditional):
动机:
复杂条件逻辑的挑战:
作者指出,复杂的条件逻辑通常会导致程序复杂度上升,生成大型函数,并降低代码的可读性。在包含复杂条件逻辑的函数中,虽然代码会告诉你发生了什么,但常常不清楚为什么会发生这样的事,这就降低了代码的可读性。
分解大型函数:
与处理任何大块代码一样,你可以将大型函数分解为多个独立的函数。对于每个小块代码,根据其用途命名,将原函数中对应的代码改为调用新函数,这可以更清楚地表达你的意图。
分解条件逻辑:
对于条件逻辑,将每个分支条件分解为新函数有额外的好处。它可以使条件逻辑更突出,更清楚地表明每个分支的作用,同时也更清楚地突出了每个分支的原因;
提炼函数:
虽然本重构手法只是提炼函数(106)的一个应用场景,但作者强调了这个场景,因为他发现它经常能带来很大的价值。
做法:
- 对条件判断和每个条件分支分别运用提炼函数(106)手法;
范例:
Martin Fowler是在讲述一个常见的重构技术,叫做“提炼函数”。这个技术的目标是将复杂的代码块拆分为更小,更管理的函数,从而提高代码的可读性,减少错误,并增强代码复用。
这个原则在任何语言中都是通用的,包括Java。让我们把这个例子转换为Java,并设想一个Spring Boot支付服务的例子。假设我们有一个PaymentService类,它计算商品的总价,而这个商品在夏季和其他季节的价格不同。
这个类在重构前可能是这样的:
@Service
public class PaymentService {
@Autowired
private PricingPlan pricingPlan;
public double calculateCharge(int quantity, LocalDate date) {
double charge;
if (!date.isBefore(pricingPlan.getSummerStart()) && !date.isAfter(pricingPlan.getSummerEnd())) {
charge = quantity * pricingPlan.getSummerRate();
} else {
charge = quantity * pricingPlan.getRegularRate() + pricingPlan.getRegularServiceCharge();
}
return charge;
}
}
重构后,我们可以把条件判断和计算费用的逻辑分别提取到不同的方法中,从而使代码更清晰:
@Service
public class PaymentService {
@Autowired
private PricingPlan pricingPlan;
public double calculateCharge(int quantity, LocalDate date) {
return isSummer(date) ? summerCharge(quantity) : regularCharge(quantity);
}
private boolean isSummer(LocalDate date) {
return !date.isBefore(pricingPlan.getSummerStart()) && !date.isAfter(pricingPlan.getSummerEnd());
}
private double summerCharge(int quantity) {
return quantity * pricingPlan.getSummerRate();
}
private double regularCharge(int quantity) {
return quantity * pricingPlan.getRegularRate() + pricingPlan.getRegularServiceCharge();
}
}
如你所见,通过提取方法,我们可以使calculateCharge方法变得更简洁,也更容易理解。同时,由于我们把逻辑分到了几个小方法中,如果将来需要修改计费规则,我们也只需要修改对应的方法,而不用去找这个复杂的if-else语句。
这样的重构技术在任何复杂的业务逻辑中都非常有用,不仅限于支付场景。
8、合并条件表达式(Consolidate Conditional Expression):
动机:
合并条件的原因:
- 合并条件的目的有两个。
- 首先,合并之后的条件代码会清楚地表示,这实际上只是一次条件检查,只是有多个并列条件需要检查。尽管合并前后的代码的效果是一样的,但原来的代码给人的印象是“这里有一些各自独立的条件测试,它们只是恰好同时发生”。
- 其次,合并条件通常可以为使用提取函数做好准备。将条件检查提取为独立的函数可以帮助澄清代码的含义,因为它将描述“做什么”的语句转化为了“为什么这样做”。
- 使用短路运算符来合并~;
- ||
- &&
- if (currentBannedDetail.contains(bannedDetail) && (BANNED_INTERCEPTOR_SWITCHER.get() || ADMIN_APP_ID_BANNED_CONFIG_WHITE_LIST.get().contains(appId)))
不合并条件的原因:
另一方面,如果你认为这些检查确实是独立的,不应该被视为同一次检查,那么你就不应该使用这种重构手法。
做法:
确保无副作用:
首先,你需要确保要合并的所有条件表达式都没有副作用。如果有条件表达式带有副作用,你需要首先使用将查询函数和修改函数分离的方法来处理。
使用逻辑运算符合并:
然后,你需要使用适当的逻辑运算符(例如"或"或"与")将两个相关的条件表达式合并为一个。如果是顺序执行的条件表达式,可以使用逻辑或来合并;如果是嵌套的if语句,可以使用逻辑与来合并。
测试:
进行合并后,必须执行测试来确认代码的功能没有被改变。
重复合并过程:
然后,你需要重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
考虑提炼函数:
最后,你可以考虑将合并后的条件表达式提炼成一个新的函数。
范例:
这个例子展示了如何通过使用重构手法将多个单独的条件检查合并到一个表达式中,并将这个表达式提炼到一个独立的函数中,以提高代码的可读性和维护性。
首先,这是一个以Java编写的原始的SpringBoot服务例子,该服务判断员工是否有资格获取残疾津贴:
@Service
public class EmployeeService {
public double disabilityAmount(Employee employee) {
if (employee.getSeniority() < 2) return 0;
if (employee.getMonthsDisabled() > 12) return 0;
if (employee.isPartTime()) return 0;
// Compute the disability amount
}
}
通过使用重构手法,我们可以将这些条件检查合并到一个表达式中:
@Service
public class EmployeeService {
public double disabilityAmount(Employee employee) {
if ((employee.getSeniority() < 2)
|| (employee.getMonthsDisabled() > 12)
|| (employee.isPartTime())) {
return 0;
}
// Compute the disability amount
}
}
然后我们可以将这个条件表达式提炼到一个独立的函数中:
@Service
public class EmployeeService {
public double disabilityAmount(Employee employee) {
if (isNotEligibleForDisability(employee)) return 0;
// Compute the disability amount
}
private boolean isNotEligibleForDisability(Employee employee) {
return ((employee.getSeniority() < 2)
|| (employee.getMonthsDisabled() > 12)
|| (employee.isPartTime()));
}
}
最终,我们得到了一个更清晰、更易于维护的代码片段。这种重构技术在处理复杂的条件表达式时特别有用,不仅限于支付或残疾津贴的计算场景,也可以应用于任何其他需要决策逻辑的地方。
范例:使用逻辑与:
这个例子展示了如何使用逻辑与运算符将多个嵌套的if语句合并为一个表达式,以提高代码的可读性和维护性。我将使用一个支付系统的SpringBoot服务来演示这个重构技术。
假设我们有一个服务,根据员工是否在休假和员工的工龄来确定其工资。
@Service
public class PaymentService {
public double calculatePayment(Employee employee) {
if (employee.isOnVacation()) {
if (employee.getSeniority() > 10) {
return 1;
}
}
return 0.5;
}
}
我们可以使用逻辑与运算符来合并这两个条件判断:
@Service
public class PaymentService {
public double calculatePayment(Employee employee) {
if (employee.isOnVacation() && employee.getSeniority() > 10) {
return 1;
}
return 0.5;
}
}
这样,我们就简化了代码,使其更易读和维护。这种重构技术可以广泛应用于需要复杂决策逻辑的地方,不仅限于支付系统。
9、以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)(提前return,去除不必要的else)
动机:
两种条件表达式的风格:
第一种是两个条件分支都属于正常行为,这种情况下,应使用if...else...的条件表达式。第二种是其中一个条件分支是正常行为,另一个是异常情况,这时应考虑使用卫语句(guard clauses)。
卫语句的用途:
卫语句是一种专门处理异常或特殊情况的方法。如果卫语句的条件为真,通常会立即从函数中返回,不再执行后续的正常逻辑。这种方法突出了正常逻辑的主线,强调了异常情况不是函数关注的主要问题。
通过反转条件表达式和引入卫语句来简化代码;
写if语句能return就直接return,这样更清晰;
单一入口,多个出口:
对于一些程序员,他们习惯于遵守每个函数只能有一个入口和一个出口的规则。然而,作者认为这个规则并不总是有用。保持代码清晰和可读更加重要。如果多个出口(例如,通过卫语句提前返回)可以使函数更易读,那么就可以使用多个出口。
做法:
选中最外层的条件逻辑:
这是指找到最初的、最外层的条件逻辑,这个条件逻辑需要被替换为卫语句。这个步骤可能需要你阅读并理解当前的代码逻辑。
替换为卫语句:
将选中的条件逻辑替换为卫语句。卫语句是一个立即返回的条件判断,通常用于处理异常或边缘情况。这样可以使主要的代码逻辑更加清晰和直观。
测试:
在替换代码后,一定要进行测试,以确保代码的功能没有被改变。这是所有重构步骤中的必要步骤。
重复上述步骤:
如果还有更多的条件逻辑需要被替换,那么就重复这个过程,直到所有需要的部分都被替换为卫语句。
合并卫语句:
如果所有的卫语句都产生相同的结果,那么可以考虑使用合并条件表达式的重构手法将它们合并,以减少代码冗余。
范例:
该例子描述了在代码中使用卫语句的策略,这样可以提前结束函数并使得代码更具可读性。下面,我会用Java和SpringBoot在支付场景中创建一个类似的例子来说明。
假设我们有一个Spring服务,用于计算员工的支付金额,其中包含对员工是否已离职或退休的检查。
@Service
public class PaymentService {
public Result payAmount(Employee employee) {
Result result;
if (employee.isSeparated()) {
result = new Result(0, "SEP");
} else {
if (employee.isRetired()) {
result = new Result(0, "RET");
} else {
// logic to compute amount
double amount = computeAmount(employee);
result = new Result(amount, "OK");
}
}
return result;
}
private double computeAmount(Employee employee) {
// Compute the payment amount based on various factors...
return 1000.0; // a placeholder
}
}
按照重构的步骤,我们首先处理最外层的条件语句。如果员工已经离职,我们就提前返回结果:
@Service
public class PaymentService {
public Result payAmount(Employee employee) {
if (employee.isSeparated()) {
return new Result(0, "SEP");
}
Result result;
if (employee.isRetired()) {
result = new Result(0, "RET");
} else {
// logic to compute amount
double amount = computeAmount(employee);
result = new Result(amount, "OK");
}
return result;
}
private double computeAmount(Employee employee) {
// Compute the payment amount based on various factors...
return 1000.0; // a placeholder
}
}
然后,我们可以做同样的事情对于员工退休的情况:
@Service
public class PaymentService {
public Result payAmount(Employee employee) {
if (employee.isSeparated()) {
return new Result(0, "SEP");
}
if (employee.isRetired()) {
return new Result(0, "RET");
}
// logic to compute amount
double amount = computeAmount(employee);
return new Result(amount, "OK");
}
private double computeAmount(Employee employee) {
// Compute the payment amount based on various factors...
return 1000.0; // a placeholder
}
}
现在代码更清楚,更容易理解。如果员工已经离职或已经退休,我们将立即得到结果,否则我们会计算付款金额。这种模式对于需要处理多个条件检查并提前返回结果的场景特别有用。
范例:将条件反转:
这个例子介绍了如何通过反转条件表达式和引入卫语句来简化代码。这样做能使代码的主干逻辑更清晰,并将异常或边缘情况提前处理。下面,我们用Java和SpringBoot中在贷款计息场景创建一个类似的例子。
假设我们有一个Spring服务,用于计算一个金融工具的调整资本。
@Service
public class FinancialService {
public double adjustedCapital(FinancialInstrument instrument) {
double result = 0;
if (instrument.getCapital() > 0) {
if (instrument.getInterestRate() > 0 && instrument.getDuration() > 0) {
result = (instrument.getIncome() / instrument.getDuration()) * instrument.getAdjustmentFactor();
}
}
return result;
}
}
按照重构的步骤,我们首先反转最外层的条件语句。如果资本小于等于0,我们就提前返回结果:
@Service
public class FinancialService {
public double adjustedCapital(FinancialInstrument instrument) {
if (instrument.getCapital() <= 0) {
return 0;
}
double result = 0;
if (instrument.getInterestRate() > 0 && instrument.getDuration() > 0) {
result = (instrument.getIncome() / instrument.getDuration()) * instrument.getAdjustmentFactor();
}
return result;
}
}
然后,我们可以反转内层的条件语句。如果利率小于等于0,或者持续时间小于等于0,我们就提前返回结果:
@Service
public class FinancialService {
public double adjustedCapital(FinancialInstrument instrument) {
if (instrument.getCapital() <= 0) {
return 0;
}
if (instrument.getInterestRate() <= 0 || instrument.getDuration() <= 0) {
return 0;
}
return (instrument.getIncome() / instrument.getDuration()) * instrument.getAdjustmentFactor();
}
}
现在,如果我们满足任何一个提前返回的条件,我们就会立即返回结果,否则我们会计算调整后的资本。这种模式对于需要处理多个条件检查并提前返回结果的场景特别有用。它让代码的主干逻辑更清晰,并减少了嵌套的深度。
10、以多态取代条件表达式(Replace Conditional with Polymorphism)
动机:
使用多态管理复杂条件逻辑:
如果一个复杂的条件逻辑可以根据不同的情况或高阶用例进行拆分,那么使用面向对象的方式,例如多态,可以让这个逻辑更清晰。比如在一个电商平台中,不同的商品(图书、音乐、食品等)可能会有不同的处理方式,这个时候就可以为每种类型的商品创建一个类,然后利用多态来处理各种商品的特定行为。
基类和子类的使用:
当存在一种基础逻辑,然后在其上有一些变体或者特殊情况的时候,可以使用面向对象编程的继承特性。基础逻辑可以放在一个基类(超类)中,然后每种特殊情况可以放在一个继承自基类的子类中。这样的话,可以先理解和实现基础逻辑,然后再分别处理每种特殊情况。
多态的适当使用:
虽然多态是一种很有用的工具,特别是在处理复杂条件逻辑的时候,但并不是所有的条件逻辑都需要使用多态。对于简单的条件逻辑,使用基本的if/else或者switch/case语句就足够了。
接口+工厂类/继承+工厂类;
做法:
使用工厂函数:
如果现有的类还没有多态行为,可以使用工厂函数来创建类的实例。工厂函数应该根据传入的参数决定创建哪个子类的实例并返回。
使用工厂函数创建实例:
在需要创建对象实例的地方,不再直接调用构造函数,而是使用前面创建的工厂函数。
将条件逻辑移至超类:
如果存在一个包含条件逻辑的函数,将它移动到超类中。
提取函数:
如果条件逻辑还未单独提炼为一个函数,那么应该先使用提取函数的重构手法来对其进行处理。
创建覆写函数:
在子类中创建一个新函数,这个新函数覆写超类中含有条件表达式的那个函数。将与这个子类相关的条件逻辑复制到这个新函数中,并根据需要进行调整。
重复以上步骤:
对其他的条件分支重复以上步骤。
处理超类中的函数:
在超类中的函数应只保留默认的处理逻辑。如果超类应该是抽象的,那么这个函数应声明为抽象函数,或者在函数中直接抛出异常,表示具体的处理逻辑应该由子类来实现。
范例:
在Spring Boot和Java中,类似的重构模式也可以用于改进代码的设计。让我们通过一个支付场景的例子来看一下这个问题。
假设我们在开发一个电商应用程序,并且支持多种支付方式,如信用卡、PayPal、银行转账等。可能存在一个如下的类来处理支付:
public class PaymentProcessor {
public void process(Payment payment) {
switch (payment.getType()) {
case CREDIT_CARD:
processCreditCardPayment(payment);
break;
case PAYPAL:
processPaypalPayment(payment);
break;
case BANK_TRANSFER:
processBankTransferPayment(payment);
break;
default:
throw new IllegalArgumentException("Invalid payment type: " + payment.getType());
}
}
private void processCreditCardPayment(Payment payment) {
// process credit card payment
}
private void processPaypalPayment(Payment payment) {
// process PayPal payment
}
private void processBankTransferPayment(Payment payment) {
// process bank transfer payment
}
}
在这个类中,process方法根据支付类型的不同来选择对应的处理方式。当我们需要添加新的支付方式时,就需要修改PaymentProcessor类,违反了开闭原则。
我们可以通过引入多态来解决这个问题。首先,我们定义一个PaymentProcessor接口,然后为每种支付方式创建一个实现了这个接口的类:
public interface PaymentProcessor {
void process(Payment payment);
}
public class CreditCardPaymentProcessor implements PaymentProcessor {
@Override
public void process(Payment payment) {
// process credit card payment
}
}
public class PaypalPaymentProcessor implements PaymentProcessor {
@Override
public void process(Payment payment) {
// process PayPal payment
}
}
public class BankTransferPaymentProcessor implements PaymentProcessor {
@Override
public void process(Payment payment) {
// process bank transfer payment
}
}
然后,我们可以创建一个工厂类来创建对应的支付处理器:
public class PaymentProcessorFactory {
public static PaymentProcessor create(PaymentType type) {
switch (type) {
case CREDIT_CARD:
return new CreditCardPaymentProcessor();
case PAYPAL:
return new PaypalPaymentProcessor();
case BANK_TRANSFER:
return new BankTransferPaymentProcessor();
default:
throw new IllegalArgumentException("Invalid payment type: " + type);
}
}
}
现在,如果我们需要添加新的支付方式,只需要创建一个实现了PaymentProcessor接口的新类,并在PaymentProcessorFactory类中添加一个新的case即可。
最后,我们的主程序可能如下:
public class PaymentService {
public void processPayment(Payment payment) {
PaymentProcessor processor = PaymentProcessorFactory.create(payment.getType());
processor.process(payment);
}
}
这样,我们就成功地消除了PaymentProcessor类中的条件逻辑,改为使用多态来处理不同类型的支付,从而提高了代码的可读性和可维护性。
范例:用多态处理变体逻辑:
上述内容主要解释了使用面向对象编程的继承和多态概念来改进代码的结构和设计。这个概念非常适用于处理那些在核心逻辑基础上带有一些额外规则或变化的场景。在这个例子中,额外的规则就是“中国因素”。
为了说明这个概念,让我们以一个具体的SpringBoot工程中的支付场景为例。我们有一个基础的支付服务,但对于不同的支付方式(例如,信用卡,借记卡,PayPal等)可能有一些额外的处理逻辑。
首先,我们定义一个基础的支付服务:
public abstract class PaymentService {
protected PaymentData paymentData;
public PaymentService(PaymentData paymentData) {
this.paymentData = paymentData;
}
public void processPayment() {
// Some basic payment processing logic
}
}
然后,我们定义一些具体的支付服务,如CreditCardPaymentService:
public class CreditCardPaymentService extends PaymentService {
public CreditCardPaymentService(PaymentData paymentData) {
super(paymentData);
}
@Override
public void processPayment() {
// Apply some extra rules for credit card payment
super.processPayment();
}
}
对于每种特定类型的支付,我们创建一个继承自PaymentService的新类,并覆盖processPayment方法来处理额外的逻辑。
在SpringBoot应用程序中,我们可以使用一个工厂方法来创建正确类型的PaymentService实例:
@Service
public class PaymentServiceFactory {
public PaymentService getPaymentService(PaymentType paymentType, PaymentData paymentData) {
switch (paymentType) {
case CREDIT_CARD:
return new CreditCardPaymentService(paymentData);
case DEBIT_CARD:
return new DebitCardPaymentService(paymentData);
case PAYPAL:
return new PaypalPaymentService(paymentData);
default:
throw new IllegalArgumentException("Unsupported payment type: " + paymentType);
}
}
}
然后,在你的业务逻辑中,你可以这样使用它:
@Service
public class PaymentProcessingService {
@Autowired
private PaymentServiceFactory paymentServiceFactory;
public void processPayment(PaymentType paymentType, PaymentData paymentData) {
PaymentService paymentService = paymentServiceFactory.getPaymentService(paymentType, paymentData);
paymentService.processPayment();
}
}
这样做的好处是,你可以清晰地分离出核心逻辑(在PaymentService类中)和特定类型的逻辑(在CreditCardPaymentService等类中)。当添加新的支付类型或更改现有支付类型的逻辑时,只需修改或添加一个新的PaymentService子类,而无需修改核心逻辑。这样可以保持代码的清晰性和易维护性,同时遵循面向对象编程的封装和多态原则。
11、引入特例(Introduce Special Case):
曾用名:引入Null对象(Introduce Null Object)
动机:
特例模式的动机:
当你发现代码库中有多处以同样方式应对同一个特殊值时,这就是一个重构的信号。例如,很多地方的代码都在检查某个特殊值,并且当这个特殊值出现时所做的处理也都相同,这就会造成代码的重复。为了避免这种重复,可以使用特例模式。
特例模式的定义:
特例模式的核心思想是创建一个特殊的对象,这个对象能够表达对一种特殊情况的共用行为的处理。通过使用这个特殊的对象,你可以用一个函数调用取代大部分特例检查逻辑,从而简化代码。
特例模式的形式:
特例模式有几种不同的形式。如果你只需要从这个对象读取数据,可以创建一个字面量对象,其中所有的值都是预先填充好的。如果除了简单的数值之外,你还需要更多的行为,那么就需要创建一个特殊的对象,这个对象包含所有共用行为所对应的函数。这个特例对象可以由一个封装类来返回,也可以通过变换插入一个数据结构。
Null对象模式:
Null对象模式是特例模式的一种特殊形式,主要用于处理null值。这个模式的基本思想是创建一个对象来代表null值,这样可以避免在代码中多处检查null值。
在DAO层中进行转换,处理输入的时候也要最先转换,来防止NPE问题;
做法:
找出需要重构的目标:
从一个包含目标属性的数据结构(或类)开始。这个属性就是我们要重构的目标。每次使用这个属性时,客户端都需要将其与某个特例值进行比对。
添加检查特例的属性:
给需要重构的目标添加一个新的属性,该属性用于检查是否为特例。初始时,这个属性返回false。
创建特例对象:
创建一个新的特例对象。这个对象中只有一个属性,就是刚刚添加的检查特例的属性。对于特例对象,这个属性返回true。
提炼新函数:
将“与特例值做比对”的代码提炼为一个新的函数。确保所有客户端都使用这个新函数,而不再直接比对特例值。
引入特例对象:
将新的特例对象引入代码中,可以通过函数返回,也可以在数据转换函数中生成。
修改特例比对函数:
在特例比对函数中,直接使用新的检查特例的属性。
测试:
确保重构后的代码与重构前的行为一致。
把特例逻辑搬移到特例对象中:
使用"函数组合成类"或"函数组合成变换"的重构方法,将共享的特例处理逻辑都搬移到新建的特例对象中。
实现特例类:
对于简单的请求,特例类通常会返回固定的值,因此可以将其实现为一个字面记录。
内联特例比对函数:
将特例比对函数内联到仍然需要的地方,即使用"内联函数"的重构方法。
范例:
让我们假设你正在开发一个电子商务应用程序,其中涉及到处理支付。在处理支付的过程中,你可能会遇到一种情况,那就是在某些情况下,你并不知道具体的支付方式,可能是因为用户尚未选择,或者是因为某种原因,支付方式的信息丢失了。
在这种情况下,你的支付处理代码可能会包含许多检查支付方式是否已知的条件判断。如果支付方式未知,你可能需要采取一些默认的行为。这种模式非常类似于你在《重构改善既有代码的设计》中描述的场景。
例如,你的支付类可能是这样的:
public class Payment {
private PaymentMethod method;
// 其他属性...
public PaymentMethod getMethod() {
return this.method;
}
public void setMethod(PaymentMethod method) {
this.method = method;
}
// 其他方法...
}
你可能有一些业务代码,它们在处理支付时,需要检查支付方式是否已知:
如果你发现这样的条件判断在你的代码库中出现了很多次,那么就应该考虑使用特例对象模式来重构你的代码。
首先,创建一个表示未知支付方式的特例类。为了保持类型的一致性,它应该实现PaymentMethod接口(或者是继承自PaymentMethod类,取决于你的设计):
public class UnknownPaymentMethod implements PaymentMethod {
// 实现PaymentMethod的方法
}
然后,修改你的Payment类,让它在支付方式未知时返回这个特例对象:
public class Payment {
private PaymentMethod method;
// 其他属性...
public PaymentMethod getMethod() {
return (this.method == null) ? new UnknownPaymentMethod() : this.method;
}
public void setMethod(PaymentMethod method) {
this.method = method;
}
// 其他方法...
}
现在,你的业务代码就可以删除所有的null检查了。由于未知支付方式现在被表示为一个具体的对象,而不是null,所以当getMethod()返回这个特例对象时,你可以安全地调用它的方法,而无需担心空指针异常:
Payment payment = ...;
// 这里不再需要检查payment.getMethod()是否为null
payment.getMethod().process();
最后,你还可以进一步提炼你的代码,将特例对象的创建逻辑移到Payment类的构造函数中,这样就可以保证Payment对象总是处于有效的状态:
public class Payment {
private PaymentMethod method;
// 其他属性...
public Payment() {
this.method = new UnknownPaymentMethod();
}
public PaymentMethod getMethod() {
return this.method;
}
public void setMethod(PaymentMethod method) {
this.method = (method == null) ? new UnknownPaymentMethod() : method;
}
// 其他方法...
}
总的来说,特例对象模式可以帮助你消除冗余的条件判断,提高代码的可读性和可维护性,同时减少空指针异常的风险。当你发现自己的代码中有许多地方都在检查同一个特例条件时,就应该考虑使用这种模式。
范例:使用对象字面量:
作者提出了一种使用对象字面量(在JavaScript中常用)作为特例的方式。这种方式在Java中并没有直接对应的概念,但是可以通过创建不可变的特例对象来模拟。
在这个例子中,Customer类包含一些属性和方法,包括name,billingPlan和paymentHistory。在某些情况下,可能并不知道具体的客户信息,因此需要处理"unknown"的情况。
对于这种情况,作者提出了一种使用对象字面量的解决方案。在Java中,可以通过创建一个特例对象来实现类似的功能。首先,创建一个UnknownCustomer类,它实现了Customer类的所有方法,但是它的属性是只读的,且具有默认值:
public class UnknownCustomer extends Customer {
private static final String DEFAULT_NAME = "occupant";
private static final BillingPlan DEFAULT_BILLING_PLAN = BillingPlan.basic();
private static final PaymentHistory DEFAULT_PAYMENT_HISTORY = new PaymentHistory(0);
@Override
public String getName() {
return DEFAULT_NAME;
}
@Override
public BillingPlan getBillingPlan() {
return DEFAULT_BILLING_PLAN;
}
@Override
public PaymentHistory getPaymentHistory() {
return DEFAULT_PAYMENT_HISTORY;
}
@Override
public void setName(String name) {
throw new UnsupportedOperationException("Cannot modify an unknown customer");
}
@Override
public void setBillingPlan(BillingPlan billingPlan) {
throw new UnsupportedOperationException("Cannot modify an unknown customer");
}
@Override
public void setPaymentHistory(PaymentHistory paymentHistory) {
throw new UnsupportedOperationException("Cannot modify an unknown customer");
}
}
接下来,修改Site类,使其在客户信息未知时返回这个特例对象:
public class Site {
private Customer customer;
public Customer getCustomer() {
return (this.customer == null) ? new UnknownCustomer() : this.customer;
}
// 其他方法...
}
然后,你的业务代码就可以删除所有的"unknown"检查了。由于未知客户信息现在被表示为一个具体的对象,而不是"unknown",所以当getCustomer()返回这个特例对象时,你可以安全地调用它的方法,而无需担心空指针异常:
Site site = ...;
Customer customer = site.getCustomer();
// 这里不再需要检查customer是否为"unknown"
String customerName = customer.getName();
BillingPlan plan = customer.getBillingPlan();
int weeksDelinquent = customer.getPaymentHistory().getWeeksDelinquentInLastYear();
这样,你就能够在Java中模拟JavaScript中的对象字面量特例对象了。这种方法同样可以帮助你消除冗余的条件判断,提高代码的可读性和可维护性,同时减少空指针异常的风险。
范例:使用变换:
作者讨论了如何处理"未知"客户的情况,但这次我们从一个原始的数据结构开始(在此例中,是JavaScript中的一个对象)。当我们收到一个包含"未知"客户的数据结构时,我们首先进行一个"变换"(或者说"转换")步骤,将原始数据结构转换成一个更容易处理的格式。
在Java和Spring Boot环境中,这种变换通常会在服务层或者是数据访问对象(DAO)层进行。我们可以通过创建数据传输对象(DTO)和对应的转换器来实现这种变换。以一个简单的支付服务为例,我们可能有以下的数据结构:
public class SiteData {
private String name;
private String location;
private CustomerData customer;
// getter and setter
}
public class CustomerData {
private String name;
private String billingPlan;
private PaymentHistoryData paymentHistory;
// getter and setter
}
public class PaymentHistoryData {
private int weeksDelinquentInLastYear;
// getter and setter
}
假设我们从外部服务或者数据库获取了一个SiteData对象,CustomerData字段可能是一个具体的客户,也可能是"unknown"。我们可以创建一个服务来处理这个变换:
@Service
public class SiteEnrichmentService {
public SiteData enrichSiteData(SiteData rawSite) {
if (isUnknown(rawSite.getCustomer())) {
rawSite.setCustomer(createUnknownCustomer());
} else {
rawSite.getCustomer().setUnknown(false);
}
return rawSite;
}
private boolean isUnknown(CustomerData customer) {
return customer == null || "unknown".equals(customer.getName());
}
private CustomerData createUnknownCustomer() {
CustomerData unknownCustomer = new CustomerData();
unknownCustomer.setUnknown(true);
unknownCustomer.setName("occupant");
unknownCustomer.setBillingPlan("basic");
unknownCustomer.setPaymentHistory(new PaymentHistoryData(0));
return unknownCustomer;
}
}
这个服务首先检查SiteData中的CustomerData是否是"未知",如果是,就用一个特例对象替换它;如果不是,就添加一个标记,表明这不是一个未知客户。
在你的业务代码中,你就可以使用SiteEnrichmentService来获取"丰富"后的SiteData,并安全地访问其中的CustomerData,无需担心空指针异常:
@Autowired
private SiteEnrichmentService siteEnrichmentService;
public void processSiteData(SiteData rawSite) {
SiteData site = siteEnrichmentService.enrichSiteData(rawSite);
CustomerData customer = site.getCustomer();
// 现在可以直接访问customer的属性和方法,无需检查它是否为"unknown"
String customerName = customer.getName();
String plan = customer.getBillingPlan();
int weeksDelinquent = customer.getPaymentHistory().getWeeksDelinquentInLastYear();
// ...
}
这样,你就能够在Java和Spring Boot中实现JavaScript的数据变换和特例对象的模式了。这种方法同样可以帮助你消除冗余的条件判断,提高代码的可读性和可维护性,同时减少空指针异常的风险。
12、引入断言(Introduce Assertion):
动机:
断言的使用场景:
有些代码块只在特定条件满足时才能正常运行,例如计算平方根需要正数,或者一个对象需要至少有一个字段不为null。在这些情况下,你可以使用断言来确保这些条件得到满足。
断言的重要性:
断言是明确表达程序中的假设的一种方法,比注释更加明确。断言明确指出,如果程序运行到这一点,那么断言中的条件必须为真。
断言的效果:
如果断言失败,那么表示有一个程序错误。程序的行为应该在有无断言的情况下都一样。实际上,一些编程语言允许在编译期通过一个开关来禁用所有断言。
断言的价值:
尽管断言可以用来发现程序中的错误,但这并不是唯一的用途。断言也是一种有效的交流方式,它们向阅读者传达了关于程序状态的明确假设。此外,断言对于调试也非常有帮助。由于断言在传达信息上的价值,即使问题被解决,作者仍建议保留断言。尽管单元测试可以帮助调试,但断言在提供关于程序假设的明确信息上仍有其独特的价值。
做法:
如果你发现代码假设某个条件始终为真,就加入一个断言明确说明这种情况。
因为断言应该不会对系统运行造成任何影响,所以“加入断言”永远都应该是行为保持的。
范例:
在Java开发中,我们也可以使用断言来确保某些"必须为真"的条件总是满足。在Spring Boot应用中,如果涉及到支付或者折扣逻辑,这一点尤其重要,因为它关系到金钱的计算和处理。
假设我们有一个代表用户的类(Customer),它有一个折扣率属性(discountRate)和一个应用折扣的方法(applyDiscount):
public class Customer {
private Double discountRate;
public Double getDiscountRate() {
return discountRate;
}
public void setDiscountRate(Double discountRate) {
this.discountRate = discountRate;
}
public Double applyDiscount(Double price) {
if (discountRate == null) {
return price;
} else {
assert discountRate >= 0;
return price - (discountRate * price);
}
}
}
在上述代码中,我们的applyDiscount方法使用了断言来确保折扣率总是大于或等于0。如果这个条件不满足,Java虚拟机(JVM)将会抛出一个AssertionError异常。
然而,这并不是在所有情况下都是最佳做法。就像《重构改善既有代码的设计》一书中所说的,我们更倾向于将断言放在设值函数中,因为这可以更早地发现非法的值:
public void setDiscountRate(Double discountRate) {
assert discountRate == null || discountRate >= 0;
this.discountRate = discountRate;
}
这样,我们就可以确保每次设置折扣率时,它总是一个合法的值。而且,这也可以帮助我们追踪到折扣率设置为非法值的源头,这可能是某个输入数据的错误,或者是数据转换时的错误。
需要注意的是,断言只能用于内部的、不可控制的程序员错误。对于来自外部的,如用户输入或外部系统提供的数据,我们应该进行适当的错误处理和验证,而不是依赖断言。对于这些数据,我们不能假设它们总是满足我们的预期。在Spring Boot应用中,我们可能需要使用Java Bean Validation、Spring的错误处理机制,或者是自定义的验证逻辑来处理这些情况。
总的来说,断言是一种强有力的工具,可以帮助我们保证代码的正确性,但它并不能替代适当的错误处理和数据验证。我们应该在适当的地方使用它,同时避免滥用。