Contrats : interfaces, traits et enums
Séance 3 · ~3 périodes
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 :
- 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 le dessiner en UML sans le confondre avec un héritage.
- é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)

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 |

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

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',
};
}
}

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
->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')s'arrête surValueError: "epic" is not a valid backing value for enum Dungeon\Rarity.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.
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é
interfaceaffiche«interface»au-dessus du nom. - Le mot-clé
enumaffiche«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.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 6. Les trois lignesusedu haut du fichier importent des noms : deux traits et une classe. Ce qui colle les traits dansUser, c'est la ligneuse HasFactory, Notifiable;écrite à l'intérieur des accolades.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.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. La couche base de données de Laravel fait lefrom()et le->valuedans les deux sens. C'est exactementRarity, 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 listeHero|Monster. - 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 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
matchcomplet.from()lève une erreur,tryFrom()renvoienull.
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
- Interface, classe abstraite ou trait ? Un
Trapet unePotionqui savent tous deux blesser un héros. UnSkeletonqui est un monstre. Un code de journal des actions queHero,MonsteretRoomdoivent tous porter. Justifiez chaque choix par la question de la section 2. class_uses(Goblin::class)rend un tableau vide alors que$goblin->takeDamage(2)fonctionne. Expliquez en deux phrases.- Le chapitre 4 a laissé l'état d'un exemplaire de bibliothèque en attente : neuf, bon ou usé. Écrivez l'enum
Conditionavec une méthodelabel(), puis la propriété qui la porte dansCopy.
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
- Dans un fichier à part, hors du dépôt, copiez l'enum
Rarityet ajoutez-lui un casCursed, multiplicateur0.5, libellé « Maudit ». Appelezmultiplier()etlabel()sur ce cas. Combien dematchavez-vous dû corriger, et à quel moment l'erreur est-elle apparue ? Ne touchez pas au fichier du kata : un test compte les cas. - 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}]. - Dans un fichier à part, reprenez la
Roomdu chapitre 5 et donnez-lui une liste d'objets au sol au lieu d'un seulloot. Faites-lui implémenter\Countableet\IteratorAggregate, puis écrivez leforeachqui affiche son butin, un objet par ligne.