驾驭魔术方法的变化

PHP 魔法方法是预定义的钩子,可以中断 OOP 应用程序的正常流程。每个魔法方法(如果已定义)都会改变应用程序的行为,从创建对象实例的那一刻起,直到实例退出作用域为止。

对象实例在被取消设置(unset)或覆盖后会退出作用域。当在函数或类方法中定义了对象实例,并且该函数或类方法的执行结束时,对象实例也会退出作用域。最终,如果没有其他原因,对象实例会在 PHP 程序结束时退出作用域。

本节将使您对 PHP 8 中引入的魔法方法用法和行为的重要变化有一个扎实的了解。一旦理解了本节中描述的情况,就可以对代码进行适当的修改,以防止迁移到 PHP 8 时应用程序代码出现故障。

首先让我们看看对象构造方法的变化。

处理构造函数的更改

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

在 PHP 8 中,类构造函数的调用方式发生了一些变化。这意味着在将应用程序迁移到 PHP 8 时有可能出现向后兼容性中断。我们要研究的第一个变化与已废弃的方法用法有关,该方法与用作类构造函数的类同名。

处理同名方法和类的更改

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

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

从 PHP 5 开始,随着新的、更强大的 OOP 实现,引入了魔法方法。其中一个方法是 __construct() ,它专门用于类的构造,旨在取代旧的用法。在 PHP 5 的其余版本和 PHP 7 的所有版本中,都支持使用与类同名的方法作为构造函数。

在 PHP 8 中,删除了对与类同名的类构造函数方法的支持。如果同时定义了 __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. 然后,我们添加三行程序代码来练习该类,提供包含 Gettysburg Address 的文件名:

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

    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. 然而,在 PHP 8 中运行相同的程序,却会抛出一个致命错误,从输出中可以看到这一点:

    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

需要注意的是,PHP 8 中显示的错误并不能说明程序失败的真正原因。因此,扫描一下 PHP 应用程序,尤其是旧版本的程序,看看是否有与类同名的方法是非常重要的。因此,最好的做法是将与类同名的方法重命名为 __construct()

现在让我们看看 PHP 8 是如何解决类构造函数中处理 Exceptionexit 时的不一致问题的。

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

PHP 8 中解决的另一个问题与类构造方法抛出异常或执行 exit() 时的情况有关。在 PHP 8 之前的 PHP 版本中,如果在类构造函数中抛出异常,则不会调用已定义的 __destruct() 方法。另一方面,如果在构造函数中使用了 exit()die()(这两个 PHP 函数彼此等价),就会调用 __destruct() 方法。在 PHP 8 中,这种不一致性得到了解决。现在,无论在哪种情况下,都不会调用 __destruct() 方法。

您可能想知道为什么要关注这个问题。您需要注意这一重要变化的原因是,您可能会在调用 exit()die() 的情况下调用 __destruct() 方法中的逻辑。在 PHP 8 中,您不能再依赖这段代码,这可能会导致向后兼容性中断。

在本例中,我们有两个连接类。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(),因此被标记为抽象类:

    // /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. 从上面的讨论中我们可以知道,在 PHP 7 中运行的输出显示,当创建 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. 在 PHP 8 中,这两种情况都没有调用 __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() 方法的更改。

使用 __toString() 的更改

当一个对象被用作字符串时,就会调用 __toString() 魔术方法。一个典型的例子就是简单地 echo 一个对象。echo 命令的参数是字符串。当提供的是非字符串数据时,PHP 会执行类型杂耍将数据转换为字符串。由于对象不能轻易转换成字符串,PHP 引擎会查看是否定义了 __toString(),如果定义了,就返回它的值。

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

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

在 PHP 8 中运行的任何定义了 __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;

在 PHP 7 中运行的前几行输出(如图所示)显示,这是一个 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

然而,在 PHP 8 中运行相同的代码示例,就会发现与 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

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