(六)重构的艺术:简化复杂条件逻辑的秘诀

代码重构专题文章

代码重构精要:提升代码品质的艺术(汇总篇)

(一)代码匠心:重构之道,化腐朽为神奇

(二)重构的艺术:精进代码的第一组基本功

(三)封装与结构优化:让代码更优雅

(四)优雅重构:洞悉"搬移特性"的艺术与实践

(五)数据重构的艺术:优化你的代码结构与可读性

(六)重构的艺术:简化复杂条件逻辑的秘诀

(七)API 重构的艺术:打造优雅、可维护的 API

(八)掌握继承的艺术:重构之路,化繁为简

第 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,并不需要劳师动众地引入多态。但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。

做法

  1. 如果现有的类尚不具备多态行为,就用工厂函数创建之,令工厂函数返回恰当的对象实例。
  2. 在调用方代码中使用工厂函数获得对象实例。
  3. 将带有条件逻辑的函数移到超类中。
  4. 如果条件逻辑还未提炼至独立的函数,首先对其使用提炼函数(Extract Method)。
  5. 任选一个子类,在其中建立一个函数,使之覆写超类中容纳条件表达式的那个函数。将与该子类相关的条件表达式分支复制到新函数中,并对它进行适当调整。
  6. 重复上述过程,处理其他条件分支。
  7. 在超类函数中保留默认情况的逻辑。或者,如果超类应该是抽象的,就把该函数声明为 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;
    }
}

有两个不同的操作,其行为都随着"鸟的类型"发生变化,因此可以创建出对应的类,用多态来处理各类型特有的行为。

我先对 airSpeedVelocityplumage 两个函数使用函数组合成类(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;
}

voyageRiskcaptainHistoryRisk 两个函数负责打出风险分数,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();
    }
}

说明:

  1. 数据封装VoyageHistoryEntry 类用来封装航行和历史记录的数据。
  2. Rating 超类 :包含了所有航行评级的通用逻辑。所有计算风险和利润因子的方法都定义在这里,并且被声明为 protected 以便子类访问和重写。
  3. ExperiencedChinaRating 子类 :继承自 Rating,并重写了 getCaptainHistoryRisk()getVoyageProfitFactor() 方法,以实现针对"中国经验"的特殊逻辑。
    • getCaptainHistoryRisk() 中,我们调用 super.getCaptainHistoryRisk() 获取基础风险,然后在此基础上进行调整。
    • getVoyageProfitFactor() 中,由于"中国因素"的影响较大,我们选择完全重写这个方法,而不是在父方法的基础上修修补补。
  4. RatingFactory 工厂类 :负责根据 VoyageHistory 的特定条件(是否涉及中国航线且有中国经验)返回 RatingExperiencedChinaRating 的实例。这消除了客户端代码中的条件判断。
  5. 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 类中提供更通用的 getHistoryLengthFactorgetVoyageLengthFactor 方法,并在 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 对象模式",它是特例模式的一个具体应用。

