发布于 2026-01-06 10 阅读
0

如何处理软件中复杂性的出现

如何处理软件中复杂性的出现

所有复杂系统都具有所谓的涌现特性。例如,水就具有潮湿和湿润等涌现特性。当一个表面上只有10个水分子时,我们不会将其归类为湿润,但当表面上的水分子数量达到一定程度时,它就会变得湿润。潮湿的特性源于水分子与物体之间的相互作用。

表面状态的难点在于如何界定其边界,例如干、湿和潮湿之间的界限。表面状态也取决于具体情况,花岗岩台面会像T恤衫一样变湿吗?固体表面通常被定义为干或湿,而可渗透表面则可能变湿。

在软件开发中,复杂性本身就是代码涌现的一种属性。在开发过程中的某个阶段,软件会跨越简单与复杂的界限。软件会从易读易懂变得难以阅读和理解。这种复杂性的出现取决于多种因素,例如代码的编写方式、代码量、问题的难度等等。

作为软件开发人员,我们的首要目标之一是尽可能降低复杂性,而且这样做有很多好处。显而易见的一点是经济效益,软件越复杂,维护起来就越困难、成本越高。你需要更多的开发人员来维持系统正常运行并完成各项任务。其次是开发人员的身心健康,编写过于复杂的代码会让人感到沮丧。开发人员往往感觉自己存在的唯一意义就是防止系统崩溃,而不是添加能够创造商业价值并提升用户体验的新功能。

什么是软件复杂度?

当我们谈到软件的复杂性时,准确定义其含义至关重要。软件本质上就是复杂的,大多数开发者在任何特定时刻都只能处理其中一小部分复杂性。JavaScript之所以存在,是因为有许多其他语言编写的软件层支撑着它运行。我们关注的并非这种复杂性本身,因为任何开发者都不需要考虑软件的全部复杂性。如果他们试图这样做,他们不仅会失败,而且很可能会崩溃。

当我们谈论软件的复杂性时,我们指的是它的可理解性和可读性。例如,如果让一位新开发人员面对一份现有的代码库,他们能否解释代码的功能,以及修改代码的难易程度?如果代码复杂度低且易于理解,那么他们就能解释代码的功能并轻松进行修改。反之,很可能存在复杂性问题。

如何识别复杂性

那么,如何才能最大限度地减少代码库中复杂性的出现呢?第一步是学会识别复杂性。幸运的是,有一些工具和指标可以帮助我们做到这一点。

三个重要的复杂性指标是:

  • 圈复杂度:代码中有多少个控制结构?
  • NPath复杂度:代码中有多少条路径?
  • CRAP:考虑到代码的复杂性,测试是否足够?

在这些指标中,圈复杂度是最容易理解和使用的。它考察一段代码单元(通常是一个方法),检查其中有多少个控制结构或决策点。例如if,` switchif`、foreach`if`、`if` 等。方法中的决策点越多,该方法可能出现的结果就越多,复杂度也就越高。理想情况下,代码的圈复杂度应该低于 5,绝对不能低于 10。如果代码库中有很多方法的圈复杂度高于 10,则很可能存在问题。

还有许多工具,例如PHPMDESLint,可以让你运行并自动执行复杂度检查。你可以将它们添加到持续集成管道中,设置一些阈值,如果新代码的复杂度超过阈值,你就可以对其进行审查并修复。仅此一项就能帮助你有效控制代码的复杂性。

当然,复杂性这个话题远没有那么简单。你还需要能够阅读代码,并发现复杂性何时悄然渗入设计之中。

例如,下面的 PHP 代码的圈复杂度得分为 4,这很好。

public function childrenAboveFiveFeet(array $parents): array
{
    $children = [];

    foreach ($parents as $parent) {
        foreach ($parent->getChildren() as $child) {
            $heightInFeet = $child->getHeight() / 30.48;

            if ($heightInFeet > 5) {
                $children[] = $child;
            }
        }
    }

    return $children;
}

这段代码表面上看起来并不复杂,它简短易懂,但实际上存在一些问题。主要问题在于业务逻辑没有被隔离,而是隐藏在层层嵌套的 foreach 循环中。

/** The Business Logic **/
$heightInFeet = $child->getHeight() / 30.48;

if ($heightInFeet > 5) {
    $children[] = $child;
}

业务逻辑才是我们真正关心的代码,它负责做出决策,我们需要确保它能正常运行。但由于它嵌套在两个 foreach 循环中,业务逻辑的测试难度远超预期。

要详细测试业务逻辑,每次编写测试时都需要创建一组人员和儿童对象。如果我们只需要确保厘米到英尺的转换正确,以便准确计算儿童身高是否超过五英尺,那么这种做法很快就会变得非常繁琐。理想情况下,我们应该将业务逻辑隔离到单独的方法中,以便更轻松地进行测试。

为了确保代码不会过于复杂,我们需要能够手动分析代码,并指出哪些代码设计可以改进。正如上面的例子所示,仅仅依靠工具和指标是不够的。

代码隔离

这就是代码隔离原则的用武之地,也是我们处理和降低复杂性的主要方法之一。代码隔离有两个基本原则:

  1. A不应该知道BC或其他任何事。
  2. 如果A使用B,它不应该知道B 的来源或去向

实际上,这些规则可能如下所示:

  1. 业务逻辑不应该了解数据库文件系统或其他任何相关知识。
  2. 如果业务逻辑使用数据,它不应该知道数据的来源或去向

