Aller au contenu

L'encapsulation

Séance 2 · ~1 période

Slides du chapitre (PDF)PDF

Depuis le chapitre 1, personne ne peut écrire $hero->hp = 9999 : hp est public private(set). Pour chaque propriété, trois questions. Qui lit, qui écrit, où vit la vérification.

À la fin de ce chapitre, vous serez capables de :

  1. expliquer ce que l'encapsulation protège, et quels bugs elle rend impossibles ;
  2. choisir pour chaque propriété entre private, public readonly et public private(set) ;
  3. attacher une règle d'écriture à une propriété, et une valeur calculée à sa lecture ;
  4. refuser un objet invalide dès le constructeur, avec une exception.

Le code qui sent mauvais

Cinq minutes, en binôme. La classe Hero du chapitre 1, et deux appels un peu tordus. Lisez, et répondez à une seule question : qu'affichent les deux echo ?

class Hero
{
    public private(set) int $hp;
    public private(set) int $maxHp;

    public function __construct(
        public readonly string $name,
        int $maxHp = 10,
        public readonly int $strength = 2,
    ) {
        $this->maxHp = $maxHp;
        $this->hp = $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 __toString(): string
    {
        return sprintf('%s (%d/%d PV)', $this->name, $this->hp, $this->maxHp);
    }
}

$arthur = new Hero('Arthur');
$arthur->takeDamage(-5);
echo $arthur, PHP_EOL;

$morgane = new Hero('Morgane');
$morgane->heal(-20);
echo $morgane, PHP_EOL;

1. Ce que la classe promet

Hero tient une promesse qui n'est écrite nulle part : hp reste entre 0 et maxHp. Le reste du programme y compte. L'affichage Arthur (8/10 PV) n'a de sens que si cette promesse tient. Une règle qui doit rester vraie pendant toute la vie d'un objet s'appelle un invariant.

Cette façon de faire s'appelle l'encapsulation : la classe cache ses données et garantit ses règles. Ailleurs dans le programme, vous écrivez $arthur->takeDamage(3) sans rien vérifier. Dans Hero, la méthode s'occupe des bornes.

  • La règle est écrite une seule fois. La borne « jamais sous 0 » est dans Hero, pas dans les vingt endroits du jeu qui infligent des dégâts.
  • Un bug se cherche à un seul endroit. Si hp vaut 9999, l'erreur est dans Hero. Avec le tableau du chapitre 1, elle pouvait être n'importe où.
  • Les méthodes publiques suffisent à comprendre la classe. takeDamage(), heal(), isAlive() : on lit ce qu'un héros sait faire sans ouvrir le reste du programme.

Le code qui sent mauvais montre la limite du chapitre 1 : l'écriture est fermée, mais la vérification est éparpillée.

2. Choisir la visibilité

Vous connaissez public private(set) depuis le chapitre 1 : lecture publique, écriture réservée à la classe.

Déclaration Qui lit Qui écrit Quand
private int $turn la classe la classe un détail interne dont personne dehors n'a besoin
public readonly string $name tout le monde personne, après le constructeur une valeur fixée à la création, comme le nom
public private(set) int $hp tout le monde la classe une valeur qui change, mais seulement par les actions de la classe
public int $hp tout le monde tout le monde jamais, dans ce cours

La troisième ligne, à l'essai :

$arthur = new Hero('Arthur');
echo $arthur->hp, PHP_EOL;
$arthur->hp = 9999;
10
Fatal error: Uncaught Error: Cannot modify private(set) property Hero::$hp from global scope

La règle, dans l'ordre :