做法

  1. 识别目标: 找到一个包含属性的数据结构或类,其属性值经常被客户端代码与某个"特例值"(如 null 或魔术字符串)进行比较。
  2. 添加特例检查属性: 在原始类(非特例情况)中添加一个名为 isUnknown()isSpecialCase() 的布尔属性或方法,使其返回 false
  3. 创建特例类/对象: 创建一个新的类或对象,用于表示这种特殊情况。在这个特例类中,实现相同的特例检查方法,使其返回 true。这个特例类可以实现与原始类相同的接口或继承自原始类(在Java中推荐实现接口或继承)。
  4. 封装特例值比较: 对所有客户端代码中直接与特例值进行比较的逻辑,使用提炼函数 (Extract Method) 将其封装成一个独立的方法,确保所有客户端都调用这个新方法,而不是直接进行比较。
  5. 引入特例对象: 修改原始数据结构或类的创建/获取逻辑,使其在遇到特例值时,不再返回特例值本身,而是返回新创建的特例对象。
  6. 更新特例比较函数: 修改第4步创建的特例比较方法,使其现在调用特例对象的 isUnknown()isSpecialCase() 方法。
  7. 逐步迁移行为: 遍历所有处理特例情况的客户端代码。将那些通用且重复的特例处理逻辑(例如返回默认值、执行空操作等)逐步从客户端代码中移除,并将其搬移到特例对象中。特例对象会覆写或实现相应的方法,提供特殊情况下的行为。
  8. (可选)字面量记录: 如果特例对象只包含固定的值且是只读的,可以将其实现为不可变的字面量记录(在Java中,可以使用 record 类型或包含 final 字段的简单类)。
  9. 内联特例比较函数: 当所有客户端都已改为直接调用特例对象的方法后,如果之前封装的特例比较方法变得多余,可以使用内联函数 (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),而不是一个需要正常处理的运行时异常。断言通常在开发和测试阶段启用,而在生产环境中可以禁用以避免性能开销。

断言的价值不仅仅在于发现错误。它们也是一种重要的交流形式。通过断言,你可以清晰地告诉阅读你代码的开发者:"嘿,在这个地方,我假设这个条件是真的。"这有助于其他开发者更好地理解代码的意图和预期行为,从而在修改代码时避免引入新的错误。

做法

  1. 识别核心假设: 仔细审查你的代码,寻找那些你认为在特定执行点"始终为真"的条件。这些条件往往是代码逻辑的基石,如果它们被违反,则说明程序存在根本性错误。
  2. 选择断言位置: 将断言放置在条件必须为真的逻辑点之前。
  3. 插入断言: 使用你所选编程语言提供的断言机制(例如Java的 assert 关键字)来表达这个条件。
  4. 确保行为不变: 断言不应该对程序的正常行为产生任何影响。如果断言失败,程序应该立即终止或抛出未捕获的错误,而不是尝试恢复或优雅降级。断言的成功执行也不应改变程序的任何可观测状态。
  5. 测试: 在引入断言后,执行测试以确保其不会错误地触发,并且在预期条件被违反时能够正确地捕获错误。

范例

考虑一个 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 选项。在生产环境中,通常会禁用断言以避免潜在的性能开销。
  • 不要滥用: 只在那些"必须为真"的关键假设上使用断言。过度使用断言可能会导致代码冗余或分散注意力。

引入断言能显著提高代码的健壮性和可维护性,因为它使得隐藏的假设变得显式,并为调试提供了宝贵的信息。通过将断言视为一种交流工具,你可以帮助团队中的每个人更好地理解和维护代码。

参考

《重构:改善既有代码的设计(第二版)》

相关推荐
帅次1 天前
系统分析师-软件工程-信息系统开发方法&面向对象&原型化方法&面向服务&快速应用开发
软件工程·团队开发·软件构建·需求分析·代码规范·敏捷流程·结对编程
San302 天前
JavaScript 流程控制与数组操作全解析:从条件判断到数据高效处理
javascript·面试·代码规范
文心快码BaiduComate3 天前
再获殊荣!文心快码荣膺2025年度优秀软件产品!
前端·后端·代码规范
许雪里9 天前
XXL-TOOL v2.1.0 发布 | Java工具类库
后端·github·代码规范
召摇10 天前
如何避免写垃圾代码:Java篇
java·后端·代码规范
那个下雨天14 天前
护城河式编程模式:黑色幽默中的工程生存学
职场发展·代码规范·护城河式编程·职场心得
想用offer打牌15 天前
线程池踩坑之一:将其放在类的成员变量
后端·面试·代码规范
幻灵尔依19 天前
前端编码统一规范
javascript·vue.js·代码规范
薛定谔的猫219 天前
前端工程化系列(一):编码规范相关
前端·代码规范·前端工程化