PHP8 发现潜在的 OOP 向后兼容性中断详解

本章标志着本书第 2 部分的开始,PHP8 技巧。在本部分中,您将发现 PHP8 的黑暗角落:向后兼容性中断存在的地方。本部分让您深入了解如何在将现有应用迁移到 PHP8 之前避免问题。您将了解如何在现有代码中查找可能导致其在 PHP8 升级后停止工作的内容。一旦您掌握了本书这一部分中介绍的主题,您将能够很好地修改现有代码,使其在 PHP8 升级后继续正常运行。

在本章中,将向您介绍针对面向对象编程(OOP)的新 PHP8 特性。本章为您提供了大量简短的代码示例,这些示例清楚地说明了新的特性和概念。本章对于帮助您快速利用 PHP8 的强大功能非常关键,因为您可以根据自己的实践调整代码示例。本章的重点是在 PHP8 迁移后,面向对象代码可能中断的情况。

本章涵盖的主题包括以下内容:

要检查并运行本章中提供的代码示例,建议使用的最低硬件如下:

  • 基于 x86_64 的台式 PC 或笔记本电脑
  • 1 GB 的可用磁盘空间
  • 4 GB 内存
  • 每秒 500 千比特(Kbps)或更快的 internet 连接

此外,您还需要安装以下软件:

  • 码头工人
  • Docker Compose

请参考第 1 章中的技术要求一节,介绍新的 PHP8 OOP 特性,了解有关 Docker 和 Docker Compose 安装的更多信息,以及如何构建用于演示本书中解释的代码的 Docker 容器。在本书中,我们将您还原本书样本代码的目录称为/repo

本章的源代码位于以下位置:https://github.com/PacktPublishing/PHP-8-Programming-Tips-Tricks-and-Best-Practices.

我们现在可以通过检查核心 OOP 编码差异来开始讨论。

在 PHP8 中编写 OOP 代码的方式有很多重大的改变。在本节中,我们将重点介绍三个可能会给您带来潜在向后兼容性中断的关键领域。我们在本节中讨论的领域是与进行静态方法调用、处理对象属性和 PHP 自动加载相关的常见错误实践。

在阅读了本节内容,并通过示例进行研究之后,您将能够更好地发现 OOP 的错误做法,并了解 PHP8 是如何限制此类使用的。在本章中,您将学习良好的编码实践,这将最终使您成为一名更好的程序员。您还可以解决 PHP 自动加载中可能导致迁移到 PHP8 的应用失败的更改。

让我们首先看看 PHP8 是如何加紧进行静态调用的。

在 PHP8 中处理静态调用

令人惊讶的是,PHP 版本 7 及以下版本允许开发人员对未声明static的类方法进行静态调用。乍一看,任何未来的开发人员审查您的代码时,都会立即假定该方法已定义为static。这可能会导致未来的开发人员在错误的假设下开始误用代码时出现意外行为。

在这个简单的例子中,我们用一个nonStatic()方法定义了一个Test类。在类定义之后的过程代码中,我们回显此方法的返回值,但是,在这样做时,我们进行了一个静态调用:

// /repo/ch05/php8_oop_diff_static.php
class Test {
    public function notStatic() {
        return __CLASS__ . PHP_EOL;
    }
}
echo Test::notStatic();

在 PHP 7 中运行此代码时,结果如下:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_oop_diff_static.php
PHP Deprecated:  Non-static method Test::notStatic() should not be called statically in /repo/ch05/php8_oop_diff_static.php on line 11
Test

从输出中可以看到,PHP7 发出了一个弃用通知,但允许进行调用!然而,在 PHP 8 中,结果是致命的Error,如下所示:

root@php8_tips_php8 [ /repo/ch05 ]#
php php8_oop_diff_static.php
PHP Fatal error:  Uncaught Error: Non-static method Test::notStatic() cannot be called statically in /repo/ch05/php8_oop_diff_static.php:11

使用静态方法调用语法调用非静态方法是一种不好的做法,因为编写良好的代码使代码开发人员的意图非常清楚。如果您不将某个方法定义为静态的,但以后以静态的方式调用它,那么指定在将来维护您的代码的开发人员可能会感到困惑,并可能对代码的原始意图做出错误的假设。最终的结果将是更糟糕的代码!

在 PHP8 中,您不能再使用静态方法调用来调用非静态方法。现在让我们看一看另一个糟糕的做法,即将对象属性视为键。

处理对象属性处理变更

从最早的版本开始,数组一直是 PHP 的核心特性。另一方面,OOP 直到 PHP4 才被引入。在 OOP 的早期,数组函数经常被扩展以适应对象属性。这导致对象和数组之间的区别变得模糊,这反过来又导致了许多不良做法。

为了在数组处理和对象处理之间保持清晰的分离,PHP8 现在限制array_key_exists()函数只接受数组作为参数。为了说明这一点,考虑下面的例子:

  1. 首先,我们用一个属性定义一个简单的匿名类:

    // /repo/ch05/php8_oop_diff_array_key_exists.php
    $obj = new class () { public $var = 'OK.'; };
  2. 然后,我们使用isset()property_exists()array_key_exists()

    // not all code is shown
    $default = 'DEFAULT';
    echo (isset($obj->var)) 
        ? $obj->var : $default;
    echo (property_exists($obj,'var')) 
        ? $obj->var : $default;
    echo (array_key_exists('var',$obj)) 
        ? $obj->var : $default;

    运行三个测试,每个测试都检查$var是否存在

当我们在 PHP7 中运行此代码时,所有测试都成功,如下所示:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_oop_diff_array_key_exists.php
OK.OK.OK.

然而,在 PHP8 中,会出现致命的TypeError,因为array_key_exists()现在只接受数组作为参数。PHP 8 输出如下所示:

root@php8_tips_php8 [ /repo/ch05 ]# 
php php8_oop_diff_array_key_exists.php
OK.OK.PHP Fatal error:  Uncaught TypeError: array_key_exists(): Argument #2 ($array) must be of type array, class@anonymous given in /repo/ch05/php8_oop_diff_array_key_exists.php:10

最佳做法是使用property_exists()isset()。现在我们将注意力转向 PHP 自动加载中的更改。

使用 PHP8 自动加载

PHP5.1 中首次引入的基本自动加载类机制在 PHP8 中的工作原理相同。主要区别在于 PHP7.2 中不推荐使用的对全局函数__autoload()的支持在 PHP8 中被完全删除。从 PHP7.2 开始,开发人员被鼓励使用spl_autoload_register()注册他们的自动加载逻辑,自 PHP5.1 起就可用于此目的。另一个主要区别是spl_autoload_register()在无法注册自动加载器时的反应。

了解使用spl_autoload_register()时自动加载过程的工作方式对您作为开发人员的工作至关重要。未能掌握 PHP 如何自动定位和加载类将限制您作为开发人员成长的能力,并可能对您的职业道路产生不利影响。

在进入spl_autoload_register()之前,我们先来看看__autoload()函数。

了解 _autoload()函数

__autoload()函数被许多开发人员用作自动加载逻辑的主要来源。此函数的行为类似于魔术方法,这就是它根据上下文自动调用的原因。触发自动调用__autoload()函数的情况包括创建新类实例但尚未加载类定义的时刻。此外,如果该类扩展了另一个类,那么也会调用自动加载逻辑,以便在创建扩展它的子类之前加载该超类。

使用__autoload()函数的优点是它很容易定义,并且通常在网站的初始index.php文件中定义。缺点包括以下几点:

  • __autoload()是一个 PHP 过程函数;未使用 OOP 原则定义或控制。例如,在为应用定义单元测试时,这可能会成为一个问题。
  • 如果应用使用名称空间,__autoload()函数必须在全局名称空间中定义;否则,定义了__autoload()函数的命名空间之外的类将无法加载。
  • __autoload()功能与spl_autoload_register()不兼容。如果同时使用__autoload()功能和spl_autoload_register()功能定义自动加载逻辑,__autoload()功能逻辑将被完全忽略。