  1. Écrivez private. Tout autre choix demande une raison. Un compteur de tours dans Dungeon, que seul le donjon utilise, reste private int $turn = 0.
  2. Ouvrez la lecture quand quelqu'un en a besoin. L'affichage doit lire hp : public private(set). Le nom ne change jamais : public readonly.
  3. N'ouvrez jamais l'écriture. Quand le reste du programme veut modifier un héros, c'est qu'il y a une action du jeu derrière. Le nom de cette action contient un verbe : takeDamage(), heal(), equip().

Note

protected, vu au chapitre 1, attend le chapitre 6 : il sert quand une classe hérite d'une autre, et ces classes ont rarement besoin d'écrire dans les propriétés de leur parent.

Attention

PHP accepte public private(set) readonly, mais cette écriture n'ajoute rien à readonly : une propriété readonly refuse déjà toute réécriture, même depuis la classe. Choisissez l'un ou l'autre. La valeur change-t-elle pendant la vie de l'objet ? Non : readonly. Oui : private(set).

3. Le hook set : la règle vit sur la propriété

Relisez takeDamage() et heal(). La règle « entre 0 et maxHp » est coupée en deux : la borne basse dans une méthode, la borne haute dans l'autre. Ajoutez demain une potion empoisonnée qui fait $this->hp -= 3 : il faudra penser à réécrire max(0, …) une troisième fois.

Depuis PHP 8.4, une propriété peut porter du code. C'est un hook : du code exécuté à chaque lecture ou à chaque écriture de la propriété. Il y en a deux, set et get. Voici le set de hp.

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

$value est une variable fournie par PHP : elle contient la valeur qu'on a tenté d'écrire. L'expression après => donne la valeur réellement stockée. Avec maxHp à 10, après 50 dégâts, la valeur proposée est -40. min(10, -40) donne -40, puis max(0, -40) donne 0.

Les affectations à hp dans les méthodes passent toutes par ce hook. Les deux méthodes se réduisent à une ligne :

public function takeDamage(int $amount): void
{
    $this->hp -= $amount;
}

public function heal(int $amount): void
{
    $this->hp += $amount;
}
$arthur = new Hero('Arthur');
$arthur->takeDamage(50);
echo $arthur, PHP_EOL;
$arthur->heal(999);
echo $arthur, PHP_EOL;
Arthur (0/10 PV)
Arthur (10/10 PV)

Sans hook, la règle est coupée en deux. Avec un hook set, elle vit sur la propriété

Les deux bornes sont maintenant vérifiées dans le hook de hp. La potion empoisonnée pourra écrire $this->hp -= 3 sans rien vérifier. Seule exception : la valeur par défaut écrite dans la déclaration, ici = 0, ne passe pas par le hook.

Le même hook, écrit avec des accolades :

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

Avec les accolades, vous écrivez vous-même $this->hp = … pour enregistrer la valeur : le hook remplace l'écriture, il ne vient pas s'y ajouter. Le résultat est le même, Arthur (0/10 PV) après 50 dégâts. Cette forme sert quand il faut plusieurs instructions, par exemple un throw pour refuser une valeur.

Attention

Un hook set n'est pas un setter, comme le setHp() refusé au chapitre 1. Cette méthode ouvrirait l'écriture à tout le programme. Ici, private(set) réserve l'écriture à Hero, et le hook vérifie la valeur que Hero écrit.

4. Le hook get : une valeur calculée

Un héros est en pleine forme quand hp vaut maxHp. Vous pourriez stocker cette information dans un booléen, c'est-à-dire une valeur vraie ou fausse, $isFullHealth, et le mettre à jour dans takeDamage() et dans heal(). Un jour, une méthode oubliera cette mise à jour, et le booléen mentira. Un hook get calcule la valeur à chaque lecture :

public bool $isFullHealth {
    get => $this->hp === $this->maxHp;
}
$arthur = new Hero('Arthur');
var_dump($arthur->isFullHealth);
$arthur->takeDamage(1);
var_dump($arthur->isFullHealth);
bool(true)
bool(false)

Quand vous lisez $arthur->isFullHealth, PHP exécute le code du get. La valeur n'est pas stockée : elle est calculée à partir de hp et de maxHp. La propriété est virtuelle. Après un coup ou un soin, la prochaine lecture utilise les nouveaux points de vie. Et comme il n'y a pas de set, personne ne peut l'écrire :

$arthur->isFullHealth = true;
Fatal error: Uncaught Error: Property Hero::$isFullHealth is read-only

Un hook get fait le même travail qu'une méthode, sans les parenthèses.

