简化条件逻辑(下):以多态取代条件表达式、引入特例、引入断言
好,我们接着聊条件逻辑的简化。上一讲我们聊了分解条件、合并条件这些基础手法。今天要讲的这三个手法——多态取代条件、引入特例、引入断言——说实话,是我在实际项目中用得最频繁的。尤其是多态,几乎每次重构大型业务系统都会用到。
以多态取代条件表达式
先问个问题:你见过那种switch-case写了几十行的代码吗?我见过。有一次接手一个电商项目,光是订单状态的处理就用了三个switch,分布在不同的类里。每次加一个新状态,得改三四个地方。这种代码,说白了就是欠重构。
多态取代条件表达式的核心思想很简单:把条件分支变成多态调用。每个分支对应一个子类,条件判断的逻辑被分散到各个子类的方法中。
来看个例子。假设我们有这样一个计算员工奖金的函数:
function calculateBonus(employee) {
if (employee.type === 'engineer') {
return employee.salary * 0.1;
} else if (employee.type === 'manager') {
return employee.salary * 0.2 + 1000;
} else if (employee.type === 'salesman') {
return employee.salary * 0.15 + 500;
} else {
throw new Error('未知员工类型');
}
}
这段代码的问题在哪?每次加新类型,都得改这个函数。而且如果其他地方也有类似的判断,你很可能忘了同步修改。我在项目中遇到过这种情况——加了一个新角色,结果奖金计算对了,但休假天数计算还是旧的,线上出了bug。
用多态重构后:
class Employee {
constructor(salary) {
this.salary = salary;
}
calculateBonus() {
throw new Error('子类必须实现');
}
}
class Engineer extends Employee {
calculateBonus() {
return this.salary * 0.1;
}
}
class Manager extends Employee {
calculateBonus() {
return this.salary * 0.2 + 1000;
}
}
class Salesman extends Employee {
calculateBonus() {
return this.salary * 0.15 + 500;
}
}
调用方就变成了:
function calculateBonus(employee) {
return employee.calculateBonus();
}
你看,原来的条件判断消失了。每个类型自己负责自己的计算逻辑。加新类型?直接加个子类就行,不用改任何现有代码。这就是开闭原则的体现。
什么时候该用多态取代条件?
- 条件表达式根据类型做不同处理,且类型经常增加
- 多个地方有相同的类型判断逻辑
- 每个分支的逻辑比较复杂,不适合放在一个函数里
我的习惯:如果switch-case超过3个分支,我就会考虑用多态。但也不是绝对的——如果分支逻辑很简单,比如就一行赋值,那用多态反而过度设计了。
引入特例
接下来聊一个很实用但容易被忽视的手法——引入特例。说白了,就是处理那些「边界情况」。
你想想看,代码里最常见的重复是什么?我猜是判空。每次调用一个可能为null的对象,都得先检查一下。代码里到处都是if (xxx === null) return defaultXXX; 这种模式。
举个例子:
function getCustomerName(customer) {
if (customer === null) {
return '未知客户';
}
return customer.name;
}
function getCustomerAddress(customer) {
if (customer === null) {
return '地址不详';
}
return customer.address;
}
function getCustomerPhone(customer) {
if (customer === null) {
return '无联系方式';
}
return customer.phone;
}
每个函数都要判空,重复了三遍。而且如果以后要加一个新字段,很容易忘记判空。
引入特例的做法是:创建一个「特例对象」,它和正常对象有相同的接口,但返回默认值。
class Customer {
constructor(name, address, phone) {
this.name = name;
this.address = address;
this.phone = phone;
}
}
class NullCustomer {
get name() { return '未知客户'; }
get address() { return '地址不详'; }
get phone() { return '无联系方式'; }
}
// 使用
function getCustomerInfo(customer) {
const safeCustomer = customer ?? new NullCustomer();
console.log(safeCustomer.name);
console.log(safeCustomer.address);
console.log(safeCustomer.phone);
}
这样一来,调用方就不需要判空了。特例对象自己知道该返回什么。
我曾经踩过的坑:特例对象一定要实现完整的接口。有一次我只实现了几个常用方法,结果某个新来的同事调了一个没实现的方法,直接抛异常了。所以建议给特例对象加个类型标记,或者在基类里提供默认实现。
引入特例不仅适用于null,也适用于其他边界情况。比如:
- 空集合(空列表、空Map)
- 默认配置
- 未登录用户
- 测试环境下的模拟数据
引入断言
最后一个手法,引入断言。这个手法其实不是「简化」条件逻辑,而是「明确化」条件逻辑。
什么是断言?就是明确告诉读者:这里我假设某个条件一定成立。如果条件不成立,那就是程序出错了,应该立刻报错。
来看个例子:
function applyDiscount(price, discountRate) {
// 折扣率应该在0到1之间
if (discountRate < 0 || discountRate > 1) {
throw new Error('折扣率不合法');
}
return price * (1 - discountRate);
}
这段代码用if判断来检查参数合法性。但问题是,这个检查到底是「业务逻辑」还是「防御性检查」?从代码上看,它和业务逻辑混在一起,读者分不清。
用断言改写:
function applyDiscount(price, discountRate) {
assert(discountRate >= 0 && discountRate <= 1, '折扣率必须在0到1之间');
return price * (1 - discountRate);
}
assert函数在开发环境会抛出异常,在生产环境可以关闭。这样就把「假设条件」和「业务逻辑」分开了。
断言的使用场景:
- 方法入口的参数合法性检查(但不是用户输入校验)
- 某个分支执行前的前置条件
- 某个方法执行后的后置条件
- 循环不变量的检查
我个人习惯在以下情况使用断言:
- 某个值理论上不可能为null,但为了保险加个检查
- 某个枚举值只有三种可能,但switch里我写了default分支
- 某个计算结果应该在某个范围内
注意:断言不是用来替代用户输入校验的。用户输入校验应该用if+错误提示,因为用户输入错误是「预期内」的情况。断言是用来捕获「不应该发生」的程序错误。
三种手法的对比
| 手法 | 解决的问题 | 适用场景 | 风险 |
|---|---|---|---|
| 多态取代条件 | 类型判断导致的重复条件 | 类型经常增加、多处判断 | 过度设计,简单场景反而复杂 |
| 引入特例 | 边界值导致的重复判空 | null、空集合、默认值 | 特例对象接口不完整 |
| 引入断言 | 隐含假设不明确 | 前置条件、后置条件、不变量 | 误用为输入校验 |
知识体系图
下面这张图展示了本章三个手法在条件逻辑简化中的位置和关系:
总结
这三个手法,本质上都是在做同一件事:把条件逻辑从「判断」变成「结构」。多态用类结构取代类型判断,特例用对象结构取代边界判断,断言用语言机制取代隐含假设。
你想想看,好的代码是什么样的?好的代码不是没有条件判断,而是条件判断被合理地组织起来,每个判断都有明确的目的,不会重复,不会散落得到处都是。
嗯,今天就到这里。这三个手法你可以在下次重构时试试看,尤其是多态和特例,效果立竿见影。
公众号:蓝海资料掘金营,微信deep3321