了解 PHP 8 扩展差异支持

差异概念是 OOP 的核心。差异(Variance)是一个总括术语,涵盖了各种子类型之间的相互关系。大约 20 年前,一对早期的计算机科学家 Wing 和 Liskov 设计了一个重要的定理,它是 OOP 子类型的核心,现在被称为 里氏替换原则

在不涉及精确数学的情况下,这一原理可以被理解为以下内容:

如果可以用 X 的实例代替 Y 的实例,而应用程序的行为不会发生任何改变,那么类 X 就可以被视为类 Y 的子类型。

首次描述并提供利斯科夫替换原则精确数学公式定义的实际论文可在此处找到: A behavioral notion of subtyping,ACM Transactions on Programming Languages and Systems,作者 B. Liskov 和 J. Wing,1994 年 11 月 ( https://dl.acm.org/doi/10.1145/197320.197383 )。

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

让我们从协变返回开始。

了解协变 returns

PHP 中的协变支持旨在保持类型从最特殊到最一般的顺序。一个典型的例子就是尝试/捕获块(try/catch block)的设计:

  1. 在本例中,在 try 代码块中创建了一个 PDO 实例。下面的两个捕获块首先查找 PDOException。接下来的第二个捕获块会捕获任何实现 Throwable 的类。因为 PHP Exception 和 Error 类都实现了 Throwable,所以第二个捕获块最终会成为 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 实例因参数无效而失败,错误日志中就会出现 "数据库错误" 条目,后面跟着从 PDOException 中获取的信息。

  3. 另一方面,如果发生了其他一般错误,错误日志中就会出现 "未知错误" 条目,然后是来自其他异常或错误类的信息。

  4. 但在本例中,捕捉块的顺序是相反的:

    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 协变支持的工作方式,第二个捕获块永远不会被调用。相反,源于该代码块的所有错误日志条目都会以未知错误开头。

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

  1. 首先,我们定义了一个接口 FactoryIterface,它标识了一个方法 make()。该方法接受一个数组作为参数,并返回一个 ArrayObject 类型的对象:

    interface FactoryInterface {
        public function make(array $arr): ArrayObject;
    }
  2. 接下来,我们定义一个扩展 ArrayObjectArrTest 类:

    class ArrTest extends ArrayObject {
        const DEFAULT_TEST = 'This is a test';
    }
  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. 在 PHP 7.1 中,由于不支持共变返回数据类型,因此会抛出一个致命错误。输出结果(如图所示)告诉我们,方法的返回类型声明与 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. 在 PHP 8 中运行同样的代码时,可以看到返回类型支持协变。如图所示,执行过程畅通无阻:

    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 替换原则所定义的标准。从最后的输出结果可以看出,PHP 8 比 PHP 早期版本更全面地遵循了真正的 OOP 原则。最终的结果是,在使用 PHP 8 时,你的代码和应用程序架构会更直观、逻辑更合理。

现在让我们来看看逆变参数。

使用逆变参数

协变涉及子类型从一般到特殊的排序,而逆变涉及相反的排序:从特殊到一般。在 PHP 7 和更早的版本中,还不能完全支持协变。因此,在 PHP 7 中,实现接口或扩展抽象类时,参数类型提示是 不变的

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

这样,在定义接口或抽象类时,您就可以更自由地定义整体架构。在 PHP 8 中,使用接口或超类的开发人员在实现子类逻辑时有了更大的灵活性。

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

  1. 在本例中,我们首先定义了一个 IterObj 类,它扩展了内置的 ArrayIterator PHP 类:

    // /repo/ch05/php8_variance_contravariant.php
    class IterObj extends ArrayIterator {}
  2. 然后,我们定义了一个抽象基类,该基类指定了一个方法 stringify()。请注意,该方法唯一参数的数据类型是 IterObj

    abstract class Base {
        public abstract function stringify(IterObj $it);
    }
  3. 接下来,我们定义了一个 IterTest 类,该类扩展了 Base 并提供了 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. 在 PHP 7.1 中运行此代码示例会产生预期的致命错误,如图所示:

    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

    由于 PHP 7.1 不支持逆变参数,因此它将参数的数据类型视为变量,并简单地显示一条信息,说明子类的数据类型与父类中指定的数据类型不兼容。

  6. 另一方面,PHP 8 提供了对逆变参数的支持。因此,它能识别 IterObjBase 类中指定的数据类型)是与 iterable 兼容的子类型。此外,所提供的两个参数也都与 iterable 兼容,从而允许程序继续执行。下面是 PHP 8 的输出结果:

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

PHP 8 支持 协变返回逆变参数 的主要优点是不仅可以重写方法逻辑,还可以重写方法签名。您会发现,尽管 PHP 8 在执行良好的编码实践方面要严格得多,但增强的变量支持使您在设计继承结构时有了更大的自由度。从某种意义上说,至少在参数和返回值数据类型方面,PHP 8 的限制更少!

有关如何在 PHP 7.4 和 PHP 8 中应用差异支持的完整解释,请访问: https://wiki.php.net/rfc/covariantreturns-and-contravariant-parameters

现在我们来看看 SPL 的变化,以及这些变化在迁移到 PHP 8 后对应用程序性能的影响。