为了说明潜在的问题,我们将定义一个OopBreakScan类,在第 11 章中详细讨论,将现有 PHP 应用迁移到 PHP 8

  1. First, we define and then add a method to the OopBreakScan class that scans the file contents for the __autoload() function. Note that the error message is a class constant defined in the Base class that simply warns of the presence of the __autoload() function:

    namespace Migration;
    class OopBreakScan extends Base {
        public static function scanMagicAutoloadFunction(
            string $contents, array &$message) : bool {
            $found  = 0;
            $found += (stripos($contents, 
                'function __autoload(') !== FALSE);
            $message[] = ($found)
                       ? Base::ERR_MAGIC_AUTOLOAD
                       : sprintf(Base::OK_PASSED,
                           __FUNCTION__);
            return (bool) $found;
        }
        // remaining methods not shown

    该类扩展了一个Migration\Base类(未显示)。这一点很重要,因为任何自动加载逻辑不仅需要找到子类,还需要找到它的超类。

  2. 接下来,我们定义一个调用程序,其中定义了一个神奇的__autoload()函数:

    // /repo/ch05/php7_autoload_function.php
    function __autoLoad($class) {
        $fn = __DIR__ . '/../src/'
            . str_replace('\\', '/', $class)
            . '.php';
        require_once $fn;
    }
  3. 然后我们通过让调用程序扫描自身来使用该类:

    use Migration\OopBreakScan;
    $contents = file_get_contents(__FILE__);
    $message  = [];
    OopBreakScan::
        scanMagicAutoloadFunction($contents, $message);
    var_dump($message);

以下是在 PHP 7 中运行的输出:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php7_autoload_function.php
/repo/ch05/php7_autoload_function.php:23:
array(1) {
  [0] =>  string(96) "WARNING: the "__autoload()" function is removed in PHP 8: replace with "spl_autoload_register()""
}

从输出中可以看到,Migration\OopBreakScan类是自动加载的。我们之所以知道这一点,是因为调用了scanMagicAutoloadFunction方法,我们得到了它的结果。此外,我们知道Migration\Base类也是自动加载的。我们之所以知道这一点,是因为输出中出现的错误消息是超类的常量。

但是,在 PHP 8 中运行的相同代码会产生以下结果:

root@php8_tips_php8 [ /repo/ch05 ]# 
php php7_autoload_function.php 
PHP Fatal error:  __autoload() is no longer supported, use spl_autoload_register() instead in /repo/ch05/php7_autoload_function.php on line 4

这个结果并不令人惊讶,因为 PHP8 中删除了对神奇的__autoload()函数的支持。在 PHP8 中,必须使用spl_autoload_register()代替。现在我们来关注spl_autoload_register()

学习使用 spl 自动加载 寄存器()

spl_autoload_register()功能的主要优点是允许您注册多个自动加载器。尽管这看起来有点过头了,但想象一下噩梦般的场景,您正在使用许多不同的开源 PHP 库。。。他们都有自己的自动装弹机定义!只要所有这些库都使用spl_autoload_register(),拥有多个自动加载回调就不会有问题。

使用spl_autoload_register()注册的每个自动加载器必须可调用。以下任何一项均被视为callable

  • 一个 PHP 过程函数

  • 匿名函数

  • 可以以静态方式调用的类方法

  • 定义__invoke()魔术方法的任何类实例

  • An array in this form: [$instance, 'method']

    提示

    作曲家维护自己的自动加载器,而自动加载器又依赖spl_autoload_register()。如果您使用 Composer 来管理您的开放源码 PHP 软件包,您只需在应用代码的开头包含/path/to/project/vendor/autoload.php即可使用 Composer autoloader。要让 Composer 自动加载您的应用源代码文件,请在autoload : psr-4键下的composer.json文件中添加一个或多个条目。更多信息,请参见https://getcomposer.org/doc/04-schema.md#psr-4

一个非常典型的自动加载器类可能出现如下所示。注意这是我们在本书所示的许多 OOP 示例中使用的类:

  1. __construct()方法中,我们分配源目录。接下来,我们使用上面提到的数组可调用语法调用spl_auto_register()

    // /repo/src/Server/Autoload/Loader.php
    namespace Server\Autoload;
    class Loader {
        const DEFAULT_SRC = __DIR__ . '/../..';
        public $src_dir = '';
        public function __construct($src_dir = NULL) {
            $this->src_dir = $src_dir 
                ?? realpath(self::DEFAULT_SRC);
            spl_autoload_register([$this, 'autoload']);
        }
  2. 实际的自动加载代码与上面的__autoload()函数示例中显示的代码类似。以下是实际自动加载的方法:

        public function autoload($class) {
            $fn = str_replace('\\', '/', $class);
            $fn = $this->src_dir . '/' . $fn . '.php';
            $fn = str_replace('//', '/', $fn);
            require_once($fn);
        }
    }

既然您已经了解了如何使用spl_auto_register()函数,我们必须检查运行 PHP8 时可能出现的代码中断。

PHP8 中可能出现的 spl_auto_register()代码中断

spl_auto_register()函数的第二个参数是一个可选的布尔值,默认为FALSE。如果第二个参数设置为TRUE,则如果自动加载器注册失败,spl_auto_register()函数在 PHP 7 及以下版本中抛出Exception。然而,在 PHP8 中,如果第二个参数的数据类型不是callable,则会抛出致命的TypeError,而不管第二个参数的值是多少!

下面显示的简单程序示例说明了这种危险。在本例中,我们使用spl_auto_register()函数注册一个不存在的 PHP 函数。我们将第二个参数设置为TRUE

// /repo/ch05/php7_spl_spl_autoload_register.php
try {
    spl_autoload_register('does_not_exist', TRUE);
    $data = ['A' => [1,2,3],'B' => [4,5,6],'C' => [7,8,9]];
    $response = new \Application\Strategy\JsonResponse($data);
    echo $response->render();
} catch (Exception $e) {
    echo "A program error has occurred\n";
}

如果我们在 PHP7 中运行这段代码,结果如下:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php7_spl_spl_autoload_register.php 
A program error has occurred

正如您可以从输出中确定的那样,抛出一个Exception。调用catch块,出现程序错误消息。但是,当我们在 PHP 8 中运行相同的程序时,会抛出一个致命的Error

root@php8_tips_php8 [ /repo/ch05 ]# 
php php7_spl_spl_autoload_register.php 
PHP Fatal error:  Uncaught TypeError: spl_autoload_register(): Argument #1 ($callback) must be a valid callback, no array or string given in /repo/ch05/php7_spl_spl_autoload_register.php:12

显然,catch块被绕过,因为它被设计用来捕捉Exception,而不是Error。简单的解决方案是使用catch块捕捉Throwable而不是Exception。这允许相同的代码在 PHP7 或 PHP8 中运行。

下面是重写代码的显示方式。输出未显示,因为它与在 PHP 7 中运行的相同示例相同:

// /repo/ch05/php8_spl_spl_autoload_register.php
try {
    spl_autoload_register('does_not_exist', TRUE);
    $data = ['A' => [1,2,3],'B' => [4,5,6],'C' => [7,8,9]];
    $response = new \Application\Strategy\JsonResponse($data);
    echo $response->render();
} catch (Throwable $e) {
    echo "A program error has occurred\n";
}

现在您对 PHP8 自动加载有了更好的理解,以及如何发现和纠正潜在的自动加载向后兼容性中断。现在让我们看一下 PHP8 中与魔术方法有关的更改。

PHP魔术方法是中断 OOP 应用正常流程的预定义挂钩。如果定义了每个魔术方法,则从创建对象实例的那一刻起,直到实例超出范围为止,都会改变应用的行为。

重要提示

当对象实例未设置或被覆盖时,它会超出范围。在函数或类方法中定义对象实例时,对象实例也会超出范围,并且该函数或类方法的执行将结束。最终,如果没有其他原因,当 PHP 程序结束时,对象实例将超出范围。

本节将让您深入了解 PHP8 中引入的魔术方法用法和行为的重要变化。一旦您了解了本节中描述的情况,您将能够进行适当的代码修改,以防止在迁移到 PHP8 时应用代码失败。

让我们首先看看对对象构造方法的更改。

处理施工变更

理想情况下,类构造函数是一个方法,在创建对象实例时自动调用,并用于执行某种类型的对象初始化。此初始化大多数通常涉及使用作为此方法参数提供的值填充对象属性。初始化还可以执行任何必要的任务,如打开文件句柄、建立数据库连接等。

在 PHP8 中,类构造函数的调用方式发生了许多变化。这意味着,当您将应用迁移到 PHP8 时,可能会出现向后兼容性中断。我们将检查的第一个更改与不推荐使用与用作类构造函数的类同名的方法有关。

处理同名方法和类中的更改

在 PHP 版本 4 中引入的第一个 PHP OOP 实现中,确定与类同名的方法将承担类构造函数的角色,并在创建新对象实例时自动调用。

一个鲜为人知的事实是,即使在 PHP8 中,函数、方法甚至类名都是不区分大小写的。因此$a = new ArrayObject();相当于$b = new arrayobject();。另一方面,变量名是区分大小写的。

从 PHP5 开始,伴随着一个新的、更加健壮的 OOP 实现,引入了一些神奇的方法。其中一个方法是__construct(),专门为类构造保留,旨在取代旧用法。PHP5 的其余版本以及 PHP7 的所有版本都支持使用与类同名的方法作为构造函数。

在 PHP8 中,对与类本身同名的类构造函数方法的支持已被删除。如果还定义了一个__construct()方法,您将不会有问题:__construct()作为类构造函数优先。如果没有__construct()方法,并且您检测到与class``()同名的方法,则您有可能失败。请记住,方法名和类名都不区分大小写!

请看下面的示例。它在 PHP 7 中工作,但在 PHP 8 中不工作:

  1. 首先,我们使用同名的类构造函数方法定义一个Text类。构造函数方法根据提供的文件名创建一个SplFileObject实例:

    // /repo/ch05/php8_oop_bc_break_construct.php
    class Text {
        public $fh = '';
        public const ERROR_FN = 'ERROR: file not found';
        public function text(string $fn) {
            if (!file_exists($fn))
                throw new Exception(self::ERROR_FN);
            $this->fh = new SplFileObject($fn, 'r');
        }
        public function getText() {
            return $this->fh->fpassthru();
        }
    }
  2. 然后我们添加三行过程代码来练习该类,提供包含葛底斯堡地址的文件名:

    $fn   = __DIR__ . '/../sample_data/gettysburg.txt';
    $text = new Text($fn);
    echo $text->getText();
  3. 在 PHP7 中运行该程序首先会生成一个弃用通知,然后是预期的文本。此处仅显示输出的前几行:

    root@php8_tips_php7 [ /repo/ch05 ]# 
    php php8_bc_break_construct.php
    PHP Deprecated:  Methods with the same name as their class will not be constructors in a future version of PHP; Text has a deprecated constructor in /repo/ch05/php8_bc_break_construct.php on line 4
    Fourscore and seven years ago our fathers brought forth on this continent a new nation, conceived in liberty and dedicated to the proposition that all men are created equal. ... <remaining text not shown>
  4. 但是,在 PHP8 中运行相同的程序时,会抛出一个致命的Error,正如您从这个输出中看到的:

    root@php8_tips_php8 [ /repo/ch05 ]# php php8_bc_break_construct.php 
    PHP Fatal error:  Uncaught Error: Call to a member function fpassthru() on string in /repo/ch05/php8_bc_break_construct.php:16

需要注意的是,PHP8 中显示的错误并没有告诉您程序失败的真正原因。因此,扫描 PHP 应用,尤其是较旧的应用,以查看是否存在与类同名的方法,这一点非常重要。因此,最佳实践是简单地将与类同名的方法重命名为__construct()

现在让我们看看在 PHP8 中如何处理类构造函数中的Exceptionexit的不一致性。

解决类构造函数中的不一致性

PHP8 中解决的另一个问题与有关,即类构造方法要么抛出Exception,要么执行exit()。在 PHP8 之前的 PHP 版本中,如果类构造函数中抛出了一个Exception,那么__destruct()方法(如果已定义)将不会被调用。另一方面,如果构造函数中使用了exit()die()(两个 PHP 函数彼此等效),则调用__destruct()方法。在 PHP8 中,这种不一致性得到了解决。现在,在这两种情况下,__destruct()方法都没有被调用。

你可能想知道为什么这会引起关注。您需要注意这一重要变化的原因是,您可能在__destruct()方法中有逻辑,该方法在您也可能调用exit()die()的情况下被调用。在 PHP8 中,您不能再依赖此代码,这可能会导致向后兼容性中断。

在本例中,我们有两个连接类。ConnectPdo使用 PDO 扩展提供查询结果,ConnectMysqli使用 MySQLi 扩展:

  1. 我们首先定义一个接口,指定一个查询方法。此方法需要一个 SQL 字符串作为参数,并应返回一个数组作为结果:

    // /repo/src/Php7/Connector/ConnectInterface.php
    namespace Php7\Connector;
    interface ConnectInterface {
        public function query(string $sql) : array;
    }
  2. 接下来,我们定义一个基类,其中定义了一个__destruct()魔术方法。因为该类实现了ConnectInterface但没有定义query(),所以标记为abstract

    // /repo/src/Php7/Connector/Base.php
    namespace Php7\Connector;
    abstract class Base implements ConnectInterface {
        const CONN_TERMINATED = 'Connection Terminated';
        public $conn = NULL;
        public function __destruct() {
            $message = get_class($this)
                     . ':' . self::CONN_TERMINATED;
            error_log($message);
        }
    }
  3. 接下来,我们定义ConnectPdo类。它扩展了Base,其query()方法使用PDO语法生成结果。__construct()方法在创建连接时出现问题时抛出PDOException

    // /repo/src/Php7/Connector/ConnectPdo.php
    namespace Php7\Connector;
    use PDO;
    class ConnectPdo extends Base {
        public function __construct(
            string $dsn, string $usr, string $pwd) {
            $this->conn = new PDO($dsn, $usr, $pwd);
        }
        public function query(string $sql) : array {
            $stmt = $this->conn->query($sql);
            return $stmt->fetchAll(PDO::FETCH_ASSOC);
        }
    }
  4. 以类似的方式,我们定义了ConnectMysqli类。它扩展了Base,其query()方法使用MySQLi语法生成结果。如果创建连接时出现问题,__construct()方法将执行die()

    // /repo/src/Php7/Connector/ConnectMysqli.php
    namespace Php7\Connector;
    class ConnectMysqli extends Base {
        public function __construct(
            string $db, string $usr, string $pwd) {
            $this->conn = mysqli_connect('localhost', 
                $usr, $pwd, $db) 
                or die("Unable to Connect\n");
        }
        public function query(string $sql) : array {
            $result = mysqli_query($this->conn, $sql);
            return mysqli_fetch_all($result, MYSQLI_ASSOC);
        }
    }
  5. 最后,我们定义了一个调用程序,该程序使用前面描述的两个连接类,并为连接字符串、用户名和密码定义了无效值:

    // /repo/ch05/php8_bc_break_destruct.php
    include __DIR__ . '/../vendor/autoload.php';
    use Php7\Connector\ {ConnectPdo,ConnectMysqli};
    $db  = 'test';
    $usr = 'fake';
    $pwd = 'xyz';
    $dsn = 'mysql:host=localhost;dbname=' . $db;
    $sql = 'SELECT event_name, event_date FROM events';
  6. 接下来,在调用程序中,我们调用这两个类并尝试执行查询。连接故意失败,因为我们提供了错误的用户名和密码:

    $ptn = "%2d : %s : %s\n";
    try {
        $conn = new ConnectPdo($dsn, $usr, $pwd);
        var_dump($conn->query($sql));
    } catch (Throwable $t) {
        printf($ptn, __LINE__, get_class($t), 
               $t->getMessage());
    }
    $conn = new ConnectMysqli($db, $usr, $pwd);
    var_dump($conn->query($sql));
  7. 正如您现在从上面的讨论中了解到的,在 PHP7 中运行的输出显示了在创建ConnectPdo实例时从类构造函数中抛出PDOException。另一方面,当ConnectMysqli实例失败时,调用die(),消息无法连接。您还可以在输出的最后一行看到源自__destruct()方法的错误日志信息。这是输出:

    root@php8_tips_php7 [ /repo/ch05 ]# 
    php php8_bc_break_destruct.php 
    15 : PDOException : SQLSTATE[28000] [1045] Access denied for user 'fake'@'localhost' (using password: YES)
    PHP Warning:  mysqli_connect(): (HY000/1045): Access denied for user 'fake'@'localhost' (using password: YES) in /repo/src/Php7/Connector/ConnectMysqli.php on line 8
    Unable to Connect
    Php7\Connector\ConnectMysqli:Connection Terminated
  8. 在 PHP8 中,在这两种情况下都不调用__destruct()方法,从而产生此处所示的输出。正如您在输出中看到的,PDOException被捕获,并发出die()命令。__destruct()方法没有输出。PHP 8 输出如图所示:

    root@php8_tips_php8 [ /repo/ch05 ]# 
    php php8_bc_break_destruct.php 
    15 : PDOException : SQLSTATE[28000] [1045] Access denied for user 'fake'@'localhost' (using password: YES)
    PHP Warning:  mysqli_connect(): (HY000/1045): Access denied for user 'fake'@'localhost' (using password: YES) in /repo/src/Php7/Connector/ConnectMysqli.php on line 8
    Unable to Connect

既然您已经了解了如何发现__destruct()方法的潜在代码中断以及对die()exit()的调用,那么让我们将注意力转向__toString()方法的更改。

处理对 uu toString()的更改

当对象用作字符串时,__toString()魔术方法被调用。一个典型的例子是当你只是回显一个对象。echo命令需要一个字符串作为参数。当提供非字符串数据时,PHP 执行类型转换以将数据转换为string。由于对象不能很容易地转换为string,PHP 引擎然后查看__toString()是否已定义,如果已定义,则返回其值。

这种神奇方法的主要变化是引入了全新的界面Stringable。新接口定义如下:

interface Stringable {
   public function __toString(): string;
}

在 PHP8 中运行的任何定义了__toString()魔术方法的类都会静默地实现Stringable接口。这种新的行为不会出现任何严重的代码中断。但是,由于类现在实现了Stringable接口,因此不再允许您修改__toString()方法签名。

下面是一个简短的示例,展示了与Stringable接口的新关联:

  1. 在本例中,我们定义了一个Test类,该类定义了__toString()

    // /repo/ch05/php8_bc_break_magic_to_string.php
    class Test {
        public $fname = 'Fred';
        public $lname = 'Flintstone';
        public function __toString() : string {
            return $this->fname . ' ' . $this->lname;
        }
    }
  2. 然后我们创建该类的一个实例,后跟一个ReflectionObject实例:

    $test = new Test;
    $reflect = new ReflectionObject($test);
    echo $reflect;

PHP7 中运行的前几行输出(如图所示)简单地揭示了它是Test类的一个实例:

root@php8_tips_php7 [ /repo/ch05 ]# 
php php8_bc_break_magic_to_string.php
Object of class [ <user> class Test ] {
  @@ /repo/ch05/php8_bc_break_magic_to_string.php 3-12

但是,在 PHP8 中运行相同的代码示例会揭示与Stringable接口的无声关联:

root@php8_tips_php8 [ /repo/ch05 ]# 
php php8_bc_break_magic_to_string.php
Object of class [ <user> class Test implements Stringable ] {
  @@ /repo/ch05/php8_bc_break_magic_to_string.php 3-12

输出显示,即使您没有显式实现Stringable接口,关联也是在运行时创建的,并由ReflectionObject实例显示。

提示

有关魔术方法的更多信息,请查看此文档页面:https://www.php.net/manual/en/language.oop5.magic.php

现在,您已经了解了涉及魔术方法的 PHP8 代码可能导致代码中断的情况,让我们来看一下序列化过程中的变化。

很多时候,本机 PHP 数据需要存储在文件或数据库表中。当前技术的问题是,除了一些例外,直接存储复杂的 PHP 数据(如对象或数组)是不可能的。

克服此限制的一种方法是将对象或数组转换为字符串。出于这个原因,通常会选择JSONJavaScript 对象表示法)作为。一旦数据转换成字符串,就可以轻松地将其存储在任何文件或数据库中。但是,使用 JSON 格式化对象存在问题。尽管 JSON 能够很好地表示对象属性,但它无法直接恢复原始对象的类和方法。

为了解决这一缺陷,PHP 语言包括两个本机函数,serialize()unserialize(),它们可以轻松地将对象或数组转换为字符串,并将它们恢复到原始状态。这听起来很美妙,但与本机 PHP 序列化相关的问题很多。

在正确讨论现有 PHP 序列化体系结构的问题之前,我们需要仔细了解本机 PHP 序列化是如何工作的。

理解 PHP 序列化

当 PHP 对象或数组需要保存到平面文件或关系数据库表等非 OOP 环境中时,serialize()可以用来对象或数组展平为字符串,适合存储。相反,unserialize()恢复原始对象或数组。

下面是一个简单的示例,演示了这个概念:

  1. 首先,我们定义一个具有三个属性的类:

    // /repo/ch05/php8_serialization.php
    class Test  {
        public $name = 'Doug';
        private $key = 12345;
        protected $status = ['A','B','C'];
    }
  2. 然后,我们创建一个实例,序列化该实例,并显示结果字符串:

    $test = new Test();
    $str = serialize($test);
    echo $str . "\n";
  3. Here is how the serialized object appears:

    O:4:"Test":3:{s:4:"name";s:4:"Doug";s:9:"Testkey"; i:12345;
    s:9:"*status";a:3:{i:0;s:1:"A";i:1;s:1:"B";i:2;s:1:"C";}}

    从序列化字符串中可以看到,字母O表示对象a表示数组s表示字符串i表示整数

  4. 然后,我们将对象反序列化为一个新变量,并使用var_dump()检查这两个变量:

    $obj = unserialize($str);
    var_dump($test, $obj);
  5. var_dump()输出并排放置,您可以清楚地看到恢复的对象与原始对象相同:

现在让我们来看一下提供传统 PHP 序列化支持的神奇方法:__sleep()__wakeup()

了解“睡眠”的神奇方法

__sleep()魔术方法的目的是提供一个过滤器,用于防止某些属性出现在序列化字符串中。以用户对象为例,您可能希望从序列化中排除敏感属性,如国家标识号、信用卡号码或密码。

下面是一个使用__sleep()魔术方法排除密码的示例:

  1. 首先,我们定义一个具有三个属性的Test类:

    // /repo/ch05/php8_serialization_sleep.php
    class Test  {
        public $name = 'Doug';
        protected $key = 12345;
        protected $password = '$2y$10$ux07vQNSA0ctbzZcZNA'
             . 'lxOa8hi6kchJrJZzqWcxpw/XQUjSNqacx.';
  2. 然后,我们定义一个排除$password属性的__sleep()方法:

        public function __sleep() {
            return ['name','key'];
        }
    }
  3. 然后我们创建这个类的实例并序列化它。最后一行回显序列化字符串的状态:

    $test = new Test();
    $str = serialize($test)
    echo $str . "\n";
  4. 在输出中,您可以清楚地看到,$password属性不存在。以下是输出:

    O:4:"Test":2:{s:4:"name";s:4:"Doug";s:6:"*key";i:12345;}

这一点很重要,因为在大多数情况下,需要序列化对象的原因是希望将其存储在某个位置,无论是在会话文件中还是数据库中。如果文件系统或数据库随后遭到破坏,您就少了一个需要担心的安全漏洞!

了解\uuu sleep()方法中的潜在代码中断

有一个潜在的代码中断,涉及__sleep()魔法方法。在 PHP8 之前的版本中,如果__sleep()返回一个不存在属性的数组,它们仍然被序列化并分配一个值NULL。这种方法的问题是,当对象随后被取消序列化时,会出现一个额外的属性,一个设计上不存在的属性!

在 PHP8 中,__sleep()魔术方法返回中不存在的属性被默默地忽略。如果您的遗留代码预测到旧的行为并采取步骤删除不需要的属性,或者更糟糕的是,如果您的代码假定不需要的属性存在,那么您最终将出现错误。这样的假设非常危险,因为它们可能导致意外的代码行为。

为了说明这个问题,请看下面的代码示例:

  1. 首先,我们定义一个Test类,该类定义__sleep()以返回一个不存在的变量:

    class Test {
        public $name = 'Doug';
        public function __sleep() {
            return ['name', 'missing'];
        }
    }
  2. 接下来,我们创建一个Test实例并序列化它:

    echo "Test instance before serialization:\n";
    $test = new Test();
    var_dump($test);
  3. 然后,我们将字符串反序列化为一个新实例,$restored

    echo "Test instance after serialization:\n";
    $stored = serialize($test);
    $restored = unserialize($stored);
    var_dump($restored);
  4. 理论上,两个对象实例$test$restored应该是相同的。但是,请查看在 PHP7 中运行的输出:

    root@php8_tips_php7 [ /repo/ch05 ]#
    php php8_bc_break_sleep.php
    Test instance before serialization:
    /repo/ch05/php8_bc_break_sleep.php:13:
    class Test#1 (1) {
      public $name =>  string(4) "Doug"
    }
    Test instance after serialization:
    PHP Notice:  serialize(): "missing" returned as member variable from __sleep() but does not exist in /repo/ch05/php8_bc_break_sleep.php on line 16
    class Test#2 (2) {
      public $name =>  string(4) "Doug"
      public $missing =>  NULL
    }
  5. 从输出中可以看到,两个对象显然相同!但是,在 PHP8 中,不存在的属性被忽略。看看在 PHP8 中运行的相同脚本:

    root@php8_tips_php8 [ /repo/ch05 ]# php php8_bc_break_sleep.php 
    Test instance before serialization:
    object(Test)#1 (1) {
      ["name"]=>  string(4) "Doug"
    }
    Test instance after serialization:
    PHP Warning:  serialize(): "missing" returned as member variable from __sleep() but does not exist in /repo/ch05/php8_bc_break_sleep.php on line 16
    object(Test)#2 (1) {
      ["name"]=>  string(4) "Doug"
    }

