Aller au contenu

Contrats : interfaces, traits et enums

Séance 3 · ~3 périodes

Slides du chapitre (PDF)PDF

Le chapitre 6 a donné l'héritage : dire ce qu'une chose est. Il a des limites. Une classe n'a qu'un seul parent. Or il faut parfois dire « ceci sait se battre » sans imposer une famille. Il faut parfois partager du code entre deux classes sans parent commun. Il faut parfois n'autoriser que trois valeurs, pas une de plus. Trois besoins, trois outils : interface, trait, enum. Vous les rencontrerez dans presque 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 le dessiner en UML sans le confondre avec un héritage.
  5. écrire une enum avec des méthodes, et l'utiliser à la place d'une chaîne de caractères.

Le code de ce chapitre vit dans le dépôt des katas, dans le namespace Dungeon du chapitre 2. Les sorties de PHP affichent donc des noms complets, comme Dungeon\Rarity.

Le code qui sent mauvais

Cinq minutes, en binôme. Ce code sent mauvais : un détail d'écriture y trahit un défaut de conception. Le second affichage vaut 1, pas 3. Pourquoi ?

final class Treasure
{
    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 Treasure('rare'))->multiplier(), PHP_EOL;
echo (new Treasure('legendary'))->multiplier(), PHP_EOL;

1. L'interface : un contrat sans code

Il faut 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.

interface Fighter
{
    public function attack(): int;

    public function takeDamage(int $amount): void;

    public function isAlive(): bool;
}

Une interface est un contrat : la liste de ce qu'une classe doit savoir faire, sans dire comment. Elle ne contient que des signatures de méthodes publiques : le nom de chaque méthode, ses paramètres, son type de retour, et pas une ligne de code. Une classe déclare qu'elle remplit ce contrat avec implements, et PHP vérifie au chargement qu'elle a bien écrit chaque méthode.

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

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

Une classe concrète doit fournir les trois méthodes, directement ou par héritage. Une classe qui en oublie une est refusée au chargement :

final class Ghost implements Fighter
{
    public function attack(): int
    {
        return 1;
    }
}
Fatal error: Class Dungeon\Ghost contains 2 abstract methods and must therefore be declared abstract or implement the remaining methods (Dungeon\Fighter::takeDamage, Dungeon\Fighter::isAlive)

Une interface est un contrat : la liste des méthodes à gauche, les classes qui la signent au milieu, et strike() qui ne lit que le contrat

Un contrat sert d'abord à celui qui appelle. strike(), plus bas, sait ce qu'elle peut demander à ses deux paramètres sans savoir de quelle classe ils sont. Ce sera utile chaque fois que des classes sans parent commun doivent jouer le même rôle, chaque fois qu'un objet doit se brancher sur une syntaxe de PHP, comme count() à la section 3, et chaque fois qu'une pièce doit pouvoir être remplacée par une autre, comme le dé truqué des tests du niveau 5.

Une classe implémente autant d'interfaces qu'elle veut, séparées par des virgules. Elle 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.

Un paramètre se type par le contrat, pas par la classe :

