发现核心 OOP 编码差异

在 PHP 8 中,编写 OOP 代码的方式发生了许多重大变化。 在本节中,我们将重点讨论可能导致向后兼容性中断的三个关键领域。本节将讨论与静态方法调用、对象属性处理和 PHP 自动加载相关的常见错误做法。

阅读本节并通过示例学习后,您就能更好地发现 OOP 的不良实践,并了解 PHP 8 是如何对此类使用进行限制的。在本章中,你将学习到良好的编码实践,这最终将使你成为一名更好的程序员。您还将能够处理 PHP 自动加载中的变化,这些变化可能会导致迁移到 PHP 8 的应用程序出现故障。

首先让我们看看 PHP 8 是如何加强静态调用的。

在 PHP 8 中处理静态调用

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

在这个简单的例子中,我们定义了一个带有 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

从输出中可以看到,PHP 7 发出了弃用通知,但允许调用!而在 PHP 8 中,结果是致命错误,如图所示:

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

使用静态方法调用语法调用非静态方法是一种糟糕的做法,因为写得好的代码会让代码开发者的意图一目了然。如果您没有将一个方法定义为静态方法,但后来却在静态意义上调用了它,那么将来被指派维护您的代码的开发人员可能会感到困惑,并对代码的原始意图做出错误的假设。最终的结果将是更多糟糕的代码!

在 PHP 8 中,不能再使用静态方法调用来调用非静态方法了。现在让我们看看另一种把对象属性当作键的糟糕做法。

处理对象属性处理更改

早在 PHP 最早的版本中,数组就已经是一个核心功能了。另一方面,OOP 直到 PHP 4 才被引入。 在 OOP 的早期,数组函数经常被扩展以容纳对象属性。这导致对象和数组之间的区别变得模糊,进而产生了许多不好的做法。

为了保持数组处理和对象处理之间的明确区分,PHP 8 现在限制 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(),分别运行三个测试来检查 $var 是否存在:

    // 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;

在 PHP 7 中运行这段代码时,所有测试都成功了,如图所示:

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

但在 PHP 8 中,由于 array_key_exists() 现在只接受数组作为参数,因此出现了致命的 TypeError。下面是 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 自动加载的变化。

使用 PHP 8 自动加载

在 PHP 5.1 中首次引入的基本自动加载类机制在 PHP 8 中同样有效。主要区别在于,PHP 7.2 中废弃的全局函数 __autoload() 在 PHP 8 中被完全删除。从 PHP 7.2 开始,我们鼓励开发者使用 spl_autoload_register()(从 PHP 5.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. 首先,我们在 OopBreakScan 类中定义并添加了一个方法,用于扫描文件内容以查找 __autoload() 函数。请注意,错误信息是 Base 类中定义的一个类常量,它只是警告存在 __autoload() 函数:

    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

在 PHP 8 中,必须使用 spl_autoload_register()。现在我们来看看 spl_autoload_register()

学习使用 spl_autoload_register()

spl_autoload_register() 函数的主要优点是可以注册多个自动加载器。虽然这看起来有点矫枉过正,但设想一下噩梦般的场景:您正在使用多个不同的开源 PHP 库…​…​而且它们都定义了自己的自动加载器!只要所有此类库都使用 spl_autoload_register(),那么拥有多个自动加载器回调就不成问题。

使用 spl_autoload_register() 注册的每个自动加载器都必须是可调用的。以下任何一种情况都被视为可调用:

  • 一个 PHP 程序函数

  • 匿名函数

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

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

  • 这种形式的数组 [$instance,'method'] 数组

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

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

  1. __construct() 方法中,我们指定了源代码目录。然后,我们使用上述数组可调用语法调用 spl_autoload_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() 函数,我们必须检查运行 PHP 8 时可能出现的代码错误。

PHP 8 中可能存在的 spl_auto_register() 代码断点

spl_auto_register() 函数的第二个参数是一个可选的布尔值,默认值为 FALSE。在 PHP 7 及以下版本中,如果第二个参数设置为 TRUE,自动加载器注册失败时,spl_auto_register() 函数会抛出异常。但在 PHP 8 中,如果第二个参数的数据类型不是可调用的,无论第二个参数的值是多少,都会抛出一个致命的 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";
}

如果我们在 PHP 7 中运行该代码块,结果如下:

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

从输出结果可以看出,异常被抛出。catch 块被调用,并显示出 "A program error has occurred"(发生程序错误)的信息。然而,当我们在 PHP 8 中运行同样的程序时,却抛出了一个致命的错误:

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 块被绕过了,因为它是用来捕获异常而不是错误的。简单的解决办法是让 catch 块捕捉 Throwable 而不是 Exception。这样,同样的代码就可以在 PHP 7 或 PHP 8 中运行。

下面是重写后的代码。由于输出结果与在 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";
}

现在你对 PHP 8 自动加载有了更好的了解,也知道了如何发现和纠正潜在的自动加载向后兼容性问题。现在让我们来看看 PHP 8 中与魔法方法有关的变化。