设计高质量的代码

高质量的代码并非偶然产生的。它是经过精心设计的。它是成千上万个小决策的结果,每一个决策都影响着代码的可读性、可测试性、可组合性和可更改性。我们必须在 快速而粗糙的临时解决方案(我们不知道覆盖了哪些边缘情况)和 更健壮的方法(我们确信无论用户如何滥用代码,它都能按预期工作)之间做出选择。

每一行源代码都至少涉及其中一个决策。我们需要做的决策实在是太多了。

你会注意到,到目前为止我们还没有提到 TDD。正如我们将看到的,TDD 并不会为你设计代码。它不会取代将需求转化为代码所需的基本工程敏感性和创造性输入。说实话,我很感激这一点——这是我喜欢的部分。

然而,这确实会导致 TDD 早期的许多失败,这一点值得注意。期望在不投入自己的设计的情况下,通过实施 TDD 流程就能得到高质量的代码,这是行不通的。正如我们将看到的,TDD 是一种工具,可以让你在这些设计决策上获得快速反馈。你可以在代码仍然便宜且易于更改时改变主意并调整,但这些仍然是你自己的设计决策在发挥作用。

那么,什么是优质代码?我们的目标是什么?

对我来说,优质代码的核心是 可读性。我追求的是清晰度。我希望通过设计清晰且安全的代码,善待未来的自己和长期受苦的同事。我希望创建清晰、简单的代码,避免隐藏的陷阱。

尽管关于什么是优质代码有大量的建议,但基本原则很简单:

  • 言如其意,意如其言

  • 在私下处理细节

  • 避免意外的复杂性

值得快速回顾一下我对这些原则的理解。

说出你的意思,意思要明确

这里有一个有趣的实验。取一段源代码(任何语言),去掉所有不属于语言规范的部分,然后看看你是否能弄清楚它的作用。为了让事情更加突出,我们将所有方法名和变量标识符替换为符号 ???。以下是一个简单的例子:

public boolean ??? (int ???) {
    if ( ??? > ??? ) {
        return ???;
    }
    return ???;
}

你知道这段代码是做什么的吗?不,我也不知道。我完全没有头绪。

从代码的结构来看,我可以看出它是一种评估方法,传入某些内容并返回 true/false。也许它实现了某种阈值或限制。它使用了多路径返回结构,我们检查某些内容,然后一旦知道答案就立即返回。

虽然代码的结构和语法告诉我们一些信息,但它并没有告诉我们太多。这绝对不够。我们分享的关于代码功能的几乎所有信息都来自于我们选择的自然语言标识符。命名对于优质代码至关重要。它们不仅仅是重要,它们是一切。 名称可以揭示意图、解释结果并描述为什么某段数据对我们很重要,但如果我们选择名称不当,它们就无法做到这些。

我对命名有两个指导原则,一个用于命名活动代码(方法和函数),另一个用于变量:

  • 方法——说明它的作用。结果是什么?为什么要调用它?

  • 变量——说明它包含什么。为什么要访问它?

方法命名的一个常见错误是描述其内部工作原理,而不是描述结果。一个名为 addTodoItemToItemQueue 的方法将我们绑定到一种特定的实现方式,而我们并不真正关心这种实现方式。要么是这样,要么就是误导。我们可以通过将其命名为 add(Todo item) 来改进名称。这个名称告诉我们为什么要调用这个方法。它让我们可以自由地在以后修改其实现方式。

变量命名的经典错误是描述它们的组成。例如,变量名 String string 对任何人都没有帮助,而 String firstName 清楚地告诉我这个变量是某人的名字。它告诉我为什么要读取或写入这个变量。也许更重要的是,它告诉我们 不要 在这个变量中写入什么内容。让一个变量在同一作用域内服务于多个用途是一个真正的麻烦。我曾经这样做过,但再也不会了。

事实证明,代码就是讲故事,纯粹而简单。 我们向人类程序员讲述我们正在解决的问题以及我们如何决定解决它的故事。我们可以将任何旧代码扔进编译器,计算机会让它运行,但如果我们希望人类理解我们的工作,我们必须更加小心。

在私有方法中关注细节

“在私下处理细节” 是描述计算机科学中抽象信息隐藏概念的简单方式。这些基本思想使我们能够将复杂系统分解为更小、更简单的部分。

我对抽象的理解与我对雇佣电工修理家中电路的理解相同。我知道我的电热水器需要修理,但我不想了解如何修理。我不想学习如何修理它。我不想弄清楚需要什么工具并购买它们。除了要求在我需要时完成修理外,我希望与此事完全无关。所以,我会打电话给电工,请他们来完成。只要我不必自己动手,我非常愿意为一份好工作付费。

这就是抽象的含义。电工抽象了修理热水器的工作。复杂的事情通过我的简单请求得以完成。

