[Code翻译]衡量软件的复杂性。使用什么指标?

1,649 阅读15分钟

本文由 简悦SimpRead 转码,原文地址 thevaluable.dev

我们需要衡量复杂性吗?用什么指标?它能带来什么好处?这就是问题所在......

"代码库的这一部分感觉不对!"

这是Dave,你的同事开发人员,在另一个永无止境的会议上争论着要重写你公司代码库的一部分。他的论点是什么?技术债务,高熵,以及对遗留系统的恐惧。

我们的工作,作为开发者,促使我们做出许多决定,从架构设计到代码实现。我们如何做出这些决定?大多数时候,我们遵循 "感觉正确 "的东西,也就是说,我们依靠我们的直觉。它来自于我们的经验,是一个重要的信息来源。但同样的直觉也可能是许多问题的来源。我们是人,我们会受到许多偏见的影响,导致错误的假设。

这就是为什么,要做出重要的决定,比如重写一整块代码库,我们需要更多的_无偏见的信息。测量复杂性可以带来我们需要的信息,以确保我们朝着好的方向发展。这就是本文的主题:我们如何测量这种复杂性?更确切地说,我们将看到。

  • 复杂性度量如何与经验和实验相辅相成。
  • 我们在测量复杂性时的目标是什么。
  • 有哪些最常见的复杂性衡量标准以及它们的局限性。

这篇文章的目的是向你广泛介绍你可以使用的不同的复杂度量。因此,我的目标是做一个总体概述,而不涉及太多的细节。

我在这里主要讲的是_模块,它可以被看作是代码单位。一个函数、一个类、一个对象、一个包、一个服务(或微服务)都可以是一个模块。

这篇文章也是关于测量复杂性系列的第一篇。在接下来的文章中,我们会看到更多具体的例子,如何结合这些度量标准来获得我们想要的信息。

现在,准备好你最喜欢的饮料,带上你的测量工具,让我们开始吧!

首先提出你的问题

在我们这个以信息为导向的世界里,各种数据都可以广泛使用。只要我们愿意,我们可以整天看着仪表盘,上面满是一切事物的时髦指标。似乎,通过这些指标,我们搜索到了一些东西,但我们往往不能真正确定_是什么_。也许是看到我们代码库的循环复杂度很低而感到高兴?看到越来越多的人访问我们的登陆页面而感到兴奋?

在没有想到任何问题的情况下看指标,就像在一个巨大的干草堆里寻找是否有一些针,在某个地方,即使在现实中我们需要塑料鸭子。相反,我们应该定义我们想要解决的问题,然后才试图找到我们需要的信息来解决它们。简而言之,衡量标准应该主要用于_告知我们的决策。

我们,作为美丽的人类,对数字是非常敏感的。我们喜欢它们上升的时候。这让我们有一种很好的成就感,即使这些指标并没有为我们提供有用的决策信息。这些指标通常被称为 vanity metrics。我们很容易落入这个陷阱;这也是为什么在决定看什么指标之前,首先要探索问题空间的另一个原因。

所以我们要问:通过测量我们代码库中的复杂性,我们试图解决什么问题?主要有两件事。

  1. 找到我们代码库中的复杂部分,使代码更容易理解和推理。
  2. 防止代码库出现新的bug。

第一点很重要:我们的大脑并没有进化到为一个有数百种不同状态、过程和漏洞的代码库建立一个准确的心理模型。根据认知负荷理论,我们的工作记忆不能同时保留许多信息。这就是为什么减少我们系统的复杂性是至关重要的。

根据这项研究,代码库中的缺陷遵循帕累托原则;也就是说,大多数缺陷(80%到100%)往往是从几个模块(代码库的10%到20%)中冒出来的。测量代码库的复杂性可以帮助我们隔离这些有问题的模块。

第二点是不容易实现的。预测复杂系统的未来演变远远不是一个已经解决的问题。因此,我们的目标不是要获得关于可能带来可怕的bug的模块的绝对确定性,而是_减少这种不确定性_。

减少不确定性

image.png

测量

