条件逻辑增加了程序的完整性,但同样也增加了程序的复杂度。本章会通过分解条件表达式、合并条件表达式以及用卫语句取代嵌套条件表达式等方法来简化复杂的表达式,以表达更清晰的用意。
分解条件表达式(Decompose Conditional)
- 动机
代码中,条件逻辑是最常导致复杂度上升的方式之一。编写代码来检查不同的条件分支,根据不同的条件做不同的需求,这样久而久之,很快会获得一个相当长的函数。大型函数本身就会让代码的可读性下降,而条件逻辑则会让代码更难理解。
和任何大型函数一样,将各个条件中的行为分解成多个独立的函数,从而更清楚的表达不同条件需要的行为需求。
- 范例
当在计算购买某样商品的总价(总价 = 数量 * 单价),而这个商品在冬季和夏季的单价是不同的:
if (![aDate isBefore:plan.summerStart]
&&![aDate isAfter:plan.summerEnd]) {
charge = quantity * plan.summerRate;
} else {
charge = quantity * plan.regularRate + plan.regularServiceCharge;
}
首先将判断条件提炼到一个独立的函数中:
- (BOOL)inSummer {
return ![aDate isBefore:plan.summerStart]
&&![aDate isAfter:plan.summerEnd];
}
再将各个条件分支内的行为分别进行提炼:
- (CGFloat)summerCharge {
return charge = quantity * plan.summerRate;
}
- (CGFloat)regularCharge {
return quantity * plan.regularRate + plan.regularServiceCharge;
}
....
if ([self isSummer]) {
charge = [self summerCharge];
} else {
charge = [self regularCharge];
}
以上的代码将不同的行为放置在对应的函数中,也便于后续的扩展。当然到这一步很多开发者喜欢使用三元运算符以到达一行代码模式:
charge = [self isSummer] ? [self summerCharge] : [self regularCharge];
合并条件表达式(Consolidate Conditional Expression)
- 动机
有时在代码中会发现一串条件检查逻辑:检查条件各不相同,但最终的行为却一致。如果发现这种情况,就应该使用"逻辑与"和"逻辑或"将它们合并为一个条件表达式。
因为这样不仅让检查的用意更清晰,合并后的条件代码会表达出"实际只有一次条件检查,只不过有多个并列条件需检查";还对之后提炼函数做好了准备。
当然如果这些检查确实彼此独立,那么不应该被视为同一次检查,不要使用本项重构手段。
- 范例
在蔬菜入库时,计算需要购买的数量:
- (NSInteger)vegetableWarehousing:(Vegetable *)vegetable {
if (vegetable.storageCapacity <= vegetable.hasCount) return 0;
if (vegetable.buyingPrice >= vegetable.sellingPrice) return 0;
if (vegetable.BlacklistedVendors) return 0;
// 具体需购买数量计算
...
}
以上函数中有一连串的条件检查,都指向了相同的结果。将检查全都合并成一个条件并且提炼函数:
- (BOOL)isNotNeedToBuy:(Vegetable *)vegetable {
return vegetable.storageCapacity <= vegetable.hasCount
|| vegetable.buyingPrice >= vegetable.sellingPrice
|| vegetable.BlacklistedVendors;
}
- (NSInteger)vegetableWarehousing:(Vegetable *)vegetable {
if ([self isNotNeedToBuy:vegetable]) {
return 0;
}
// 具体需购买数量计算
}
从 vegetableWarehousing: 开发的角度来看,后续需求变动只需要明确是"需要更新不能购买条件" 还是"更新具体购买数量",代码阅读量降低,提高了开发效率。
以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
- 动机
条件表达式通常有两种风格:
① 两个条件分支都属于正常开发行为
② 只有一个条件分支是正常开发行为,另一个分支则是异常情况。
如果两条分支都是正常行为,就应该使用形如 if... else... 或 switch... case...(多条件)的条件表达式;但是当其中一个条件分支是处理异常情况时,就应该单独检查该条件,并在该条件为真时立刻从函数返回。这样单独检查常常被称为"卫语句"(Guard clauses)。
理解"卫语句"所表达的含义:
"这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。"
- 范例
计算需支付给员工Employee的工资,只有还在公司上班的员工才需要支付工资,所以这个函数需检查"员工是否在公司上班中"的情况:
- (NSDictionary *)payAmount(Employee *)employee {
NSDictionary *result;
if (employee.isSeparated) {
result = @{@"amount": @(0), @"reasonCode": @"SEP"};
} else if (employee.hasInduction) {
result = @{@"amount": @(0), @"reasonCode": @"UNE"};
} else {
if (employee.isRetired) {
result = @{@"amount": @(0), @"reasonCode": @"RET"};
} else {
// 计算员工工资
result = [self someFinalComputation];
}
}
return result;
}
嵌套的条件逻辑复杂,无法快速了解代码真实的含义。只有当前三个条件表达式均不为真时,函数中才真正的开始它主要的工作。所以,引入卫语句来取代嵌套条件:
- (NSDictionary *)payAmount(Employee *)employee {
if (employee.isSeparated) {
return @{@"amount": @(0), @"reasonCode": @"SEP"};
}
if (employee.hasInduction) {
return @{@"amount": @(0), @"reasonCode": @"UNE"};
}
if (employee.isRetired) {
return @{@"amount": @(0), @"reasonCode": @"RET"};
}
// 计算员工工资
return [self someFinalComputation];
}
以上改动后便可对核心逻辑一目了然了。
作者还提供了一个思路:
通过将条件表达式反转,以实现用卫语句取代嵌套条件表达式:
- (NSInteger)adjustedCapital:(Instrument *)anInstrument {
NSInteger result = 0;
if (anInstrument.capital > 0) {
if (anInstrument.interestRate > 0 && anInstrument.duration > 0) {
result = [self someComputation];
}
}
return result;
}
采用 卫语句取代嵌套条件表达式的手段,通过条件反转、逻辑或将条件合并,明确区分两段代码的作用:
- (NSInteger)adjustedCapital:(Instrument *)anInstrument {
if (anInstrument.capital <= 0
|| anInstrument.interestRate <= 0
|| anInstrument.duration <= 0) return 0;
return [self someComputation];
}
引入特例(Introduce Special Case)
- 动机
当一个数据结构的使用方都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同,这时候就能通过创建一个特例元素,用以表达对这种特例的共同行为的处理。
特例有几种表现形式:如果只需从这个对象读取数据,可以提供一个字面量(literal object);当除了简单的数值之外还需更多的行为,可以通过封装一个特殊的类结构、或定义函数等方式来实现。
- 范例
在不同的调用位置,通过下发的数据type字段处理:
调用位置1
NSString *objType;
if (!data.type
|| [data.type isEqualToString: @""]
|| [data.type isEqualToString: @"Unknown"]) {
objType = @"unknown";
}
调用位置 2:
if (!data.type || [data.type isEqualToString: @""]) {
return [PlaceholderCell class];
}
// 更加type定制不同的 cell
.....
调用位置3:
NSString *objType;
if (!data.type
|| [data.type isEqualToString: @""]
|| [data.type isEqualToString: @"Unknown"]) {
objType = @"Unknown";
}
调用的位置都针对"不支持的数据类型"的情况做了处理,并且在观察时可知对于"不支持"的认定均相同,所以这种情况下,开发时可直接在 数据源DataModel中提供一个特例值:
DataModel.h
@property (nonatomic, assign) BOOL isSupportedType;
DataModel.m
- (BOOL)isSupportedType {
return !data.type
|| [data.type isEqualToString: @""]
|| [data.type isEqualToString: @"Unknown"]
}
当然如调用的位置在"不支持的数据类型" 和 "判断后的处理行为"均一致时,如各个调用点只做了 objType 的设置,那么可以将判断和行为都提炼到一个独立的数据结构中:
xxxLog.h
// 入参
@property (nonatomic, strong) NSString *dataType;
....
// 根据入参计算结果
@property (nonatomic, strong, readonly) NSString *objType;
...
引入断言
- 动机
常常会有这样的一段代码逻辑:只有当某个条件为真时,该段代码才能正常运行。如:除法中的除数不能为0,某个对象中存储的数据必须都大于200。
以上这些情况有时候并没有明确的表现出来,必须阅读完整个算法才能看出。有时开发者会通过注释来标注,但注释本身只是简单标识并不能强制认知,所以引入本节的手段 ---- 断言。
断言是一个条件表达式,应该总是为真。如果它失败,表示开发者犯了错误。整个程序的行为在没有断言出现时都应该完全一样。
- 范例
计算顾客,在获得折扣率(discount rate)后得到的购买价格:
- (CGFloat)applyDiscount:(CGFloat)price {
return (self.discountRate) ? ((1 - self.discountRate ) * price): price;
}
以上代码表达出:折扣率 discount rate 必须是正数。这种情况可以使用断言明确的标识:
- (CGFloat)applyDiscount:(CGFloat)price {
if (!self.discountRate) return price;
NSAssert(self.discountRate >= 0, @"折扣率为负数");
return (1 - self.discountRate ) * price;
}
以上代码中使用断言,是因为符合检查"必须为真"的条件,而不只是"我认为应该是真"的条件。
断言是一个双刃剑?
在团队开发工作中,大家负责的模块不同,通过断言可以更快的为模块调用方提供一些字段认知(比如 A字段必须 > 200)。但是如上所言,并不是所有场合都适合加入断言。
对于一些数据源,如通过数据下发的type选择显示不同的cell类:
switch(data.type) {
case 1: {
return [LZCell1 class];
}
break;
case 2: {
return [LZCell2 class];
}
break;
default: {
NSAssert(NO, @"不支持的数据类型");
}
break;
}
以上的NSAssert依赖于数据源,而数据源本身就无法保证绝对不会出错,所以如果在这种情况下添加,会导致开发其他模块的同学无意间触发时,还需要耗费时间了解断点的位置、原因和解决方案,大大印象自己的开发时间。所以断言还是需要谨慎使用。