您可能还注意到,在 PHP7 中,会发出一个Notice,而在 PHP8 中,同样的情况会产生一个Warning。在这种情况下,对潜在代码中断进行迁移前检查是困难的,因为如果定义了魔术方法__sleep(),您需要确定列表中是否包含不存在的属性。

现在我们来看看对应的方法__wakeup()

了解 uu wakeup()

__wakeup()魔术方法的目的是主要对未序列化的对象执行额外的初始化。例如,恢复数据库连接或恢复文件句柄。下面是一个非常简单的例子,它使用__wakeup()魔法重新打开文件句柄:

  1. 首先,我们定义一个在实例化时打开文件句柄的类。我们还定义了一个返回文件内容的方法:

    // /repo/ch05/php8_serialization_wakeup.php
    class Gettysburg {
        public $fn = __DIR__ . '/gettysburg.txt';
        public $obj = NULL;
        public function __construct() {
            $this->obj = new SplFileObject($this->fn, 'r');
        }
        public function getText() {
            $this->obj->rewind();
            return $this->obj->fpassthru();
        }
    }
  2. 要使用该类,请创建一个实例,然后运行getText()。(假设$this->fn引用的文件存在!)

    $old = new Gettysburg();
    echo $old->getText();
  3. 输出(未显示)是葛底斯堡地址。

  4. If we now attempt to serialize this object, a problem arises. Here's an example of code that would serialize the object:

    $str = serialize($old);

  5. 此时,运行代码到目前为止,这里是输出:

    PHP Fatal error:  Uncaught Exception: Serialization of 'SplFileObject' is not allowed in /repo/ch05/php8_serialization_wakeup.php:19
  6. 为了解决这个问题,我们返回类并添加一个__sleep()方法来防止SplFileObject实例被序列化:

        public function __sleep() {
            return ['fn'];
        }
  7. 如果我们随后重新运行代码来序列化对象,那么一切都很好。以下是取消序列化并调用getText()

    $str = serialize($old);
    $new = unserialize($str);
    echo $new->getText();

    的代码

  8. However, if we then attempt to unserialize the object, another error occurs:

    PHP Fatal error:  Uncaught Error: Call to a member function rewind() on null in /repo/ch05/php8_serialization_wakeup.php:13

    当然,问题是文件句柄在序列化过程中丢失。当对象未序列化时,不调用__construct()方法。

  9. 这正是魔法方法存在的原因。为了解决这个错误,我们定义了一个调用__construct()方法的__wakeup()方法:

        public function __wakeup() {
            self::__construct();
        }
  10. 如果我们重新运行代码,现在将看到葛底斯堡地址两次(未显示)。

