代码气味

代码气味本质上是一些不好的做法,它们会使代码变得不必要地难以理解。代码气味通常会违反一些基本的软件设计原则,并相应地对整个代码的设计质量产生负面影响。

Martin Fowler 对代码气味的定义如下:

“代码气味是一种表面迹象,通常对应于系统中更深层次的问题”。

在本书的开头,我们讨论了 "技术债务"(technical debt)一词,从这个意义上说,代码臭味会导致整体的技术债务。

代码臭味不一定会构成错误,也不会阻止程序的执行,但它会助长日后引入错误的过程,并使代码更难重构为合适的设计。

让我们来介绍一下在处理遗留 PHP 项目时可能会遇到的一些基本代码气味。

我们将讨论一些代码气味以及如何以相当简单的方式解决它们,但现在让我们考虑一些稍微重要的、经常出现的模式,以及如何通过应用设计模式来解决这些问题,从而简化代码的维护工作。

在这里,我们将专门讨论重构模式,在某些情况下,当重构模式简化了代码的设计时,你可能会从重构模式中获益。本章反复强调的主题是,代码的设计如何贯穿代码的整个开发生命周期,而不仅仅是在一个任意的设计阶段结束后被丢弃。

模式可以用来传达意图,可以作为开发人员之间的语言;这就是为什么了解并继续使用大量模式在软件工程师的整个职业生涯中都至关重要。

在《向模式重构》(Refactoring To Patterns)一书中还有更多这样的方法,这里我精选了最适合 PHP 开发人员的几种。

冗长的方法和重复的代码

重复代码是一种非常常见的代码气味。开发人员会经常复制和粘贴代码,而不是为其应用程序使用适当的控制结构。如果同一个控制结构出现在多个地方,那么将两个结构合并为一个,就能使你的代码受益匪浅。

如果重复的代码完全相同,则可以使用提取方法。那么什么是提取方法呢?从本质上讲,提取方法只是将冗长函数中的业务逻辑移除到较小的函数中。

让我们想象一个骰子(dice)类,一旦掷出骰子,它将返回一个罗马数字 1 到 6 之间的随机数。

遗留(Legacy)类可以是这样的:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/extractMethod/before/LegacyDice.php[]

让我们提取将随机数转换为罗马数字的方法并将其放入单独的函数中:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/extractMethod/after/Dice.php[]

我们对原始代码块只做了两处修改:我们将执行罗马数字转换的函数分离出来,将其放在一个单独的函数中。我们用 DocBlock 代替了函数本身的内联注释。

如果代码存在于多个地方(且完全相同),我们只需调用一个函数即可,而无需在多个地方重复代码。

如果代码存在于不相关的类中,则应查看它在逻辑上的位置(存在于其中一个类中或一个单独的类),然后将其提取出来。

在本书的前面部分,我们已经讨论了保持小函数的必要性。这对于确保代码的长期可读性绝对重要。

我经常看到开发人员在函数中注释代码块,为什么不将这些方法拆分成自己的函数呢?这样就可以通过 DocBlocks 添加可读文档。因此,我们在这里用来解决重复代码问题的提取方法可以有一个更简单的用途,那就是分割长方法。

在处理较小的方法时,各种业务问题的解决方案更容易共享。

大类

大类的出现往往违反了 "单一责任原则"。在某一特定时间点,您所处理的类是否只有一个需要更改的原因?一个类只应对功能的一个部分负责,此外,该类应完全封装该责任。

通过提取与单一职责不完全一致的方法,将类划分为多个类,是帮助减轻这种代码气味的简单有效的方法。

用多态性或策略模式取代复杂的逻辑语句和开关语句

通过使用多态行为,可以在很大程度上消除开关语句(或无休止的大 if 语句);我在本书的前几章中介绍过多态性,它提供了一种比使用开关语句更优雅的处理计算问题的方法。

假设你在切换国家代码:US 或 GB,通过使用多态性,你可以运行相同的方法,而不是以switch 这种方式进行切换。

在不可能使用多态行为的情况下(例如,没有通用接口),在某些情况下,用策略代替类型代码甚至会让你受益匪浅;实际上,你可以将多个切换语句合并为仅仅向客户端的构造函数注入一个类,客户端将自行处理与各个类的关系。

例如,假设我们有一个输出接口,该接口由包含 load 方法的多个其他类实现。这个 load 方法允许我们注入一个数组,然后我们就能按照要求的格式得到一些数据。这些类对该行为的实现非常粗糙:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/switch/before.php[]

在撰写本文时,PHP 仍然认为 xmlrpc_encode 函数是实验性的,因此,我建议不要在生产中使用它。这里纯粹是为了演示目的(为了保持代码简短)。