/** 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)
@startuml
interface Fighter {
  +attack(): int
  +takeDamage(amount: int): void
  +isAlive(): bool
}
class Hero
abstract class Monster <<abstract>>
Fighter <|.. Hero
Fighter <|.. Monster
@enduml

Le trait pointillé au triangle vide {uml:realisation} se lit « réalise » : Hero et Monster remplissent le contrat Fighter, sans lien de parenté entre elles.

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

PHP offrait une autre écriture, sans interface. Un type union accepte l'un ou l'autre de plusieurs types, séparés par une barre verticale :

function strike(Hero|Monster $attacker, Hero|Monster $target): void
{
    $target->takeDamage($attacker->attack());
}

Vous croiserez souvent des types union dans du code réel : int|float pour un nombre, string|null pour une valeur qui peut manquer, Countable|array dans le message d'erreur de la section 3. Ici, l'écriture marche, mais elle liste des classes. Le jour où un Trap doit blesser le héros, il faut rouvrir strike() et allonger la liste. Et rien ne garantit que Hero et Monster aient bien attack() et takeDamage() : PHP ne le vérifie qu'au moment de l'appel.

L'interface renverse la question. strike() ne demande plus « es-tu un Hero ou un Monster ? », mais « sais-tu te battre ? ». La liste des classes n'existe plus, et la vérification des trois méthodes se fait au chargement de chaque classe qui écrit implements Fighter.

Hero|Monster Fighter
Ce que le paramètre dit l'une de ces deux classes tout objet qui sait se battre
Ajouter un Trap qui blesse rouvrir strike() écrire implements Fighter dans Trap
Qui vérifie que attack() existe personne avant l'appel PHP, au chargement de la classe
Quand l'utiliser deux types de nature différente, sans contrat commun : int|float plusieurs classes qui jouent le même rôle

Lister des classes ou nommer un rôle : le type union à gauche, qu'il faut rouvrir pour accepter Trap, l'interface à droite, qu'une ligne dans Trap suffit à rejoindre

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. Ce cours n'utilise pas cette possibilité : une interface y contient seulement 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 un nom en -able, comme Countable et Stringable. Une classe abstraite porte le nom d'une famille : Item, Monster, Model.

Les deux vont souvent ensemble. Monster est abstraite et implémente Fighter.

3. Les interfaces natives de PHP

L'Inventory du chapitre 6 a une méthode count() depuis le chapitre 5. La fonction count() de PHP ne la connaît pas :

$inventory = new Inventory();
echo count($inventory);
Fatal error: Uncaught TypeError: count(): Argument #1 ($value) must be of type Countable|array, Dungeon\Inventory given

Et foreach ($inventory as $item) ne parcourt rien : PHP parcourt les propriétés publiques d'un objet, il n'y en a aucune, la boucle ne fait pas un tour et ne dit rien. PHP fournit une série d'interfaces toutes faites pour brancher vos objets sur sa syntaxe.

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']

Le Donjon utilise les trois premières. JsonSerializable et ArrayAccess sont dans le tableau pour que vous les reconnaissiez quand vous les croiserez dans Laravel ; l'exercice 2 de « Pour aller plus loin » fait implémenter JsonSerializable à l'inventaire.

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);
    }

    // … le constructeur, add() et totalWeight() des chapitres 5 et 6, has() et remove() du kata
}

La méthode count() existait déjà. Ce qui est neuf, c'est implements \Countable, qui la branche sur la fonction count() du langage. Les antislashs disent que Countable, IteratorAggregate, Traversable et ArrayIterator viennent du namespace global de PHP, pas du dossier Dungeon, comme \RuntimeException au chapitre 6. Sans eux, PHP chercherait Dungeon\Countable et ne le trouverait pas.

ArrayIterator est une classe de PHP : donnez-lui un tableau, elle sait le parcourir. Avec IteratorAggregate, vous lui déléguez le parcours. Avec Iterator, vous écririez vous-même les cinq méthodes du parcours, current(), key(), next(), rewind() et valid(). Le type de retour annoncé est Traversable, l'interface commune à tous les parcours.

Stringable suit la même idée. Une classe qui déclare __toString() implémente Stringable sans qu'on l'écrive, depuis PHP 8.0. L'écrire à la main documente le choix et permet de typer un paramètre par cette interface :

abstract class Item implements \Stringable
{
    // … name, weight et describe() du chapitre 6

    /** Contrat de Stringable : afficher un objet, c'est le décrire. */
    public function __toString(): string
    {
        return $this->describe();
    }
}
$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 se branche sur count() et foreach : deux contrats fournis par PHP, chacun ouvre une syntaxe du langage

$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().

4. Le trait HasHealth : sortir le code dupliqué

Rouvrez le src/Hero.php et le src/Monster.php que vous avez écrits aux niveaux 1 à 3. Les deux classes portent exactement les mêmes propriétés et les mêmes méthodes de santé : hp avec son hook, maxHp, isFullHealth, 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
{
    public private(set) int $maxHp;

    public private(set) int $hp {
        set => max(0, min($this->maxHp, $value));
    }

    public function takeDamage(int $amount): void
    {
        // … la vérification du chapitre 3
        $this->hp -= $amount;
    }

    // … et isFullHealth, heal(), isAlive()
}

Monster contient les mêmes déclarations, mot pour mot. Chaque règle de santé demande de modifier deux classes, et un jour l'une des deux sera oubliée.

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.

Déplacez d'abord les propriétés, le hook et les méthodes de santé dans src/HasHealth.php, qui ne contient que la déclaration du trait dans le squelette.

trait HasHealth
{
    /** Le maximum de points de vie. */
    public private(set) int $maxHp;

    /** Les points de vie courants : le hook les garde entre 0 et $maxHp. */
    public private(set) int $hp {
        set => max(0, min($this->maxHp, $value));
    }

    public bool $isFullHealth {
        get => $this->hp === $this->maxHp;
    }

    public function takeDamage(int $amount): void
    {
        if ($amount < 0) {
            throw new \InvalidArgumentException("Un dégât ne peut pas être négatif, $amount reçu.");
        }

        $this->hp -= $amount;
    }