How to Measure Anything一书中关于测量的定义是正确的。

基于一个或多个观察结果,定量地表达不确定性的减少。

我们经常认为测量是一种提供确定性的行为,但事实并非如此。你可以在抽象的数学世界里找到确定性,但在我们的世界里,没有什么是确定的。换句话说,我们不需要完美的公式,而是需要不完美的启发式方法来获得我们需要的信息。我们的目标是获得一些关于下一步该做什么的线索:我们需要重构代码库的哪一部分,或者,至少,更密切地监测未来的bug。

自70年代以来,关于测量代码库的复杂性的研究一直在进行。这也不是一个已经解决的问题。这就是为什么测量对于隔离有问题的代码是有用的,但还不足以知道该如何处理它。我们还需要使用一些过去的信息,我们通过自己的经验、别人的经验、过去的测量,可能还有过去的实验来获得。

当然,困难在于 "现实世界 "并不完全是正式的,在这个意义上,我们不能用精确的数学关系来模拟它。我们所能希望的最好结果是工程上的近似。

实验

获得更多信息并减少我们的不确定性的另一种方法是通过实验。我在本博客的许多文章中大力提倡实验,因为它可以给我们提供即时的反馈,告诉我们哪些是有效的,哪些是无效的。它可以向我们展示我们的错误假设和认知偏见。

有时,实验并不能给我们带来更多信息。它可能被看作是浪费时间。但这也是快速了解我们是否在朝着好的方向发展的最有效方法。

实验可以是尝试一些你以前没有尝试过的新东西,比如创建一个小系统来验证一些假设。如果这个系统不再有用了,不要犹豫,把它扔掉。我们在这里搜索信息,而不是试图写出下一个独角兽。

使用我们的经验

我们的经验也可以用来完善我们的信息不足,并进一步减少不确定性。如果我们的推理足够好,我们的记忆不是太模糊,它可以帮助我们做出好的决定。

这是决策过程中的一块重要拼图。然而,要小心对待它:同样,我们有许多偏见。如果我们不牢记我们的大脑喜欢走捷径,我们可能最终会走上错误的推理道路。

牢记你的经验不一定比别人的好,也是很有用的。

用代码指标衡量复杂度

现在让我们来看看用于衡量代码库复杂性的流行指标以及它们的限制。

Halstead度量标准

如果你搜索关于代码度量的信息,你会发现Halstead度量。即使它们并不总是在你最喜欢的静态分析工具中明确使用,它们也经常在引擎盖下被用来计算某种 "可维护性指数 "或 "复杂性指数"。

这些指标是由Maurice Halstead在1977年发明的,当时几乎所有东西都是程序化的。代码库通常是用COBOL写成的,只有几个文件。函数(或者更准确地说,程序)是用于抽象化的主要结构。

对Halstead来说,一个代码库是两类标记的序列:操作符和操作数。他所有的度量衡都是基于这个简单的想法。例如,在操作 "1+2 "中,"1 "和 "2 "是操作数,"+"是一个操作数。

从这里,我们可以计算出一个特定模块的以下指标。

  • 长度 - 操作符和操作数的数量
  • 词汇量 - 独特的运算符和独特的操作数的数量
  • 难度 - (独特的运算符/2) * (操作数/独特的操作数)

这些指标是其他更有意义的指标的基础。

  • Halstead Volume - 读者需要吸收多少信息来理解代码。
  • Halstead Effort - 重写代码库的工作量(不包括所有相关的工作,如理解规范)。
  • Halstead Bugs - 系统中存在多少个bug。
  • Halstead时间 - 重写一个代码库需要多少时间。这个饱受诟病,在实践中从未使用过。

不要相信这些指标的表面价值。根据我的经验,它们并不是衡量复杂性的最佳指标,主要是因为在现代语言中很难找到什么是运算符和操作数。以下面的代码为例。

<?php declare(strict_types=1);

class Parser
{
    public function count(string $filepath) {
        printf("I'm counting lines of %s pretty hard!", $filepath);
    }
}