现在,您已经了解了 PHP 本机序列化的工作原理,并且还了解了一些关于__sleep()__wakeup()的神奇方法,以及潜在的代码中断。现在让我们来看一个接口,该接口旨在促进对象的自定义序列化。

引入可串行化接口

为了方便对象的序列化,以 PHP5.1 开头的语言中添加了Serializable接口。这个接口背后的想法是提供一种方法来识别能够序列化自身的对象。此外,此接口指定的方法旨在对对象序列化提供某种程度的控制。

只要一个类实现了这个接口,开发人员就可以确保定义了两个方法:serialize()unserialize()。以下是接口定义:

interface Serializable {
    public serialize () : string|null
    public unserialize (string $serialized) : void
}

任何实现此接口的类都会在本机序列化或非序列化期间自动调用其自定义serialize()unserialize()方法。为了说明这种技术,请考虑下面的例子:

  1. 首先,我们定义一个实现Serializable接口的类。该类定义了三个属性–两个类型为 string,另一个表示日期和时间:

    // /repo/ch05/php8_bc_break_serializable.php
    class A implements Serializable {
        private $a = 'A';
        private $b = 'B';
        private $u = NULL;
  2. 然后我们定义一个自定义的serialize()方法,在序列化对象属性之前初始化日期和时间。unserialize()方法将值恢复到所有属性:

        public function serialize() {
            $this->u = new DateTime();
            return serialize(get_object_vars($this));
        }
        public function unserialize($payload) {
            $vars = unserialize($payload);
            foreach ($vars as $key => $val)
                $this->$key = $val;
        }
    }
  3. 然后我们创建一个实例,并使用var_dump()

    $a1 = new A();
    var_dump($a1);

    检查其内容

  4. var_dump()的输出显示u属性尚未初始化:

    object(A)#1 (3) {
      ["a":"A":private]=> string(1) "A"
      ["b":"A":private]=> string(1) "B"
      ["u":"A":private]=> NULL
    }
  5. 然后我们将其序列化,并将其还原为一个变量$a2

    $str = serialize($a1);
    $a2 = unserialize($str);
    var_dump($a2);
  6. 从下面的var_dump()输出中,您可以看到对象已完全恢复。此外,我们知道调用自定义的serialize()方法是因为u属性是用日期和时间值初始化的。以下是输出:

    object(A)#3 (3) {
      ["a":"A":private]=> string(1) "A"
      ["b":"A":private]=> string(1) "B"
      ["u":"A":private]=> object(DateTime)#4 (3) {
        ["date"]=> string(26) "2021-02-12 05:35:10.835999"
        ["timezone_type"]=> int(3)
        ["timezone"]=> string(3) "UTC"
      }
    }