  • une propriété pour ce qui se lit comme un état : hp, name, isFullHealth ;
  • une méthode pour une action, dont le nom contient un verbe et qui change quelque chose : takeDamage(), heal(), roll() ;
  • une méthode pour un calcul qui a besoin d'un paramètre : distanceTo(Point $other).

Entre isAlive() et isAlive, les deux écritures se valent. Le Donjon garde la méthode. Ce qui compte, c'est qu'aucune des deux ne s'écrit de l'extérieur.

5. Valider à l'entrée : le constructeur

Une propriété readonly ne peut pas avoir de hook. Sa règle se vérifie donc au moment où le constructeur lui donne sa valeur. Un dé à zéro face ne doit pas exister :

class Dice
{
    public function __construct(
        public readonly int $sides,
    ) {
        if ($sides < 2) {
            throw new \InvalidArgumentException("Un dé a au moins 2 faces, $sides reçu.");
        }
    }
}

$d0 = new Dice(0);
Fatal error: Uncaught InvalidArgumentException: Un dé a au moins 2 faces, 0 reçu.

Une exception est un objet qui signale un problème et interrompt le code en cours. throw la lance : le constructeur s'arrête, new ne renvoie aucun dé, et le programme s'arrête aussi, sauf si quelqu'un attrape l'exception. InvalidArgumentException est une classe fournie par PHP pour ce cas précis : l'argument reçu n'est pas acceptable. Le chapitre 6 revient sur les exceptions. Retenez la règle : un objet impossible ne se construit pas. On vérifie à l'entrée, une fois, et tout le reste du programme peut faire confiance.

Même chose pour un héros. Un nom vide ou un maximum de points de vie nul n'ont pas de sens. Dans le constructeur de Hero, avant les deux affectations, trim() enlève les espaces aux deux bouts, pour qu'un nom fait d'espaces soit refusé aussi :

if (trim($name) === '') {
    throw new \InvalidArgumentException('Un héros a un nom.');
}

if ($maxHp < 1) {
    throw new \InvalidArgumentException("maxHp doit valoir au moins 1, $maxHp reçu.");
}

Chaque essai se lance seul, puisque le premier arrête le script.

new Hero('   ');
Fatal error: Uncaught InvalidArgumentException: Un héros a un nom.
new Hero('Arthur', 0);
Fatal error: Uncaught InvalidArgumentException: maxHp doit valoir au moins 1, 0 reçu.

6. Hero au complet

Voici la classe entière. Elle fait quinze lignes de plus que celle du chapitre 1, et trois règles y sont écrites une seule fois : les bornes de hp, le nom non vide, le maximum positif. Pour l'essayer seule, copiez-la dans un fichier avec les lignes qui suivent. Dans le kata, vous modifiez la classe existante de src/Hero.php, vous ne remplacez pas le fichier.

<?php

declare(strict_types=1);

class Hero
{
    public private(set) int $maxHp;

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

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

    public function __construct(
        public readonly string $name,
        int $maxHp = 10,
        public readonly int $strength = 2,
    ) {
        if (trim($name) === '') {
            throw new \InvalidArgumentException('Un héros a un nom.');
        }

        if ($maxHp < 1) {
            throw new \InvalidArgumentException("maxHp doit valoir au moins 1, $maxHp reçu.");
        }

        $this->maxHp = $maxHp;
        $this->hp = $maxHp;
    }

    public function takeDamage(int $amount): void
    {
        $this->hp -= $amount;
    }

    public function heal(int $amount): void
    {
        $this->hp += $amount;
    }

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

    public function __toString(): string
    {
        return sprintf('%s (%d/%d PV)', $this->name, $this->hp, $this->maxHp);
    }
}

$arthur = new Hero('Arthur');
$arthur->takeDamage(50);
echo $arthur, ' ', var_export($arthur->isFullHealth, true), PHP_EOL;
$arthur->heal(50);
echo $arthur, ' ', var_export($arthur->isFullHealth, true), PHP_EOL;
Arthur (0/10 PV) false
Arthur (10/10 PV) true

L'ordre des deux dernières lignes du constructeur compte. Le hook de hp lit maxHp. Si hp reçoit sa valeur en premier, maxHp n'en a pas encore, et PHP s'arrête :

Fatal error: Uncaught Error: Typed property Hero::$maxHp must not be accessed before initialization

maxHp reste private(set) et non readonly, alors que rien ne la modifie ici. C'est un choix pour la suite du kata : un objet pourra un jour augmenter le maximum. Une propriété readonly l'interdirait.

En UML, la convention du cours est simple : une propriété que seule la classe écrit se note avec un -, qu'elle soit lisible de dehors ou non. Une propriété calculée se note comme les autres.

@startuml
class Hero {
  +name: string
  +strength: int
  -hp: int
  -maxHp: int
  +isFullHealth: bool
  +takeDamage(amount: int): void
  +heal(amount: int): void
  +isAlive(): bool
  +__toString(): string
}
@enduml

Dans Laravel, vous écrirez $post->title pour lire un titre et $post->title = 'Le donjon' pour le changer, avant d'enregistrer avec save(). Ces propriétés n'existent pas dans le fichier Post.php : le modèle les fabrique à partir des colonnes de la table. Il le fait avec les méthodes magiques __get() et __set(), cousines du __toString() du chapitre 1. C'est le modèle qui décide ce qui se lit, ce qui s'écrit et ce qui se convertit au passage. Le chapitre 11 le montre sur du vrai code.

À retenir