代号;是什么?一个操作符?一个操作数?它有语义,因此应该有助于读者吸收更多的信息,比如说。代号<?php呢?class?

Halstead在这个问题上保持着模糊的态度。根据他的说法,运算符和操作数之间的区别应该是 "直观上明显的"。这是令人惊讶的:正如我们所看到的,当我们开始测量一些东西时,我们不应该使用我们的直觉。我们的目标是要有无偏见的信息。

由于这种模糊性,静态分析工具以不同的方式计算这些标记。在同一个代码库中,对于同样的指标,你可以有两种不同的结果,这取决于你使用的工具。

这还不是全部。对不同结果的_解释_往往也是不同的,从一个工具到另一个。对有些人来说,Halstead长度是复杂性的衡量标准,对另一些人来说,它只是运算符和操作数的数量。

我还想知道,是否有更多的信息从这些运算符和操作数的_组合_中出现,这些信息在我们只计算代码库的不同标记时并没有出现。例如,一个有10个类几乎没有耦合的系统 "A "与有10个独立类的系统 "B "是不同的。然而,两个系统中的类的数量是一样的。

最后,当你看到一些使用Halstead度量的复杂性指标时,要保持警惕。它们可以表明代码库中可能存在的问题,但是,由于它们的局限性,往往需要进一步检查。就个人而言,我完全不依赖它们。

循环复杂度

啊!传说中的循环复杂性。你会在每一个你能想到的静态分析工具中看到这个。

它是由Thomas McCabe提出的,并在1976年由于论文A Complexity Measure而得到推广。McCabe的目标是提出一个使用模块的控制图流来衡量复杂性的指标。

当时,复杂性通常是通过计算代码行数(LOC)来衡量的。McCabe想创造一些更准确的东西来取代这种度量。其主要目的是告知何时对系统的某部分进行模块化,以使产生的模块更易维护和测试。

为了理解它是如何工作的,让我们来看看这个例子。

<?php

class Cyclo
{
    public function cyclo()
    {
        for ($i = 0; $i < 10; $i++) {
            if ($i == 2 || $i == 4) {
                echo 'Hello!';
            } else {
                echo 'Bye!';
            }
        }
        echo 'this is the end';
    }
}

为了找出我们的方法cyclo的循环复杂性,我们需要画出它的控制流图,计算边和节点,并使用一个公式来得到你的结果。简而言之,为了知道我们需要测试多少个分支,我们要计算程序在运行时可能采取的路径。

但还有一个更简单的方法。

  1. 计算代码中分支点的数量("if"、"while"、"for "和布尔运算符如"&&"或"||")。
  2. 添加1.

这个例子中的分支点是什么?

  1. for循环
  2. $i == 2
  3. $i == 4

再加上一个,你就得到了奇妙的循环复杂度4,太好了。然而,还有一个问题:我们的代码需要什么结果才能被认为是_太复杂?根据McCabe的说法,"合理的上限 "是10,没有更多的解释。

这就是我们开始在循环复杂度的缺点中陷入困境的地方:没有经验证据表明这个上限是有意义的。更广泛地说,没有证据表明一个代码库的路径数量与它的复杂性相关。

此外,循环复杂性没有考虑到不同分支点的嵌套。根据我的经验,当你有嵌套的条件式时,混乱会迅速上升,比条件式本身的数量多得多。

其他的研究(比如这个)也表明,循环复杂性与缺陷的数量没有关系。此外,即使McCabe想停止计算代码行来衡量复杂度,但已经表明,计算LOC与循环复杂度是相关的。在某些情况下,测量这些代码行甚至更好 在这种情况下,测量循环复杂度有什么意义呢?

我的建议是:当循环复杂度真的很高时(超过20或30),要考虑到它。在大多数情况下,可以看一下LOC,而不是。

计算代码行数

计算一个模块的代码行数是你可以计算的最简单的复杂度指标之一。LOC高的模块可能是复杂的模块。这可能表明他们有太多的责任,而且这些责任不应该纠缠在一起(缺乏凝聚力)。

