PHP Conference Japan 2024

协变和逆变

在 PHP 7.2.0 中,通过删除子方法中参数的类型限制引入了部分逆变。从 PHP 7.4.0 开始,添加了完整的协变和逆变支持。

协变允许子方法返回比其父方法的返回类型更具体的类型。逆变允许子方法中的参数类型比其父方法的参数类型不那么具体。

在以下情况下,类型声明被认为更具体

如果相反的情况为真,则类型类被认为不那么具体。

协变

为了说明协变的工作原理,创建了一个简单的抽象父类 AnimalAnimal 将被子类 CatDog 扩展。

<?php

abstract class Animal
{
protected
string $name;

public function
__construct(string $name)
{
$this->name = $name;
}

abstract public function
speak();
}

class
Dog extends Animal
{
public function
speak()
{
echo
$this->name . " barks";
}
}

class
Cat extends Animal
{
public function
speak()
{
echo
$this->name . " meows";
}
}

请注意,在此示例中没有任何返回值的函数。将添加一些工厂,它们将返回类类型 AnimalCatDog 的新对象。

<?php

interface AnimalShelter
{
public function
adopt(string $name): Animal;
}

class
CatShelter implements AnimalShelter
{
public function
adopt(string $name): Cat // 而不是返回类类型 Animal,它可以返回类类型 Cat
{
return new
Cat($name);
}
}

class
DogShelter implements AnimalShelter
{
public function
adopt(string $name): Dog // 而不是返回类类型 Animal,它可以返回类类型 Dog
{
return new
Dog($name);
}
}

$kitty = (new CatShelter)->adopt("Ricky");
$kitty->speak();
echo
"\n";

$doggy = (new DogShelter)->adopt("Mavrick");
$doggy->speak();

以上示例将输出

Ricky meows
Mavrick barks

逆变

继续使用前面使用类 AnimalCatDog 的示例,将包含一个名为 FoodAnimalFood 的类,并在 Animal 抽象类中添加一个方法 eat(AnimalFood $food)

<?php

class Food {}

class
AnimalFood extends Food {}

abstract class
Animal
{
protected
string $name;

public function
__construct(string $name)
{
$this->name = $name;
}

public function
eat(AnimalFood $food)
{
echo
$this->name . " eats " . get_class($food);
}
}

为了查看逆变的行为,在 Dog 类中覆盖了 eat 方法以允许任何 Food 类型对象。 Cat 类保持不变。

<?php

class Dog extends Animal
{
public function
eat(Food $food) {
echo
$this->name . " eats " . get_class($food);
}
}

下一个示例将显示逆变的行为。

<?php

$kitty
= (new CatShelter)->adopt("Ricky");
$catFood = new AnimalFood();
$kitty->eat($catFood);
echo
"\n";

$doggy = (new DogShelter)->adopt("Mavrick");
$banana = new Food();
$doggy->eat($banana);

以上示例将输出

Ricky eats AnimalFood
Mavrick eats Food

但是如果 $kitty 尝试 eat() $banana 会发生什么?

$kitty->eat($banana);

以上示例将输出

Fatal error: Uncaught TypeError: Argument 1 passed to Animal::eat() must be an instance of AnimalFood, instance of Food given

属性变异

默认情况下,属性既不是协变也不是逆变,因此是不变的。也就是说,它们的类型在子类中根本不能改变。其原因是“获取”操作必须是协变的,“设置”操作必须是逆变的。属性满足这两个要求的唯一方法是不变的。

从 PHP 8.4.0 开始,随着抽象属性(在接口或抽象类上)和虚拟属性的添加,可以声明一个只具有获取或设置操作的属性。因此,仅需要“获取”操作的抽象属性或虚拟属性可能是协变的。类似地,仅需要“设置”操作的抽象属性或虚拟属性可能是逆变的。

但是,一旦属性同时具有获取和设置操作,它便不再适用于进一步扩展的协变或逆变。也就是说,它现在是不变的。

示例 #1 属性类型差异

<?php
class Animal {}
class
Dog extends Animal {}
class
Poodle extends Dog {}

interface
PetOwner
{
// 仅需要获取操作,因此这可能是协变的。
public Animal $pet { get; }
}