抽象在优质软件中无处不在。

每当你使某些细节变得不那么重要时,你就对它进行了抽象。一个方法有一个简单的签名,但其内部的代码可能很复杂。这是对算法的抽象。一个局部变量可能被声明为 String 类型。这是对每个文本字符的内存管理和字符编码的抽象。一个微服务,向那些最近没有访问网站的高价值客户发送折扣券,是对业务流程的抽象。抽象在编程中无处不在,跨越所有主要范式——面向对象编程(OOP)过程式编程函数式编程

将软件拆分为组件,每个组件为我们处理某些事情,这是一个巨大的质量驱动力。我们集中决策,这意味着我们不会在重复的代码中犯错。我们可以彻底地隔离测试一个组件。我们通过编写一次代码并提供一个易于使用的接口,设计出由难以编写的代码引起的问题。

避免偶然的复杂性

这是我最喜欢的破坏优质代码的元凶——根本不需要存在的复杂代码。

编写一段代码总是有很多方法。其中一些方法使用了复杂的功能,或者绕了很多弯路;它们使用复杂的操作链来完成一件简单的事情。所有版本的代码都能得到相同的结果,但有些代码只是偶然地以更复杂的方式实现了它。

我对代码的目标是一眼就能看出我正在解决的问题,而将如何解决问题的细节留给更深入的分析。这与我最开始学习编程的方式截然不同。我选择强调 领域而不是 机制。这里的 “领域” 意味着使用与用户相同的语言,例如用业务术语表达问题,而不仅仅是原始的计算机代码语法。如果我正在编写一个银行系统,我希望看到资金、分类账和交易成为核心。代码讲述的故事必须是关于银行业的。

实现细节(如消息队列和数据库)很重要,但仅限于它们描述了我们今天如何解决问题。它们可能需要在以后更改。无论它们是否更改,我们仍然希望主要故事是关于交易进入账户,而不是消息队列与 REST 服务通信。

随着我们的代码更好地讲述我们正在解决的问题的故事,我们使编写替代组件变得更加容易。替换数据库为另一个供应商的产品变得简化,因为我们确切地知道它在系统中服务的目的是什么。

这就是我们所说的隐藏细节。在某种程度上,了解我们如何连接数据库很重要,但只有在我们首先了解为什么需要它之后。

为了给你一个具体的例子,这里有一段代码,类似于我在生产系统中发现的一些代码:

public boolean isTrue (Boolean b) {
    boolean result = false;

    if ( b == null ) {
        result = false;
    }
    else if ( b.equals(Boolean.TRUE)) {
        result = true;
    }
    else if ( b.equals(Boolean.FALSE)) {
        result = false;
    }
    else {
        result = false;
    }

    return result;
}

你可以看到这里的问题。是的,确实需要这样的方法。它是一种低层机制,将 Java 的 true/false 对象安全地转换为其等效的基本类型。它涵盖了与 null 值输入相关的所有边缘情况,以及有效的 true/false 值。

然而,它存在问题。这段代码很杂乱。它难以阅读和测试。它具有较高的圈复杂度(CYC)。圈复杂度 是基于代码段中可能的独立执行路径数量的客观衡量标准。

前面的代码不必要地冗长且过于复杂。我几乎可以肯定它的最后一个 else 路径是死代码路径——意味着包含无法访问的代码。

从所需的逻辑来看,只有三个有趣的输入条件:nulltruefalse。它肯定不需要所有这些 else/if 链来解码。一旦你解决了 nullfalse 的转换,你真正只需要检查一个值就可以完全决定返回什么。

更好的等效代码如下:

public boolean isTrue (Boolean b) {
    return Boolean.TRUE.equals(b);
}

这段代码以更简洁的方式完成了相同的功能。它没有像前一段代码那样具有意外复杂性。它更易读。它更容易测试,因为需要测试的路径更少。它具有更好的圈复杂度,这意味着更少的地方可以隐藏错误。它更好地讲述了方法存在的原因。说实话,我甚至可能会通过内联来重构这个方法。我不确定这个方法是否为实现增加了任何有价值的额外解释。

这个方法是简单的例子。想象一下,看到这种情况扩展到数千行复制粘贴、略微修改的代码。你可以理解为什么意外复杂性是一个杀手。这种冗余会随着时间的推移而积累,并呈指数增长。一切都变得更难阅读和安全更改。

是的,我见过这种情况。每当我看到时,我都会感到难过。我们可以做得比这更好。作为专业的软件工程师,我们真的应该如此。

本节是对良好设计基础的快速概述。它们适用于所有编程风格。然而,如果我们能把事情做对,我们也可能把事情做错。在下一节中,我们将看看 TDD 测试如何帮助我们防止糟糕的设计。