LOC指标的另一个好处是:无论你使用什么编程语言,你都可以用同样的工具来计算它,比如说cloc)。

然而,请记住,当你的模块真的比其他模块大的时候,这个指标的效果最好。此外,一个有小模块的代码库并不意味着它是一个没有复杂性的代码库。

代码形状

当你正在阅读的代码意外地被屏幕的右侧吸引时,你会害怕吗?你应该如此。在许多常见的编程语言中,包含许多缩进的行往往表示嵌套结构(如条件语句)。

我喜欢把复杂性看作是许多不同的模块相互交织在一起,形成了一个混沌的模糊地带。嵌套结构绝对是在这个范围内。

这也很容易衡量:你只需计算一个模块的逻辑缩进(空格或制表符)的数量,每行都是如此。然后你就可以找出一个文件中缩进的最大程度,缩进的总次数,你还可以将这些信息与其他更健康的模块进行比较。

考虑一下下面的代码。

<?php

foreach ( (array) $post_links as $url ) {
    $url = strip_fragment_from_url( $url );

    if ( '' !== $url && ! $wpdb->get_var( $wpdb->prepare( "SELECT post_id FROM $wpdb->postmeta WHERE post_id = %d AND meta_key = 'enclosure' AND meta_value LIKE %s", $post->ID, $wpdb->esc_like( $url ) . '%' ) ) ) {

        $headers = wp_get_http_headers( $url );
        if ( $headers ) {
            $len           = isset( $headers['content-length'] ) ? (int) $headers['content-length'] : 0;
            $type          = isset( $headers['content-type'] ) ? $headers['content-type'] : '';
            $allowed_types = array( 'video', 'audio' );

            // Check to see if we can figure out the mime type from the extension.
            $url_parts = parse_url( $url );
            if ( false !== $url_parts && ! empty( $url_parts['path'] ) ) {
                $extension = pathinfo( $url_parts['path'], PATHINFO_EXTENSION );
                if ( ! empty( $extension ) ) {
                    foreach ( wp_get_mime_types() as $exts => $mime ) {
                        if ( preg_match( '!^(' . $exts . ')$!i', $extension ) ) {
                            $type = $mime;
                            break;
                        }
                    }
                }
            }
        }
    }
}

这是从Wordpress源代码中提取的。我们的假设在这里是正确的:我们都可以同意,这段代码相当复杂。我是通过以下方式找到它的。

  1. 查看具有最高LOC指标的文件。
  2. 2.查看缩进量比其他的多的行。

像LOC和代码形状这样的简单指标可以比更复杂的指标给你更多的信息。

耦合和内聚力

image.png

我们现在离开了实现本身的细枝末节,放大一点,看一下架构。正如我们已经看到的,复杂性是由许多元素相互交织而成的。元素的数量和它们之间的关系决定了复杂性;如果我们的系统中所有的东西都是独立的,那么我们就不难修改我们想要的东西。

模块应该尽可能地相互独立。同时,我们需要把那些发展在一起的东西分组,以增加我们的代码的_凝聚力_,在模块本身。分析我们代码库中的耦合度可以告诉我们哪里可以避免耦合。

这项研究将耦合分为四个不同的类别,我觉得相当有用。

结构耦合。代码库的静态分析

当一个耦合可以通过分析代码库(静态分析)直接发现时,它就是结构性耦合。换句话说,你不需要运行你的代码来发现它们。

不幸的是,计算代码库中不同级别的耦合的工具往往与语言有关,这与LOC或代码形状等其他指标相反。

下面是你可以找到的可能的耦合类别。

  1. 内容耦合--模块之间相互访问对方的内容。
  2. 2.共同耦合--模块在更大范围内突变共同变量(如全局变量)。
  3. 3.控制耦合--模块控制其他模块的逻辑。
  4. 4.外部耦合--模块之间使用外部手段交换信息,如文件。
  5. 5.标记耦合 - 模块之间交换元素,但接收端并不对所有元素采取行动。例如,一个数组传递给一个模块,该模块并不使用数组的所有元素。
  6. 6.数据耦合--模块交换元素,而接收端使用所有的元素。