    public function heal(int $amount): void
    {
        if ($amount < 0) {
            throw new \InvalidArgumentException("Un soin ne peut pas être négatif, $amount reçu.");
        }

        $this->hp += $amount;
    }

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

Écrivez ensuite une seule ligne dans chacune des deux classes : use HasHealth;.

final class Hero implements Fighter
{
    use HasHealth;

    public readonly Inventory $inventory;
    public private(set) ?Weapon $weapon = null;

    public function __construct(
        public readonly string $name,
        int $maxHp = 10,
        public readonly int $strength = 2,
    ) {
        // … les vérifications du nom et de maxHp, comme dans votre kata
        $this->maxHp = $maxHp;
        $this->hp = $maxHp;
        $this->inventory = new Inventory();
    }

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

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

abstract class Monster implements Fighter
{
    use HasHealth;

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

    abstract public function attack(): int;

    // … __toString() du chapitre 6
}

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

Les propriétés et les méthodes de santé 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\Dice; 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.

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

La fonction class_uses() liste les traits d'une classe. Elle regarde uniquement la classe qu'on lui donne, jamais ses parents. 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 un parent. 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.

UML n'a pas de trait : la notation date d'avant PHP 5.4. Le cours le dessine avec le mécanisme prévu par UML pour ce genre de cas, un stéréotype. Le trait est une boîte de classe marquée «trait», avec ses propriétés et ses méthodes, et chaque classe qui le colle lui est reliée par une dépendance {uml:dependance}, un trait pointillé à pointe ouverte, étiqueté «use». Pas de triangle : ce n'est ni un héritage, ni un contrat.

@startuml
class HasHealth <<trait>> {
  +maxHp: int
  +hp: int
  +isFullHealth: bool
  +takeDamage(amount: int): void
  +heal(amount: int): void
  +isAlive(): bool
}
class Hero {
  +name: string
  +attack(): int
}
abstract class Monster <<abstract>> {
  +name: string
  +{abstract} attack(): int
}
Hero ..> HasHealth : «use»
Monster ..> HasHealth : «use»
@enduml

Trois flèches, trois idées. Le triangle plein {uml:generalisation} dit « est un ». Le triangle pointillé {uml:realisation} dit « sait faire ». La pointe ouverte pointillée {uml:dependance} dit « se sert de ». La boîte du trait garde ses méthodes visibles : rien n'est perdu, et rien n'est faux.

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, les développeurs écrivaient 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',
        };
    }
}

L'enum Rarity et ses trois cas : chaque cas a une valeur, des méthodes, et cases() renvoie exactement la liste

La dernière ligne de la planche anticipe la fin de la section : Weapon attendra un Rarity, pas un texte. 'legendary' est une chaîne, Rarity::Legendary est un cas de l'enum. PHP refuse la première avec un TypeError, comme il refuse un string là où un int est attendu.

Avec : string après le nom, chaque cas porte une valeur texte. Cette forme s'appelle une enum à valeurs associées. C'est cette valeur qui s'enregistre en base de données ou dans un fichier JSON. Sans : string, l'enum est 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 :

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

    // … multiplier() et label() inchangés, sans ligne pour Cursed
}

echo Rarity::Cursed->multiplier();
Fatal error: Uncaught UnhandledMatchError: Unhandled match case Dungeon\Rarity::Cursed

Comparez avec le if du début de chapitre, qui renvoyait 1.0 en silence. Ici, PHP s'arrête et nomme le cas oublié.

$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
  • ->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') s'arrête sur ValueError: "epic" is not a valid backing value for enum Dungeon\Rarity.
  • 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.

Item reçoit sa rareté en quatrième paramètre, avec Common par défaut, et Weapon et Potion la passent au parent :

abstract class Item implements \Stringable
{
    public function __construct(
        public readonly string $name,
        public readonly float $weight,
        public readonly Rarity $rarity = Rarity::Common,
    ) {
        // … la validation du poids du chapitre 5
    }

