使用接口和traits

PHP 8 的特质实现在多个方面进行了扩展。还有几个新接口可能会改变您与 DOM 和 DateTime 扩展的工作方式。在大多数情况下,这些变化提高了这两个扩展的能力。不过,由于方法签名在某些情况下发生了变化,您可能会遇到潜在的代码中断。因此,请务必密切关注本节的讨论,以确保现有和将来的 PHP 代码仍能正常运行。

首先,让我们来看看新的 DOM 扩展接口。

发现新的 DOM 扩展接口

世界上许多国家的政府每年都会发布生活费用经济统计数据。它描述了一个普通公民每年的生活成本。随着网络技术的成熟,类似的原则也被应用到网络中—​首先是 HTML,现在是 DOM。DOM 生活标准由网络超文本应用技术工作组 (WHATWG) 维护 ( https://whatwg.org/ )。

这些信息之所以对 PHP 开发人员很重要,是因为 PHP 8 决定将 PHP DOM 扩展移至 DOM 生活标准。因此,从 PHP 8 开始,将根据生活标准的变化对该扩展进行一系列渐进和持续的更改。

在大多数情况下,这些更改是向后兼容的。不过,由于某些方法签名的更改是为了与标准保持一致,因此可能会出现代码中断。PHP 8 中对 DOM 扩展的最大改动是引入了两个新接口。让我们来看看这些接口,然后讨论它们对 PHP 开发的影响。

检查新的 DOMParentNode 接口

两个新接口中的第一个是 DOMParentNode。在 PHP 8 中,下列类实现了该接口:

  • DOMDocument

  • DOMElement

  • DOMDocumentFragment

下面是接口定义:

interface DOMParentNode {
    public readonly ?DOMElement $firstElementChild;
    public readonly ?DOMElement $lastElementChild;
    public readonly int $childElementCount;
    public function append(
        ...DOMNode|string|null $nodes) : void;
    public function prepend(
        ...DOMNode|string|null $nodes) : void;
}

需要注意的是,PHP 开发人员无法使用只读属性。不过,接口规范将属性显示为只读,因为它们是内部生成的,不能更改。

实际上,2014 年曾提出过一个 PHP RFC,建议为类属性添加一个 readonly 属性。但该提议已被撤回,因为通过定义常量或简单地标记属性为私有属性就能达到同样的效果!有关该提议的更多信息,请参见 https://wiki.php.net/rfc/readonly_properties

下表总结了新 DOMParentNode 接口的属性和方法:

Table 1. Table 9.2 – DOMParentNode interface methods and properties
方法/属性 描述

$firstElementChild

包含此对象中的第一个 DOMElement 实例或 NULL。

$lastElementChild

包含此对象中的最后一个 DOMElement 实例或 NULL。

$elementChildCount

整数值,表示此对象中子元素的总数。

append()

将节点添加到当前子节点列表的末尾。

prepend()

将节点添加到当前子节点列表的开头。

新界面所代表的功能并没有为现有的 DOM 功能添加任何新内容。它的主要目的是使 PHP DOM 扩展符合生活标准。

未来对 DOM 扩展进行架构改造还有另一个目的。在 PHP 的未来版本中,DOM 扩展将能够操作 DOM 树的整个分支。例如,将来当您发出 append() 命令时,您不仅可以追加一个节点,还可以追加它的所有子节点。更多信息,请参阅以下 RFC: https://wiki.php.net/rfc/dom_living_standard_api

现在,让我们来看看第二个新接口。

检查新的 DOMChildNode 接口

两个新接口中的第二个是 DOMChildNode。在 PHP 8 中,DOMElementDOMCharacterData 类实现了该接口。

下面是接口定义:

interface DOMChildNode {
    public readonly ?DOMElement $previousElementSibling;
    public readonly ?DOMElement $nextElementSibling;
    public function remove() : void;
    public function before(
        ...DOMNode|string|null $nodes) : void;
    public function after(
        ...DOMNode|string|null $nodes) : void;
    public function replaceWith(
        ...DOMNode|string|null $nodes) : void;
}

下表总结了 DOMChildNode 的方法和属性:

Table 2. Table 9.3 – DOMChildNode interface methods and properties
方法/属性 描述

$previousElementSibling

包含此对象中同一节点级别的前一个 DOMElement 实例,或者 NULL。

$nextElementSibling

包含此对象中同一节点级别的下一个 DOMElement 实例,或者 NULL。

remove()

删除此节点的简化方法。 减少了首先获取父节点的需要。

before()

将该对象中同一级别的节点添加到该节点之前。

after()

在该节点之后添加该对象中同一级别的节点。

replaceWith()

将此节点替换为指定元素。

在这种情况下,其功能与现有的 DOM 功能略有不同。最明显的偏离是 DOMChildNode::remove()。在 PHP 8 之前,要删除一个节点,必须访问它的父节点。假设 $topic 是一个 DOMElement 实例,PHP 7 或更早版本的代码可能如下所示:

$topic->parentNode->removeChild($topic);

在 PHP 8 中,相同的代码可以写成如下:

$topic->remove();

除了前面两个表格中提到的新方法外,DOM 功能保持不变。现在,让我们看看如何在 PHP 8 中利用新接口重写移动子节点。

DOM 使用示例—​比较 PHP 7 和 PHP 8

为了说明新界面的使用,让我们来看一个代码示例。在本节中,我们将介绍一个代码块,该代码块使用 DOM 扩展将表示主题 X 的节点从一个文档移动到另一个文档:

  1. 下面是一个 HTML 片段,其中包含一组嵌套的 <div> 标记:

    <!DOCTYPE html>
    <!-- /repo/ch09/dom_test_1.html -->
    <div id="content">
        <div id="A">Topic A</div>
        <div id="B">Topic B</div>
        <div id="C">Topic C</div>
        <div id="X">Topic X</div>
    </div>
  2. 第二个 HTML 片段包括主题 D、E 和 F:

    <!DOCTYPE html>
    <!-- /repo/ch09/dom_test_2.html -->
    <div id="content">
        <div id="D">Topic D</div>
        <div id="E">Topic E</div>
        <div id="F">Topic F</div>
    </div>
  3. 要从两个片段中的每一个创建 DOMDocument 实例,我们可以进行静态调用,即 loadHTMLFile。请注意,这种用法在 PHP 7 中已被弃用,在 PHP 8 中已被删除:

    $doc1 = DomDocument::loadHTMLFile( 'dom_test_1.html');
    $doc2 = DomDocument::loadHTMLFile('dom_test_2.html');
  4. 然后,我们可以将主题 X 提取到 $topic 中,并以 $new 的形式导入到第二个文档中。接下来,检索目标节点,即内容:

    $topic = $doc1->getElementById('X');
    $new = $doc2->importNode($topic);
    $new->textContent= $topic->textContent;
    $main = $doc2->getElementById('content');
  5. 这就是 PHP 7 和 PHP 8 的不同之处。在 PHP 7 中,要移动节点,代码必须如下:

    // /repo/ch09/php7_dom_changes.php
    $main->appendChild($new);
    $topic->parentNode->removeChild($topic);
  6. 不过,在 PHP 8 中,当使用新界面时,代码会更加紧凑。在 PHP 8 中删除主题时,无需引用父节点:

    // /repo/ch09/php8_dom_changes.php
    $main->append($new);
    $topic->remove();
  7. 对于 PHP 7 和 PHP 8,我们可以像这样查看生成的 HTML:

    echo $doc1->saveHTML();
    echo $doc2->saveHTML();
  8. 另一个区别是如何提取 $main 的最后一个新子元素的值。下面是在 PHP 7 中可能出现的情况:

    // /repo/ch09/php7_dom_changes.php
    echo $main->lastChild->textContent . "\n";
  9. 在 PHP 8 中也是如此:

    // /repo/ch09/php8_dom_changes.php
    echo $main->lastElementChild->textContent . "\n";

两个示例代码的输出结果略有不同。在 PHP 7 中,您将看到一个弃用通知,如图所示:

root@php8_tips_php7 [ /repo/ch09 ]# php php7_dom_changes.php
PHP Deprecated: Non-static method DOMDocument::loadHTMLFile()
should not be called statically in /repo/ch09/php7_dom_changes.
php on line 6

如果我们尝试在 PHP 8 中运行 PHP 7 代码,由于 loadHTMLFile() 方法的静态使用不再被允许,因此会出现致命错误。否则,如果我们运行纯 PHP 8 示例,输出将如下所示:

root@php8_tips_php8 [ /repo/ch09 ]# php php8_dom_changes.php
<!DOCTYPE html>
<html><body><div id="content">
<div id="A">Topic A</div>
<div id="B">Topic B</div>
<div id="C">Topic C</div>
</div>
</body></html>
<!DOCTYPE html>
<html><body><div id="content">
<div id="D">Topic D</div>
<div id="E">Topic E</div>
<div id="F">Topic F</div>
<div id="X">Topic X</div></div>
</body></html>
Last Topic in Doc 2: Topic X

正如您所看到的,无论在哪种情况下,主题 X 都从第一个 HTML 片段移到了第二个 HTML 片段中。

在未来的 PHP 版本中,DOM 扩展将继续发展,同时遵循 DOM 的生活标准。此外,它的使用也会越来越简单,提供更多的灵活性和效率。

现在,让我们来看看 DateTime 扩展的变化。

使用新的 DateTime 方法

在处理日期和时间时,创建 DateTimeImmutable 实例通常很有用。DateTimeImmutable 对象与 DateTime 对象相同,只是其属性值不能更改。了解如何在 DateTimeDateTimeImmutable 之间来回切换是一项有用的技术,可以避免许多隐藏的逻辑错误。

在讨论 PHP 8 中的改进之前,我们先来看看 DateTimeImmutable 所解决的潜在问题。

DateTimeImmutable 的用例

在这个简单的示例中,将创建一个由三个实例组成的数组,分别代表从今天起的 30 天、60 天和 90 天。这些实例将构成 30-60-90 天应收账款账龄报告的基础:

  1. 首先,让我们初始化几个关键变量,它们分别代表时间间隔、日期格式和用于保存最终值的数组:

    // /repo/ch09/php7_date_time_30-60-90.php
    $days = [0, 30, 60, 90];
    $fmt = 'Y-m-d';
    $aging = [];
  2. 现在,让我们定义一个循环,将时间间隔添加到 DateTime 实例中,生成(希望能生成!)一个代表 0、30、60 和 90 天的数组。资深开发人员很可能已经发现了这个问题!

    $dti = new DateTime('now');
    foreach ($days as $span) {
        $interval = new DateInterval('P' . $span . 'D');
        $item = $dti->add($interval);
        $aging[$span] = clone $item;
    }
  3. 接下来,显示已生成的一组日期:

    echo "Day\tDate\n";
    foreach ($aging as $key => $obj)
        echo "$key\t" . $obj->format($fmt) . "\n";
  4. 输出完全是一场灾难,如下所示:

    root@php8_tips_php7 [ /repo/ch09 ]#
    php php7_date_time_30-60-90.php
    Day     Date
    0       2021-11-20
    30      2021-06-23
    60      2021-08-22
    90      2021-11-20

    正如你所看到的,问题在于 DateTime 类不是不可变的。因此,每次添加 DateInterval 时,原始值都会被更改,导致显示的日期不准确。

  5. 不过,只要做一个简单的修改,我们就能纠正这个问题。我们只需创建一个 DateTimeImmutable 实例,而不是最初创建的 DateTime 实例:

    $dti = new DateTimeImmutable('now');
  6. 不过,要想用 DateTime 实例填充数组,我们需要将 DateTimeImmutable 转换为 DateTime。在 PHP 7.3 中,引入了 DateTime::createFromImmutable() 方法。因此,当赋值给 $aging 时,修改后的代码可能如下所示:

    $aging[$span] = DateTime::createFromImmutable($item);
  7. 否则,就只能创建一个新的 DateTime 实例,如图所示:

    $aging[$span] = new DateTime($item->format($fmt));

只需做这一个改动,正确的输出结果就会如下所示:

Day   Date
0     2021-05-24
30    2021-06-23
60    2021-07-23
90    2021-08-22

您现在知道 DateTimeImmutable 的用法了,也知道如何转换成 DateTime 了。在 PHP 8 中,由于引入了 createFromInterface() 方法,两种对象类型之间的转换变得更容易了。

检查 createFromInterface() 方法

在 PHP 8 中,在 DateTimeDateTimeImmutable 之间进行转换和返回要容易得多。这两个类都添加了一个名为 createFromInterface() 的新方法。该方法的签名只是简单地调用 DateTimeInterface 实例,这意味着 DateTimeDateTimeImmutable 实例都可以作为该方法的参数。

下面的简短代码示例演示了在 PHP 8 中将一种类型转换为另一种类型是多么容易:

  1. 首先,让我们定义一个 DateTimeImmutable 实例,并呼应它的类和日期:

    // /repo/ch09/php8_date_time.php
    $fmt = 'l, d M Y';
    $dti = new DateTimeImmutable('last day of next month');
    echo $dti::class . ':' . $dti->format($fmt) . "\n";
  2. 然后,从 $dti 中创建一个 DateTime 实例,并添加一个 90 天的间隔,显示其类别和当前日期:

    $dtt = DateTime::createFromInterface($dti);
    $dtt->add(new DateInterval('P90D'));
    echo $dtt::class . ':' . $dtt->format($fmt) . "\n";
  3. 最后,从 $dtt 创建 DateTimeImmutable 实例,并显示其类和日期:

    $dtx = DateTimeImmutable::createFromInterface($dtt);
    echo $dtx::class . ':' . $dtx->format($fmt) . "\n";

以下是该代码示例在 PHP 8 中运行的输出结果:

root@php8_tips_php8 [ /repo/ch09 ]# php php8_date_time.php
DateTimeImmutable:Wednesday, 30 Jun 2021
DateTime:Tuesday, 28 Sep 2021
DateTimeImmutable:Tuesday, 28 Sep 2021

正如你所看到的,我们使用了相同的 createFromInterface() 方法来创建实例。当然,请记住,我们实际上并没有把类实例转换成另一个实例。相反,我们创建的是不同类类型的克隆实例。

你现在知道为什么要使用 DateTimeImmutable 而不是 DateTime 了吧。您还知道,在 PHP 8 中,一个名为 createFromInterface() 的新方法提供了从一个类创建另一个类实例的统一方法。接下来,我们将看看在 PHP 8 中是如何改进对特质的处理的。

了解 PHP 8 特征处理改进

特质的实现最早是在 PHP 5.4 版本中引入的。从那时起,我们不断对其进行改进。PHP 8 延续了这一趋势,它提供了一种方法,当多个 traits 有相互冲突的方法时,可以清楚地识别哪些方法被使用。此外,除了消除可见性声明中的不一致性外,PHP 8 还解决了特质如何处理(或不处理!)抽象方法的问题。

作为一名开发人员,完全掌握特质的使用可以让你编写的代码更高效、更易于维护。特质可以帮助你避免产生冗余代码。它们可以解决在不同命名空间或不同类继承结构中需要使用相同逻辑的问题。本节介绍的信息将帮助您在 PHP 8 下运行的代码中正确使用特性。

首先,让我们看看在 PHP 8 中如何解决 traits 之间的冲突。

解决特征之间的方法冲突

只需列出以逗号分隔的特征名,即可使用多个特征。但是,如果两个 trait 定义了相同的方法,就会出现潜在的问题。为了解决这种冲突,PHP 提供了 as 关键字。在 PHP 7 及以下版本中,要避免两个同名方法之间的冲突,只需重命名其中一个方法即可。执行重命名的代码如下:

use Trait1, Trait2 { <METHOD> as <NEW_NAME>; }

然而,这种方法的问题在于 PHP 是在做一个无声的假设:假定 METHOD 来自 Trait1!为了继续执行良好的编码规范,PHP 8 不再允许这种假设。在 PHP 8 中,解决办法是使用 insteadof 而不是 as,从而更加具体。

下面是一个微不足道的例子来说明这个问题:

  1. 首先,让我们定义两个特质,它们定义了相同的方法 test(),但返回的结果不同:

    // /repo/ch09/php7_trait_conflict_as.php
    trait Test1 {
        public function test() {
            return '111111';
        }
    }
    trait Test2 {
        public function test() {
            return '222222';
        }
    }
  2. 然后,定义一个匿名类,使用这两种特质,并将 test() 指定为 otherTest() 以避免命名冲突:

    $main = new class () {
        use Test1, Test2 { test as otherTest; }
        public function test() { return 'TEST'; }
    };
  3. 接下来,定义一个代码块来回显这两个方法的返回值:

    echo $main->test() . "\n";
    echo $main->otherTest() . "\n";

下面是 PHP 7 的输出结果:

root@php8_tips_php7 [ /repo/ch09 ]#
php php7_trait_conflict_as.php
TEST
111111

正如您所看到的,PHP 7 会默默地认为我们的意思是将 Trait1::test() 重命名为 otherTest()。但从示例代码中,我们完全看不出这是程序员的意图!

在 PHP 8 中运行相同的示例代码,我们会得到不同的结果:

root@php8_tips_php8 [ /repo/ch09 ]#
php php7_trait_conflict_as.php
PHP Fatal error: An alias was defined for method test(), which
exists in both Test1 and Test2. Use Test1::test or Test2::test
to resolve the ambiguity in /repo/ch09/php7_trait_conflict_
as.php on line 6

很明显,PHP 8 不会做这种无声的假设,因为它们很容易导致意想不到的行为。在本例中,最好的做法是使用作用域解析(::)操作符。以下是重写后的代码:

$main = new class () {
    use Test1, Test2 { Test1::test as otherTest; }
    public function test() { return 'TEST'; }
};

如果我们在 PHP 8 中重新运行该代码,输出结果将与 PHP 7 的输出结果相同。作用域解析操作符确认 Trait1test() 方法的源特质,从而避免了任何歧义。现在,让我们看看 PHP 8 特质是如何处理抽象方法签名的。

使用特征抽象签名检查

API 开发人员都很清楚,将方法标记为抽象方法是向 API 用户发出信号,表明方法是必须使用的,但尚未定义。这种技术不仅允许 API 开发人员决定方法的名称,还可以决定其签名。

然而,在 PHP 7 及以下版本中,在特质中定义的抽象方法会忽略签名,这就违背了使用抽象方法的部分初衷!在 PHP 8 中使用带有抽象方法的特质时,会根据使用该特质的类中的实现来检查其签名。

下面的示例在 PHP 7 中有效,但在 PHP 8 中由于方法签名不同而失败:

  1. 首先,让我们声明严格的类型检查,并定义一个带有抽象方法的特质,即 add()。请注意,该方法的签名调用的都是整数数据类型:

    // /repo/ch09/php7_trait_abstract_signature.php
    declare(strict_types=1);
    trait Test1 {
        public abstract function add(int $a, int $b) : int;
    }
  2. 接下来,定义一个匿名类,使用该特质并定义 add()。请注意,该类的数据类型都是 float

    $main = new class () {
        use Test1;
        public function add(float $a, float $b) : float {
            return $a + $b;
        }
    };
  3. 然后,回显 111.111 和 222.222 相加的结果:

    echo $main->add(111.111, 222.222) . "\n";

这个在 PHP 7 中运行的小代码示例的结果令人惊讶:

root@php8_tips_php7 [ /repo/ch09 ]#
php php7_trait_abstract_signature.php
333.333

从结果中可以看出,特性中抽象定义的方法签名被完全忽略了!但在 PHP 8 中,结果就大不相同了。下面是代码在 PHP 8 中运行的输出结果:

root@php8_tips_php8 [ /repo/ch09 ]#
php php7_trait_abstract_signature.php
PHP Fatal error: Declaration of class@anonymous::add(float $a,
float $b): float must be compatible with Test1::add(int $a,
int $b): int in /repo/ch09/php7_trait_abstract_signature.php on
line 9

前面的 PHP 8 输出向我们表明,无论抽象方法定义的来源如何,良好的编码实践都会被执行。

本节的最后一个主题将告诉你如何在 traits 中处理抽象私有方法。

处理特征中的私有抽象方法

一般来说,在 PHP 中,无法对抽象超类中的 抽象私有方法 实施控制,因为它不会被继承。但在 PHP 8 中,可以在特质(trait)中定义抽象私有方法!在进行应用程序接口(API)开发时,如果要求使用类定义一个指定的私有方法,这就可以作为一种强制使用代码的机制。

请注意,虽然可以在 PHP 8 特质中将抽象方法指定为私有方法,但在使用特质的类中,特质方法的可见性很容易被覆盖。因此,我们将不在本节中展示任何代码示例,因为私有抽象特质方法的效果与使用其他可见性级别的抽象特质方法完全相同。

有关 PHP 8 中 trait 抽象方法处理的更多信息,请参阅此 RFC: https://wiki.php.net/rfc/abstract_trait_method_validation

现在,让我们来看看私有方法的一般用法变化。