使用 switch 语句的极其粗略的实现可能如下所示:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/switch/before.php[]

但显然,我们可以通过实现一个客户端来做很多事情,该客户端允许我们将 Output 类注入到 Client 中,并相应地允许我们接收输出。这样的类可以是这样的:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/switch/after.php[]

遵循单一控制结构的重复代码

我不想在这里重复模板设计模式的工作原理,但我想说明的是,它可以用来帮助消除重复代码。

我在本书前面演示的模板设计模式允许我们有效地抽象出程序的结构,然后我们只需填充特定于实现的方法。这可以帮助我们避免重复使用单一的控制结构,从而减少代码的重复。

冗长的参数列表和对基本类型的痴迷

基本类型的痴迷是指开发人员过度使用基元数据类型,而不是使用对象。

PHP 支持八种基元类型;这类类型又可细分为标量类型、复合类型和特殊类型。

标量类型是保存单个值的数据类型。如果你问自己 "这个值可以在一个刻度上吗?",就可以识别它们。一个数字可以从 X 到 Y,一个布尔值可以从假到真。下面是一些标量类型的示例:

  • Boolean

  • Integer

  • Float

  • String

复合类型由一组标量值组成:

  • Array

  • Object

特殊类型如下:

  • Resource (引用外部资源)

  • NULL

假设我们有一个简单的 "工资(Salary)计算器" 类,它会获取员工的基本工资、佣金率和养老金率;发送这些数据后,就可以使用计算(calculate)方法输入他们的销售额,从而计算出他们的工资总额:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/primativeObsession/before/Salary.php[]

注意这个构造函数有多长。是的,我们可以使用生成器模式创建一个对象,然后注入到构造函数中,但在这种情况下,我们可以专门抽象出复杂的信息。在这种情况下,如果我们将雇员信息转移到一个单独的类中,就能确保更好地遵守单一责任原则。

第一步,分离出类的职责,这样我们就可以分离出类的职责:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/primativeObsession/after/Employee.php[]

从现在起,我们可以简化 Salary 类的构造函数,只需要输入 Employee 对象就可以使用该类:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/primativeObsession/after/Salary.php[]

不雅暴露

假设我们有一个 Human 类,如下所示:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/indecentExposure/before/Human.php[]

我们可以随心所欲地设置数值,没有验证,也没有获取信息的统一途径。这有什么错呢?在面向对象中,封装原则至关重要;我们要隐藏数据。换句话说,我们的数据不应该在拥有对象不知道的情况下被显示出来。

相反,我们用私有变量代替所有公共数据变量。除此之外,我们还要添加适当的方法来获取和设置数据:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/indecentExposure/after/Human.php[]

确保设置器和获取器符合逻辑,而不是仅仅因为存在类属性而设置。完成上述工作后,您需要检查应用程序,替换变量的任何直接访问,使其首先通过适当的方法。

不过,这也暴露了另一个代码问题:羡慕特性。

羡慕特性

当一个类过多地使用另一个类的特性(方法或属性)时,就可能出现 “羡慕特性”。

这种情况通常意味着这些类之间的职责分配可能不合理,可能需要重构来改进设计。例如,如果一个类频繁地调用另一个类的方法或访问其属性,这可能意味着这些功能更应该属于调用类。

因此,在前面的示例中,我们有自己的 Salary 计算器类,如下所示:

Unresolved include directive in modules/ROOT/pages/ch07/ch7-03.adoc - include::example$Chapter 7/primativeObsession/after/Salary.php[]

相反,让我们看看如何在 Employee 类中实现这个函数,这样我们就可以省略不必要的获取器,并保持属性的内部化:

class Employee
{
    private $name;
    private $baseSalary;
    private $commission = 0;
    private $pension = 0;

    public function __construct(string $name, float $baseSalary)
    {
        $this->name = $name;
        $this->baseSalary = $baseSalary;
    }

    public function setCommission(float $percentage)
    {
        $this->commission = $percentage;
    }

    public function setPension(float $rate)
    {
        $this->pension = $rate;
    }

    public function calculate(float $sales): float
    {
        $base = $this->baseSalary;
        $commission = $this->commission * $sales;
        $deducation = $base * $this->pension;
        return $commission + $base - $deducation;
    }
}

不恰当的亲密关系

马丁-福勒(Martin Fowler)对此作了如下精辟的阐述:

"子类对父母的了解总是比父母希望他们知道的要多"。

一般来说,当一个字段在另一个类中的使用率高于该类本身的使用率时,我们可以使用 move field 方法在一个新类中创建一个字段,然后将该字段的用户重定向到新类中。

