概述
我们有时候会把“短的代码”和“易于理解的代码”等价起来,但实际上它俩是有区别的。比如下面的 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 放在一起考虑要轻松很多。