    /** 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();
    }

    // … describe() et __toString()
}

final class Weapon extends Item
{
    public function __construct(string $name, float $weight, public readonly int $damage, Rarity $rarity = Rarity::Common)
    {
        parent::__construct($name, $weight, $rarity);
    }

    // … describe()
}

Le problème du début disparaît. Le paramètre est typé Rarity, donc une chaîne est refusée :

new Weapon('Épée courte', 2.0, 5, 'legendary');
Fatal error: Uncaught TypeError: Dungeon\Weapon::__construct(): Argument #4 ($rarity) must be of type Dungeon\Rarity, string given

Il faut écrire Rarity::Legendary, et l'éditeur propose les trois cas.

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

  • La réalisation {uml:realisation} dit « cette classe implémente cette interface ». C'est un trait pointillé terminé par un triangle vide, collé à l'interface. La généralisation {uml:generalisation} du chapitre 6 a le même triangle, mais un trait plein.
  • Le mot-clé interface affiche «interface» au-dessus du nom.
  • Le mot-clé enum affiche «enumeration», avec les cas dans le compartiment des attributs.
  • Un trait est une boîte de classe stéréotypée «trait». La dépendance {uml:dependance} «use», pointillée à pointe ouverte, va de la classe vers le trait qu'elle colle.

Le premier diagramme montre le contrat de combat et le trait de santé. Le héros, son inventaire et son arme sont dessinés au chapitre 5.

@startuml
interface Fighter {
  +attack(): int
  +takeDamage(amount: int): void
  +isAlive(): bool
}
class Hero {
  +name: string
  +strength: int
  +attack(): int
}
abstract class Monster <<abstract>> {
  +name: string
  +{abstract} attack(): int
}
class Goblin {
  +attack(): int
}
class Dragon {
  +attack(): int
}
class HasHealth <<trait>> {
  +hp: int
  +takeDamage(amount: int): void
  +isAlive(): bool
}
Fighter <|.. Hero
Fighter <|.. Monster
Monster <|-- Goblin
Monster <|-- Dragon
Hero ..> HasHealth : «use»
Monster ..> HasHealth : «use»
@enduml

Un héros et un monstre savent se battre et se servent du même trait de santé. Un gobelin et un dragon sont des monstres. Monster réalise Fighter sans écrire attack() : ses classes filles remplissent le contrat pour elle, et takeDamage() comme isAlive() lui viennent de HasHealth.

Le second diagramme montre les objets, leur rareté et les deux interfaces natives de l'inventaire.

@startuml
interface Countable {
  +count(): int
}
interface IteratorAggregate {
  +getIterator(): Traversable
}
class Inventory {
  -items: Item [0..*]
  +add(item: Item): void
  +count(): int
  +getIterator(): Traversable
}
abstract class Item <<abstract>> {
  +name: string
  +weight: float
  +rarity: Rarity
  +value(): float
  +{abstract} describe(): string
}
class Weapon {
  +damage: int
  +describe(): string
}
class Potion {
  +healing: int
  +describe(): string
}
enum Rarity {
  Common
  Rare
  Legendary
  +multiplier(): float
  +label(): string
}
Countable <|.. Inventory
IteratorAggregate <|.. Inventory
Item <|-- Weapon
Item <|-- Potion
@enduml

Un inventaire se compte et se parcourt. Le losange vide {uml:agregation} qui le relie aux objets est dessiné au chapitre 5 : ici, seule la propriété -items: Item [0..*] le rappelle. Chaque objet porte une rareté : elle se lit dans la propriété +rarity: Rarity, sans trait vers l'enum. Une enum est un type de valeur, comme string ou int, pas une classe à laquelle on s'associe. C'est la convention du chapitre 4 pour Condition dans Copy.

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 6. Les trois lignes use du haut du fichier importent des noms : deux traits et une classe. Ce qui colle les traits dans User, c'est la ligne use HasFactory, Notifiable; écrite à l'intérieur des accolades. 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. 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. La couche base de données de Laravel fait le from() et le ->value dans les deux sens. C'est exactement Rarity, avec la base de données autour.

Quand vous verrez que Collection implémente Enumerable, une interface qui prolonge Countable et IteratorAggregate, vous saurez pourquoi count($users) et foreach ($users as $user) fonctionnent sur un objet.

À retenir

  • Une interface est un contrat : que des signatures de méthodes publiques, pas de code, et autant d'interfaces que nécessaire par classe. Typez les paramètres par l'interface, comme Fighter $target, pas par la classe concrète ni par une liste Hero|Monster.
  • 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 un parent. En UML, une boîte «trait» et une dépendance «use» {uml:dependance}, jamais un triangle. 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.

Trois outils qui se ressemblent, trois rôles :

interface Fighter abstract class Monster trait HasHealth
Ce que c'est un contrat : des signatures, pas de code une famille, avec du code en commun du code réutilisable, sans famille
Ce que la classe écrit implements Fighter extends Monster use HasHealth;
Combien par classe autant que nécessaire un seul parent autant que nécessaire
En UML trait pointillé, triangle vide {uml:realisation} trait plein, triangle vide {uml:generalisation} boîte «trait», pointillé à pointe ouverte «use» {uml:dependance}
Type utilisable dans une signature oui, Fighter $target oui, Monster $target non

Exercices

Sur papier

  1. Interface, classe abstraite ou trait ? Un Trap et une Potion qui savent tous deux blesser un héros. Un Skeleton qui est un monstre. Un code de journal des actions que Hero, Monster et Room doivent tous porter. Justifiez chaque choix par la question de la section 2.
  2. class_uses(Goblin::class) rend un tableau vide alors que $goblin->takeDamage(2) fonctionne. Expliquez en deux phrases.
  3. Le chapitre 4 a laissé l'état d'un exemplaire de bibliothèque en attente : neuf, bon ou usé. Écrivez l'enum Condition avec une méthode label(), puis la propriété qui la porte dans Copy.

Kata niveau 4

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

composer test -- --group=niveau-4

Les déclarations implements, l'interface Fighter et les trois cas de Rarity sont déjà dans les fichiers fournis. Vous écrivez les corps des méthodes, le trait, et la rareté dans Item.

Ce que les tests vérifient :

Ce qui est testé Détail vérifié
enum Rarity: string cases() a trois cas. Rarity::Common->value vaut 'common'. Rarity::from('rare') rend Rarity::Rare. Rarity::tryFrom('inconnu') rend null.
Rarity::multiplier(): float 1.0, 1.5, 3.0
Rarity::label(): string 'Commun', 'Rare', 'Légendaire'
Item::$rarity public readonly, vaut Rarity::Common par défaut
Item::value(): float poids × multiplicateur : 2.0 pour une épée commune de 2 kg, 3.0 pour une lame rare de 2 kg, 3.0 pour un élixir légendaire de 1 kg
Item::__toString(): string (string) $sword vaut exactement $sword->describe()
interface Fighter new Hero('Arthur') et new Goblin() sont des Fighter. is_subclass_of(Monster::class, Fighter::class) vaut true.
Fighter::attack(): int un héros de force 2 sans arme, un gobelin et un dragon : la somme des trois attack() vaut 12
trait HasHealth class_uses(Hero::class) et class_uses(Monster::class) le contiennent
HasHealth::takeDamage() après takeDamage(100), un héros de 10 PV et un gobelin ont hp à 0 et isAlive() à false : le hook set a suivi le déplacement
Inventory implements \Countable count($bag) vaut 2 après deux add()
Inventory implements \IteratorAggregate foreach ($bag as $item) donne ['Épée courte', 'Potion de soin'], dans l'ordre d'ajout

Attention

class_uses() ne regarde que la classe qu'on lui donne. Écrivez use HasHealth; dans Hero et dans Monster, et nulle part ailleurs : Goblin et Dragon le reçoivent par héritage.

Bonus, niveau 5 : Battle

composer test -- --group=niveau-5

Battle fait s'affronter deux Fighter tour par tour. C'est la réponse à la question du chapitre 6 : un dé reçu de l'extérieur se remplace par un dé truqué dans les tests. Ce dé truqué, FixedDice, est fourni dans tests/Support/.

Ce qui est testé Détail vérifié
Battle::__construct(Fighter $a, Fighter $b, Dice $dice) le dé est reçu, jamais fabriqué dans Battle
Battle::fight(): Fighter renvoie le vainqueur. $a frappe en premier, chaque coup vaut attack() plus un lancer, le combat s'arrête dès qu'un des deux tombe.
FixedDice new FixedDice([1, 3]) rend 1, 3, puis 1 à nouveau
trois combats Arthur (20 PV, force 3) bat le gobelin avec un dé à 1 et finit à 17 PV. Arthur (10 PV, force 1) perd contre le dragon. Le même Arthur gagne avec une arme de 20 dégâts.

Pour aller plus loin

  1. Dans un fichier à part, hors du dépôt, copiez l'enum Rarity et ajoutez-lui un cas Cursed, multiplicateur 0.5, libellé « Maudit ». Appelez multiplier() et label() sur ce cas. Combien de match avez-vous dû corriger, et à quel moment l'erreur est-elle apparue ? Ne touchez pas au fichier du kata : un test compte les cas.
  2. Faites implémenter \JsonSerializable à Inventory, dans le dépôt. json_encode($bag, JSON_UNESCAPED_UNICODE) sur une épée courte et une potion de soin doit sortir [{"name":"Épée courte","weight":2,"value":2},{"name":"Potion de soin","weight":0.5,"value":0.5}].
  3. Dans un fichier à part, reprenez la Room du chapitre 5 et donnez-lui une liste d'objets au sol au lieu d'un seul loot. Faites-lui implémenter \Countable et \IteratorAggregate, puis écrivez le foreach qui affiche son butin, un objet par ligne.