Aller au contenu

Contrats : interfaces, traits et enums

Séance 3 · ~3 périodes

Le chapitre 5 a donné l'héritage : dire ce qu'une chose est. Il a des limites. Une classe n'a qu'un seul parent. Or on veut parfois dire « ceci sait se battre » sans imposer une famille. On veut parfois partager du code entre deux classes sans parent commun. On veut parfois n'autoriser que trois valeurs, pas une de plus. Trois besoins, trois outils : interface, trait, enum. Vous les rencontrerez dans tous les fichiers de Laravel.

À la fin de ce chapitre, vous serez capables de :

  1. définir une interface, l'implémenter, et typer un paramètre par un contrat plutôt que par une classe ;
  2. utiliser les interfaces natives de PHP pour rendre vos objets compatibles avec count() et foreach ;
  3. choisir entre une interface et une classe abstraite ;
  4. extraire du code dupliqué dans un trait, et dire pourquoi un trait n'est pas une relation ;
  5. écrire une enum avec des méthodes, et l'utiliser à la place d'une chaîne de caractères.

Le code qui sent mauvais

Cinq minutes, en binôme. Ce code sent mauvais (code smell : un détail d'écriture qui trahit un défaut de conception). Lisez-le et prévoyez le résultat des deux affichages. Pourquoi le second vaut-il 1 et pas 3 ?

final class Item
{
    public function __construct(private string $rarity) {}

    public function multiplier(): float
    {
        if ($this->rarity === 'rare') {
            return 1.5;
        }
        if ($this->rarity === 'Legendary') {
            return 3.0;
        }

        return 1.0;
    }
}

echo (new Item('rare'))->multiplier(), PHP_EOL;
echo (new Item('legendary'))->multiplier(), PHP_EOL;
1.5
1

1. L'interface : un contrat sans code

Nous voulons une fonction qui permette à un héros ou à un monstre d'en attaquer un autre. Problème : Hero et Monster n'ont aucun parent commun, et il n'y a aucune raison d'en inventer un. Un héros n'est pas un monstre.

Une interface déclare ce qu'une classe doit savoir faire, sans dire comment. Dans ce cours, nos interfaces déclarent des méthodes sans écrire leur code.

interface Fighter
{
    public function attack(): int;

    public function takeDamage(int $amount): void;

    public function isAlive(): bool;
}

Une classe s'engage avec implements. Une classe concrète doit fournir les trois méthodes, directement ou par héritage. Sinon, PHP refuse sa déclaration.

final class Hero implements Fighter { /* les méthodes du chapitre 5 */ }

abstract class Monster implements Fighter { /* les méthodes du chapitre 5 */ }

Deux remarques. On implémente autant d'interfaces qu'on veut, séparées par des virgules, alors qu'on n'hérite que d'un seul parent. Et Monster peut rester abstraite tout en implémentant Fighter : elle laisse attack() à ses classes filles, qui remplissent le contrat pour elle.

Voici à quoi ça sert : typer un paramètre par le contrat.

Hero et Monster signent le même contrat Fighter

/** Ne demande ni un Hero ni un Goblin : deux objets qui savent se battre, c'est tout. */
function strike(Fighter $attacker, Fighter $target): void
{
    $target->takeDamage($attacker->attack());
}

$arthur = new Hero('Arthur');
$goblin = new Goblin();

strike($arthur, $goblin);
strike($goblin, $arthur);

echo $arthur, PHP_EOL;
echo $goblin, PHP_EOL;
var_dump($arthur instanceof Fighter, $goblin instanceof Fighter);
Arthur (8/10 PV)
Gobelin (3/5 PV)
bool(true)
bool(true)

strike() ne connaît ni Hero, ni Goblin, ni Dragon. Tout nouvel objet dont la classe implémente Fighter pourra lui être passé sans qu'on modifie la fonction. C'est encore du polymorphisme, comme au chapitre 5, mais sans obliger les deux classes à descendre du même parent.

Note

Histoire de PHP. Les interfaces arrivent avec PHP 5, en 2004, en même temps que les classes abstraites. Depuis PHP 8.4, une interface peut aussi imposer une propriété accessible en lecture. Nous n'utiliserons pas cette possibilité : dans ce cours, une interface ne contient que des signatures de méthodes.

2. Interface ou classe abstraite ?

Les deux imposent des méthodes. La question qui tranche : les classes concernées appartiennent-elles à la même famille ?

Classe abstraite Interface
Peut contenir du code oui : méthodes complètes, constructeur non : que des signatures
Peut avoir des propriétés oui non, dans ce cours
Combien par classe une seule (extends) autant qu'on veut (implements)
Ce qu'elle dit « ceci est un … » « ceci sait faire … »
Dans le Donjon Monster rassemble le code commun aux monstres Fighter permet une même fonction de combat pour le héros et les monstres

Astuce

Une règle de nommage utile pour lire le code des autres : une interface porte souvent un rôle, comme Fighter ou Renderer, ou un nom en -able, comme Countable et Stringable. Une classe abstraite porte le nom d'une famille : Item, Monster, Model.

En pratique, on utilise souvent les deux ensemble. Monster est abstraite et implémente Fighter.

3. Les interfaces natives de PHP

PHP fournit une série d'interfaces toutes faites. Elles indiquent au langage comment compter, parcourir ou afficher vos objets.

Interface Méthode à écrire Ce qu'elle permet
Stringable __toString(): string echo $item; et l'interpolation "{$item}"
Countable count(): int count($inventory)
IteratorAggregate getIterator(): Traversable foreach ($inventory as $item)
JsonSerializable jsonSerialize(): mixed choisir ce que json_encode() produit
ArrayAccess offsetGet(), offsetSet() $inventory['épée']

Les deux premières lignes de ce tableau demandent une précision. Depuis PHP 8.0, une classe qui déclare __toString() implémente Stringable sans qu'on l'écrive. Le implements Stringable explicite ne change rien au comportement : il documente le choix et permet de typer un paramètre par cette interface. Les deux dernières lignes sont là pour que vous les reconnaissiez dans du code réel. Nous n'en aurons pas besoin ici.

Les interfaces natives branchent Inventory sur count et foreach

final class Inventory implements Countable, IteratorAggregate
{
    /** @var Item[] */
    private array $items = [];

    /** Contrat de Countable : ce que count($inventory) doit renvoyer. */
    public function count(): int
    {
        return count($this->items);
    }

    /** Contrat de IteratorAggregate : ce que foreach doit parcourir. */
    public function getIterator(): Traversable
    {
        return new ArrayIterator($this->items);
    }

    // … add(), has(), remove(), totalWeight() du chapitre 5
}

ArrayIterator est une classe toute faite de PHP : on lui donne un tableau, elle sait le parcourir. C'est pourquoi on préfère IteratorAggregate, où l'on délègue le parcours, à Iterator, où il faut écrire soi-même current(), next() et valid(). Le type de retour annoncé est Traversable, l'interface commune à tous les parcours.

$inventory = new Inventory();
$inventory->add(new Weapon('Épée courte', 2.0, 5));
$inventory->add(new Potion('Potion de soin', 0.5, 5));

echo count($inventory), ' objets', PHP_EOL;

foreach ($inventory as $item) {
    echo '- ', $item, PHP_EOL;
}
2 objets
- Épée courte : arme (2 kg, 5 dégâts)
- Potion de soin : potion (0.5 kg, +5 PV)

$inventory n'est pas un tableau. Il se comporte comme un tableau parce qu'il implémente deux interfaces. Le - de la deuxième ligne affiche $item directement, grâce à Item::__toString(), qui renvoie la même chose que describe().

4. Le trait HasHealth : sortir le code dupliqué

Ouvrez src/Hero.php et src/Monster.php dans le dépôt des katas. Les deux classes portent exactement les mêmes propriétés et les mêmes cinq méthodes de santé : hp, maxHp, hp(), maxHp(), takeDamage(), heal() et isAlive(). Elles ne peuvent pas hériter l'une de l'autre : un héros n'est pas un monstre.

final class Hero implements Fighter
{
    protected int $hp = 0;
    protected int $maxHp = 0;

    public function takeDamage(int $amount): void
    {
        $this->hp = max(0, $this->hp - $amount);
    }

    // … et hp(), maxHp(), heal(), isAlive()
}

Monster contient les mêmes déclarations, mot pour mot. C'est ce que le niveau 4 du kata vous demande de corriger.

Un trait regroupe des propriétés et des méthodes réutilisables dans plusieurs classes. Il ne crée aucun lien d'héritage : c'est du code que PHP recopie dans la classe qui l'utilise, au chargement.

Note

Histoire de PHP. Les traits arrivent avec PHP 5.4, en 2012. Avant, deux classes sans parent commun qui voulaient le même code n'avaient que deux solutions : le recopier, ou passer par un troisième objet. Laravel en est rempli, et vous en verrez deux dans la section 7.

Le travail du niveau 4 tient en deux gestes. D'abord, déplacer les propriétés et les cinq méthodes dans src/HasHealth.php, qui est vide dans le squelette.

trait HasHealth
{
    /** Les points de vie courants, toujours entre 0 et $maxHp. */
    protected int $hp = 0;

    /** Le maximum de points de vie. */
    protected int $maxHp = 0;

    public function hp(): int
    {
        return $this->hp;
    }

    public function maxHp(): int
    {
        return $this->maxHp;
    }

    public function takeDamage(int $amount): void
    {
        $this->hp = max(0, $this->hp - $amount);
    }

    public function heal(int $amount): void
    {
        $this->hp = min($this->maxHp, $this->hp + $amount);
    }

    public function isAlive(): bool
    {
        return $this->hp > 0;
    }
}

Ensuite, écrire une seule ligne dans chacune des deux classes : use HasHealth;.

final class Hero implements Fighter
{
    use HasHealth;

    private Inventory $inventory;
    private ?Weapon $weapon = null;

    public function __construct(
        public readonly string $name,
        int $maxHp = 10,
        public readonly int $strength = 2,
    ) {
        $this->maxHp = $maxHp;
        $this->hp = $maxHp;
        $this->inventory = new Inventory();
    }

    public function attack(): int
    {
        return $this->strength + ($this->weapon?->damage() ?? 0);
    }

    // … inventory(), equip(), weapon(), drink(), __toString()
}

abstract class Monster implements Fighter
{
    use HasHealth;

    public function __construct(
        public readonly string $name,
        int $maxHp,
    ) {
        $this->maxHp = $maxHp;
        $this->hp = $maxHp;
    }
}

Le trait HasHealth est collé dans Hero et dans Monster, sans lien de parenté

Les deux propriétés et les cinq méthodes ont disparu des deux fichiers. Le constructeur, lui, n'a pas changé : il affecte toujours $this->maxHp et $this->hp, qui sont maintenant déclarées dans le trait. Le contrat Fighter reste rempli, puisque takeDamage() et isAlive() viennent de HasHealth.

Note

Le mot-clé use a deux sens selon l'endroit où il se trouve. En haut du fichier, après le namespace, il importe une classe : use Dungeon\Item; du chapitre 2. À l'intérieur d'une classe, il colle un trait : use HasHealth;. Même mot, deux mécanismes. Quand vous lirez un fichier de Laravel, regardez d'abord l'indentation.

La fonction class_uses() liste les traits d'une classe. Elle regarde uniquement la classe qu'on lui donne, jamais ses parents.

var_dump(class_uses(Hero::class));
var_dump(class_uses(Goblin::class));
array(1) {
  ["Dungeon\HasHealth"]=>
  string(17) "Dungeon\HasHealth"
}
array(0) {
}

Goblin utilise pourtant bien les méthodes de HasHealth, mais le trait est collé dans Monster, pas dans Goblin.

Attention

Un trait n'est pas une relation. Dans nos diagrammes, un trait ne se dessine ni comme un parent ni comme une interface. Si vous voulez le signaler, une note suffit. Ce qui compte dans un diagramme, ce sont les types : les classes, les interfaces et les enums.

Conséquence pour votre code : n'utilisez pas un trait pour dire ce qu'une chose est. Un trait permet de réutiliser du code. Une interface indique les méthodes attendues. Une classe abstraite peut faire les deux pour une même famille.

5. L'enum : trois valeurs, pas une de plus

Retour au code du début de chapitre. La rareté d'un objet n'est pas un texte libre, c'est un choix parmi trois.

Note

Histoire de PHP. Les enums arrivent avec PHP 8.1, en 2021. Avant, on écrivait des constantes de classe, et le type restait une chaîne non contrôlée :

class Rarity
{
    const COMMON = 'common';
    const RARE = 'rare';
}

match date de PHP 8.0. Avant lui, switch faisait le travail, avec une comparaison non stricte, des break à ne pas oublier et aucune valeur de retour.

enum Rarity: string
{
    case Common = 'common';
    case Rare = 'rare';
    case Legendary = 'legendary';

    /** Combien vaut un objet de cette rareté, par rapport à un objet banal. */
    public function multiplier(): float
    {
        return match ($this) {
            Rarity::Common => 1.0,
            Rarity::Rare => 1.5,
            Rarity::Legendary => 3.0,
        };
    }

    /** Le libellé affiché au joueur. */
    public function label(): string
    {
        return match ($this) {
            Rarity::Common => 'Commun',
            Rarity::Rare => 'Rare',
            Rarity::Legendary => 'Légendaire',
        };
    }
}

Avec : string après le nom, chaque cas porte une valeur texte. On appelle ça une enum à valeurs associées, ou backed enum. C'est cette valeur qu'on enregistre en base de données ou dans un fichier JSON. Sans : string, on a une enum simple, avec des cas nommés et rien d'autre.

Une enum est une classe un peu particulière : ses cas sont des objets, et elle peut avoir des méthodes. match ($this) va naturellement avec. Il compare strictement, il renvoie une valeur, et il refuse de deviner quand un cas manque. Ajoutez Cursed sans compléter les deux match, et l'appel de multiplier() sur ce cas s'arrête net :

PHP Fatal error:  Uncaught UnhandledMatchError: Unhandled match case Rarity::Cursed

Comparez avec le if du début de chapitre, qui renvoyait 1.0 en silence. Ici, PHP signale l'erreur, et un analyseur statique la signale avant même l'exécution.

$r = Rarity::Legendary;
echo $r->name, ' / ', $r->value, ' / ', $r->label(), ' / x', $r->multiplier(), PHP_EOL;

foreach (Rarity::cases() as $case) {
    printf('%-10s %-10s x%.1f%s', $case->value, $case->label(), $case->multiplier(), PHP_EOL);
}

var_dump(Rarity::from('rare'));
var_dump(Rarity::tryFrom('epic'));
Legendary / legendary / Légendaire / x3
common     Commun     x1.0
rare       Rare       x1.5
legendary  Légendaire x3.0
enum(Dungeon\Rarity::Rare)
NULL

Quatre points à retenir de cette sortie :

  • ->name est le nom du cas, ->value sa valeur associée. Ne les confondez pas.
  • cases() donne la liste complète, dans l'ordre de déclaration. Plus besoin de maintenir un tableau à côté, qui finirait désynchronisé.
  • from('rare') retrouve le cas correspondant. from('epic') lève une ValueError.
  • tryFrom('epic') renvoie null au lieu de lever. Utilisez tryFrom() quand la donnée vient de l'extérieur, from() quand elle vient de votre base et qu'une valeur inconnue est un vrai bug.

Le problème du début disparaît. Le paramètre est typé Rarity, donc new Weapon('Épée courte', 2.0, 5, 'legendary') lève un TypeError. Il faut écrire Rarity::Legendary, l'éditeur propose les trois cas, et Item::value() tient en une ligne :

/** Règle du jeu, arbitraire : la valeur marchande dépend du poids et de la rareté. */
public function value(): float
{
    return $this->weight * $this->rarity->multiplier();
}

6. En UML : réalisation, interface, enum

Trois nouveautés dans vos diagrammes.

  • La réalisation dit « cette classe implémente cette interface ». C'est un trait pointillé terminé par un triangle vide. À distinguer de la généralisation du chapitre 5, qui a le même triangle mais un trait plein. En PlantUML : Fighter <|.. Hero.
  • Le mot-clé interface affiche «interface» au-dessus du nom.
  • Le mot-clé enum affiche «enumeration», avec les cas dans le compartiment des attributs.
@startuml
interface Fighter {
  +attack(): int
  +takeDamage(amount: int): void
  +isAlive(): bool
}
class Hero {
  +name: string
  +attack(): int
  +equip(w: Weapon): void
}
abstract class Monster {
  +name: string
  +{abstract} attack(): int
}
class Goblin {
  +attack(): int
}
class Weapon {
  -damage: int
}
enum Rarity {
  Common
  Rare
  Legendary
  +multiplier(): float
  +label(): string
}
abstract class Item {
  #name: string
  #weight: float
  #rarity: Rarity
  +name(): string
  +weight(): float
  +value(): float
  +{abstract} describe(): string
}
class Inventory {
  +add(item: Item): void
  +count(): int
  +getIterator(): Traversable
}
Fighter <|.. Hero
Fighter <|.. Monster
Monster <|-- Goblin
Item <|-- Weapon
Hero "1" *-- "1" Inventory : inventory
Hero "1" --> "0..1" Weapon : wields
Inventory "1" o-- "0..*" Item : items
Item "0..*" --> "1" Rarity : rarity
note bottom of Monster : Hero et Monster utilisent le trait HasHealth, qui ne se dessine pas.
@enduml

Lisez ce diagramme comme une phrase : un héros et un monstre savent se battre, un gobelin est un monstre, un héros **est fait d'**un inventaire, qui contient des objets, chacun ayant une rareté. Notez que Monster réalise Fighter sans écrire attack() : ce sont ses classes filles qui remplissent le contrat.

7. Ce que vous verrez dans Laravel

Ces trois mots-clés sont partout dans le framework.

use Illuminate\Database\Eloquent\Factories\HasFactory;
use Illuminate\Foundation\Auth\User as Authenticatable;
use Illuminate\Notifications\Notifiable;

class User extends Authenticatable
{
    use HasFactory, Notifiable;

    protected function casts(): array
    {
        return ['role' => UserRole::class];
    }
}
  • use HasFactory, Notifiable; : deux traits, collés dans la classe. HasFactory apporte les méthodes qui fabriquent des User de test, Notifiable apporte notify(). Aucun des deux ne dit ce qu'un User est : ils lui ajoutent des méthodes. Et ils ne peuvent pas être des parents, parce que User en a déjà un.
  • extends Authenticatable : l'héritage du chapitre 5. Les trois lignes use du haut du fichier importent des classes, pas des traits. Ici, Authenticatable désigne la classe Illuminate\Foundation\Auth\User, renommée à l'import. Cette classe implémente plusieurs interfaces, et c'est en signant ces contrats que votre User devient utilisable par le système de connexion.
  • 'role' => UserRole::class : une conversion de valeur, appelée cast. UserRole est une enum de votre application. En base, la colonne contient 'admin'. En PHP, $user->role est un cas de l'enum, avec ses méthodes. Eloquent fait le from() et le ->value dans les deux sens. C'est exactement Rarity, avec la base de données autour.

Quand vous verrez Collection implements ArrayAccess, Countable, IteratorAggregate, JsonSerializable, vous saurez pourquoi count($users) et foreach ($users as $user) fonctionnent sur un objet.

À retenir

  • Une interface est un contrat : que des signatures, autant qu'on veut par classe. On type les paramètres par l'interface, comme Fighter $target, pas par la classe concrète.
  • Les interfaces natives relient vos objets à la syntaxe du langage : Countable pour count(), IteratorAggregate et ArrayIterator pour foreach, Stringable pour echo.
  • Classe abstraite quand il y a du code et une famille à partager. Interface quand il n'y a qu'un rôle à déclarer. Souvent les deux ensemble.
  • Un trait est du code recopié dans la classe, pas un type et pas une relation. Il ne se dessine pas en UML, et class_uses() ne remonte pas aux parents.
  • Une enum remplace une chaîne libre par une liste fermée de cas, avec des méthodes et un match complet. from() lève une erreur, tryFrom() renvoie null.

Exercices

Kata niveau 4

Dans https://github.com/opmvpc/poo-katas-26 :

composer test -- --group=niveau-4

Ce que les tests attendent :

Ce qu'on écrit Détail vérifié
enum Rarity: string trois cas, valeurs 'common', 'rare', 'legendary' ; Rarity::from('rare') ; Rarity::tryFrom('inconnu') vaut null
Rarity::multiplier() 1.0, 1.5, 3.0
Rarity::label() 'Commun', 'Rare', 'Légendaire'
Item::rarity() vaut Rarity::Common par défaut
Item::value() poids × multiplicateur : 2.0 pour une épée commune de 2 kg, 3.0 pour la même en Rare
Item implements Stringable (string) $item renvoie exactement $item->describe()
interface Fighter Hero et Monster l'implémentent : is_subclass_of(Monster::class, Fighter::class) vaut true
trait HasHealth class_uses(Hero::class) et class_uses(Monster::class) le contiennent
Inventory implements Countable count($bag) renvoie le nombre d'objets
Inventory implements IteratorAggregate foreach ($bag as $item) parcourt les objets dans l'ordre d'ajout

Attention

class_uses() ne regarde que la classe qu'on lui donne. Le test l'appelle sur Hero et sur Monster : écrivez donc use HasHealth; dans ces deux classes, et nulle part ailleurs.

Bonus, niveau 5 : Battle

Battle fait s'affronter deux Fighter tour par tour et renvoie le vainqueur. $a frappe en premier, les dégâts valent attack() plus un lancer de dé, et le combat s'arrête dès qu'un des deux tombe. Sa signature est __construct(Fighter $a, Fighter $b, Dice $dice) : on lui donne le dé au lieu qu'il le fabrique, ce qui permet aux tests de fournir un dé aux valeurs connues. Ce dé truqué, FixedDice, est fourni dans tests/Support/.

Pour vous entraîner

  1. Ajoutez un cas Cursed à Rarity, avec le multiplicateur 0.5 et le libellé « Maudit ». Appelez ensuite multiplier() et label() sur ce cas. Combien de match avez-vous dû corriger, et à quel moment l'erreur est-elle apparue ?
  2. Faites implémenter JsonSerializable à Inventory pour que json_encode($inventory) sorte le nom, le poids et la valeur de chaque objet.
  3. Donnez à la Room du chapitre 4 une liste d'objets au sol, puis faites-lui implémenter Countable et IteratorAggregate. Écrivez le foreach qui affiche son butin.