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
extends,protectedetparent::__construct(); - 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 (code smell : un détail d'écriture qui 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 4, Item avait un nom et un poids. Il nous faut maintenant des armes, qui infligent des dégâts, et des potions, qui soignent. La tentation est d'écrire deux classes complètes :
final class Weapon
{
public function __construct(
private readonly string $name,
private readonly float $weight,
private readonly int $damage,
) {}
// + name(), + weight()…
}
final class Potion
{
public function __construct(
private readonly string $name,
private readonly float $weight,
private readonly int $healing,
) {}
// + name(), + weight()… les mêmes, copiées-collées
}
Deux problèmes. Le premier est la duplication : 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::
Trois mots-clés suffisent.
extends déclare que la classe fille est un cas particulier de la classe parente. Elle en hérite toutes les propriétés et méthodes non privées.
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 lire ou utiliser la donnée |
private |
la classe seule | même les filles n'ont pas à y toucher |
Dans nos exemples, nous choisissons la visibilité la plus fermée qui suffit. Ici, Weapon::describe() a besoin de lire le nom de l'objet, donc name est protected dans Item.
parent::__construct() appelle le constructeur du parent. Si la classe fille définit son propre constructeur, elle doit faire cet appel elle-même. Sans lui, les propriétés déclarées dans le parent restent vides. C'est l'erreur la plus fréquente du chapitre. Une classe fille qui n'a pas de constructeur du tout, elle, utilise directement celui du parent.
abstract class Item
{
public function __construct(
protected readonly string $name,
protected readonly float $weight,
) {
}
public function name(): string
{
return $this->name;
}
public function weight(): float
{
return $this->weight;
}
/** 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, private readonly int $damage)
{
parent::__construct($name, $weight); // sans cette ligne, $this->name reste vide
}
public function damage(): int
{
return $this->damage;
}
public function describe(): string
{
return sprintf('%s : arme (%s kg, %d dégâts)', $this->name, $this->weight, $this->damage);
}
}
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, private readonly int $healing)
{
parent::__construct($name, $weight);
}
public function healing(): int
{
return $this->healing;
}
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. On le met sur les classes du bas de la hiérarchie, comme Weapon et Potion.
3. Abstrait : une classe qu'on ne peut pas fabriquer
Dans notre modèle, un objet ramassable est toujours une arme ou une potion. Personne ne peut écrire Item::describe(), puisque la description dépend justement du type d'objet.
Le mot-clé abstract dit ces deux choses.
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);
PHP 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.
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 6. Monster porte les points de vie et les dégâts encaissés, et laisse attack() abstraite.
abstract class Monster
{
protected int $hp;
protected int $maxHp;
public function __construct(
public readonly string $name,
int $maxHp,
) {
$this->maxHp = $maxHp;
$this->hp = $maxHp;
}
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 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 on ne peut plus prévoir le résultat d'un test. Le chapitre 6 rendra le hasard maîtrisable.
Un choix de visibilité, volontaire : name est public readonly, donc on écrit $goblin->name. hp et maxHp restent fermés et se lisent par hp() et maxHp(), comme chez le héros au chapitre 1.
Redéfinir une méthode
Une classe fille peut redéfinir une méthode du parent : elle la réécrit avec la même signature. Elle peut aussi la compléter, en appelant la version du parent avec parent::.
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)
Cet exemple montre le mécanisme. Dans le kata, Dragon garde le __toString() de Monster.
@startuml
abstract class Item {
#name: string
#weight: float
+name(): string
+weight(): float
+{abstract} describe(): string
}
class Weapon {
-damage: int
+damage(): int
+describe(): string
}
class Potion {
-healing: int
+healing(): int
+describe(): string
}
abstract class Monster {
+name: string
#hp: int
#maxHp: int
+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
note right of Item : Classe abstraite, en italique dans un dessin à la main.
@enduml
En UML, la généralisation est un trait plein terminé par un triangle vide qui pointe vers le parent. En PlantUML, Item <|-- Weapon se lit « Weapon est un Item ». Le nom d'une classe abstraite et celui d'une méthode abstraite s'écrivent en italique.
4. Le polymorphisme : une boucle sans if
C'est là que l'héritage devient vraiment utile. 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(); // pas un seul if
$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)
À 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, puis au parent de Monster. 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 qu'on fait dès qu'on hérite pour récupérer du code.
Le contre-exemple le plus courant n'est pas dans le donjon, et c'est voulu.
class Voiture
{
public function __construct(public readonly int $places) {}
public function ouvrirCoffre(): string
{
return "Le coffre s'ouvre.";
}
}
// « Un camion, c'est une voiture en plus gros. » Vraiment ?
class Camion extends Voiture
{
public function charger(float $tonnes): string
{
return "Chargement de {$tonnes} t.";
}
}
$camion = new Camion(places: 5);
echo $camion->ouvrirCoffre(), PHP_EOL;
echo $camion->places, ' 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 Camion que parce qu'on 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 n'était pas celui qu'on croyait.
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 6.
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, hp(), maxHp(), takeDamage(), heal() et isAlive() : inchangés
* depuis le chapitre 1, omis ici pour ne montrer que l'arme. */
private ?Weapon $weapon = null;
public function __construct(
public readonly string $name,
public readonly int $strength = 2,
) {
}
/** Le héros porte une arme. Il n'en est pas une. */
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
En UML, ce lien est une association navigable de multiplicité 0..1 : le héros connaît son arme, l'arme ne connaît pas son porteur. L'arme arrive de l'extérieur et le héros peut en changer, alors qu'une classe fille ne change jamais de parent. drink(Potion $potion) suit le même principe : le héros possède un sac, il appelle heal() puis retire la potion de l'inventaire.
| La phrase juste | Ce que vous écrivez |
|---|---|
| « un X est un Y » | class X extends Y |
| « un X a un Y » | une propriété private Y $y dans X |
Une classe, un rôle. Si vous hésitez parce que votre classe fait deux choses, le problème n'est pas l'héritage : c'est la classe.
6. Les exceptions : une hiérarchie qu'on prolonge
Au chapitre 4, Inventory::add() renvoyait false quand le sac était plein. Ça fonctionne, mais c'est facile à rater. $inventory->add($sword); sans lire la valeur de retour s'exécute très bien et perd l'épée sans rien dire.
Une exception est un objet qui signale un problème. Elle interrompt les instructions en cours, et PHP cherche un catch capable de la traiter. S'il n'en trouve pas, le programme s'arrête avec une erreur. Les exceptions de PHP forment une hiérarchie de classes, donc on y ajoute la nôtre avec extends.
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.
@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 en est la sous-branche pour ce qui ne se détecte qu'à l'exécution, et un sac trop plein en est l'exemple parfait : ça 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, on ne l'attrape pas, on corrige le bug. Dans un test, on l'attrape pour vérifier qu'elle est bien levée, exactement comme au niveau 1 du kata.
/** Corps vide : tout vient de RuntimeException. Seul le nom nous intéresse. */
final class InventoryFullException extends RuntimeException
{
}
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 qu'on passe 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;
}
La signature change, et c'est volontaire. Aux chapitres 3 et 4, 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. C'est tout l'intérêt de l'échange. À partir d'ici, et jusqu'au chapitre 6, 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
Deux choses à voir. 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, donc une faute de frappe sur un nom de méthode dans le même bloc continuera de provoquer une erreur.
Attention
catch (Exception $e) { } autour de tout le programme fait disparaître des erreurs que PHP signalait clairement. Attrapez le type le plus précis possible, et seulement là où vous savez quoi faire de l'erreur.
À 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. 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.
InventoryFullException extends RuntimeException: trois lignes suffisent, le nom de la classe rend lecatchprécis. Attrapez le type le plus étroit possible.
Exercices
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 |
(new ReflectionClass(Item::class))->isAbstract() vaut true |
Weapon |
__construct(string $name, float $weight, int $damage, …) |
damage(): int ; describe() renvoie "Épée courte : arme (2 kg, 5 dégâts)" |
Potion |
__construct(string $name, float $weight, int $healing, …) |
healing(): int ; describe() renvoie "Potion de soin : potion (0.5 kg, +5 PV)" |
Monster |
abstract, __construct(string $name, int $maxHp) |
name public, hp(), maxHp(), takeDamage(), isAlive(), __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, weapon(): ?Weapon |
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 |
Quatre 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 le squelette. Il ne sert qu'au niveau 4. Passez-le auparent::__construct()depuisWeaponetPotion, où il arrive en quatrième position.- Le squelette déclare 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é.
Lisez les tests avant de coder : ils sont l'énoncé complet.
Pour vous entraîner
- Ajoutez un
Skeleton(4 points de vie, attaque 3). Combien de classes et de boucles existantes avez-vous dû modifier ? Si la réponse n'est pas « aucune », cherchez pourquoi. - É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. CréezItemNotFoundExceptionet décidez : faut-il lever une exception, ou ne rien faire est-il correct ici ? Justifiez en deux phrases. Les deux réponses se défendent, c'est le raisonnement qui compte.