《重构》第十章 - 简化条件逻辑

条件逻辑增加了程序的完整性,但同样也增加了程序的复杂度。本章会通过分解条件表达式、合并条件表达式以及用卫语句取代嵌套条件表达式等方法来简化复杂的表达式,以表达更清晰的用意。

分解条件表达式(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依赖于数据源,而数据源本身就无法保证绝对不会出错,所以如果在这种情况下添加,会导致开发其他模块的同学无意间触发时,还需要耗费时间了解断点的位置、原因和解决方案,大大印象自己的开发时间。所以断言还是需要谨慎使用。

断点是帮助我们跟踪bug的最后一招,只有当认为断言绝对不会失败的时候才使用断言。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,126评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,254评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,445评论 0 341
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,185评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,178评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,970评论 1 284
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,276评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,927评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,400评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,883评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,997评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,646评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,213评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,204评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,423评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,423评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,722评论 2 345

推荐阅读更多精彩内容