class
DogOwner implements PetOwner
{
// 这可能是更严格的类型,因为“获取”端
// 仍然返回一个 Animal。但是,作为原生属性
// 此类的子类不能再更改类型了。
public Dog $pet;
}

class
PoodleOwner extends DogOwner
{
// 这不允许,因为 DogOwner::$pet 定义并需要
// 获取和设置操作。
public Poodle $pet;
}
?>
添加注释

用户贡献的注释 3 条注释

xedin dot unknown at gmail dot com
4 年前
我想解释为什么协变和逆变很重要,以及为什么它们分别应用于返回类型和参数类型,而不是相反。

协变可能最容易理解,并且与里氏替换原则直接相关。使用上面的示例,假设我们收到一个 `AnimalShelter` 对象,然后我们想通过调用其 `adopt()` 方法来使用它。我们知道它返回一个 `Animal` 对象,无论该对象究竟是什么,例如它是 `Cat` 还是 `Dog`,我们都可以以相同的方式对待它们。因此,专门化返回类型是可以的:我们至少知道任何可以返回的事物的通用接口,并且我们可以以相同的方式处理所有这些值。

逆变稍微复杂一些。它与提高方法灵活性的实用性密切相关。再次使用上面的示例,也许“基本”方法 `eat()` 接受特定类型的食物;但是,_特定_动物可能希望支持_更广泛_的食物类型。也许,就像上面的示例一样,它向原始方法添加了功能,使其能够消耗_任何_种类的食物,而不仅仅是为动物准备的食物。“基本”方法在 `Animal` 中已经实现了允许它消耗专门为动物准备的食物的功能。`Dog` 类中的覆盖方法可以检查参数是否为 `AnimalFood` 类型,并简单地调用 `parent::eat($food)`。如果参数_不是_专门类型,它可以执行该参数的其他或甚至完全不同的处理 - 不会破坏原始签名,因为它_仍然_处理专门类型,而且更多。这就是它也与里氏替换原则密切相关的原因:消费者仍然可以将专门的食物类型传递给 `Animal`,而无需确切知道它是 `Cat` 还是 `Dog`。
Hayley Watson
2 年前
里氏替换原则如何应用于类类型的要点基本上是:“如果一个对象是某事物的实例,则应该可以在任何允许使用某事物的实例的地方使用它”。当您记住“某事物”可能是对象的父类时,协变和逆变规则就来自此期望。

对于文本中的 Cat/Animal 示例,Cats 是 Animals,因此 Cats 应该可以去任何 Animals 可以去的地方。方差规则将此形式化。

协变:子类可以使用返回类型更窄的方法覆盖父类中的方法。(返回类型可以在更具体的子类中更具体;它们“朝同一方向变化”,因此“协变”)。
如果一个对象具有您期望产生 Animals 的方法,则应该能够用一个对象替换它,该对象的方法只能产生 Cats。您只会从中获得 Cats,但 Cats 是 Animals,这就是您对该对象期望的。

逆变:子类可以使用参数类型更宽的方法覆盖父类中的方法。(参数可以在更具体的子类中更不具体;它们“朝相反的方向变化”,因此“逆变”)。
如果一个对象具有您期望采用 Cats 的方法,则应该能够用一个对象替换它,该对象的方法可以采用任何类型的 Animal。您只会给它 Cats,但 Cats 是 Animals,这就是对象对您的期望。

因此,如果您的代码正在使用某个特定类的对象,并且它给出了一个子类的实例来使用,则不应该引起任何麻烦
它可能会接受任何类型的 Animal,而您只提供 Cats,或者它只在您乐于接收任何类型的 Animal 时返回 Cats,但 LSP 说“那又怎样?Cats 是 Animals,所以你们都应该满意。”
匿名
4 年前
协变也适用于通用类型提示,还要注意接口

interface xInterface
{
public function y() : object;
}

abstract class x implements xInterface
{
abstract public function y() : object;
}

class a extends x
{
public function y() : \DateTime
{
return new \DateTime("now");
}
}

$a = new a;
echo '<pre>';
var_dump($a->y());
echo '</pre>';
To Top