概述

我们有时候会把“短的代码”和“易于理解的代码”等价起来,但实际上它俩是有区别的。比如下面的 foo 方法:

void foo(...) 
{
    if (A && B) 
    {
        if (C) 
        {
            do_1();
        }
        
        if (D) 
        {
            do_2();
        }
        
        do_3();
    }
    
    do_4();
}

区别

如果我们的目的是理解 foo 的主要职责,比如我们正在熟悉一个服务的逻辑脉络、foo 是其中一个方法,那么并不困难。

但如果我们现在是在分析一个棘手的问题,需要全面地掌握其实现逻辑,那可就不简单了。因为我们需要做大量的脑补工作。

如下,深灰色背景的代码都是我们脑补出来。为了将它们一一脑补出来,并分析其空实现是否符合设计预期,我们必然会消耗不少脑力。

 1void foo(...) 
 2{
 3    if (A && B) 
 4    {
 5        if (C) 
 6        {
 7            do_1();
 8        }
 9        else // 隐藏分支之一
10        {
11            // 脑中的声音:如果 A+B 满足、但 C 不满足就会走到这里,也就是跳过了对 do_1 的调用
12            // 脑中的疑惑:
13            //     1. 满足 A+B、但不满足 C 的场景是什么?
14            //     2. 在满足 A+B、但不满足 C 的时候就什么都不做吗?这符合设计预期吗?
15        }
16        
17        if (D) 
18        {
19            do_2();
20        }
21        else // 隐藏分支之二
22        {
23            // 脑中的声音:如果 A+B 满足、但 D 不满足就会走到这里,也就是跳过了对 do_2 的调用
24            // 脑中的疑惑:
25            //     1. 满足 A+B、但不满足 D 的场景是什么?
26            //     2. 在满足 A+B、但不满足 D 的时候就什么都不做吗?也就是不调用 do_2、但紧接着就要调用 do_3 符合设计预期吗?
27        }
28        
29        do_3();
30    }
31    else // 隐藏分支之三
32    {
33        // 脑中的声音:如果 A 不满足,或者 B 不满足,就会走到这里,也就是不会调用 do_1 或 do_2 或 do_3
34        // 脑中的疑惑:
35        //    1. A 不满足的场景是什么?
36        //       1.1. 在不满足 A 的时候就不用考虑 C 或 D 了吗?为什么?
37        //       1.2. 在不满足 A 的时候,这里什么都不做、但紧接着就要调用 do_4 了,这符合设计预期吗?
38        //    2. B 不满足的场景是什么?
39        //       2.1. 在不满足 B 的时候,这里就不用考虑 C 或 D 了吗?为什么?
40        //       2.2. 在不满足 B 的时候,这里什么都不做、但紧接着就要调用 do_4 符合设计预期吗?
41    }
42    
43    do_4();
44}

原因

我们来梳理一下为什么。

我们可以看到 foo 方法里一共有 4 个条件变量,A 到 D,组合起来是 2^4 即 16 种不同的情况。

而我们现在正在分析一个棘手的问题,必须仔细地排查每个分支。即便我们可以根据业务常识快速地过滤掉其中一半无意义的情况,剩下的一半仍有不小的复杂度。

把上面的代码转化成流程图能帮助我们更容易地发现它们,如下图。其中 4 条红线分支就是我们需要脑补并排查的隐藏分支。

对策

如果可能,减少单个方法中的条件变量总数。

比如将上面第 5 到第 27 行代码抽取成一个独立的、有单一职责的方法,那么 foo 方法内部就只需要考虑条件 A 和 B,也就是 4 种分支情况。这比把 A 到 D 放在一起考虑要轻松很多。