现在让我们来看一下实现Serializable接口的对象的序列化过程的问题。

检查 PHP 可序列化接口问题

早期的序列化方法存在一个总体问题。如果一个要序列化的类定义了一个__wakeup()魔术方法,那么在非序列化时不会立即调用它。相反,任何已定义的__wakeup()魔术方法都是先排队,整个对象链都是未序列化的,然后才执行队列中的方法。这可能导致对象的unserialize()方法所看到的内容与其排队的__wakeup()方法所看到的内容不匹配。

在处理实现Serializable接口的对象时,此架构缺陷可能导致行为不一致和结果不明确。许多开发人员认为,由于在嵌套对象的序列化发生时需要创建返回引用,所以 Tyle T1 接口会被严重破坏。这种需求出现在发生嵌套序列化调用的情况中。

例如,当一个类定义一个方法,该方法反过来调用 PHPserialize()函数时,可能会发生这种嵌套调用。在 PHP8 之前的 PHP 序列化中,创建反向引用的顺序是预先设置的,这可能会导致大量级联故障。

解决方案是使用两种新的神奇方法,让您完全控制序列化和非序列化排序,如下所述。

控制 PHP 序列化的新魔术方法

PHP7.4 中首先引入了一种新的控制序列化的方法,并将其引入 PHP8 中。为了利用这项新技术,您只需实现两种神奇的方法:__serialize()__unserialize()。如果实现,PHP 会将序列化的控制权完全交给__serialize()方法。同样,非序列化完全由__unserialize()魔术方法控制。如果已定义,__sleep()__wakeup()方法将被忽略。

作为另一个好处,PHP 8 在以下 SPL 类中提供了对两个新魔术方法的完全支持:

  • ArrayObject

  • ArrayIterator

  • SplDoublyLinkedList

  • SplObjectStorage

    最佳做法

    要获得对序列化的完全控制,请实现新的__serialize()__unserialize()魔术方法。您不再需要实现Serializable接口,也不需要定义__sleep()__wakeup()。有关Serializable接口最终中断的更多信息,请参阅此 RFC:https://wiki.php.net/rfc/phase_out_serializable

