1 分解条件表达式(Decompose Conditional)
程序之中,复杂的条件逻辑是最常导致复杂度上升的地点之一。我必须编写代码来检查不同的条件分支,根据不同的条件做不同的事,然后,我很快就会得到一个相当长的函数。
大型函数本身就会使代码的可读性下降,而条件逻辑则会使代码更难阅读。在带有复杂条件逻辑的函数中,代码(包括检查条件分支的代码和真正实现功能的代码)会告诉我发生的事,但常常让我弄不清楚为什么会发生这样的事,这就说明代码的可读性的确大大降低了。
和任何大块头代码一样,我可以将它分解为多个独立的函数,根据每个小块代码的用途,为分解而得的新函数命名,并将原函数中对应的代码改为调用新函数,从而更清楚地表达自己的意图。
对于条件逻辑,将每个分支条件分解成新函数还可以带来参更多好处:可以突出条件逻辑,更清楚地表明每个分支的作用,并且突出每个分支的原因。
2 合并条件表达式(Consolidate Conditional Expression)
有时我会发现这样一串条件检查:检查条件各不相同,最终行为却一致。如果发现这种情况,就应该使用“逻辑或”和“逻辑与”将它们合并为一个条件表达式。
所以要合并条件代码,有两个重要原因。
首先,合并后的条件代码会表述“实际上只有一次条件检查,只不过有多个并列条件需要检查而已”,从而使这一次检查的用意更清晰。当然,合并前和合并后的代码有着相同的效果,但原先代码传达出的信息却是“这里有一些各自独立的条件测试,它们只是恰好同时发生”。
其次,这项重构往往可以为使用提炼函数做好准备。将检查条件提炼成一个独立的函数对于厘清代码意义非常有用,因为它把描述“做什么”的语句换成了“为什么这样做”。
条件语句的合并理由也同时指出了不要合并的理由:如果我认为这些检查的确彼此独立,的确不应该被视为同一次检查,我就不会使用本项重构。
3 以卫语句取代嵌套条件表达式(Replace Nested Conditional with Guard Clauses)
根据我的经验,条件表达式通常有两种风格。第一种风格是:两个条件分支都属于正常行为。第二种风格则是:只有一个条件分支是正常行为,另一个分支则是异常的情况。
这两类条件表达式有不同的用途,这一点应该通过代码表现出来。
如果两条分支都是正常行为,就应该使用形如if...else...的条件表达式;
如果某个条件极其罕见,就应该单独检查该条件,并在该条件为真时立刻从函数中返回。这样的单独检查常常被称为“卫语句” (guard clauses)。
以卫语句取代嵌套条件表达式的精髓就是:给某一条分支以特别的重视。如果使用if-then-else 结构,你对if分支和else 分支的重视是同等的。这样的代码结构传递给阅读者的消息就是:各个分支有同样的重要性。卫语句就不同了,它告诉阅读者:“这种情况不是本函数的核心逻辑所关心的,如果它真发生了,请做一些必要的整理工作,然后退出。”
4 以多态取代条件表达式(Replace Conditional with Polymorphism)
复杂的条件逻辑是编程中最难理解的东西之一,因此我一直在寻求给条件逻辑添加结构。很多时候,我发现可以将条件逻辑拆分到不同的场景(或者叫高阶用例),从而拆解复杂的条件逻辑。这种拆分有时用条件逻辑本身的结构就足以表达,但使用类和多态能把逻辑的拆分表述得更清晰。
一个常见的场景是:我可以构造一组类型,每个类型处理各自的一种条件逻辑。例如,我会注意到,图书、音乐、食品的处理方式不同,这是因为它们分属不同类型的商品。最明显的征兆就是有好几个函数都有基于类型代码的 switch 语句。若果真如此,我就可以针对 switch 语句中的每种分支逻辑创建一个类,用多态来承载各个类型特有的行为,从而去除重复的分支逻辑。
另一种情况是:有一个基础逻辑,在其上又有一些变体。基础逻辑可能是最常用的,也可能是最简单的。我可以把基础逻辑放进超类,这样我可以首先理解这部分逻辑,暂时不管各种变体,然后我可以把每种变体逻辑单独放进一个子类,其中的代码着重强调与基础逻辑的差异。
多态是面向对象编程的关键特性之一。跟其他一切有用的特性一样,它也很容易被滥用。我曾经遇到有人争论说所有条件逻辑都应该用多态取代。我不赞同这种观点。我的大部分条件逻辑只用到了基本的条件语句-if/else 和 switch/case,并不需要劳师动众地引入多态。但如果发现如前所述的复杂条件逻辑,多态是改善这种情况的有力工具。
5 引入特例(Introduce Special Case)
一种常见的重复代码是这种情况:一个数据结构的使用者都在检查某个特殊的值,并且当这个特殊值出现时所做的处理也都相同。如果我发现代码库中有多处以同样方式应对同一个特殊值,我就会想要把这个处理逻辑收拢到一处。处理这种情况的一个好办法是使用“特例” (Special Case)模式:创建一个特例元素,用以表达对这种特例的共用行为的处理。这样我就可以用一个函数调用取代大部分特例检查逻辑。特例有几种表现形式。如果我只需要从这个对象读取数据,可以提供一个字面量对象(literal object),其中所有的值都是预先填充好的。如果除简单的数值之外还需要更多的行为,就需要创建一个特殊对象,其中包含所有共用行为所对应的函数。特例对象可以由一个封装类来返回,也可以通过变换插入一个数据结构。一个通常需要特例处理的值就是 null,这也是这个模式常被叫作""Null 对象”(Null Object)模式的原因一一我喜欢说:Null 对象是特例的一种特例。
6引入断言(Introduce Assertion)
常常会有这样一段代码:只有当某个条件为真时,该段代码才能正常运行。例如,平方根计算只对正值才能进行,又例如,某个对象可能假设一组字段中至少有个不等于 null。这样的假设通常并没有在代码中明确表现出来,你必须阅读整个算法才能看出。有时程序员会以注释写出这样的假设,而我要介绍的是一种更好的技术——使用断言明确标明这些假设。
断言是一个条件表达式,应该总是为真。如果它失败,表示程序员犯了错误。断言的失败不应该被系统任何地方捕捉。整个程序的行为在有没有断言出现的时候都应该完全一样。实际上,有些编程语言中的断言可以在编译期用一个开关完全禁用掉。
我常看见有人鼓励用断言来发现程序中的错误。这固然是一件好事,但却不是使用断言的唯一理由。断言是一种很有价值的交流形式——它们告诉阅读者,程序在执行到这一点时,对当前状态做了何种假设。另外断言对调试也很有帮助。而且,因为它们在交流上很有价值,即使解决了当下正在追踪的错误,我还是倾向于把断言留着。自测试的代码降低了断言在调试方面的价值,因为逐步逼近的单元测试通常能更好地帮助调试,但我仍然看重断言在交流方面的价值。
真正引起错误的源头有可能很难发现——也许是输入数据中误写了一个减号,也许是某处代码做数据转换时犯了错误。像这样的断言对于发现错误源头特别有帮助。注意,不要滥用断言。我不会使用断言来检查所有“我认为应该为真”的条件,只用来检查“必须为真”的条件。滥用断言可能会造成代码重复,尤其是在处理上面这样的条件逻辑时。
我只用断言预防程序员的错误。如果要从某个外部数据源读取数据,那么所有对输入值的检查都应该是程序的一等公民,而不能用断言实现——除非我对这个外部数据源有绝对的信心。断言是帮助我们跟踪bug的最后一招,所以,或许听来讽刺,只有当我认为断言绝对不会失败的时候,我才会使用断言。