  • L'encapsulation : la classe cache ses données et garantit ses règles. Une règle qui reste toujours vraie est un invariant, et il s'écrit à un seul endroit.
  • Tout est private par défaut. On ouvre la lecture avec readonly (fixée à la création) ou private(set) (la classe la modifie). On n'ouvre jamais l'écriture.
  • Un hook set applique une règle à chaque écriture faite par la classe. Un hook get fait une propriété virtuelle : isFullHealth est calculée à chaque lecture, sans rien stocker.
  • Ce qui est readonly se valide dans le constructeur. Un objet impossible ne se construit pas : throw new \InvalidArgumentException(…).
  • Une propriété pour un état, une méthode pour une action.

Exercices

Kata : encapsulation

Dépôt : https://github.com/opmvpc/poo-katas-26, le même qu'au niveau 1. Lancez ce groupe :

composer test -- --group=encapsulation

Lancez les tests et repérez ceux qui échouent encore. Ils décrivent le travail à faire dans src/Hero.php et src/Dice.php. Après chaque étape, lancez aussi composer test -- --group=niveau-1 : les tests déjà réussis doivent encore réussir.

Ce que les tests attendent, exactement :

Dungeon\Dice

  • new Dice(1) et new Dice(0) lèvent une InvalidArgumentException ; new Dice(2) est accepté.

Dungeon\Hero

  • new Hero(' ') et new Hero('Arthur', 0) lèvent une InvalidArgumentException ;
  • maxHp ne s'écrit pas de l'extérieur : $hero->maxHp = 99 lève une Error ;
  • isFullHealth vaut true à la création, false après takeDamage(1), true à nouveau après heal(100) ;
  • isFullHealth ne s'écrit pas : $hero->isFullHealth = true lève une Error ;
  • les tests du niveau 1 passent toujours : takeDamage() et heal() gardent hp entre 0 et maxHp.
  1. Le hook sur hp. Ajoutez le hook set de la partie 3 sur la propriété hp, puis réduisez takeDamage() et heal() à une ligne chacune. Relancez le niveau 1, puis relisez votre hook : les deux bornes y sont, et nulle part ailleurs.
  2. isFullHealth. Dans src/Hero.php, la propriété est déjà déclarée avec un hook get qui lève « À implémenter ». Remplacez-le par le calcul.
  3. Valider à l'entrée. new Dice(1) et new Dice(0) doivent lever une InvalidArgumentException. Même chose pour new Hero(' ') et new Hero('Arthur', 0).
  4. Refuser les montants négatifs. Dans Hero, un dégât négatif soigne encore, et un soin négatif blesse encore : le hook borne les points de vie, il ne juge pas le sens de l'action. Refusez les montants négatifs dans takeDamage() et heal() avec la technique de la partie 5. Puis écrivez le test qui le vérifie, à la fin de tests/EncapsulationTest.php. C'est le premier test que vous écrivez : prenez modèle sur ceux du fichier, et terminez-le par ->group('encapsulation'), sinon la commande ci-dessus ne le lance pas.
  5. Poussez, et vérifiez que la tâche encapsulation est verte dans l'onglet Actions, à côté de niveau-1.

Si vous bloquez plus de dix minutes sur un test, montrez le test et votre code.

Sur papier

Une salle du donjon a un nom, qui ne change jamais. Elle contient au plus un objet posé au sol : un Item, ou rien du tout. Ce type s'écrit ?Item, et le point d'interrogation veut dire « ou null ». Elle compte ses visites, un nombre qui augmente à chaque passage du héros et que l'affichage doit pouvoir lire.

Pour chacune des trois propriétés, écrivez la ligne de déclaration complète : visibilité de lecture, visibilité d'écriture ou readonly, type, valeur de départ s'il en faut une. Puis nommez la ou les méthodes qui ont le droit de modifier chacune.