我们可以将此方法与 move 方法结合起来,将一个函数放在使用该函数最多的类中,然后将其从原来的类中移除,如果无法做到这一点,我们可以在新类中简单地引用该函数。

深嵌套语句

嵌套的 if 语句既杂乱又难看。这会导致逻辑混乱,难以理解;相反,应使用内联函数调用。

从最内层的代码块开始,设法将这些代码抽取到自己的函数中,让它们快乐地生活。在第 1 章 "为什么说’优秀的 PHP 开发人员’不是一个贬义词" 中,我们通过一个例子讨论了如何做到这一点。

这里有一个给 PHPStorm 用户的小贴士:在重构菜单中有一个可爱的小选项,可以自动帮你完成重构。只需选中要提取的代码块,进入菜单栏中的 重构,然后单击 提取>方法。然后会弹出一个对话框,允许你配置重构的运行方式:

image 2023 10 31 16 32 03 758

删除对参数的赋值

尽量避免在函数体中设置参数:

class Before
{
    function deductTax(float $salary, float $rate): float
    {
        $salary = $salary * $rate;

        return $salary;
    }
}

可以通过设置一个内部参数来正确实现:

class After
{
    function deductTax(float $salary, float $rate): float
    {
        $netSalary = $salary * $rate;
        return $netSalary;
    }
}

通过这种行为,我们可以轻松识别并提取重复的代码,此外还可以在日后维护这些代码时更轻松地替换代码。

这是一个简单的调整,可以让我们识别代码中的特定参数在做什么。

注释

注释本身并不是一种代码味道,在许多情况下,注释是非常有益的。正如马丁·福勒所说:

"在我们的嗅觉类比中,注释并不是一种难闻的气味,而是一种甜美的气味"。

不过,Fowler 接着演示了如何将注释用作隐藏代码气味的除臭剂。当你发现自己对函数中的代码块进行注释时,就能找到使用提取方法的好机会。

如果注释中隐藏了不好的味道,那么重构后就会发现原来的注释是多余的。这并不是不对函数进行 DocBlock 或无谓地寻找代码注释的借口,但重要的是要记住,当你重构设计得更加简单时,特定的注释可能会变得无用。

将 Composite 与 Builder 结合

正如本书前面所讨论的,Builder 设计模式的工作原理是,我们将一长串参数转化为一个对象,然后将其扔到另一个类的构造函数中。

例如,我们有一个名为 APIBuilder 的类,这个构建器类本身可以用 API 的 key 和密码实例化,但一旦实例化为一个对象,我们就可以简单地将整个对象传递到另一个类的构建器中。

在实际应用中,可以通过创建一个 Builder 类来封装 Composite 对象的创建过程。这个 Builder 类通常提供一系列的方法来设置复合对象的各个属性或添加子组件,最后通过一个 build 方法来返回完全创建的 Composite 对象。通过这种方式,我们可以通过单个类获得更强的控制能力,从而有机会浏览和更改 Composite 系列的整个树形结构。

用观察者取代硬编码通知

硬编码通知通常是将两个类紧密耦合在一起,以便其中一个能通知另一个。相反,通过使用 SplObserverSplSubject 接口,观察者可以使用更灵活的方式更新主题。在观察者中实现 update 方法后,主体只需实现 Subject 接口即可:

SplSubject {
    /* Methods */
    abstract public void attach ( SplObserver $observer )
    abstract public void detach ( SplObserver $observer )
    abstract public void notify ( void )
}

由此产生的架构是一个更加可插拔的通知系统,它不是紧密耦合的。

用 Composite 代替一/多区别

如果我们有单独的逻辑将个人数据移交给组,我们可以使用复合模式将其合并。这是我们在本书前面已经介绍过的一种模式;为了合并到这种模式中,开发人员只需修改代码,使一个类可以同时处理两种形式的数据。

要做到这一点,我们必须首先确保两个不同的类都实现了相同的接口。

在最初演示这种模式时,我写到这种模式可用于将单首歌曲和播放列表视为一体。假设我们的 Music 接口如下:

interface Music
{
    public function play();
}

最关键的任务只是确保该接口在一和多的区别上都得到遵守。Song 类和 Playlist 类都必须实现 Music 接口。从根本上说,这样我们才能用行为来处理这两个类。

用适配器分离版本

在本书的前半部分,我已经对适配器进行了详尽的介绍,因此我不会在此赘述,但我只想让你考虑一下,适配器可以用于支持不同版本的 API。

请注意,不要在同一个类中为多个 API 版本封装代码,而是可以将这些版本间的差异抽象到适配器中。在使用这种方法时,我建议您首先尝试使用封装方法,而不是基于继承的方法,因为这将为今后提供更大的自由度。