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 :
- définir une interface, l'implémenter, et typer un paramètre par un contrat plutôt que par une classe ;
- utiliser les interfaces natives de PHP pour rendre vos objets compatibles avec
count()etforeach; - choisir entre une interface et une classe abstraite ;
- extraire du code dupliqué dans un trait, et dire pourquoi un trait n'est pas une relation ;
- é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.
/** 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.
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;
}
}
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 :
->nameest le nom du cas,->valuesa 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 uneValueError.tryFrom('epic')renvoienullau lieu de lever. UtiliseztryFrom()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é
interfaceaffiche«interface»au-dessus du nom. - Le mot-clé
enumaffiche«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.HasFactoryapporte les méthodes qui fabriquent desUserde test,Notifiableapportenotify(). Aucun des deux ne dit ce qu'unUserest : ils lui ajoutent des méthodes. Et ils ne peuvent pas être des parents, parce queUseren a déjà un.extends Authenticatable: l'héritage du chapitre 5. Les trois lignesusedu haut du fichier importent des classes, pas des traits. Ici,Authenticatabledésigne la classeIlluminate\Foundation\Auth\User, renommée à l'import. Cette classe implémente plusieurs interfaces, et c'est en signant ces contrats que votreUserdevient utilisable par le système de connexion.'role' => UserRole::class: une conversion de valeur, appelée cast.UserRoleest une enum de votre application. En base, la colonne contient'admin'. En PHP,$user->roleest un cas de l'enum, avec ses méthodes. Eloquent fait lefrom()et le->valuedans les deux sens. C'est exactementRarity, 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 :
Countablepourcount(),IteratorAggregateetArrayIteratorpourforeach,Stringablepourecho. - 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
matchcomplet.from()lève une erreur,tryFrom()renvoienull.
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
- Ajoutez un cas
CursedàRarity, avec le multiplicateur 0.5 et le libellé « Maudit ». Appelez ensuitemultiplier()etlabel()sur ce cas. Combien dematchavez-vous dû corriger, et à quel moment l'erreur est-elle apparue ? - Faites implémenter
JsonSerializableàInventorypour quejson_encode($inventory)sorte le nom, le poids et la valeur de chaque objet. - Donnez à la
Roomdu chapitre 4 une liste d'objets au sol, puis faites-lui implémenterCountableetIteratorAggregate. Écrivez leforeachqui affiche son butin.