代码重构专题文章
第 10 章 重构的艺术:简化复杂条件逻辑的秘诀
第 10 章 简化条件逻辑
0 前言
程序的大部分威力来自条件逻辑,但很不幸,程序的复杂度也大多来自条件逻辑。我经常借助重构把条件逻辑变得更容易理解。我常用分解条件表达式(260)处理复杂的条件表达式,用合并条件表达式(263)厘清逻辑组合。我会用以卫语句取代嵌套条件表达式(266)清晰表达"在主要处理逻辑之前先做检查"的意图。如果我发现一处 switch 逻辑处理了几种情况,可以考虑拿出以多态取代条件表达式(272)重构手法。
很多条件逻辑是用于处理特殊情况的,例如处理 null 值。如果对某种特殊情况的处理逻辑大多相同,那么可以用引入特例(289)(常被称作引入空对象)消除重复代码。另外,虽然我很喜欢去除条件逻辑,但如果我想明确地表述(以及检查)程序的状态,引入断言(302)是一个不错的补充。
10.1 分解条件表达式(Decompose Conditional)
java
// 原始代码
public double calculateCharge(Date aDate, Plan plan, int quantity) {
double charge;
if (!aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd())) {
charge = quantity * plan.getSummerRate();
} else {
charge = quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
}
return charge;
}
重构后:
java
public double calculateCharge(Date aDate, Plan plan, int quantity) {
if (isSummer(aDate, plan)) {
return summerCharge(quantity, plan);
} else {
return regularCharge(quantity, plan);
}
}
private boolean isSummer(Date aDate, Plan plan) {
return !aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd());
}
private double summerCharge(int quantity, Plan plan) {
return quantity * plan.getSummerRate();
}
private double regularCharge(int quantity, Plan plan) {
return quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
}
动机
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。我必须编写代码来检查不同的条件分支,根据不同的条件做不同的事,然后,我很快就会得到一个相当长的函数。大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我发生的事,但常常让我弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了。
和任何大块头代码一样,我可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。对于条件逻辑,将每个分支条件分解成新函数还可以带来更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
本重构手法其实只是提炼函数(Extract Method)的一个应用场景。但我要特别强调这个场景,因为我发现它经常会带来很大的价值。
做法
对条件判断和每个条件分支分别运用提炼函数(Extract Method)手法。
范例
假设我要计算购买某样商品的总价(总价=数量 × 单价),而这个商品在冬季和夏季的单价是不同的:
java
// 原始代码
public double calculateCharge(Date aDate, Plan plan, int quantity) {
double charge;
if (!aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd()))
charge = quantity * plan.getSummerRate();
else
charge = quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
return charge;
}
我把条件判断提炼到一个独立的函数中:
java
public double calculateCharge(Date aDate, Plan plan, int quantity) {
double charge;
if (isSummer(aDate, plan))
charge = quantity * plan.getSummerRate();
else
charge = quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
return charge;
}
private boolean isSummer(Date aDate, Plan plan) {
return !aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd());
}
然后提炼条件判断为真的分支:
java
public double calculateCharge(Date aDate, Plan plan, int quantity) {
double charge;
if (isSummer(aDate, plan))
charge = summerCharge(quantity, plan); // 调用新的 summerCharge 函数
else
charge = quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
return charge;
}
private boolean isSummer(Date aDate, Plan plan) {
return !aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd());
}
private double summerCharge(int quantity, Plan plan) {
return quantity * plan.getSummerRate();
}
最后提炼条件判断为假的分支:
java
public double calculateCharge(Date aDate, Plan plan, int quantity) {
double charge;
if (isSummer(aDate, plan))
charge = summerCharge(quantity, plan);
else
charge = regularCharge(quantity, plan); // 调用新的 regularCharge 函数
return charge;
}
private boolean isSummer(Date aDate, Plan plan) {
return !aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd());
}
private double summerCharge(int quantity, Plan plan) {
return quantity * plan.getSummerRate();
}
private double regularCharge(int quantity, Plan plan) {
return quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
}
提炼完成后,我喜欢用三元运算符(在Java中,更常见的做法是直接返回条件表达式的结果)重新安排条件语句,让代码更简洁:
java
public double calculateCharge(Date aDate, Plan plan, int quantity) {
return isSummer(aDate, plan) ? summerCharge(quantity, plan) : regularCharge(quantity, plan);
}
private boolean isSummer(Date aDate, Plan plan) {
return !aDate.before(plan.getSummerStart()) && !aDate.after(plan.getSummerEnd());
}
private double summerCharge(int quantity, Plan plan) {
return quantity * plan.getSummerRate();
}
private double regularCharge(int quantity, Plan plan) {
return quantity * plan.getRegularRate() + plan.getRegularServiceCharge();
}
10.2 合并条件表达式(Consolidate Conditional Expression)
java
// 原始代码
public double disabilityAmount(Employee anEmployee) {
if (anEmployee.getSeniority() < 2) return 0;
if (anEmployee.getMonthsDisabled() > 12) return 0;
if (anEmployee.isPartTime()) return 0;
// compute the disability amount
return calculateActualDisabilityAmount(anEmployee);
}
重构后:
java
public double disabilityAmount(Employee anEmployee) {
if (isNotEligibleForDisability(anEmployee)) {
return 0;
}
// compute the disability amount
return calculateActualDisabilityAmount(anEmployee);
}
private boolean isNotEligibleForDisability(Employee anEmployee) {
return anEmployee.getSeniority() < 2 ||
anEmployee.getMonthsDisabled() > 12 ||
anEmployee.isPartTime();
}
动机
有时我会发现这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用"逻辑或"(||
)和"逻辑与"(&&
)将它们合并为一个条件表达式。
之所以要合并条件代码,有两个重要原因。首先,合并后的条件代码会表述"实际上只有一次条件检查,只不过有多个并列条件需要检查而已",从而使这一次检查的用意更清晰。当然,合并前和合并后的代码有着相同的效果,但原先代码传达出的信息却是"这里有一些各自独立的条件测试,它们只是恰好同时发生"。其次,这项重构往往可以为使用提炼函数(Extract Method)做好准备。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用,因为它把描述"做什么"的语句换成了"为什么这样做"。
条件语句的合并理由也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,我就不会使用本项重构。
做法
确定这些条件表达式都没有副作用。
如果某个条件表达式有副作用,可以先用将查询函数和修改函数分离(Separate Query from Modifier)处理。
使用适当的逻辑运算符,将两个相关条件表达式合并为一个。
顺序执行的条件表达式用逻辑或来合并,嵌套的 if 语句用逻辑与来合并。
测试。
重复前面的合并过程,直到所有相关的条件表达式都合并到一起。
可以考虑对合并后的条件表达式实施提炼函数(Extract Method)。
范例
在走读代码的过程中,我看到了下面的代码片段:
java
public double disabilityAmount(Employee anEmployee) {
if (anEmployee.getSeniority() < 2) return 0;
if (anEmployee.getMonthsDisabled() > 12) return 0;
if (anEmployee.isPartTime()) return 0;
// compute the disability amount
return calculateActualDisabilityAmount(anEmployee); // 假设有一个实际计算方法
}
这里有一连串的条件检查,都指向同样的结果。既然结果是相同的,就应该把这些条件检查合并成一条表达式。对于这样顺序执行的条件检查,可以用逻辑或运算符来合并。
java
public double disabilityAmount(Employee anEmployee) {
if (anEmployee.getSeniority() < 2 || anEmployee.getMonthsDisabled() > 12) return 0;
if (anEmployee.isPartTime()) return 0;
// compute the disability amount
return calculateActualDisabilityAmount(anEmployee);
}
测试,然后把下一个条件检查也合并进来:
java
public double disabilityAmount(Employee anEmployee) {
if (anEmployee.getSeniority() < 2 || anEmployee.getMonthsDisabled() > 12 || anEmployee.isPartTime()) return 0;
// compute the disability amount
return calculateActualDisabilityAmount(anEmployee);
}
合并完成后,再对这句条件表达式使用提炼函数(Extract Method)。
java
public double disabilityAmount(Employee anEmployee) {
if (isNotEligibleForDisability(anEmployee)) {
return 0;
}
// compute the disability amount
return calculateActualDisabilityAmount(anEmployee);
}
private boolean isNotEligibleForDisability(Employee anEmployee) {
return anEmployee.getSeniority() < 2 ||
anEmployee.getMonthsDisabled() > 12 ||
anEmployee.isPartTime();
}
范例:使用逻辑与
上面的例子展示了用逻辑或合并条件表达式的做法。不过,我有可能遇到需要逻辑与的情况。例如,嵌套 if 语句的情况:
java
// 原始代码
public double calculateBonus(Employee anEmployee) {
if (anEmployee.isOnVacation()) {
if (anEmployee.getSeniority() > 10) {
return 1;
}
}
return 0.5;
}
可以用逻辑与运算符将其合并。
java
public double calculateBonus(Employee anEmployee) {
if (anEmployee.isOnVacation() && anEmployee.getSeniority() > 10) {
return 1;
}
return 0.5;
}
如果原来的条件逻辑混杂了这两种情况,我也会根据需要组合使用逻辑与和逻辑或运算符。在这种时候,代码很可能变得混乱,所以我会频繁使用提炼函数(Extract Method),把代码变得可读。
10.3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
java
// 原始代码
public double getPayAmount(Employee employee) {
double result;
if (employee.isDead()) {
result = deadAmount();
} else {
if (employee.isSeparated()) {
result = separatedAmount();
} else {
if (employee.isRetired()) {
result = retiredAmount();
} else {
result = normalPayAmount();
}
}
}
return result;
}
重构后:
java
public double getPayAmount(Employee employee) {
if (employee.isDead()) {
return deadAmount();
}
if (employee.isSeparated()) {
return separatedAmount();
}
if (employee.isRetired()) {
return retiredAmount();
}
return normalPayAmount();
}
动机
根据我的经验,条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
这两类条件表达式有不同的用途,这一点应该通过代码表现出来。如果两条分支都是正常行为,就应该使用形如 if...else...
的条件表达式;如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为"卫语句"(guard clauses)。
以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用 if-then-else
结构,你对 if
分支和 else
分支的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句就不同了,它告诉阅读者:"这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。"
"每个函数只能有一个入口和一个出口"的观念,根深蒂固于某些程序员的脑海里。我发现,当我处理他们编写的代码时,经常需要使用以卫语句取代嵌套条件表达式。现今的编程语言都会强制保证每个函数只有一个入口,至于"单一出口"规则,其实不是那么有用。在我看来,保持代码清晰才是最关键的:如果单一出口能使这个函数更清楚易读,那么就使用单一出口;否则就不必这么做。
做法
选中最外层需要被替换的条件逻辑,将其替换为卫语句。
测试。
有需要的话,重复上述步骤。
如果所有卫语句都引发同样的结果,可以使用合并条件表达式(Consolidate Conditional Expression)合并之。
范例
下面的代码用于计算要支付给员工(employee)的工资。只有还在公司上班的员工才需要支付工资,所以这个函数需要检查两种"员工已经不在公司上班"的情况。
java
// 原始代码
public PaymentInfo payAmount(Employee employee) {
PaymentInfo result;
if (employee.isSeparated()) {
result = new PaymentInfo(0, "SEP");
} else {
if (employee.isRetired()) {
result = new PaymentInfo(0, "RET");
} else {
// logic to compute amount
// A placeholder for complex computation
performComplexCalculationStep1();
performComplexCalculationStep2();
performComplexCalculationStep3();
result = someFinalComputation();
}
}
return result;
}
// 假设 PaymentInfo 是一个简单的类
class PaymentInfo {
private double amount;
private String reasonCode;
public PaymentInfo(double amount, String reasonCode) {
this.amount = amount;
this.reasonCode = reasonCode;
}
// Getters for amount and reasonCode
public double getAmount() { return amount; }
public String getReasonCode() { return reasonCode; }
}
嵌套的条件逻辑让我们看不清代码真实的含义。只有当前两个条件表达式都不为真的时候,这段代码才真正开始它的主要工作。所以,卫语句能让代码更清晰地阐述自己的意图。
一如既往地,我喜欢小步前进,所以我先处理最顶上的条件逻辑。
java
public PaymentInfo payAmount(Employee employee) {
if (employee.isSeparated()) {
return new PaymentInfo(0, "SEP"); // 替换为卫语句
}
// else 块现在是主逻辑的开始
if (employee.isRetired()) {
return new PaymentInfo(0, "RET");
} else {
// logic to compute amount
performComplexCalculationStep1();
performComplexCalculationStep2();
performComplexCalculationStep3();
return someFinalComputation(); // 直接返回
}
}
做完这步修改,我执行测试,然后继续下一步。
java
public PaymentInfo payAmount(Employee employee) {
if (employee.isSeparated()) {
return new PaymentInfo(0, "SEP");
}
if (employee.isRetired()) { // 替换为卫语句
return new PaymentInfo(0, "RET");
}
// 现在,主要的计算逻辑是函数中剩余的部分
performComplexCalculationStep1();
performComplexCalculationStep2();
performComplexCalculationStep3();
return someFinalComputation();
}
此时,result
变量已经没有用处了,因为它在每个分支中都被直接返回了。在Java中,我们通常不会先声明 result
变量再赋值,而是直接返回。
java
public PaymentInfo payAmount(Employee employee) {
if (employee.isSeparated()) {
return new PaymentInfo(0, "SEP");
}
if (employee.isRetired()) {
return new PaymentInfo(0, "RET");
}
// logic to compute amount
performComplexCalculationStep1();
performComplexCalculationStep2();
performComplexCalculationStep3();
return someFinalComputation();
}
能减少一个可变变量总是好的。
范例:将条件反转
审阅本书第 1 版的初稿时,Joshua Kerievsky 指出:我们常常可以将条件表达式反转,从而实现以卫语句取代嵌套条件表达式。为了拯救我可怜的想象力,他还好心帮我想了一个例子:
java
// 原始代码
public double adjustedCapital(Instrument anInstrument) {
double result = 0;
if (anInstrument.getCapital() > 0) {
if (anInstrument.getInterestRate() > 0 && anInstrument.getDuration() > 0) {
result = (anInstrument.getIncome() / anInstrument.getDuration()) * anInstrument.getAdjustmentFactor();
}
}
return result;
}
// 假设 Instrument 类
class Instrument {
private double capital;
private double interestRate;
private double duration;
private double income;
private double adjustmentFactor;
// Getters for all fields
public double getCapital() { return capital; }
public double getInterestRate() { return interestRate; }
public double getDuration() { return duration; }
public double getIncome() { return income; }
public double getAdjustmentFactor() { return adjustmentFactor; }
}
同样地,我逐一进行替换。不过这次在插入卫语句时,我需要将相应的条件反转过来:
java
public double adjustedCapital(Instrument anInstrument) {
// 条件反转:如果 capital 不大于 0,则直接返回 0
if (anInstrument.getCapital() <= 0) {
return 0; // 或者 result
}
// 原始代码中的 result 变量现在可以去掉了,直接使用返回值
if (anInstrument.getInterestRate() > 0 && anInstrument.getDuration() > 0) {
return (anInstrument.getIncome() / anInstrument.getDuration()) * anInstrument.getAdjustmentFactor();
}
return 0; // 如果第二个条件不满足,同样返回 0
}
下一个条件稍微复杂一点,所以我分两步进行反转。首先加入一个逻辑非操作:
java
public double adjustedCapital(Instrument anInstrument) {
if (anInstrument.getCapital() <= 0) {
return 0;
}
// 逻辑非操作
if (!(anInstrument.getInterestRate() > 0 && anInstrument.getDuration() > 0)) {
return 0;
}
return (anInstrument.getIncome() / anInstrument.getDuration()) * anInstrument.getAdjustmentFactor();
}
但是在这样的条件表达式中留下一个逻辑非,会把我的脑袋拧成一团乱麻,所以我把它简化成下面这样(使用德摩根定律):
java
public double adjustedCapital(Instrument anInstrument) {
if (anInstrument.getCapital() <= 0) {
return 0;
}
// 简化逻辑非条件:(A && B) 的非是 (!A || !B)
if (anInstrument.getInterestRate() <= 0 || anInstrument.getDuration() <= 0) {
return 0;
}
return (anInstrument.getIncome() / anInstrument.getDuration()) * anInstrument.getAdjustmentFactor();
}
这两行逻辑语句引发的结果一样,所以我可以用合并条件表达式(Consolidate Conditional Expression)将其合并。
java
public double adjustedCapital(Instrument anInstrument) {
if (anInstrument.getCapital() <= 0 ||
anInstrument.getInterestRate() <= 0 ||
anInstrument.getDuration() <= 0) {
return 0;
}
return (anInstrument.getIncome() / anInstrument.getDuration()) * anInstrument.getAdjustmentFactor();
}
此时 result
变量做了两件事:一开始我把它设为 0,代表卫语句被触发时的返回值;然后又用最终计算的结果给它赋值。我可以彻底移除这个变量,避免用一个变量承担两重责任,而且又减少了一个可变变量。
java
public double adjustedCapital(Instrument anInstrument) {
if (anInstrument.getCapital() <= 0 ||
anInstrument.getInterestRate() <= 0 ||
anInstrument.getDuration() <= 0) {
return 0;
}
return (anInstrument.getIncome() / anInstrument.getDuration()) * anInstrument.getAdjustmentFactor();
}
10.4 以多态取代条件表达式(Replace Conditional with Polymorphism)
动机
复杂的条件逻辑是编程中最难理解的东西之一,因此我一直在寻求给条件逻辑添加结构。很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。
一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,我会注意到,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。最明显的征兆就是有好几个函数都有基于类型代码的 switch 语句。若果真如此,我就可以针对 switch 语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。
另一种情况是:有一个基础逻辑,在其上又有一些变体。基础逻辑可能是最常用的,也可能是最简单的。我可以把基础逻辑放进超类,这样我可以首先理解这部分逻辑,暂时不管各种变体,然后我可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。
多态是面向对象编程的关键特性之一。跟其他一切有用的特性一样,它也很容易被滥用。我曾经遇到有人争论说所有条件逻辑都应该用多态取代。我不赞同这种观点。我的大部分条件逻辑只用到了基本的条件语句------if/else 和 switch/case,并不需要劳师动众地引入多态。但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。
做法
- 如果现有的类尚不具备多态行为,就用工厂函数创建之,令工厂函数返回恰当的对象实例。
- 在调用方代码中使用工厂函数获得对象实例。
- 将带有条件逻辑的函数移到超类中。
- 如果条件逻辑还未提炼至独立的函数,首先对其使用提炼函数(Extract Method)。
- 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
- 重复上述过程,处理其他条件分支。
- 在超类函数中保留默认情况的逻辑。或者,如果超类应该是抽象的,就把该函数声明为 abstract,或在其中直接抛出异常,表明计算责任都在子类中。
范例:鸟类行为分析
我的朋友有一群鸟儿,他想知道这些鸟飞得有多快,以及它们的羽毛是什么样的。所以我们写了一小段程序来判断这些信息。
原始 JavaScript 代码:
js
function plumages(birds) {
return new Map(birds.map(b => [b.name, plumage(b)]));
}
function speeds(birds) {
return new Map(birds.map(b => [b.name, airSpeedVelocity(b)]));
}
function plumage(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return "average";
case 'AfricanSwallow':
return (bird.numberOfCoconuts > 2) ? "tired" : "average";
case 'NorwegianBlueParrot':
return (bird.voltage > 100) ? "scorched" : "beautiful";
default:
return "unknown";
}
}
function airSpeedVelocity(bird) {
switch (bird.type) {
case 'EuropeanSwallow':
return 35;
case 'AfricanSwallow':
return 40 - 2 * bird.numberOfCoconuts;
case 'NorwegianBlueParrot':
return (bird.isNailed) ? 0 : 10 + bird.voltage / 10;
default:
return null;
}
}
有两个不同的操作,其行为都随着"鸟的类型"发生变化,因此可以创建出对应的类,用多态来处理各类型特有的行为。
我先对 airSpeedVelocity
和 plumage
两个函数使用函数组合成类(Encapsulate Function into Class)。
Java 代码 (重构第一步:引入 Bird
类封装行为):
java
import java.util.HashMap;
import java.util.List;
import java.util.Map;
// 假设 BirdData 是一个简单的POJO,包含 type, name, numberOfCoconuts, voltage, isNailed
class BirdData {
public String type;
public String name;
public int numberOfCoconuts;
public int voltage;
public boolean isNailed;
public BirdData(String type, String name, int numberOfCoconuts, int voltage, boolean isNailed) {
this.type = type;
this.name = name;
this.numberOfCoconuts = numberOfCoconuts;
this.voltage = voltage;
this.isNailed = isNailed;
}
}
class Bird {
protected BirdData birdData; // 使用 protected 以便子类访问
public Bird(BirdData birdData) {
this.birdData = birdData;
}
public String getPlumage() {
switch (birdData.type) {
case "EuropeanSwallow":
return "average";
case "AfricanSwallow":
return (birdData.numberOfCoconuts > 2) ? "tired" : "average";
case "NorwegianBlueParrot":
return (birdData.voltage > 100) ? "scorched" : "beautiful";
default:
return "unknown";
}
}
public Integer getAirSpeedVelocity() {
switch (birdData.type) {
case "EuropeanSwallow":
return 35;
case "AfricanSwallow":
return 40 - 2 * birdData.numberOfCoconuts;
case "NorwegianBlueParrot":
return (birdData.isNailed) ? 0 : 10 + birdData.voltage / 10;
default:
return null;
}
}
public String getName() {
return birdData.name;
}
}
class BirdAnalyzer {
public Map<String, String> plumages(List<BirdData> birdsData) {
Map<String, String> result = new HashMap<>();
for (BirdData b : birdsData) {
result.put(b.name, new Bird(b).getPlumage());
}
return result;
}
public Map<String, Integer> speeds(List<BirdData> birdsData) {
Map<String, Integer> result = new HashMap<>();
for (BirdData b : birdsData) {
result.put(b.name, new Bird(b).getAirSpeedVelocity());
}
return result;
}
}
现在,针对每种鸟创建一个子类,用一个工厂函数来实例化合适的子类对象。
Java 代码 (重构第二步:引入子类和工厂方法)
java
// ... (BirdData, Bird 类定义不变)
class EuropeanSwallow extends Bird {
public EuropeanSwallow(BirdData birdData) {
super(birdData);
}
@Override
public String getPlumage() {
return "average";
}
@Override
public Integer getAirSpeedVelocity() {
return 35;
}
}
class AfricanSwallow extends Bird {
public AfricanSwallow(BirdData birdData) {
super(birdData);
}
@Override
public String getPlumage() {
return (birdData.numberOfCoconuts > 2) ? "tired" : "average";
}
@Override
public Integer getAirSpeedVelocity() {
return 40 - 2 * birdData.numberOfCoconuts;
}
}
class NorwegianBlueParrot extends Bird {
public NorwegianBlueParrot(BirdData birdData) {
super(birdData);
}
@Override
public String getPlumage() {
return (birdData.voltage > 100) ? "scorched" : "beautiful";
}
@Override
public Integer getAirSpeedVelocity() {
return (birdData.isNailed) ? 0 : 10 + birdData.voltage / 10;
}
}
class BirdFactory {
public static Bird createBird(BirdData birdData) {
switch (birdData.type) {
case "EuropeanSwallow":
return new EuropeanSwallow(birdData);
case "AfricanSwallow":
return new AfricanSwallow(birdData);
case "NorwegianBlueParrot":
return new NorwegianBlueParrot(birdData);
default:
// 默认情况下返回基础 Bird 对象,或抛出异常表示未知类型
return new Bird(birdData);
}
}
}
class BirdAnalyzerRefactored {
public Map<String, String> plumages(List<BirdData> birdsData) {
Map<String, String> result = new HashMap<>();
for (BirdData b : birdsData) {
Bird bird = BirdFactory.createBird(b);
result.put(bird.getName(), bird.getPlumage());
}
return result;
}
public Map<String, Integer> speeds(List<BirdData> birdsData) {
Map<String, Integer> result = new HashMap<>();
for (BirdData b : birdsData) {
Bird bird = BirdFactory.createBird(b);
result.put(bird.getName(), bird.getAirSpeedVelocity());
}
return result;
}
}
说明:
BirdData
:一个简单的数据类,用来承载原始的鸟类信息,避免将原始数据与行为混淆。Bird
:作为超类,包含了所有鸟类共享的属性(通过BirdData
)和默认行为。在最终的重构中,getPlumage()
和getAirSpeedVelocity()
在Bird
类中可以变为抽象方法,强制子类实现。如果希望保留默认行为,则可以像示例中那样提供默认实现,或者在工厂方法中确保只返回具体子类,并在Bird
类中抛出异常。EuropeanSwallow
,AfricanSwallow
,NorwegianBlueParrot
:具体子类,各自覆写了getPlumage()
和getAirSpeedVelocity()
方法,实现了特定类型的行为。BirdFactory
:一个静态工厂方法,根据birdData.type
返回正确的Bird
子类实例。这取代了客户端代码中的switch
语句。BirdAnalyzerRefactored
:调用方代码现在使用工厂方法获取Bird
对象,并通过多态调用正确的方法,无需再进行条件判断。
通过这种方式,我们成功地用多态取代了复杂的 switch
条件表达式,使得代码更具扩展性、更易于理解和维护。如果将来要增加新的鸟类,只需添加一个新的子类和修改工厂方法即可,而无需修改现有的逻辑。
范例:用多态处理变体逻辑------航运评级系统
在前面的例子中,"鸟"的类型体系是一个清晰的泛化体系:超类是抽象的"鸟",子类是各种具体的鸟。这是教科书(包括我写的书)中经常讨论的继承和多态,但并不是实践中使用继承的唯一方式。实际上,这种方式很可能不是最常用或最好的方式。另一种使用继承的情况是:我想表达某个对象与另一个对象大体类似,但又有一些不同之处。
下面有一个这样的例子:有一家评级机构,要对远洋航船的航行进行投资评级。这家评级机构会给出"A"或者"B"两种评级,取决于多种风险和盈利潜力的因素。在评估风险时,既要考虑航程本身的特征,也要考虑船长过往航行的历史。
原始 JavaScript 代码:
js
function rating(voyage, history) {
const vpf = voyageProfitFactor(voyage, history);
const vr = voyageRisk(voyage);
const chr = captainHistoryRisk(voyage, history);
if (vpf * 3 > (vr + chr * 2)) return "A";
else return "B";
}
function voyageRisk(voyage) {
let result = 1;
if (voyage.length > 4) result += 2;
if (voyage.length > 8) result += voyage.length - 8;
if (["china", "east-indies"].includes(voyage.zone)) result += 4;
return Math.max(result, 0);
}
function captainHistoryRisk(voyage, history) {
let result = 1;
if (history.length < 5) result += 4;
result += history.filter(v => v.profit < 0).length;
if (voyage.zone === "china" && hasChina(history)) result -= 2;
return Math.max(result, 0);
}
function hasChina(history) {
return history.some(v => "china" === v.zone);
}
function voyageProfitFactor(voyage, history) {
let result = 2;
if (voyage.zone === "china") result += 1;
if (voyage.zone === "east-indies") result += 1;
if (voyage.zone === "china" && hasChina(history)) {
result += 3;
if (history.length > 10) result += 1;
if (voyage.length > 12) result += 1;
if (voyage.length > 18) result -= 1;
}
else {
if (history.length > 8) result += 1;
if (voyage.length > 14) result -= 1;
}
return result;
}
voyageRisk
和 captainHistoryRisk
两个函数负责打出风险分数,voyageProfitFactor
负责打出盈利潜力分数,rating
函数将 3 个分数组合到一起,给出一次航行的综合评级。
调用方的代码大概是这样:
js
const voyage = { zone: "west-indies", length: 10 };
const history = [
{ zone: "east-indies", profit: 5 },
{ zone: "west-indies", profit: 15 },
{ zone: "china", profit: -2 },
{ zone: "west-africa", profit: 7 },
];
const myRating = rating(voyage, history);
代码中有两处同样的条件逻辑,都在询问"是否有到中国的航程"以及"船长是否曾去过中国"。
我会用继承和多态将处理"中国因素"的逻辑从基础逻辑中分离出来。如果还要引入更多的特殊逻辑,这个重构就很有用------这些重复的"中国因素"会混淆视听,让基础逻辑难以理解。
起初代码里只有一堆函数,如果要引入多态的话,我需要先建立一个类结构,因此我首先使用函数组合成类(Encapsulate Function into Class)。
Java 代码 (重构第一步:封装成 Rating
类)
java
import java.util.List;
import java.util.Arrays;
import java.util.Objects;
class Voyage {
public String zone;
public int length;
public Voyage(String zone, int length) {
this.zone = zone;
this.length = length;
}
}
class HistoryEntry {
public String zone;
public int profit;
public HistoryEntry(String zone, int profit) {
this.zone = zone;
this.profit = profit;
}
}
class Rating {
protected Voyage voyage;
protected List<HistoryEntry> history;
public Rating(Voyage voyage, List<HistoryEntry> history) {
this.voyage = voyage;
this.history = history;
}
public String getValue() {
int vpf = getVoyageProfitFactor();
int vr = getVoyageRisk();
int chr = getCaptainHistoryRisk();
if (vpf * 3 > (vr + chr * 2)) return "A";
else return "B";
}
protected int getVoyageRisk() {
int result = 1;
if (voyage.length > 4) result += 2;
if (voyage.length > 8) result += voyage.length - 8;
if (Arrays.asList("china", "east-indies").contains(voyage.zone)) result += 4;
return Math.max(result, 0);
}
protected int getCaptainHistoryRisk() {
int result = 1;
if (history.size() < 5) result += 4;
result += history.stream().filter(v -> v.profit < 0).count();
if (voyage.zone.equals("china") && hasChinaHistory()) result -= 2;
return Math.max(result, 0);
}
protected boolean hasChinaHistory() {
return history.stream().anyMatch(v -> "china".equals(v.zone));
}
protected int getVoyageProfitFactor() {
int result = 2;
if (voyage.zone.equals("china")) result += 1;
if (voyage.zone.equals("east-indies")) result += 1;
if (voyage.zone.equals("china") && hasChinaHistory()) {
result += 3;
if (history.size() > 10) result += 1;
if (voyage.length > 12) result += 1;
if (voyage.length > 18) result -= 1;
} else {
if (history.size() > 8) result += 1;
if (voyage.length > 14) result -= 1;
}
return result;
}
}
class RatingCalculator {
public String calculateRating(Voyage voyage, List<HistoryEntry> history) {
return new Rating(voyage, history).getValue();
}
}
于是我就有了一个类,用来安放基础逻辑。现在我需要另建一个空的子类,用来安放与超类不同的行为。
Java 代码 (重构第二步:引入子类和工厂方法)
java
// ... (Voyage, HistoryEntry 类定义不变)
class ExperiencedChinaRating extends Rating {
public ExperiencedChinaRating(Voyage voyage, List<HistoryEntry> history) {
super(voyage, history);
}
@Override
protected int getCaptainHistoryRisk() {
// 在基础风险上减2,并确保不小于0
int result = super.getCaptainHistoryRisk() - 2;
return Math.max(result, 0);
}
@Override
protected int getVoyageProfitFactor() {
// 覆盖整个利润因子计算逻辑
int result = 2;
result += 1; // "china" zone always adds 1
result += 3; // Experienced China factor
if (history.size() > 10) result += 1;
if (voyage.length > 12) result += 1;
if (voyage.length > 18) result -= 1;
return result;
}
}
class RatingFactory {
public static Rating createRating(Voyage voyage, List<HistoryEntry> history) {
if (voyage.zone.equals("china") && history.stream().anyMatch(h -> "china".equals(h.zone))) {
return new ExperiencedChinaRating(voyage, history);
} else {
return new Rating(voyage, history);
}
}
}
class RatingCalculatorRefactored {
public String calculateRating(Voyage voyage, List<HistoryEntry> history) {
return RatingFactory.createRating(voyage, history).getValue();
}
}
说明:
- 数据封装 :
Voyage
和HistoryEntry
类用来封装航行和历史记录的数据。 Rating
超类 :包含了所有航行评级的通用逻辑。所有计算风险和利润因子的方法都定义在这里,并且被声明为protected
以便子类访问和重写。ExperiencedChinaRating
子类 :继承自Rating
,并重写了getCaptainHistoryRisk()
和getVoyageProfitFactor()
方法,以实现针对"中国经验"的特殊逻辑。- 在
getCaptainHistoryRisk()
中,我们调用super.getCaptainHistoryRisk()
获取基础风险,然后在此基础上进行调整。 - 在
getVoyageProfitFactor()
中,由于"中国因素"的影响较大,我们选择完全重写这个方法,而不是在父方法的基础上修修补补。
- 在
RatingFactory
工厂类 :负责根据Voyage
和History
的特定条件(是否涉及中国航线且有中国经验)返回Rating
或ExperiencedChinaRating
的实例。这消除了客户端代码中的条件判断。RatingCalculatorRefactored
:客户端代码现在只需要调用工厂方法,然后通过多态机制,自动调用正确类的getValue()
方法。
进一步优化 getVoyageProfitFactor
:
在 Rating
超类中,getVoyageProfitFactor
方法内部的条件逻辑比较复杂,尤其是在 if (this.voyage.zone === "china" && this.hasChinaHistory)
分支中。这表明这个方法本身还可以进一步重构,例如使用提炼函数(Extract Method)。
我们先将 voyageProfitFactor
中的复杂条件逻辑提炼为更小的方法,例如 getVoyageZoneFactor()
、getHistoryLengthFactor()
和 getVoyageLengthFactor()
。
Java 代码 (重构第三步:提炼因子方法)
java
// ... (Voyage, HistoryEntry 类定义不变)
class Rating {
// ... (构造函数和getValue方法不变)
// ... (getVoyageRisk, getCaptainHistoryRisk, hasChinaHistory 方法不变)
protected int getVoyageProfitFactor() {
int result = 2;
result += getVoyageZoneFactor();
result += getHistoryAndVoyageLengthFactor(); // 暂时合并,稍后拆分
return result;
}
protected int getVoyageZoneFactor() {
int factor = 0;
if (voyage.zone.equals("china")) factor += 1;
if (voyage.zone.equals("east-indies")) factor += 1;
return factor;
}
// 这是一个中间步骤,方法名包含"And"是一个坏味道
protected int getHistoryAndVoyageLengthFactor() {
int factor = 0;
if (voyage.zone.equals("china") && hasChinaHistory()) {
factor += 3;
if (history.size() > 10) factor += 1;
if (voyage.length > 12) factor += 1;
if (voyage.length > 18) factor -= 1;
} else {
if (history.size() > 8) factor += 1;
if (voyage.length > 14) factor -= 1;
}
return factor;
}
}
class ExperiencedChinaRating extends Rating {
// ... (构造函数和getCaptainHistoryRisk方法不变)
@Override
protected int getHistoryAndVoyageLengthFactor() {
int factor = 0;
factor += 3; // 经验中国航线额外加3分
if (history.size() > 10) factor += 1;
if (voyage.length > 12) factor += 1;
if (voyage.length > 18) factor -= 1;
return factor;
}
}
// ... (RatingFactory 和 RatingCalculatorRefactored 不变)
现在,getHistoryAndVoyageLengthFactor
这个方法名很糟糕,且其中仍包含两种不同的逻辑。我们可以将其进一步拆分,在 Rating
类中提供更通用的 getHistoryLengthFactor
和 getVoyageLengthFactor
方法,并在 ExperiencedChinaRating
中覆写它们。
Java 代码 (重构第四步:进一步拆分因子方法)
java
// ... (Voyage, HistoryEntry 类定义不变)
class Rating {
// ... (构造函数和getValue方法不变)
// ... (getVoyageRisk, getCaptainHistoryRisk, hasChinaHistory, getVoyageZoneFactor 方法不变)
protected int getVoyageProfitFactor() {
int result = 2;
result += getVoyageZoneFactor();
result += getHistoryLengthFactor(); // 拆分历史长度因子
result += getVoyageLengthFactor(); // 拆分航程长度因子
return result;
}
protected int getHistoryLengthFactor() {
return (history.size() > 8) ? 1 : 0;
}
protected int getVoyageLengthFactor() {
return (voyage.length > 14) ? -1 : 0;
}
}
class ExperiencedChinaRating extends Rating {
// ... (构造函数和getCaptainHistoryRisk方法不变)
@Override
protected int getHistoryLengthFactor() {
return (history.size() > 10) ? 1 : 0; // 经验中国航线,历史长度要求更高
}
@Override
protected int getVoyageLengthFactor() {
int factor = 0;
if (voyage.length > 12) factor += 1;
if (voyage.length > 18) factor -= 1;
return factor;
}
@Override
protected int getVoyageProfitFactor() {
return super.getVoyageProfitFactor() + 3; // 经验中国航线,总利润因子额外加3
}
}
// ... (RatingFactory 和 RatingCalculatorRefactored 不变)
最终,我们得到了更清晰、更易于扩展的代码结构。Rating
类包含了所有通用的评级逻辑,而 ExperiencedChinaRating
类则精确地表达了与"中国经验"相关的差异。每个方法都只做一件事,方法名也更具描述性。
10.5 引入特例(Introduce Special Case)
曾用名: 引入 Null 对象(Introduce Null Object)
当你的代码库中充斥着对某个特定值(例如 null
或一个表示"未知"的魔术字符串)的重复检查,并且这些检查之后通常伴随着相同的处理逻辑时,你就应该考虑使用"引入特例"重构手法了。这种模式通过创建一个专门的"特例对象"来封装这些通用行为,从而将分散的条件逻辑收敛到一处,使代码更加清晰和易于维护。
动机
想象一下,如果一个数据结构的使用者在多个地方都检测一个特定的值,并且每次检测到这个特殊值时都执行相同的操作。这种重复的条件检查不仅冗余,而且一旦特殊值的处理逻辑需要修改,你就不得不在多个地方进行改动,增加了出错的风险。
引入特例模式的目的是提供一个统一的接口来处理这些特殊情况。通过用一个"特例对象"替换原始的特殊值,我们可以将处理特殊情况的逻辑从客户端代码中移除,转而由特例对象自身来提供。这样,客户端代码就可以统一地对待普通对象和特例对象,无需进行显式的条件判断,从而大大简化了代码。
特例对象可以是简单的字面量(在Java中通常通过静态工厂方法返回的不可变实例),也可以是一个具有特定行为的完整类。对于 null
值的处理,特例模式被称为"Null 对象模式",它是特例模式的一个具体应用。
做法
- 识别目标: 找到一个包含属性的数据结构或类,其属性值经常被客户端代码与某个"特例值"(如
null
或魔术字符串)进行比较。 - 添加特例检查属性: 在原始类(非特例情况)中添加一个名为
isUnknown()
或isSpecialCase()
的布尔属性或方法,使其返回false
。 - 创建特例类/对象: 创建一个新的类或对象,用于表示这种特殊情况。在这个特例类中,实现相同的特例检查方法,使其返回
true
。这个特例类可以实现与原始类相同的接口或继承自原始类(在Java中推荐实现接口或继承)。 - 封装特例值比较: 对所有客户端代码中直接与特例值进行比较的逻辑,使用提炼函数 (Extract Method) 将其封装成一个独立的方法,确保所有客户端都调用这个新方法,而不是直接进行比较。
- 引入特例对象: 修改原始数据结构或类的创建/获取逻辑,使其在遇到特例值时,不再返回特例值本身,而是返回新创建的特例对象。
- 更新特例比较函数: 修改第4步创建的特例比较方法,使其现在调用特例对象的
isUnknown()
或isSpecialCase()
方法。 - 逐步迁移行为: 遍历所有处理特例情况的客户端代码。将那些通用且重复的特例处理逻辑(例如返回默认值、执行空操作等)逐步从客户端代码中移除,并将其搬移到特例对象中。特例对象会覆写或实现相应的方法,提供特殊情况下的行为。
- (可选)字面量记录: 如果特例对象只包含固定的值且是只读的,可以将其实现为不可变的字面量记录(在Java中,可以使用
record
类型或包含final
字段的简单类)。 - 内联特例比较函数: 当所有客户端都已改为直接调用特例对象的方法后,如果之前封装的特例比较方法变得多余,可以使用内联函数 (Inline Method) 将其移除。
范例
我们以一家公共事业服务公司为例,其系统中有表示"场所"(Site
)和"顾客"(Customer
)的类。某些场所可能没有对应的顾客,此时 customer
字段被设为一个特殊的字符串 "unknown"
。
原始Java代码示例
java
// Customer.java
public class Customer {
private String name;
private String billingPlan;
private PaymentHistory paymentHistory;
public Customer(String name, String billingPlan, PaymentHistory paymentHistory) {
this.name = name;
this.billingPlan = billingPlan;
this.paymentHistory = paymentHistory;
}
public String getName() {
return name;
}
public String getBillingPlan() {
return billingPlan;
}
public void setBillingPlan(String billingPlan) {
this.billingPlan = billingPlan;
}
public PaymentHistory getPaymentHistory() {
return paymentHistory;
}
}
// PaymentHistory.java
public class PaymentHistory {
private int weeksDelinquentInLastYear;
public PaymentHistory(int weeksDelinquentInLastYear) {
this.weeksDelinquentInLastYear = weeksDelinquentInLastYear;
}
public int getWeeksDelinquentInLastYear() {
return weeksDelinquentInLastYear;
}
}
// Site.java
public class Site {
private Object customer; // Could be Customer object or "unknown" string
public Site(Object customer) {
this.customer = customer;
}
public Object getCustomer() {
return customer;
}
}
// Client Code Example 1
public class Client1 {
public static void processSite(Site site) {
Object aCustomer = site.getCustomer();
String customerName;
if (aCustomer.equals("unknown")) {
customerName = "occupant";
} else {
customerName = ((Customer) aCustomer).getName();
}
System.out.println("Customer Name: " + customerName);
}
}
// Client Code Example 2
public class Client2 {
public static void processSite(Site site) {
Object aCustomer = site.getCustomer();
String plan;
if (aCustomer.equals("unknown")) {
plan = "basic"; // Assuming registry.billingPlans.basic is "basic"
} else {
plan = ((Customer) aCustomer).getBillingPlan();
}
System.out.println("Billing Plan: " + plan);
}
}
// Client Code Example 3
public class Client3 {
public static void processSite(Site site, String newPlan) {
Object aCustomer = site.getCustomer();
if (!aCustomer.equals("unknown")) {
((Customer) aCustomer).setBillingPlan(newPlan);
System.out.println("Billing plan updated.");
} else {
System.out.println("Cannot update billing plan for unknown customer.");
}
}
}
// Client Code Example 4
public class Client4 {
public static void processSite(Site site) {
Object aCustomer = site.getCustomer();
int weeksDelinquent;
if (aCustomer.equals("unknown")) {
weeksDelinquent = 0;
} else {
weeksDelinquent = ((Customer) aCustomer).getPaymentHistory().getWeeksDelinquentInLastYear();
}
System.out.println("Weeks Delinquent: " + weeksDelinquent);
}
}
我们的目标是消除客户端代码中对 aCustomer.equals("unknown")
的重复检查。
1. 修改 Customer
接口和创建 UnknownCustomer
类
首先,我们定义一个 Customer
接口(或抽象类),让 Customer
类和 UnknownCustomer
类都实现它。在接口中添加 isUnknown()
方法。
java
// ICustomer.java (Interface)
public interface ICustomer {
String getName();
String getBillingPlan();
void setBillingPlan(String billingPlan);
PaymentHistory getPaymentHistory();
boolean isUnknown(); // New method
}
// Customer.java (Modified to implement ICustomer)
public class Customer implements ICustomer {
private String name;
private String billingPlan;
private PaymentHistory paymentHistory;
public Customer(String name, String billingPlan, PaymentHistory paymentHistory) {
this.name = name;
this.billingPlan = billingPlan;
this.paymentHistory = paymentHistory;
}
@Override
public String getName() {
return name;
}
@Override
public String getBillingPlan() {
return billingPlan;
}
@Override
public void setBillingPlan(String billingPlan) {
this.billingPlan = billingPlan;
}
@Override
public PaymentHistory getPaymentHistory() {
return paymentHistory;
}
@Override
public boolean isUnknown() {
return false;
}
}
// UnknownCustomer.java (New Special Case Class)
public class UnknownCustomer implements ICustomer {
@Override
public String getName() {
return "occupant"; // Default name for unknown customer
}
@Override
public String getBillingPlan() {
return "basic"; // Default billing plan for unknown customer
}
@Override
public void setBillingPlan(String billingPlan) {
// Ignore, unknown customer's billing plan cannot be set
}
@Override
public PaymentHistory getPaymentHistory() {
return new NullPaymentHistory(); // Return a special NullPaymentHistory
}
@Override
public boolean isUnknown() {
return true;
}
}
// NullPaymentHistory.java (New Special Case Class for PaymentHistory)
public class NullPaymentHistory extends PaymentHistory {
public NullPaymentHistory() {
super(0); // Default weeksDelinquentInLastYear for null history
}
@Override
public int getWeeksDelinquentInLastYear() {
return 0; // Always 0 for null payment history
}
}
2. 封装特例比较逻辑
创建一个辅助方法 isUnknownCustomer()
来集中处理"是否未知顾客"的判断。
java
// Utility class or within a relevant context
public class CustomerUtils {
public static boolean isUnknownCustomer(Object customer) {
// This initial implementation still checks the "unknown" string
// We'll update it in a later step
if (!(customer instanceof Customer || customer instanceof String)) {
throw new IllegalArgumentException("Investigate bad customer value: <" + customer + ">");
}
return customer.equals("unknown");
}
}
现在,所有客户端代码都可以改为使用 CustomerUtils.isUnknownCustomer()
。
java
// Client Code Example 1 (Modified)
public class Client1 {
public static void processSite(Site site) {
Object aCustomer = site.getCustomer();
String customerName;
if (CustomerUtils.isUnknownCustomer(aCustomer)) {
customerName = "occupant";
} else {
customerName = ((ICustomer) aCustomer).getName();
}
System.out.println("Customer Name: " + customerName);
}
}
// Client Code Example 2 (Modified)
public class Client2 {
public static void processSite(Site site) {
Object aCustomer = site.getCustomer();
String plan;
if (CustomerUtils.isUnknownCustomer(aCustomer)) {
plan = "basic";
} else {
plan = ((ICustomer) aCustomer).getBillingPlan();
}
System.out.println("Billing Plan: " + plan);
}
}
// Client Code Example 3 (Modified)
public class Client3 {
public static void processSite(Site site, String newPlan) {
Object aCustomer = site.getCustomer();
if (!CustomerUtils.isUnknownCustomer(aCustomer)) {
((ICustomer) aCustomer).setBillingPlan(newPlan);
System.out.println("Billing plan updated.");
} else {
System.out.println("Cannot update billing plan for unknown customer.");
}
}
}
// Client Code Example 4 (Modified)
public class Client4 {
public static void processSite(Site site) {
Object aCustomer = site.getCustomer();
int weeksDelinquent;
if (CustomerUtils.isUnknownCustomer(aCustomer)) {
weeksDelinquent = 0;
} else {
weeksDelinquent = ((ICustomer) aCustomer).getPaymentHistory().getWeeksDelinquentInLastYear();
}
System.out.println("Weeks Delinquent: " + weeksDelinquent);
}
}
3. 修改 Site
类,返回特例对象
现在,让 Site
类在顾客未知时返回 UnknownCustomer
实例。
java
// Site.java (Modified)
public class Site {
private Object customer; // Now it will hold ICustomer or "unknown" initially
public Site(Object customer) {
this.customer = customer;
}
public ICustomer getCustomer() {
if (this.customer instanceof String && "unknown".equals(this.customer)) {
return new UnknownCustomer();
} else if (this.customer instanceof ICustomer) {
return (ICustomer) this.customer;
} else {
// Handle unexpected types, maybe throw an exception or return a default UnknownCustomer
throw new IllegalStateException("Unexpected customer type: " + this.customer.getClass().getName());
}
}
}
4. 更新 isUnknownCustomer
方法
现在,isUnknownCustomer
方法可以直接依赖 ICustomer.isUnknown()
方法。
java
// CustomerUtils.java (Modified)
public class CustomerUtils {
public static boolean isUnknownCustomer(ICustomer customer) { // Now takes ICustomer
return customer.isUnknown();
}
}
注意: 此时,所有客户端代码中 Object aCustomer = site.getCustomer();
应该改为 ICustomer aCustomer = site.getCustomer();
,并且直接调用 aCustomer
的方法。
5. 逐步迁移客户端行为并内联 isUnknownCustomer
现在我们可以开始简化客户端代码,因为它不再需要进行条件检查。特例对象会提供正确的默认行为。
java
// Client Code Example 1 (Final)
public class Client1 {
public static void processSite(Site site) {
ICustomer aCustomer = site.getCustomer(); // Now ICustomer
String customerName = aCustomer.getName(); // Direct call
System.out.println("Customer Name: " + customerName);
}
}
// Client Code Example 2 (Final)
public class Client2 {
public static void processSite(Site site) {
ICustomer aCustomer = site.getCustomer();
String plan = aCustomer.getBillingPlan(); // Direct call
System.out.println("Billing Plan: " + plan);
}
}
// Client Code Example 3 (Final)
public class Client3 {
public static void processSite(Site site, String newPlan) {
ICustomer aCustomer = site.getCustomer();
// If setBillingPlan on UnknownCustomer does nothing, this works
// If we still need to prevent calls, the check could remain but would use aCustomer.isUnknown()
if (!aCustomer.isUnknown()) { // Keeping this check for explicit prevention if needed
aCustomer.setBillingPlan(newPlan);
System.out.println("Billing plan updated.");
} else {
System.out.println("Cannot update billing plan for unknown customer.");
}
}
}
// Client Code Example 4 (Final)
public class Client4 {
public static void processSite(Site site) {
ICustomer aCustomer = site.getCustomer();
int weeksDelinquent = aCustomer.getPaymentHistory().getWeeksDelinquentInLastYear(); // Direct call
System.out.println("Weeks Delinquent: " + weeksDelinquent);
}
}
现在,CustomerUtils.isUnknownCustomer()
方法可能已经变得多余,可以考虑将其内联或移除。
范例:使用对象字面量(Java中的记录或不可变类)
在Java中,如果特例对象只是一个只读的数据结构,我们可以使用 record
类型(Java 16+)或者一个带有 final
字段的不可变类来作为特例对象。
java
// Instead of a full UnknownCustomer class, we can use a record if it's purely data-centric and immutable
public record UnknownCustomerRecord(
String name,
String billingPlan,
PaymentHistory paymentHistory,
boolean isUnknown
) implements ICustomer { // ICustomer would need to be adapted for records (e.g., default methods or separate interface)
public UnknownCustomerRecord() {
this("occupant", "basic", new NullPaymentHistory(), true);
}
// Records automatically generate getters and constructor
// For setBillingPlan, we'd still need a strategy if ICustomer defines it.
// Here, we adapt ICustomer methods to be compatible with record (e.g., throw UnsupportedOperationException for setters)
@Override
public void setBillingPlan(String billingPlan) {
throw new UnsupportedOperationException("UnknownCustomerRecord is immutable.");
}
}
// Site.java (Using the record)
public class Site {
private Object customer;
public Site(Object customer) {
this.customer = customer;
}
public ICustomer getCustomer() {
if (this.customer instanceof String && "unknown".equals(this.customer)) {
return new UnknownCustomerRecord(); // Returns the immutable record
} else if (this.customer instanceof ICustomer) {
return (ICustomer) this.customer;
} else {
throw new IllegalStateException("Unexpected customer type: " + this.customer.getClass().getName());
}
}
}
范例:使用变换(Stream
API 或辅助方法)
当输入是一个简单的记录结构(例如JSON或Map),并且需要转换为更复杂的对象图时,可以使用变换步骤来引入特例。
假设我们从外部API接收到一个 Map<String, Object>
来表示站点数据。
java
import java.util.HashMap;
import java.util.Map;
// Initial raw data structure
Map<String, Object> rawSiteData = new HashMap<>();
rawSiteData.put("name", "Acme Boston");
rawSiteData.put("location", "Malden MA");
Map<String, Object> customerData = new HashMap<>();
customerData.put("name", "Acme Industries");
customerData.put("billingPlan", "plan-451");
Map<String, Object> paymentHistoryData = new HashMap<>();
paymentHistoryData.put("weeksDelinquentInLastYear", 7);
customerData.put("paymentHistory", paymentHistoryData);
rawSiteData.put("customer", customerData);
// Or for an unknown customer:
Map<String, Object> unknownSiteData = new HashMap<>();
unknownSiteData.put("name", "Warehouse Unit 15");
unknownSiteData.put("location", "Malden MA");
unknownSiteData.put("customer", "unknown");
// A utility to convert raw map to ICustomer
public class SiteEnricher {
public static ICustomer createCustomerFromMap(Object customerRawData) {
if (customerRawData instanceof String && "unknown".equals(customerRawData)) {
return new UnknownCustomer();
} else if (customerRawData instanceof Map) {
Map<String, Object> customerMap = (Map<String, Object>) customerRawData;
String name = (String) customerMap.get("name");
String billingPlan = (String) customerMap.get("billingPlan");
PaymentHistory paymentHistory = null;
Object paymentHistoryRaw = customerMap.get("paymentHistory");
if (paymentHistoryRaw instanceof Map) {
Map<String, Object> paymentHistoryMap = (Map<String, Object>) paymentHistoryRaw;
int weeksDelinquent = (int) paymentHistoryMap.getOrDefault("weeksDelinquentInLastYear", 0);
paymentHistory = new PaymentHistory(weeksDelinquent);
} else {
paymentHistory = new NullPaymentHistory(); // Default if history is missing/malformed
}
return new Customer(name, billingPlan, paymentHistory);
}
throw new IllegalArgumentException("Unknown customer data format: " + customerRawData.getClass().getName());
}
public static Site enrichSite(Map<String, Object> rawSiteMap) {
// Deep copy not strictly necessary for simple map processing if original map is not modified
// For more complex scenarios, consider using a library like Apache Commons BeanUtils.cloneBean() or custom deep copy.
String siteName = (String) rawSiteMap.get("name");
String siteLocation = (String) rawSiteMap.get("location");
ICustomer customer = createCustomerFromMap(rawSiteMap.get("customer"));
return new Site(customer); // Site constructor now takes ICustomer directly
}
}
// Client usage
public class ClientWithTransformation {
public static void main(String[] args) {
Map<String, Object> rawSiteMap = new HashMap<>();
rawSiteMap.put("name", "Acme Boston");
rawSiteMap.put("location", "Malden MA");
Map<String, Object> customerData = new HashMap<>();
customerData.put("name", "Acme Industries");
customerData.put("billingPlan", "plan-451");
Map<String, Object> paymentHistoryData = new HashMap<>();
paymentHistoryData.put("weeksDelinquentInLastYear", 7);
customerData.put("paymentHistory", paymentHistoryData);
rawSiteMap.put("customer", customerData);
Site enrichedSite = SiteEnricher.enrichSite(rawSiteMap);
ICustomer customer = enrichedSite.getCustomer();
System.out.println("Customer Name: " + customer.getName());
System.out.println("Billing Plan: " + customer.getBillingPlan());
System.out.println("Weeks Delinquent: " + customer.getPaymentHistory().getWeeksDelinquentInLastYear());
System.out.println("---");
Map<String, Object> unknownSiteData = new HashMap<>();
unknownSiteData.put("name", "Warehouse Unit 15");
unknownSiteData.put("location", "Malden MA");
unknownSiteData.put("customer", "unknown");
Site unknownEnrichedSite = SiteEnricher.enrichSite(unknownSiteData);
ICustomer unknownCustomer = unknownEnrichedSite.getCustomer();
System.out.println("Unknown Customer Name: " + unknownCustomer.getName());
System.out.println("Unknown Billing Plan: " + unknownCustomer.getBillingPlan());
System.out.println("Unknown Weeks Delinquent: " + unknownCustomer.getPaymentHistory().getWeeksDelinquentInLastYear());
}
}
通过引入 SiteEnricher
类,我们将原始的 Map
数据转换为强类型的对象图,并在转换过程中自动处理了"未知顾客"的特例,返回一个 UnknownCustomer
实例,从而简化了后续的客户端代码。
10.6 引入断言(Introduce Assertion)
断言是一种强大的工具,用于在程序中明确地表达和检查那些"应该始终为真"的条件。它们是防御性编程的一部分,旨在帮助开发人员在早期发现逻辑错误,而不是处理由不符合预期状态引起的运行时问题。
动机
在软件的生命周期中,程序员经常会对代码的状态或输入做出假设。例如,一个计算平方根的函数可能假设输入永远是非负数;一个处理订单的函数可能假设订单中的商品列表永远不为 null
且至少包含一项。这些假设构成了代码正常运行的契约。
然而,这些关键假设往往没有在代码中明确地记录下来,或者仅仅通过注释说明。当这些假设被违反时,程序可能会进入一个不确定的状态,导致难以追踪的错误。
引入断言的目的就是将这些隐式的假设转化为显式的检查。一个断言是一个布尔表达式,它表达了在程序执行到特定点时,某个条件必须为真。如果断言失败,则表明存在一个程序错误(bug),而不是一个需要正常处理的运行时异常。断言通常在开发和测试阶段启用,而在生产环境中可以禁用以避免性能开销。
断言的价值不仅仅在于发现错误。它们也是一种重要的交流形式。通过断言,你可以清晰地告诉阅读你代码的开发者:"嘿,在这个地方,我假设这个条件是真的。"这有助于其他开发者更好地理解代码的意图和预期行为,从而在修改代码时避免引入新的错误。
做法
- 识别核心假设: 仔细审查你的代码,寻找那些你认为在特定执行点"始终为真"的条件。这些条件往往是代码逻辑的基石,如果它们被违反,则说明程序存在根本性错误。
- 选择断言位置: 将断言放置在条件必须为真的逻辑点之前。
- 插入断言: 使用你所选编程语言提供的断言机制(例如Java的
assert
关键字)来表达这个条件。 - 确保行为不变: 断言不应该对程序的正常行为产生任何影响。如果断言失败,程序应该立即终止或抛出未捕获的错误,而不是尝试恢复或优雅降级。断言的成功执行也不应改变程序的任何可观测状态。
- 测试: 在引入断言后,执行测试以确保其不会错误地触发,并且在预期条件被违反时能够正确地捕获错误。
范例
考虑一个 Customer
类,其中包含一个 discountRate
(折扣率)属性。在 applyDiscount
方法中,我们可能假设折扣率永远是一个非负数。
原始Java代码示例
java
public class Customer {
private double discountRate; // Represents a discount rate, e.g., 0.1 for 10% discount
public Customer(double discountRate) {
this.discountRate = discountRate;
}
public double getDiscountRate() {
return discountRate;
}
public void setDiscountRate(double discountRate) {
this.discountRate = discountRate;
}
public double applyDiscount(double amount) {
if (this.discountRate > 0) { // Only apply if there's a positive discount rate
return amount - (this.discountRate * amount);
} else {
return amount;
}
}
}
在这个 applyDiscount
方法中,我们隐含地假设 discountRate
不会是一个负数。如果 discountRate
是负数,那么 amount - (this.discountRate * amount)
将会增加金额,这显然不是"折扣"的本意。
为了明确这个假设,我们可以引入断言。
1. 转换条件表达式 (如果需要)
在 applyDiscount
方法中,条件逻辑已经是一个 if-else
结构,所以可以直接插入断言。
2. 在 applyDiscount
方法中引入断言
我们可以在计算折扣之前断言 discountRate
必须是非负数。
java
public class Customer {
private double discountRate;
public Customer(double discountRate) {
this.discountRate = discountRate;
}
public double getDiscountRate() {
return discountRate;
}
public void setDiscountRate(double discountRate) {
this.discountRate = discountRate;
}
public double applyDiscount(double amount) {
// Assert that discountRate is non-negative before applying it
// Note: For 'assert' to work, Java must be run with -ea (enable assertions) flag.
assert this.discountRate >= 0 : "Discount rate cannot be negative: " + this.discountRate;
if (this.discountRate > 0) {
return amount - (this.discountRate * amount);
} else {
return amount;
}
}
}
现在,如果 discountRate
被设置为一个负值,并且在启用了断言的情况下运行程序,那么当 applyDiscount
方法被调用时,断言将会失败,并立即指示程序员存在一个不合法的状态。
3. 将断言移动到设值函数中(更佳实践)
虽然在 applyDiscount
方法中放置断言可以立即捕获错误,但更好的做法通常是在数据被设置时就验证其有效性,而不是等到使用时才发现。这样可以更快地定位错误的源头。
java
public class Customer {
private double discountRate;
public Customer(double discountRate) {
// Assert during construction as well
assert discountRate >= 0 : "Initial discount rate cannot be negative: " + discountRate;
this.discountRate = discountRate;
}
public double getDiscountRate() {
return discountRate;
}
public void setDiscountRate(double discountRate) {
// Assert when the discount rate is set
assert discountRate >= 0 : "Discount rate cannot be negative: " + discountRate;
this.discountRate = discountRate;
}
public double applyDiscount(double amount) {
// No need for a redundant assert here if it's already asserted in setter/constructor
if (this.discountRate > 0) {
return amount - (this.discountRate * amount);
} else {
return amount;
}
}
}
通过在 setDiscountRate
方法和构造函数中引入断言,我们确保了 discountRate
属性在任何时候都不会被赋予一个负值。如果发生这种情况,断言会立即失败,指出错误的根源。
重要提示:
- 断言不是输入验证: 断言用于检查程序员的错误或程序内部状态的不一致性,而不是验证来自外部(如用户输入、数据库查询)的数据。对于外部输入,应该使用正常的条件逻辑或异常处理。
- 性能考量: Java的
assert
关键字在默认情况下是禁用的。要在运行时启用它们,需要使用-ea
(enable assertions) JVM 选项。在生产环境中,通常会禁用断言以避免潜在的性能开销。 - 不要滥用: 只在那些"必须为真"的关键假设上使用断言。过度使用断言可能会导致代码冗余或分散注意力。
引入断言能显著提高代码的健壮性和可维护性,因为它使得隐藏的假设变得显式,并为调试提供了宝贵的信息。通过将断言视为一种交流工具,你可以帮助团队中的每个人更好地理解和维护代码。