作为新 PHP 序列化用法的一个例子,请考虑下面的代码示例:

  1. 在本例中,Test类在实例化时使用随机键初始化:

    // /repo/ch05/php8_bc_break_serialization.php
    class Test extends ArrayObject {
        protected $id = 12345;
        public $name = 'Doug';
        private $key = '';
        public function __construct() {
            $this->key = bin2hex(random_bytes(8));
        }
  2. 我们添加了一个显示当前键值的getKey()方法:

        public function getKey() {
            return $this->key;
        }
  3. 序列化时,键从结果字符串

        public function __serialize() {
            return ['id' => $this->id, 
                    'name' => $this->name];
        }

    中过滤

  4. 非序列化后,生成一个新密钥:

        public function __unserialize($data) {
            $this->id = $data['id'];
            $this->name = $data['name'];
            $this->__construct();
        }
    }
  5. We now create an instance, and reveal the key:

    $test = new Test();
    echo "\nOld Key: " . $test->getKey() . "\n";

    以下是密钥的显示方式:

    Old Key: mXq78DhplByDWuPtzk820g==
  6. We add code to serialize the object and display the string:

    $str = serialize($test);
    echo $str . "\n";

    以下是序列化字符串的显示方式:

    O:4:"Test":2:{s:2:"id";i:12345;s:4:"name";s:4:"Doug";}

    从输出中注意,机密没有出现在序列化字符串中。这一点很重要,因为如果序列化字符串的存储位置受损,则可能会暴露安全漏洞,从而使攻击者有机会侵入您的系统。

  7. We then add code to unserialize the string and reveal the key:

    $obj = unserialize($str);
    echo "New Key: " . $obj->getKey() . "\n";

    这是输出的最后一位。请注意,已生成一个新密钥:

    New Key: kDgU7FGfJn5qlOKcHEbyqQ==

如您所见,使用新的 PHP 序列化功能并不复杂。任何计时问题现在都完全在您的控制之下,因为新的 magic 方法是按照对象序列化和非序列化的顺序执行的。

重要提示

PHP7.4 及更高版本理解来自旧版本 PHP 的序列化字符串,但是,PHP7.4 或 8.x 序列化的字符串可能不会被旧版本的 PHP 正确地取消序列化。

提示

有关完整讨论,请参阅关于自定义序列化的 RFC:

https://wiki.php.net/rfc/custom_object_serialization

现在,您对 PHP 序列化以及这两种新的 magic 方法提供的改进支持有了全面的了解。现在是时候换个角度,看看 PHP8 是如何扩展方差支持的。

方差的概念是 OOP 的核心。变异是一个涵盖各种亚型如何相互关联的总括术语。大约 20 年前,一对早期的计算机科学家 Wing 和 Liskov 设计了一个重要的定理,它是 OOP 子类型的核心,现在被称为Liskov 替换原理

在不涉及精确数学的情况下,这一原理可以解释如下:

如果您能够用 X 的实例代替 Y 的实例,并且应用的行为不会以任何方式改变,那么类 X 可以被视为类 Y 的子类型。

提示

首先描述并提供了 Liskov 替换原理精确数学公式定义的实际论文可以在这里找到:子类型的行为概念,《编程语言和系统上的 ACM 交易》,B.Liskov 和 J.Wing,1994 年 11 月(https://dl.acm.org/doi/10.1145/197320.197383).

在本节中,我们将研究 PHP8 如何以协变返回反向参数的形式提供增强的方差支持。对协方差和逆变换的理解将提高您编写良好可靠代码的能力。如果没有这种理解,您的代码可能会产生不一致的结果,并成为许多错误的根源。

让我们从覆盖协变回报开始。

了解协变回报

PHP 中的协方差支持旨在保持类型从最特定到最通用的顺序。一个经典的示例可以从try / catch块的公式中看出:

  1. 在本例中,在try块内创建了一个PDO实例。以下两个catch块首先查找PDOException。接下来是第二个catch块,它捕获实现Throwable的任何类。因为 PHPExceptionError类都实现了Throwable,所以第二个catch块将作为除PDOException

    try {
        $pdo = new PDO($dsn, $usr, $pwd, $opts);
    } catch (PDOException $p) {
        error_log('Database Error: ' . $p->getMessage());
    } catch (Throwable $t) {
        error_log('Unknown Error: ' . $t->getMessage());
    }

    之外的任何错误的回退

  2. 在本例中,如果PDO实例因参数无效而失败,则错误日志中的条目Database error后跟从PDOException收集的消息。

  3. 另一方面,如果发生其他一般性错误,则错误日志中会有条目未知错误,后跟来自其他ExceptionError类的消息。

  4. 然而,在本例中,catch块的顺序相反:

    try {
        $pdo = new PDO($dsn, $usr, $pwd, $opts);
    } catch (Throwable $t) {
        error_log('Unknown Error: ' . $t->getMessage());
    } catch (PDOException $p) {
        error_log('Database Error: ' . $p->getMessage());
    }
  5. 由于 PHP 协方差支持的工作方式,第二个catch块永远不会被调用。相反,来自此代码块的所有错误日志条目都将有一个以未知错误开头的条目。

现在让我们看看 PHP 协方差支持如何应用于对象方法返回数据类型:

  1. 首先,我们定义一个接口FactoryIterface,它标识一个方法make()。此方法接受一个array作为参数,并期望返回一个类型为ArrayObject

    interface FactoryInterface {
        public function make(array $arr): ArrayObject;
    }

    的对象

  2. 接下来,我们定义一个扩展ArrayObject

    class ArrTest extends ArrayObject {
        const DEFAULT_TEST = 'This is a test';
    }

    ArrTest

  3. ArrFactory类实现了FactoryInterface并完全定义了make()方法。但是,请注意,此方法返回的是ArrTest数据类型,而不是ArrayObject

    class ArrFactory implements FactoryInterface {
        protected array $data;
        public function make(array $data): ArrTest {
            $this->data = $data;
            return new ArrTest($this->data);
        }
    }
  4. 在过程调用代码块中,我们创建一个ArrFactory实例,并运行其make()方法两次,理论上生成两个ArrTest实例。然后我们使用var_dump()来显示生成的两个对象的当前状态:

    $factory = new ArrFactory();
    $obj1 = $factory->make([1,2,3]);
    $obj2 = $factory->make(['A','B','C']);
    var_dump($obj1, $obj2);
  5. 在 PHP7.1 中,由于不支持协变返回数据类型,因此会抛出致命的Error。此处显示的输出告诉我们,方法返回类型声明与FactoryInterface

    root@php8_tips_php7 [ /repo/ch05 ]# 
    php php8_variance_covariant.php
    PHP Fatal error:  Declaration of ArrFactory::make(array $data): ArrTest must be compatible with FactoryInterface::make(array $arr): ArrayObject in /repo/ch05/php8_variance_covariant.php on line 9

    中定义的不匹配

  6. 当我们在 PHP8 中运行相同的代码时,您可以看到为返回类型提供了协方差支持。执行过程不受阻碍,如图所示:

    root@php8_tips_php8 [ /repo/ch05 ]# 
    php php8_variance_covariant.php
    object(ArrTest)#2 (1) {
      ["storage":"ArrayObject":private]=>
      array(3) {
        [0]=>    int(1)
        [1]=>    int(2)
        [2]=>    int(3)
      }
    }
    object(ArrTest)#3 (1) {
      ["storage":"ArrayObject":private]=>
      array(3) {
        [0]=>    string(1) "A"
        [1]=>    string(1) "B"
        [2]=>    string(1) "C"
      }
    }

ArrTest扩展了ArrayObject,是一个合适的亚型,显然符合 Liskov 替代原则定义的标准。正如您可以从上一个输出中看到的,PHP8 比早期版本的 PHP 更全面地包含了真正的 OOP 原则。最终的结果是,当使用 PHP8 时,您的代码和应用架构可以更加直观和逻辑合理。

现在让我们看一下逆变参数。

使用逆变参数

协方差涉及到亚型从一般到特殊的顺序,逆方差涉及到相反的顺序:从特殊到一般。在 PHP7 和更早的版本中,对对冲的完全支持是不可用的。因此,在 PHP7 中,实现接口或扩展抽象类时,参数类型提示是不变的

另一方面,在 PHP8 中,由于对逆变参数的支持,您可以自由地指定顶级超类和接口。只要子类型兼容,就可以修改扩展或实现类中的类型提示,使其更通用。

这给了你更多的自由来定义一个整体架构,在这里你可以定义接口或抽象类。PHP8 为使用接口或超类的开发人员在实现子类逻辑时提供了更大的灵活性。

