软件设计:从专业到卓越
上QQ阅读APP看书,第一时间看更新

无论是易于理解,还是易于演进,都意味着要编写更为简洁的代码。简洁,就是“少”“清晰”“简单”。简洁的反面是繁复,意味着“多”“烦琐”“复杂”。本节将从代码特征的角度,介绍简洁的行为实现的三个重要方面。

(1) 代码元素(方法、类等)要尽量简短。

(2) 代码的表达要清晰,抽象层次要一致。

(3) 方法的实现复杂度要尽量低。

这三个方面经常是彼此促进的,做好其中一个方面也会为另外两个方面带来提升。

没有人喜欢看长长的代码。在工作环境中,我经常看到程序员把一个横着的显示器竖起来放,这往往是一个不太好的信号:代码太长了。

不过,“代码元素(方法、类等)要尽量简短”这句话存在一定的歧义,需要加以解释。在2.2节的例子中,代码清单2.1中定义的Yhsj类的长度是16行,却只包含1个方法,这个方法占14行。在代码清单2.2中,PascalTriangle类的长度达到了29行,包含5个方法,最长的方法占7行。可这两段代码实现的功能是完全相同的,那么在这种情况下,哪段代码算是更简短的呢?

简短是指“认知”层面的简短

要正确回答上面的问题,需要回到“认知”这个理解代码的核心维度上来,并不是哪段代码的总长度更短,哪段就更简洁。从认知层面讲,类、方法各是一个抽象层级。当代码阅读者理解一个类的时候,更关心方法这个层级,对于方法是怎么实现的则并不关心。更进一步,如果类的方法声明中区分了public和private,那么代码阅读者首先会关心public方法。只有当理解了一个方法的时候,查看的才是实现方法的代码行这个层级。

按照这种逻辑,当理解Yhsj类时面对的是1个方法;当理解PascalTriangle类时面对的也是1个方法,所以从类层级看二者没有本质区别。至于PascalTriangle类的另外4个private方法,代码阅读者只在需要分析PascalTriangle::dataOf方法时才会关心。这再次说明在类层级,这两个类的简短程度相同。

当理解Yhsj::yanghui方法时,面对的是14行代码;当理解PascalTriangle::dataOf方法时面对的是7行代码,所以从方法层级看,代码清单2.2的实现更为简短。

设置一个关于简短的警戒值

在方法层级,尽管严格约定每个方法的长度是不现实的,但是设定一个警戒值还是有着重要的实践意义。过长的代码往往是设计不良的信号。Martin Fowler在《重构》[8]中,将过长的方法列为代码的“坏味道”之一。至于多长才算是过长,在不同的语言、不同的业务上下文中可能有不同的解释。较好的处理办法是设定一个警戒值。例如,我会把警戒值设为10行,只要一个方法达到10行,我就会比较警惕:是不是这个方法过于复杂了?由于10行很容易感知,所以将它作为警戒值就很直观,并不需要一个代码统计工具作为辅助。

代码清单2.1展示了一些开源代码的相关数据。我统计了包含的方法数量,同时计算了这些方法的代码行数的均值、中位数和最大值,供读者参考。

表2.1 一些开源代码的相关数据