这些耦合据说是从最差到最好的分类,即使我_相信内容耦合比普通耦合好,因为更难知道什么是受全局结构影响的。

这些分类在我们写代码和分析代码时都很有用,可以了解我们在创造什么样的耦合。

那OOP呢?自从这个范式兴起以来,许多指标都是围绕着它专门设计的。我们不是在谈论一般的 "模块",而是更多的关于类和对象。

  • CBO(对象间耦合)--对象对另一个对象的作用程度。
  • CBE (Coupling Between Element) - CBO的更精确的变化。如果两个(或更多)元素之间有任何依赖关系,比如访问或修改彼此的实现细节,它就认为是耦合的。
  • CTM (Coupling Through Message passing) - 衡量一个被考虑的类向系统中其他类发送的信息数量。
  • IC (Inheritance Coupling) - 计算由于继承而产生的耦合。我已经在另一篇文章中写过耦合和继承的问题。

动态耦合。运行时的耦合

动态耦合涉及到在运行时发生的所有耦合,如动态绑定或多态性。这些指标与上面看到的结构性指标没有太大区别,只是它们不能被静态分析(只用代码)。

根据我的经验,你不应该经常需要这些指标,除非你试图用多态性来概括你代码库中的一切。这绝对是我不建议的事情。

逻辑耦合

逻辑耦合的模块是指经常一起变化的模块,即使它们之间没有结构耦合。要找出逻辑耦合,我们需要查看历史信息,比如说Git的历史。

code-maat这样的工具可以通过分析一起提交的文件来提供这种逻辑耦合。它的假设是,如果同一个文件是多个提交的一部分,那么改变一个文件就有义务改变其他文件。因此,它们在逻辑上是耦合的。

像其他任何指标一样,这个指标也有缺陷。比如说,你不确定开发者会不会把他们经常修改的文件放在同一个提交里。如果每次合并到主分支时,提交都被自动压扁,你也会得到错误的结果。也就是说,如果你记住了这些缺陷,它仍然是一个有用的度量。

根据我的经验,这种耦合的情况经常发生。如果你发现其中一些,最好的办法是合并逻辑耦合的模块,以增加它们的内聚力。它可以帮助你发现违反DRY原则的情况,例如;同一个知识出现在代码库的多个地方。

语义耦合

有时,一些模块使用其他模块的知识,增加了它们之间的耦合,降低了它们的内聚力。它们通常是逻辑耦合的,但并不总是如此。这就是所谓的语义耦合。

分析模块之间的共享语义是很难的,但是一些技术正在被开发出来,比如说,机器学习模型能够分析评论或名称之间的关系。

下面是一些用于语义耦合的指标。

  • CCM - 方法之间的概念耦合。
  • CCMC - 一个方法和一个类之间的概念耦合。
  • CCBC - 两个类之间的概念耦合,也叫CSBC(两个类之间的概念相似度)。

我从来没有测量过我的模块的语义耦合度,其他指标已经足够满足我的需要了。

测量复杂度只是一个开始

如果你只想从这篇文章中得到一样东西,那就抓住这一点:复杂度指标都是有缺陷的;如果你不小心,它们会给你带来错误的肯定。也就是说,它们对于隔离你的代码库中可能更容易出现缺陷的部分是有用的。但是,在一天结束的时候,是指标、你的经验和你的实验的结合,将显示你的代码库中的潜在问题。

当我想找到复杂性隐藏的地方时,我总是先尝试最简单的度量。按照偏好的顺序。

  1. LOC
  2. 代码形状
  3. 结构耦合(共同和内容耦合)。
  4. 逻辑耦合

Halstead度量(或基于它们的度量)和循环复杂性也是有用的,但只有在它们异常大的情况下。

然而,仅仅看复杂度指标是不够的。一个代码库就像一个活的有机体:有些部分或多或少会发生变化,由不同的参与者以不同的目的、理解和风格进行修改。这就是我们将在本系列的下一篇文章中探讨的问题:在一个社会化的、不断变化的环境中的代码度量。

相关资源