让我们来看看 PHP 8 对逆变参数的支持是如何工作的:

  1. 在本例中,我们首先定义一个扩展内置ArrayIterator PHP class

    // /repo/ch05/php8_variance_contravariant.php
    class IterObj extends ArrayIterator {}

    IterObj

  2. 然后我们定义一个抽象的Base类,该类要求使用一个方法stringify()。请注意,其唯一参数的数据类型为IterObj

    abstract class Base {
        public abstract function stringify(IterObj $it);
    }
  3. 接下来,我们定义一个扩展BaseIterTest类,并为stringify()方法提供一个实现。特别有趣的是,我们覆盖了数据类型,将其更改为iterable

    class IterTest extends Base {
        public function stringify(iterable $it) {
            return implode(',', 
                iterator_to_array($it)) . "\n";
        }
    }
    class IterObj extends ArrayIterator {}
  4. 接下来的几行代码创建了IterTestIterObjArrayIterator的实例。然后,我们调用stringify()方法两次,将后面的每个对象作为参数提供:

    $test  = new IterTest();
    $objIt = new IterObj([1,2,3]);
    $arrIt = new ArrayIterator(['A','B','C']);
    echo $test->stringify($objIt);
    echo $test->stringify($arrIt);
  5. Running this code example in PHP 7.1 produces the expected fatal Error as shown here:

    root@php8_tips_php7 [ /repo/ch05 ]#
    php php8_variance_contravariant.php
    PHP Fatal error:  Declaration of IterTest::stringify(iterable $it) must be compatible with Base::stringify(IterObj $it) in /repo/ch05/php8_variance_contravariant.php on line 11

    因为 PHP7.1 不支持逆变参数,所以它将其参数的数据类型视为不变量,并简单地显示一条消息,指示子类的数据类型与父类中指定的数据类型不兼容。

  6. 另一方面,PHP8 提供了对逆变参数的支持。因此,它认识到Base类中指定的数据类型IterObj是与iterable兼容的子类型。此外,提供的两个参数也与iterable兼容,允许继续执行程序。以下是 PHP8 输出:

    root@php8_tips_php8 [ /repo/ch05 ]# php php8_variance_contravariant.php
    1,2,3
    A,B,C

PHP8 对协变返回和逆变参数的支持的主要优势在于,不仅可以覆盖方法逻辑,还可以覆盖方法签名。您会发现,尽管 PHP8 在实施良好的编码实践方面要严格得多,但增强的方差支持在设计继承结构时给了您更大的自由度。在某种意义上,至少在参数和返回值数据类型方面,PHP8 是更少的限制!

提示

有关如何在 PHP7.4 和 PHP8 中应用差异支持的完整说明,请查看以下内容:https://wiki.php.net/rfc/covariant-returns-and-contravariant-parameters.

现在,我们将了解 SPL 的更改,以及这些更改在迁移到 PHP8 后如何对应用性能产生影响。

SPL是一个扩展,包含实现基本数据结构和增强 OOP 功能的关键类。它最初是在 PHP5 中引入的,现在默认情况下包含在所有 PHP 安装中。涵盖整个 SPL 超出了本书的范围。相反,在本节中,我们将讨论在运行 PHP8 时 SPL 中发生的重大变化。此外,我们还为您提供有关 SPL 更改的提示和指导,这些更改可能会导致现有应用停止工作。

我们首先检查SplFileObject类的变化。

了解 SplFileObject 的变化

SplFileObject是一个优秀的类,它将大部分独立f*()函数(如fgets()fread()fwrite()等)合并到一个类中。SplFileObject ::__construct()方法参数反映了提供给fopen()函数的参数。

PHP8 的主要区别在于一个相对模糊的方法fgetss()已从SplFileObject类中删除。PHP7 及以下版本中提供的SplFileObject::fgetss()方法反映了独立的fgetss()函数,因为它将fgets()strip_tags()相结合。

为了便于说明,假设您创建了一个允许用户上传文本文件的网站。在显示文本文件中的内容之前,您希望删除任何标记标记。下面是一个使用fgetss()方法实现此目的的示例:

  1. 我们首先定义一个获取文件名的代码块:

    // /repo/ch05/php7_spl_splfileobject.php
    $fn = $_GET['fn'] ?? '';
    if (!$fn || !file_exists($fn))
        exit('Unable to locate file');
  2. 然后我们创建SplFileObject实例,并使用fgetss()方法逐行读取文件。最后,我们回应安全内容:

    $obj = new SplFileObject($fn, 'r');
    $safe = '';
    while ($line = $obj->fgetss()) $safe .= $line;
    echo '<h1>Contents</h1><hr>' . $safe;
  3. 假设要读取的文件是:

    <h1>This File is Infected</h1>
    <script>alert('You Been Hacked');</script>
    <img src="http://very.bad.site/hacked.php" />
  4. Here is the output running in PHP 7.1 using this URL:

    http://localhost:7777/ch05/php7_spl_splfileobject.php? fn=includes/you_been_hacked.html

从下面显示的输出中可以看到,所有 HTML 标记都已删除:

Figure 5.1 – Result after reading a file using SplFileObject::fgetss()

图 5.1–使用 SplFileObject::fgetss()读取文件后的结果

为了在 PHP8 中实现同样的功能,需要修改前面显示的代码,将fgetss()替换为fgets()。我们还需要在连接到$safe的行上使用strip_tags()。下面是修改后的代码的显示方式:

// /repo/ch05/php8_spl_splfileobject.php
$fn = $_GET['fn'] ?? '';
if (!$fn || !file_exists($fn))
    exit('Unable to locate file');
$obj = new SplFileObject($fn, 'r');
$safe = '';
while ($line = $obj->fgets())
    $safe .= strip_tags($line);
echo '<h1>Contents</h1><hr>' . $safe;

修改后的代码输出与图 5.1相同。现在我们将关注另一个 SPL 类SplHeap中的变化。

检查 SPL 堆的更改

SplHeap是一个基础类,用于表示结构化为二叉树的数据。另外还有两个基于SplHeap的类。SplMinHeap以最小值在顶部组织树。SplMaxHeap则相反,将最大值置于顶部。

堆结构在数据无序到达的情况下特别有用。一旦插入堆中,该项将自动按正确的顺序放置。因此,在任何给定时刻,您都可以显示堆安全,因为您知道所有项都将有序,而无需运行 PHP 排序函数。

维护自动排序顺序的关键是定义一个抽象方法compare()。由于该方法是抽象的,SplHeap不能直接实例化。相反,您需要扩展类并实现compare()

在 PHP8 中使用SplHeap时,可能会出现向后兼容的代码中断,因为compare()的方法签名必须完全如下:SplHeap::compare($value1, $value2)