代码隔离是整洁架构背后的指导原则,但如果你不理解代码隔离,学习整洁架构就没有多大意义。

简单来说,代码隔离是指我们将决策过程(也称为业务逻辑或领域逻辑)与输入/输出分离。因此,在我们的代码中,我们不会将数据库或文件系统的调用与决策过程混淆在一起。

在这个 Deno / TypeScript 代码示例中,从 JSON 文件中检索数据与对数据做出决策混为一谈。

export function overEighteens(): object {
  /** Filesystem Call **/
  const file = fromFileUrl(new URL("../../assets/people.json", import.meta.url));
  const json = readJsonSync(file);

  if (json instanceof Array) {
    return json.filter((person: any) => {
      if (person.age !== undefined) {
        /** Decision Point **/
        return person.age >= 18
      }
      return false;
    });
  }

  return {};
}

作为独立方法,上述代码基本没有问题;如果这是微服务中唯一的功能,也不会有问题,因为复杂度本来就很低。但以这种方式将 I/O 和决策合并在一起就会产生问题。

由于代码与文件系统紧密耦合,因此测试起来更加困难。所以我们要么需要以某种方式模拟文件系统,要么需要确保文件系统正常工作才能测试代码。调试代码也更加困难,问题究竟出在数据检索上,还是出在年龄检查上?问题是否与 I/O 或业务逻辑有关?在这段代码中,这些问题都不太明显。

但主要问题在于,如果这种代码编写方法在整个代码库中反复出现,复杂性就会迅速增加。代码难以理解、难以测试、难以调试和难以修改的情况会比遵循代码隔离原则的代码库更快地出现。

需要注意的是,代码隔离原则与WET 或 DRY原则无关。它们都与抽象有关,但抽象并不能保证隔离。开发人员可以很容易地抽象出紧密耦合的代码。如果开发人员的目标是最大限度地降低复杂性,则需要遵循代码隔离原则。遵循 WET 或 DRY 原则既不能保证隔离,也不能保证最小的复杂性。这并不是说 WET 或 DRY 原则对实际开发没有帮助,而是说不要将它们与代码隔离原则混淆。

代码隔离示例

那么,我们如何运用代码隔离原则来改进上面的代码示例呢?我们可以将代码分解成各个组成部分。获取数据的部分放在一个方法中,而对数据做出决策的部分放在另一个方法中。

interface Person {
  id: number,
  name: string,
  age: number,
}

export function overEighteens(): Person[] {
  return retrievePeople().filter(person => overEighteen(person));
}

/** Filesystem Call **/
function retrievePeople(): Person[] {
  const file = fromFileUrl(new URL("../../assets/people.json", import.meta.url));
  const json = readJsonSync(file)

  if (json instanceof Array) {
    return json.filter((person): person is Person => {
      return (
        person instanceof Object &&
        person.hasOwnProperty("id") && 
        person.hasOwnProperty("name") &&
        person.hasOwnProperty("age")
      );
    });
  }

  return [];
}

/** Decision Point **/
function overEighteen(person: Person): boolean {
  return person.age >= 18;
}

上述代码尚未达到生产就绪状态,且在 JavaScript/TypeScript 中也难以测试。但这些更改突出了隔离原则,并且代码现在更加健壮。数据检索集中在一处,我们确保它返回正确的数据集合。而年龄检查则位于另一处,并且需要一个对象作为Person参数。

通过将代码抽象成独立的模块,可以进一步改进代码并使其更易于测试。这样,年龄检查功能就可以用单元测试进行测试,而数据检索功能则可以用集成测试进行测试。由于年龄检查overEighteen()方法不再知道数据的来源或其返回值Person的用途,因此我们实现了代码的隔离。boolean

正如这个例子所展示的,在中大型代码库中,代码隔离原则有助于保持代码的简洁性、健壮性和可测试性。这将最大限度地降低代码库的复杂性,使其更易于理解和维护。

概述

尽量减少软件复杂性的出现非常困难,因为软件本质上就是复杂的。而且,这个问题也没有万能的解决方案。如何处理复杂性取决于你需要解决的问题及其规模。

不过,有一些策略可以帮助开发者解决这个问题。首先是指标和工具,我鼓励所有开发者在持续集成 (CI) 流水线中引入圈复杂度检查。如果应用于现有代码库,可以先将阈值设为 20,然后随着代码的改进逐步降低阈值,目标是降至 10 以下。如果是新项目,不妨大胆尝试,先从 5 或 6 的阈值开始,看看效果如何。

此外,还要开始考虑代码隔离原则及其在改进代码库中的应用。分析哪些业务逻辑可以更好地隔离,以便于测试并提高其健壮性。同时,也可以开始研究整洁架构原则及其各种实现方式,或许能找到适合您用例的方案。

最后,编写一些文档,因为这是解决代码复杂性的最佳方法之一。它能迫使你解释代码的功能和用途。这不仅有助于你发现并修复代码中的一些缺陷,更重要的是,它能帮助其他开发者理解你的代码存在的意义和作用,从而让他们更容易地参与到代码开发中。

你不太可能阻止你开发的软件中出现任何复杂性,但通过应用上述一些工具和想法,你有望最大限度地减少其许多负面影响。

文章来源:https://dev.to/robdwaller/how-to-handle-the-emergence-of-complexity-in-software-5alj