PHP Conference Japan 2024

協變與逆變

在 PHP 7.2.0 中,透過移除子方法中參數的類型限制,引入了部分逆變。從 PHP 7.4.0 開始,加入了完整的協變與逆變支援。

協變允許子方法返回比其父方法返回類型更具體的類型。逆變允許子方法中的參數類型比其父方法中的參數類型更不具體。

在以下情況下,類型宣告被認為更具體:

如果相反的情況成立,則類型類別被認為較不具體。

共變性 (Covariance)

為了說明共變性如何運作,我們建立一個簡單的抽象父類別 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

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

類別
CatShelter 實作 AnimalShelter
{
public function
adopt(string $name): Cat // 可以回傳 Cat 類別型態,而非 Animal 類別型態
{
return new
Cat($name);
}
}

類別
DogShelter 實作 AnimalShelter
{
public function
adopt(string $name): Dog // 可以回傳 Dog 類別型態,而非 Animal 類別型態
{
return new
Dog($name);
}
}

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

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

以上範例會輸出

Ricky meows
Mavrick barks

逆變性 (Contravariance)

繼續前述包含 AnimalCatDog 類別的範例,我們將加入名為 FoodAnimalFood 的類別,並在 Animal 抽象類別中新增一個方法 eat(AnimalFood $food)

<?php

類別 Food {}

類別
AnimalFood 繼承 Food {}

抽象類別
Animal
{
protected
string $name;

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

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

為了觀察逆變性的行為,eat 方法在 Dog 類別中被覆寫,允許任何 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

屬性變異 (Property variance)

預設情況下,屬性既非協變也非逆變,因此是不變的。也就是說,它們的類型在子類別中完全不能更改。原因是「get」操作必須是協變的,而「set」操作必須是逆變的。屬性要同時滿足這兩個要求的唯一方法是不變的。

從 PHP 8.4.0 開始,隨著抽象屬性(在介面或抽象類別上)和虛擬屬性的加入,可以宣告僅具有 get 或 set 操作的屬性。因此,只需要「get」操作的抽象屬性或虛擬屬性可以是協變的。同樣地,只需要「set」操作的抽象屬性或虛擬屬性可以是逆變的。

然而,一旦屬性同時具有 get 和 set 操作,它對於進一步的擴展就不再是協變或逆變的。也就是說,它現在是不變的。

範例 #1 屬性類型變異

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

interface
PetOwner
{
// 只需要 get 操作,所以這可以是共變的。
public Animal $pet { get; }
}

class
DogOwner implements PetOwner
{
// 這可能是一個更嚴格的類型,因為 "get" 端
// 仍然返回一個 Animal。然而,作為一個原生屬性
// 這個類別的子類別可能無法再更改類型。
public Dog $pet;
}

class
PoodleOwner extends DogOwner
{
// 這是不允許的,因為 DogOwner::$pet 定義並需要
// get 和 set 操作。
public Poodle $pet;
}
?>
新增註釋

使用者貢獻的註釋 3 則註釋

xedin dot unknown at gmail dot com
4 年前
我想解釋一下為什麼共變數和逆變數很重要,以及為什麼它們分別應用於返回類型和參數類型,而不是相反。

共變數可能最容易理解,並且與里氏替換原則直接相關。使用上面的例子,假設我們收到一個 `AnimalShelter` 物件,然後我們想要透過呼叫它的 `adopt()` 方法來使用它。我們知道它返回一個 `Animal` 物件,無論該物件究竟是什麼,例如它是 `Cat` 還是 `Dog`,我們都可以將它們視為相同。因此,特化返回類型是可以的:我們至少知道任何可以返回的東西的共同介面,並且我們可以以相同的方式處理所有這些值。

逆變 (Contravariance) 的概念稍微複雜一點。它與提升方法靈活性的實用性息息相關。再次以先前的例子來說,基礎方法 `eat()` 接受特定類型的食物;然而,某個特定動物可能想要支援更廣泛的食物類型。或許就像先前的例子一樣,它在原始方法中添加了允許其食用任何種類食物的功能,而不僅限於動物專用的食物。「動物」中的基礎方法已經實現了允許其食用動物專用食物的功能。「狗」類別中的覆寫方法可以檢查參數是否屬於 `AnimalFood` 類型,如果是,則只需呼叫 `parent::eat($food)`。如果參數不屬於特定類型,它可以對該參數執行額外或甚至完全不同的處理 - 而不會破壞原始簽章,因為它仍然處理特定類型,但也處理更多類型。這就是為什麼它也與里氏替換原則 (Liskov Substitution Principle) 密切相關:消費者仍然可以將特定食物類型傳遞給「動物」,而無需確切知道它是「貓」還是「狗」。
Hayley Watson
2 年前
里氏替換原則應用於類別類型的核心概念基本上是:「如果一個物件是某物的實例,那麼它應該可以在任何允許使用該物實例的地方使用」。當你記住「某物」可能是物件的父類別時,共變和逆變規則就來自於這種期望。

以文中貓/動物的例子來說,貓是動物,所以貓應該可以去任何動物可以去的地方。變異規則將此正式化。

共變 (Covariance):子類別可以覆寫父類別中的一個方法,使其具有更窄的返回類型。(在更具體的子類別中,返回值可以更具體;它們「沿相同方向變化」,因此稱為「共變」)。
如果一個物件有一個你預期會產生「動物」的方法,你應該可以用一個該方法只產生「貓」的物件來替換它。你只會從它得到貓,但貓是動物,這正是你對該物件的期望。

逆變 (Contravariance):子類別可以覆寫父類別中的一個方法,使其參數具有更廣泛的類型。(在更具體的子類別中,參數可以更不具體;它們「沿相反方向變化」,因此稱為「逆變」)。
如果一個物件有一個你預期會接受「貓」的方法,你應該可以用一個該方法接受任何種類「動物」的物件來替換它。你只會給它貓,但貓是動物,這正是物件對你的期望。

因此,如果你的程式碼正在使用某個類別的物件,並且它被賦予一個子類別的實例來使用,它不應該造成任何問題。
它可能接受任何種類的動物,而你只給它貓,或者它可能只返回貓,而你樂於接受任何種類的動物,但 LSP 說「那又怎樣?貓是動物,所以你們兩個都應該滿意。」
匿名
4 年前
共變也適用於一般的類型提示,同時也要注意介面

介面 xInterface
{
public function y() : object;
}

抽象類別 x 實作 xInterface
{
abstract public function y() : object;
}

類別 a 繼承 x
{
public function y() : \DateTime
{
return new \DateTime("now");
}
}

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