现在让我们来看一个代码示例,它使用SplHeap构建一个按姓氏组织的亿万富翁列表:

  1. 首先,我们定义一个包含亿万富翁数据的文件。在本例中,我们只是从该源复制并粘贴数据:https://www.bloomberg.com/billionaires/.

  2. We then define a BillionaireTracker class that extracts information from the pasted text into an array of ordered pairs. The full source code (not shown here) for the class can be found in the source code repository here:/repo/src/Services/BillionaireTracker.php.

    下面是类生成的数据的显示方式:

    array(20) {
      [0] =>  array(1) {
        [177000000000] =>    string(10) "Bezos,Jeff"
      }
      [1] =>  array(1) {
        [157000000000] =>    string(9) "Musk,Elon"
      }
      [2] =>  array(1) {
        [136000000000] =>    string(10) "Gates,Bill"
      }
      ... remaining data not shown

    如您所见,数据按降序显示,其中键表示净值。相反,在我们的示例程序中,我们计划按姓氏的升序生成数据。

  3. 然后我们定义一个常量来标识亿万富翁数据源文件,并设置一个自动加载器:

    // /repo/ch05/php7_spl_splheap.php
    define('SRC_FILE', __DIR__ 
        . '/../sample_data/billionaires.txt');
    require_once __DIR__ 
        . '/../src/Server/Autoload/Loader.php';
    $loader = new \Server\Autoload\Loader();
  4. 接下来,我们创建一个BillionaireTracker类的实例,并将结果分配给$list

    use Services\BillionaireTracker;
    $tracker = new BillionaireTracker();
    $list = $tracker->extract(SRC_FILE);
  5. Now comes the part of most interest: creating the heap. To accomplish this, we define an anonymous class that extends SplHeap. We then define a compare() method that performs the necessary logic to place inserted elements in their proper place. PHP 7 allows you to change the method signature. In this example, we provide arguments in the form of an array:

    $heap = new class () extends SplHeap {
        public function compare(
            array $arr1, array $arr2) : int {
            $cmp1 = array_values($arr2)[0];
            $cmp2 = array_values($arr1)[0];
            return $cmp1 <=> $cmp2;
        }
    };

    您可能还注意到,$cmp1的值是从第二个数组赋值的,$cmp2的值是从第一个数组赋值的。切换的原因是我们希望按升序生成结果。

  6. 然后我们使用SplHeap::insert()将元素添加到堆中:

    foreach ($list as $item)
        $heap->insert($item);
  7. 最后,我们定义了一个BillionaireTracker::view()方法(未显示)来运行堆并显示结果:

    $patt = "%20s\t%32s\n";
    $line = str_repeat('-', 56) . "\n";
    echo $tracker->view($heap, $patt, $line);
  8. 这是我们在 PHP7.1 中运行的小程序生成的输出:

    root@php8_tips_php7 [ /repo/ch05 ]# 
    php php7_spl_splheap.php
    --------------------------------------------------------
               Net Worth                                Name
    --------------------------------------------------------
          84,000,000,000                       Ambani,Mukesh
         115,000,000,000                     Arnault,Bernard
          83,600,000,000                       Ballmer,Steve
          ... some lines were omitted to save space ...
          58,200,000,000                          Walton,Rob
         100,000,000,000                     Zuckerberg,Mark
    --------------------------------------------------------
                                           1,795,100,000,000
    --------------------------------------------------------

但是,您会注意到,当我们尝试在 PHP8 中运行相同的程序时,会抛出一个错误。以下是在 PHP 8 中运行的同一程序的输出:

root@php8_tips_php8 [ /repo/ch05 ]# php php7_spl_splheap.php 
PHP Fatal error:  Declaration of SplHeap@anonymous::compare(array $arr1, array $arr2): int must be compatible with SplHeap::compare(mixed $value1, mixed $value2) in /repo/ch05/php7_spl_splheap.php on line 16

因此,为了使其正常工作,我们必须重新定义扩展SplHeap的匿名类。以下是该部分代码的修改版本:

$heap = new class () extends SplHeap {
    public function compare($arr1, $arr2) : int {
        $cmp1 = array_values($arr2)[0];
        $cmp2 = array_values($arr1)[0];
        return $cmp1 <=> $cmp2;
    }
};

唯一的变化是compare()方法签名。执行时,结果(未显示)相同。PHP8 的完整代码可在/repo/ch05/php8_spl_splheap.php上查看。

我们对SplHeap类的更改的讨论到此结束。请注意,同样的变更也适用于SplMinHeapSplMaxHeap。现在让我们来看看SplDoublyLinkedList类中潜在的重大变化。

处理 SplDoublyLinkedList 中的更改

SplDoublyLinkedList类是一个迭代器,能够以FIFO先进先出后进先出后进先出顺序显示信息。然而,更常见的说法是,可以按正向或反向顺序遍历列表。

这是对任何开发人员库的一个非常强大的补充。例如,要对ArrayIterator执行相同的操作,至少需要十几行代码!因此,PHP 开发人员喜欢在需要随意在任意方向上导航列表的情况下使用此类。

不幸的是,由于push()unshift()方法的返回值不同,可能会出现代码中断。push()方法用于在列表的添加一个值。另一方面,unshift()方法为列表的开头添加值。

在 PHP7 及以下版本中,如果成功,这些方法将返回布尔值TRUE。如果方法失败,则返回布尔值FALSE。然而,在 PHP8 中,两个方法都不返回值。如果查看当前文档中的方法签名,您将看到返回数据类型为void。如果在继续之前检查返回值push()unshift(),则可能出现代码中断。

让我们看一个简单的示例,该示例使用五个值的简单列表填充双链接列表,并以 FIFO 和 LIFO 顺序显示它们:

  1. 首先,我们定义一个扩展了SplDoublyLinkedList的匿名类。我们还添加了一个显示列表内容的show()方法:

    // /repo/ch05/php7_spl_spldoublylinkedlist.php
    $double = new class() extends SplDoublyLinkedList {
        public function show(int $mode) {
            $this->setIteratorMode($mode);
            $this->rewind();
            while ($item = $this->current()) {
                echo $item . '. ';
                $this->next();
            }
        }
    };
  2. Next, we define an array of sample data, and use push() to insert the value into the linked list. Note that an if() statement is used to determine whether the operation succeeds or fails. If the operation fails, an Exception is thrown:

    $item = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
    foreach ($item as $key => $value)
        if (!$double->push($value))
            throw new Exception('ERROR');

    这是存在潜在代码中断的代码块。在 PHP7 及以下版本中,push()返回TRUEFALSE。在 PHP8 中,没有返回值。

  3. 然后,我们使用SplDoublyLinkedList类常量将模式设置为 FIFO(前进),并显示列表:

    echo "**************** Foward ********************\n";
    $forward = SplDoublyLinkedList::IT_MODE_FIFO
             | SplDoublyLinkedList::IT_MODE_KEEP;
    $double->show($forward);
  4. Next, we use the SplDoublyLinkedList class constants to set the mode to LIFO (reverse), and display the list:

    echo "\n\n************* Reverse *****************\n";
    $reverse = SplDoublyLinkedList::IT_MODE_LIFO
             | SplDoublyLinkedList::IT_MODE_KEEP;
    $double->show($reverse);

    以下是在 PHP 7.1 中运行的输出:

    root@php8_tips_php7 [ /repo/ch05 ]# 
    php php7_spl_spldoublylinkedlist.php
    **************** Foward ********************
    Person. Woman. Man. Camera. TV. 
    **************** Reverse ********************
    TV. Camera. Man. Woman. Person. 
  5. 如果我们在 PHP8 中运行相同的代码,结果是:

    root@php8_tips_php8 [ /home/ch05 ]# 
    php php7_spl_spldoublylinkedlist.php 
    PHP Fatal error:  Uncaught Exception: ERROR in /home/ch05/php7_spl_spldoublylinkedlist.php:23

如果push()没有返回值,则在if()语句中,PHP 假设为NULL,而这反过来又被插值为布尔值FALSE!因此,在第一个push()命令之后,if()块导致抛出Exception。由于Exception未被捕获,因此会生成致命的Error

要在 PHP8 中重写这段代码,只需删除if()语句,而不是抛出Exception。以下是重写的代码块(如步骤 2所示)的显示方式:

$item = ['Person', 'Woman', 'Man', 'Camera', 'TV'];
foreach ($item as $key => $value)
    $double->push($value);

现在,如果我们执行重写的代码,结果如下所示:

root@php8_tips_php7 [ /home/ch05 ]# 
php php8_spl_spldoublylinkedlist.php 
**************** Foward ********************
Person. Woman. Man. Camera. TV. 
**************** Reverse ********************
TV. Camera. Man. Woman. Person. 

现在您对如何使用SplDoublyLinkedList有了的概念,也了解了与push()unshift()相关的潜在代码中断。您还了解了在 PHP8 中使用各种 SPL 类和函数时可能出现的代码中断。本章的讨论到此结束。

在本章中,您了解了在迁移到 PHP8 时 OOP 代码中的潜在问题。在第一节中,您了解了 PHP7 和早期版本中允许的一些错误做法,但现在却代表了 PHP8 中潜在的代码中断。有了这些知识,您将成为一名更好的开发人员,可以交付高质量的代码,从而使您的公司受益。

在下一节中,您将学习使用魔法方法时的良好习惯。潜在的代码中断可能会发生,因为 PHP8 现在执行的一致性程度在早期版本的 PHP 中是不存在的。这些不一致性涉及类构造函数的使用和 magic 方法使用的某些方面。下一节将向您介绍 PHP 序列化,以及 PHP8 中所做的更改如何使您的代码在序列化和取消序列化过程中更具弹性,更不易受到错误或攻击。

在本章中,您还了解了增强的 PHP8 对协变返回类型和逆变参数的支持。了解差异,以及 PHP8 中的支持如何改进,可以让您在 PHP8 中开发类继承结构时更具创造性和灵活性。您现在知道了如何编写在早期版本的 PHP 中根本不可能编写的代码。

最后一节介绍了 SPL 中的一些关键类。您了解了很多关于如何在 PHP8 中实现基本数据结构(如堆和链表)的知识。该部分中的信息对于帮助您避免涉及 SPL 的代码问题至关重要。

下一章继续讨论潜在的代码中断。然而,下一章的重点是程序而不是目标代码。

教程来源于Github,感谢apachecn大佬的无私奉献,致敬!

技术教程推荐

技术领导力实战笔记 -〔TGO鲲鹏会〕

Nginx核心知识150讲 -〔陶辉〕

Java并发编程实战 -〔王宝令〕

Serverless入门课 -〔蒲松洋(秦粤)〕

用户体验设计实战课 -〔相辉〕

Serverless进阶实战课 -〔静远〕

Go进阶 · 分布式爬虫实战 -〔郑建勋〕

手把手带你写一个MiniSpring -〔郭屹〕

零基础GPT应用入门课 -〔林健(键盘)〕