Héritage et polymorphisme
Séance 3 · ~2 périodes
Vos classes se ressemblent. Une épée et une potion, c'est deux fois « un nom, un poids ». Un gobelin et un dragon, c'est deux fois « un nom, des points de vie, une attaque ». Ce chapitre donne deux mécanismes. Le premier met le code commun à un seul endroit. Le second parcourt trente monstres différents sans un seul if. Dans Laravel, class UserController extends Controller utilise le premier.
À la fin de ce chapitre, vous serez capables de :
- écrire une hiérarchie de classes avec
extendsetparent::__construct(), et dire à quoi sertprotected; - déclarer une classe et une méthode abstraites, et dire ce que l'abstrait interdit ;
- utiliser le polymorphisme : une liste d'objets de types différents, un seul appel de méthode ;
- choisir entre hériter et posséder grâce au test du « est-un » ;
- créer une exception personnalisée et l'attraper avec un
catchciblé.
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. Lisez-le et répondez à une seule question : que faudra-t-il modifier pour ajouter un squelette ?
class Monster
{
public function __construct(private string $type, private int $hp) {}
public function attack(): int
{
if ($this->type === 'goblin') {
return 2;
} elseif ($this->type === 'dragon') {
return 8;
}
return 0;
}
}
1. Deux classes qui se ressemblent trop
Au chapitre 5, Item avait un nom et un poids. Le jeu a maintenant besoin d'armes, qui infligent des dégâts, et de potions, qui soignent. La tentation est d'écrire deux classes complètes :
final class Weapon
{
public function __construct(
public readonly string $name,
public readonly float $weight,
public readonly int $damage,
) {
if ($weight < 0) {
throw new \InvalidArgumentException("Un poids n'est pas négatif, $weight reçu.");
}
}
}
final class Potion
{
public function __construct(
public readonly string $name,
public readonly float $weight,
public readonly int $healing,
) {
if ($weight < 0) {
throw new \InvalidArgumentException("Un poids n'est pas négatif, $weight reçu.");
}
}
}
Deux problèmes. Le premier est la duplication : le nom, le poids et sa validation sont écrits deux fois. Le jour où un objet gagne une durabilité, vous modifiez deux fichiers, puis trois. Le second est plus gênant. Sans classe commune, Inventory::add() doit énumérer les classes acceptées, et cette liste s'allonge à chaque nouvel objet du jeu.
L'héritage répond aux deux. Item porte le nom et le poids. Weapon ajoute les dégâts, Potion ajoute les soins. Chaque classe fille ne décrit que sa différence.
2. extends, protected, parent::
Voici Item, puis Weapon qui en hérite :
abstract class Item
{
public function __construct(
public readonly string $name,
public readonly float $weight,
) {
if ($weight < 0) {
throw new \InvalidArgumentException("Un poids n'est pas négatif, $weight reçu.");
}
}
/** Chaque objet se décrit à sa façon : le parent ne peut pas répondre. */
abstract public function describe(): string;
}
final class Weapon extends Item
{
public function __construct(string $name, float $weight, public readonly int $damage)
{
parent::__construct($name, $weight);
}
public function describe(): string
{
return sprintf('%s : arme (%s kg, %d dégâts)', $this->name, $this->weight, $this->damage);
}
}
extends déclare que Weapon est un cas particulier d'Item. Elle en hérite toutes les propriétés et méthodes non privées : name, weight et la validation du poids, écrites une seule fois. Le mot abstract, sur la classe et sur describe(), est expliqué à la section 3.
parent::__construct() appelle le constructeur du parent. Une classe fille qui définit son propre constructeur doit faire cet appel elle-même. Sans lui, les propriétés du parent ne reçoivent aucune valeur, et la première lecture arrête le programme :
Fatal error: Uncaught Error: Typed property Item::$name must not be accessed before initialization
C'est l'erreur la plus fréquente du chapitre. Une classe fille sans constructeur utilise directement celui du parent.
protected est la troisième visibilité, annoncée au chapitre 1. Une propriété protected est accessible dans sa classe et dans ses classes filles.
| Visibilité | Accessible depuis… | À utiliser quand… |
|---|---|---|
public |
n'importe où | c'est le mode d'emploi de la classe |
protected |
la classe et ses filles | les filles doivent écrire dans la donnée |
private |
la classe seule | même les filles n'ont pas à y toucher |
Ici, Weapon::describe() ne fait que lire name et weight : les deux restent public readonly, comme au chapitre 5. Aucune classe fille n'a besoin d'y écrire, donc protected ne sert pas. Choisissez toujours la visibilité la plus fermée qui suffit. protected attend le jour où une classe fille doit écrire dans une donnée du parent.
Potion est bâtie sur le même modèle, avec healing à la place de damage :
final class Potion extends Item
{
public function __construct(string $name, float $weight, public readonly int $healing)
{
parent::__construct($name, $weight);
}
public function describe(): string
{
return sprintf('%s : potion (%s kg, +%d PV)', $this->name, $this->weight, $this->healing);
}
}
echo (new Weapon('Épée courte', 2.0, 5))->describe(), PHP_EOL;
echo (new Potion('Potion de soin', 0.5, 5))->describe(), PHP_EOL;
Épée courte : arme (2 kg, 5 dégâts)
Potion de soin : potion (0.5 kg, +5 PV)
Astuce
final devant une classe interdit d'écrire une classe qui en hérite. Devant une méthode, il interdit de la redéfinir. Mettez-le sur les classes du bas de la hiérarchie, comme Weapon et Potion.
3. Abstrait : une classe qui ne se fabrique pas
Dans le Donjon, un objet ramassable est toujours une arme ou une potion. Aucun corps d'Item::describe() n'aurait de sens : la description dépend du type d'objet. Le mot-clé abstract a deux emplois.
abstract class Item: cette classe ne s'instancie pas. Elle n'existe que pour être héritée.abstract public function describe(): string;: une signature sans corps. Toute classe fille concrète doit la fournir, sinon PHP refuse de charger le fichier.
$item = new Item('Caillou', 1.0);
Fatal error: Uncaught Error: Cannot instantiate abstract class Item
PHP signale l'erreur dès la ligne fautive, pas plus loin sur un describe() vide. Une classe fille qui oublie describe() est refusée avant même de tourner :
final class Rock extends Item
{
}
Fatal error: Class Rock contains 1 abstract method and must therefore be declared abstract or implement the remaining method (Item::describe)
Note
Histoire de PHP. Les classes abstraites, les interfaces, les visibilités et le mot-clé final arrivent tous avec PHP 5, en 2004. Avant, la POO de PHP tenait en class et extends, tout était public et se déclarait avec var $hp;. Vous croiserez encore var dans du très vieux code : c'est un public.
Une classe abstraite peut contenir du code normal : des propriétés, un constructeur, des méthodes complètes. C'est ce qui la distingue d'une interface, au chapitre 7. Monster porte les points de vie et les dégâts encaissés, et laisse attack() abstraite.
abstract class Monster
{
public private(set) int $maxHp;
public private(set) int $hp = 0 {
set => max(0, min($this->maxHp, $value));
}
public function __construct(
public readonly string $name,
int $maxHp,
) {
$this->maxHp = $maxHp;
$this->hp = $maxHp;
}
public function takeDamage(int $amount): void
{
$this->hp -= $amount;
}
public function isAlive(): bool
{
return $this->hp > 0;
}
/** Chaque monstre frappe à sa façon. Le parent ne peut pas décider. */
abstract public function attack(): int;
public function __toString(): string
{
return sprintf('%s (%d/%d PV)', $this->name, $this->hp, $this->maxHp);
}
}
final class Goblin extends Monster
{
public function __construct()
{
parent::__construct('Gobelin', 5);
}
public function attack(): int
{
return 2;
}
}
final class Dragon extends Monster
{
public function __construct()
{
parent::__construct('Dragon', 30);
}
public function attack(): int
{
return 8;
}
}
Les attaques valent 2 et 8, sans hasard. Un monstre qui lance un dé est amusant, mais un test ne peut plus prévoir le résultat. Le chapitre 7 rendra le hasard maîtrisable.
name est public readonly, donc vous écrivez $goblin->name. hp et maxHp sont public private(set), comme chez le héros. Pas de protected : Goblin et Dragon n'écrivent jamais dans les points de vie, le parent s'en charge. C'est la première règle du chapitre 3 : écrivez private, tout autre choix demande une raison. Le diagramme du chapitre 4 notait #hp sur Monster. Le code retenu ici garde private(set), donc -hp.
Redéfinir une méthode
Une classe fille peut redéfinir une méthode du parent : elle écrit une méthode du même nom, et ce code remplace celui du parent pour les objets de la classe fille. La signature d'une méthode est sa ligne de déclaration : le nom, les paramètres avec leurs types, le type de retour. public function __toString(): string est la signature de __toString(). La méthode redéfinie garde la signature du parent : mêmes paramètres, même type de retour. PHP refuse le fichier sinon.
Redéfinir n'oblige pas à tout réécrire. parent:: désigne la classe parente, et parent::__toString() exécute la version de Monster, sur le même objet : $this y désigne toujours le dragon. Vous connaissez déjà ce mécanisme avec parent::__construct(), qui appelle le constructeur du parent. La classe fille peut donc compléter la réponse du parent au lieu de la remplacer :
final class Dragon extends Monster
{
// … constructeur et attack() inchangés
public function __toString(): string
{
return 'Un dragon ! ' . parent::__toString();
}
}
echo new Goblin(), PHP_EOL;
echo new Dragon(), PHP_EOL;
Gobelin (5/5 PV)
Un dragon ! Dragon (30/30 PV)
Sans parent::, Dragon::__toString() aurait recopié le sprintf() de Monster. Le jour où le format change, il change à un seul endroit. Dans le kata, Dragon garde le __toString() de Monster.
@startuml
abstract class Item <<abstract>> {
+name: string
+weight: float
+{abstract} describe(): string
}
class Weapon {
+damage: int
+describe(): string
}
class Potion {
+healing: int
+describe(): string
}
abstract class Monster <<abstract>> {
+name: string
-hp: int
-maxHp: int
+takeDamage(amount: int): void
+isAlive(): bool
+{abstract} attack(): int
}
class Goblin {
+attack(): int
}
class Dragon {
+attack(): int
}
Item <|-- Weapon
Item <|-- Potion
Monster <|-- Goblin
Monster <|-- Dragon
@enduml
En UML, la généralisation {uml:generalisation} est un trait plein terminé par un triangle vide, collé au parent. Il se lit du bas vers le haut : « Weapon est un Item ». Le cours écrit le stéréotype «abstract» au-dessus du nom, comme au chapitre 4. Le site met en plus le nom et la méthode abstraite en italique.
4. Le polymorphisme : une boucle sans if
Une liste de monstres de types différents, et un seul appel de méthode.
/** @var Monster[] $monsters */
$monsters = [new Goblin(), new Dragon(), new Goblin()];
$total = 0;
foreach ($monsters as $monster) {
$damage = $monster->attack();
$total += $damage;
printf('%s frappe pour %d.%s', $monster->name, $damage, PHP_EOL);
}
printf('Total encaissé : %d PV.%s', $total, PHP_EOL);
Gobelin frappe pour 2.
Dragon frappe pour 8.
Gobelin frappe pour 2.
Total encaissé : 12 PV.
Comparez avec le code du début de chapitre. La cascade de elseif n'a pas été déplacée ailleurs : elle a disparu. Chaque monstre porte sa propre réponse à attack(), et la boucle ne connaît que le type Monster.
Le vrai gain se voit à l'ajout d'un monstre. Vous créez Skeleton extends Monster, vous écrivez son attack(), et aucune boucle existante ne change.
Qui décide de la méthode appelée ?
C'est l'objet, pas la variable.
$monster = new Dragon();
echo get_class($monster), ' attaque pour ', $monster->attack(), PHP_EOL;
var_dump($monster instanceof Monster, $monster instanceof Goblin);
Dragon attaque pour 8
bool(true)
bool(false)
get_class() rend le nom de la classe réelle de l'objet.
À l'exécution, PHP part de la classe réelle de l'objet, ici Dragon, y cherche attack(), la trouve et l'appelle. S'il ne l'avait pas trouvée, il serait remonté à Monster. Si aucune classe ne la définit, le programme s'arrête : Call to undefined method Dragon::attack(). La recherche part toujours du bas.
instanceof répond à la question « cet objet est-il de ce type, ou d'un type qui en descend ? ». Il est utile pour une vérification ponctuelle. Mais une cascade de instanceof qui choisit l'attaque, c'est le elseif du début de chapitre qui revient sous un autre nom. La réponse reste la même : une méthode commune aux monstres.
5. Hériter ou posséder
Avant d'écrire extends, dites la phrase à voix haute : « un X est un Y ». Si elle est vraie sans explication, l'héritage est une piste sérieuse. Vérifiez ensuite que les propriétés et les méthodes du parent conviennent vraiment à la classe fille.
- « Une épée est un objet ramassable. » Juste.
Weapon extends Item. - « Un dragon est un monstre. » Juste.
Dragon extends Monster. - « Un héros est une arme. » Faux. C'est pourtant l'erreur qui arrive dès qu'une classe hérite pour récupérer du code.
Un exemple hors du Donjon :
class Car
{
public function __construct(public readonly int $seats) {}
public function openTrunk(): string
{
return "Le coffre s'ouvre.";
}
}
// « Un camion, c'est une voiture en plus gros. » Vraiment ?
class Truck extends Car
{
public function load(float $tons): string
{
return "Chargement de {$tons} t.";
}
}
$truck = new Truck(seats: 5);
echo $truck->openTrunk(), PHP_EOL;
echo $truck->seats, ' places', PHP_EOL;
Le coffre s'ouvre.
5 places
Attention
PHP ne proteste pas. Le résultat est pourtant absurde : un camion n'a ni coffre ni cinq places. Ces membres n'existent sur Truck que parce que Truck a hérité pour gagner du temps, et n'importe qui pourra les appeler.
La phrase juste est « un camion est un véhicule », et « une voiture est un véhicule ». Le parent commun est Vehicle, pas Car.
Ne choisissez donc pas l'héritage seulement pour réutiliser du code. La classe fille doit aussi représenter un cas particulier de la classe parente. Le partage de code entre classes sans parent commun se fera autrement, au chapitre 7.
Quand la phrase « est un » sonne faux, la réponse est presque toujours « a un ». Le héros équipe une arme, il n'en est pas une. En code, c'est une propriété.
final class Hero
{
/** hp, maxHp, isFullHealth, inventory, takeDamage(), heal(), isAlive() et __toString() :
* inchangés depuis les chapitres 3 et 5, omis ici pour ne montrer que l'arme. */
public private(set) ?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 equip(Weapon $weapon): void
{
$this->weapon = $weapon;
}
public function attack(): int
{
return $this->strength + ($this->weapon?->damage ?? 0);
}
}
$arthur = new Hero('Arthur');
printf('À mains nues : %d dégâts%s', $arthur->attack(), PHP_EOL);
$arthur->equip(new Weapon('Épée courte', 2.0, 5));
printf('Épée en main : %d dégâts%s', $arthur->attack(), PHP_EOL);
À mains nues : 2 dégâts
Épée en main : 7 dégâts
Hero devient final : personne n'en hérite. Le trait entre Hero et Weapon est l'association {uml:association} du chapitre 4, avec ses deux rôles :
@startuml
class Hero {
+name: string
+strength: int
-weapon: Weapon [0..1]
+equip(weapon: Weapon): void
+attack(): int
}
class Weapon {
+name: string
+weight: float
+damage: int
}
Hero "0..1 -wielder" -- "0..1 -weapon" Weapon : wields >
@enduml
L'arme arrive de l'extérieur, le héros peut en changer, et elle lui survit. Une classe fille, elle, ne change jamais de parent.
| La phrase juste | Ce que vous écrivez |
|---|---|
| « un X est un Y » | class X extends Y |
| « un X a un Y » | une propriété public private(set) ?Y $y dans X |
Une classe, un rôle. Une classe qui fait deux choses se coupe en deux avant de chercher son parent.
6. Les exceptions : une hiérarchie que vous prolongez
Au chapitre 5, Inventory::add() renvoyait false quand le sac était plein. Ça fonctionne, mais c'est facile à rater. $inventory->add(new Weapon('Épée courte', 2.0, 5)); sans lire la valeur de retour s'exécute très bien et perd l'épée sans rien dire.
Lancer, attraper
Au chapitre 3, le constructeur de Dice lançait une InvalidArgumentException avec throw, et le programme s'arrêtait sur Fatal error: Uncaught. Voici ce qui se passe quand quelqu'un l'attrape :
try {
$dice = new Dice(0);
echo 'Dé créé.', PHP_EOL;
} catch (InvalidArgumentException $e) {
echo 'Impossible : ', $e->getMessage(), PHP_EOL;
}
echo 'Le programme continue.', PHP_EOL;
Impossible : Un dé a au moins 2 faces, 0 reçu.
Le programme continue.
Le bloc try est le code surveillé. Dès que throw s'exécute, PHP quitte le try à cette ligne : new ne renvoie rien, $dice n'existe pas, le echo 'Dé créé.' est sauté. PHP regarde ensuite les catch qui suivent. Le premier dont le type correspond à l'exception lancée, la classe elle-même ou une de ses parentes, reçoit l'objet dans $e et s'exécute. Puis le programme reprend après le bloc, comme si rien ne s'était passé.
Si aucun catch ne correspond, l'exception remonte à la fonction qui a appelé, puis à celle du dessus, jusqu'à trouver un try qui l'attrape. Si elle arrive tout en haut sans être attrapée, PHP affiche Fatal error: Uncaught … avec la liste des appels traversés, et le programme s'arrête. C'est ce qui arrivait au chapitre 3.
Une exception est donc un objet, avec une classe, un message et une trace d'appels. Les exceptions de PHP forment une hiérarchie de classes, et vous y ajoutez la vôtre avec extends :
/** Corps vide : tout vient de RuntimeException. Seul le nom compte. */
final class InventoryFullException extends RuntimeException
{
}
Les classes fournies par PHP, comme RuntimeException ou InvalidArgumentException, vivent dans le namespace global, celui qui n'a pas de nom. Une barre oblique seule en tête d'un nom désigne ce namespace. Dans le dépôt du kata, vos fichiers commencent par namespace Dungeon;, donc un nom sans barre est cherché dans Dungeon : RuntimeException devient Dungeon\RuntimeException, qui n'existe pas. Écrivez extends \RuntimeException. Le chapitre 3 écrivait \InvalidArgumentException pour la même raison. Où se place votre classe dans la famille :
@startuml
class Exception {
+getMessage(): string
+getCode(): int
}
class RuntimeException
class InventoryFullException
Exception <|-- RuntimeException
RuntimeException <|-- InventoryFullException
note right of RuntimeException : Pour les problèmes visibles seulement à l'exécution.
@enduml
Exception est la branche des problèmes applicatifs, celle dont vous héritez. RuntimeException regroupe ce qui ne se détecte qu'à l'exécution. Un sac trop plein en fait partie : tout dépend de ce que le joueur a ramassé. À côté, Error regroupe les erreurs du moteur, comme une classe abstraite instanciée. Dans le code d'une application, vous ne l'attrapez pas, vous corrigez le bug. Un test l'attrape pour vérifier qu'elle est bien levée, comme au niveau 1 du kata.
Note
Histoire de PHP. Les exceptions arrivent avec PHP 5, en 2004. Avant, une fonction signalait un problème en renvoyant false ou -1, et l'appelant devait penser à tester la valeur. Les cas graves se terminaient par un die('Erreur') qui coupait la page au milieu. PHP 7, en 2015, a ajouté la branche Error pour les erreurs du moteur, jusque-là impossibles à attraper.
Trois lignes, un corps vide, et vous avez votre propre type d'erreur, avec getMessage(), getCode() et la trace d'appels. C'est de l'héritage, rien d'autre. Ce qui compte, c'est le nom de la classe, parce que c'est lui qui rendra le catch précis. Le message, lui, reste celui que vous passez au constructeur.
public function add(Item $item): void
{
if ($this->totalWeight() + $item->weight > $this->maxWeight) {
throw new InventoryFullException(sprintf(
'"%s" ne rentre pas : le sac ne porte que %s kg.',
$item->name,
$this->maxWeight,
));
}
$this->items[] = $item;
}
add() ne renvoie plus rien. Aux chapitres 4 et 5, add() était déclarée add(Item $item): bool : elle renvoyait false quand le sac débordait. Elle est maintenant déclarée add(Item $item): void et elle lève une InventoryFullException. Deux raisons. La première : avec l'exception, le retour ne vaudrait plus jamais false, et une signature qui annonce un résultat impossible ment à celui qui la lit. La seconde : un false que personne ne teste disparaît en silence, alors qu'une exception non attrapée arrête le programme. À partir d'ici, add() renvoie void et lève.
$inventory = new Inventory(maxWeight: 3.0);
try {
$inventory->add(new Weapon('Épée courte', 2.0, 5));
$inventory->add(new Weapon('Marteau de guerre', 12.0, 9));
echo 'Les deux armes sont dans le sac.', PHP_EOL;
} catch (InventoryFullException $e) {
echo 'Sac plein : ', $e->getMessage(), PHP_EOL;
}
echo 'Poids transporté : ', $inventory->totalWeight(), ' kg', PHP_EOL;
Sac plein : "Marteau de guerre" ne rentre pas : le sac ne porte que 3 kg.
Poids transporté : 2 kg
Le try s'arrête à la ligne fautive, donc le echo de succès n'est jamais exécuté. L'épée déjà ajoutée, elle, reste dans le sac : une exception n'annule pas ce qui a été fait avant. Et le catch est ciblé. Il n'attrape que les sacs pleins. Une faute de frappe sur un nom de méthode dans le même bloc continue de provoquer une erreur.
Attention
Attrapez ce que vous attendez, là où vous savez quoi faire : afficher un message au joueur, lui proposer de jeter un objet, réessayer. Le programme continue. Laissez passer ce que vous n'attendez pas : une exception qui n'aurait jamais dû arriver signale un bug, et le programme qui s'arrête avec sa trace vous dit où chercher. catch (Exception $e) { } autour de tout le programme cache les deux à la fois. Le joueur ne sait plus pourquoi son épée a disparu, et vous ne savez plus que le code est cassé.
À retenir
extendsdit ce qu'une chose est. Le test : prononcer « un X est un Y », puis vérifier que le contenu du parent convient à la fille.abstractinterdit l'instanciation et impose aux filles les méthodes sans corps.protectedouvre aux filles ce queprivateferme, le Donjon n'en a pas encore besoin. Une classe fille qui a son propre constructeur doit appelerparent::__construct().- Le polymorphisme, c'est une boucle sur une liste de
Monsterqui appelleattack()sans savoir qui répond. Ajouter un monstre ne modifie aucune boucle existante. - La méthode appelée dépend de l'objet, pas du type de la variable. PHP part de la classe réelle et remonte.
- Un
trysurveille un bloc. Le premiercatchdont le type correspond reprend la main, et le programme continue après. Sanscatch, l'exception remonte les appels jusqu'auFatal error: Uncaught. InventoryFullException extends \RuntimeException: trois lignes suffisent, le nom de la classe rend lecatchprécis. Attrapez ce que vous attendez, laissez passer le reste.
Exercices
Sur papier
Deux questions à traiter sans ordinateur, avant d'ouvrir le kata.
- Écrivez la phrase « est un » ou « a un » pour ces quatre paires, et dites ce que vous écririez en PHP :
Dungeon/Room,Room/Monster,Potion/Item,Hero/Inventory. Inventory::remove()ne fait rien quand l'objet n'est pas dans le sac. Faut-il lever une exception, ou ne rien faire est-il correct ici ? Justifiez en deux phrases, puis écrivezItemNotFoundExceptionsi vous avez répondu oui. Les deux réponses se défendent, c'est le raisonnement qui compte. Restez sur papier : les tests du niveau 2 attendent queremove()ne fasse rien.
Kata niveau 3
Dans https://github.com/opmvpc/poo-katas-26, faites passer le groupe niveau-3 :
composer test -- --group=niveau-3
Ce que les tests attendent :
| Classe | Signature | Comportement vérifié |
|---|---|---|
Item |
abstract, describe(): string abstraite |
le test demande à PHP si la classe est abstraite, et attend true |
Weapon |
__construct(string $name, float $weight, int $damage, Rarity $rarity = Rarity::Common) |
$sword->damage est public readonly. describe() renvoie "Épée courte : arme (2 kg, 5 dégâts)" |
Potion |
__construct(string $name, float $weight, int $healing, Rarity $rarity = Rarity::Common) |
$potion->healing est public readonly. describe() renvoie "Potion de soin : potion (0.5 kg, +5 PV)" |
Monster |
abstract, __construct(string $name, int $maxHp) |
name public readonly, hp et maxHp en public private(set). hp reste entre 0 et maxHp : après takeDamage(3) puis takeDamage(99), hp vaut 2 puis 0. __toString() renvoie "Gobelin (5/5 PV)" |
Goblin |
__construct() |
parent::__construct('Gobelin', 5). attack() renvoie 2 |
Dragon |
__construct() |
parent::__construct('Dragon', 30). attack() renvoie 8 |
InventoryFullException |
extends \RuntimeException |
levée par add() quand ça déborde, et le sac reste inchangé |
Hero::equip() et Hero::$weapon |
equip(Weapon): void, public private(set) ?Weapon $weapon |
$hero->weapon vaut null au départ. attack() passe de 2 à 7 avec une épée à 5 dégâts |
Hero::drink() |
drink(Potion): void |
soigne du montant de la potion, puis la retire de l'inventaire |
Le test de drink() en entier :
$hero = new Hero('Arthur');
$potion = new Potion('Potion de soin', 0.5, 5);
$hero->inventory->add($potion);
$hero->takeDamage(6);
$hero->drink($potion);
expect($hero->hp)->toBe(9);
expect($hero->inventory->has('Potion de soin'))->toBeFalse();
Cinq points de vigilance :
- Les deux
describe()sont comparés au caractère près. Notez le%spour le poids, qui affiche2et non2.00, et le+devant les points de vie de la potion. - Le gobelin a 5 points de vie, le dragon 30. Le test vérifie aussi
(string) $goblin. Itema un troisième paramètreRarity $rarity = Rarity::Commondans les fichiers fournis. Il ne sert qu'au niveau 4. Passez-le auparent::__construct()depuisWeaponetPotion, où il arrive en quatrième position.- Les fichiers fournis déclarent encore
add(Item $item): bool, la signature du niveau 2. Passez-la àvoiden même temps que vous écrivez lethrow. Les tests ne lisent pas la valeur de retour : ils vérifient que l'exception part et que le sac reste inchangé. Monsterdéclare aussiheal()et la propriétéisFullHealth, commeHero. Écrivez-les de la même façon qu'au chapitre 3.
Lisez les tests avant de coder : ils sont l'énoncé complet.
Pour aller plus loin
Dans le dépôt du kata, ajoutez src/Skeleton.php : un squelette a 4 points de vie et attack() renvoie 3. Relancez composer test -- --group=niveau-3. Tout doit rester vert sans qu'aucun autre fichier ne bouge. Si vous avez dû ouvrir une boucle ou une autre classe, cherchez pourquoi.