2 著名的Java单元测试框架(https://github.com/junit-team/junit5),基于版本5.7.2统计。

3 第一个Java单元测试框架。

4 一个开源的代码依赖分析工具(https://github.com/multilang-depends/depends)。

5 著名的Java开发框架(https://github.com/spring-projects/spring-framework)。

6 一个知名的早期IDE(https://github.com/apache/netbeans)。

在统计代码行数量时使用了开源工具javancsshttp://www.kclee.de/clemens/java/javancss/。。其中,特别值得注意的是如下这些数据。

JUnit5、Spring-core和NetBeans的代码行数量中位数都是2,JUnit1和Depends的代码行数量中位数都是3。也就是说,大多数方法的代码行数量在2、3行以下。

在Spring-core和NetBeans中存在一些特别长的方法。如果读者去查看这些方法,就会发现它们确实较难阅读。

JUnit非常优秀。从代码简短性的视角看,JUnit1已经很不错了,但是JUnit5更加优秀,可见一直在持续改进。

如果读者去翻阅早期的技术书籍,就会发现对代码长度的要求曾经非常宽松。例如,在《代码大全》中,作者认为超过200行的程序才比较难以容忍。今天的技术环境已经很不一样了,不建议再参考这类数据。

设定一个代码行数量的警戒值有助于编写更高质量的代码。之所以会有过长的方法,很多时候是因为在一个方法中做了太多事情。有意识地减少代码行(如抽取一个新方法)有助于发现不够内聚的设计,或者抽象层次不足等问题。一般来说,对复杂的方法进行简化就能得到更好的设计结果。这也是在11.3节将会介绍的核心策略。

在2.2节讨论命名问题时,我们讲到了“好的代码,应该让人读起来像在阅读文章一样”。高质量的命名和一致的抽象层次,共同组成了这样的好代码。

代码清单2.2就是这样的代码。它在任何一个方法中,都保持着同一个抽象层级。例如,在valueOf(int row, int col) 方法中,它仅把注意力集中在杨辉三角形的规律2和规律3上,没有细化到实现层次,如判断某数是不是第一个数或最后一个数,也没有去关心如何获取正上方和左上方的数。作为对比,代码清单2.1中的yanghui方法做的事情就太复杂了,它把各个抽象层级的代码放到了一个方法中,既破坏了代码的可理解性,也让代码行变得冗长臃肿。

在编码中做到“一致的抽象层次”并不是太困难,核心是要采用正确的编码顺序,也就是本书第9章将讲到的由外而内的设计和实现方法。我们可以先预览一段基于该方法编写的代码。

public void moveDown() {
    if (isFallenBottom()) {
        piledBlock.join(activeBlock);
        piledBlock.eliminate(widthOfWindow());
        fallDownIfPiledBlockHanged();
        checkGameOver();
        createActiveBlock();
    } else {
        activeBlock.moveDown();
    }
}

代码清单2.8 俄罗斯方块游戏中收到下落信号时的处理程序

这是俄罗斯方块游戏中收到下落信号时的处理程序,这段代码的抽象层级就较为一致。许多类似的处理程序都没有达到如此好的抽象层级,例如下面的代码。

public void moveDownBadExample() {
    if (collisionDetector.isCollision(activeBlock, borderBlock, MOVE_DOWN) ||
        collisionDetector.isCollision(activeBlock, piledBlock, MOVE_DOWN)) {
        piledBlock.join(activeBlock);
        piledBlock.eliminate(widthOfWindow());
        if (piledBlock.size() == 0) return;
        while (!collisionDetector.isCollision(piledBlock, borderBlock, MOVE_DOWN))
            piledBlock.moveDown();
        if (piledBlock.size() > 0) {
            Cell c = piledBlock.getAt(piledBlock.size()-1);
            if (c.x == 0) {
                ui.notifyGameOver();
            }
        }
        activeBlock = nextBlock;
        createNextBlock();
    } else {
        activeBlock.moveDown();
    }
}

代码清单2.9 抽象层级不够好的处理程序的实现

这段代码和代码清单2.8实现的功能是一模一样的,只不过二者的抽象层级不一致:这段代码一会儿检测下落块是否和底部堆叠的方块重叠,一会儿连接底部堆叠块,一会儿又判断游戏是否已经结束,继而计算底部堆叠块的大小。代码阅读者的思维同样没有办法停留在一个抽象层级,他们被迫在“做什么”和“怎么做”之间反复跳转。

关于由外而内的设计和实现方法,以及如何更好地实现一致的抽象层级,我们在第9章会进一步深入分析。

计算机非常善于处理条件判断和循环逻辑,不过对人类来讲,条件语句和循环语句的组合及嵌套实在复杂。复杂了就容易出错。

对比代码清单2.1和代码清单2.2,前者包含一个两层嵌套的循环语句(第6行至第13行),第二层循环内部还有一个条件语句;后者包含的最深嵌套也只有一层循环(第5行至第7行)。因此,从可理解性上及出错的可能性上看,后者显然更优。

针对控制代码结构的复杂性,有一些专门的度量指标,如本书10.5节将介绍的圈复杂度(McCabe复杂度)和认知复杂度。不过,在日常的编码场景中,并不需要依赖度量指标来感知复杂度,只要多留意嵌套控制结构的数量即可。一旦超过两层,就应该非常警惕:是不是设计已经变得过于复杂了?复杂的控制结构,是非常容易识别的代码坏味道。一旦识别出这种问题,就需要关注控制结构的业务逻辑,重新组织代码结构,如提取方法或者进行抽象,以获得更为简短的代码。