Aller au contenu

L'encapsulation

Séance 2 · ~1 période

Depuis le chapitre 1, personne ne peut écrire $hero->hp = 9999 : hp est public private(set). Ce chapitre donne la règle complète. Pour chaque propriété, qui a le droit de lire, qui a le droit d'écrire, et 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. écrire une règle dans un hook set et une valeur calculée dans un hook get ;
  4. refuser un objet invalide dès le constructeur, avec une exception.

Le code qui sent mauvais

Cinq minutes, en binôme. Voici la classe Hero du chapitre 1, avec ses deux méthodes de santé, 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;

    // … constructeur du chapitre 1

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

$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.

C'est ce qu'on 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. Trois conséquences, concrètes.

  • 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. Ce chapitre la rassemble.

2. Choisir la visibilité

Vous connaissez public private(set) depuis le chapitre 1 : lecture publique, écriture réservée à la classe. Voici les quatre cas possibles pour une propriété.

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. Cette action porte un verbe : takeDamage(), heal(), equip().
  4. protected seulement quand une classe fille le demande. Vous n'avez pas encore de classes filles. Le chapitre 6 en donne, et elles ont rarement besoin d'écrire dans les propriétés du parent. protected(set) existe aussi, avec la même règle.

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, on peut attacher du code à la propriété elle-même. 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. Les tests du niveau 1 restent verts. 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. 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. setHp() 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. On pourrait stocker un booléen $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. On dit que 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. Trois repères pour choisir :

  • une propriété pour ce qui se lit comme un état : hp, name, isFullHealth ;
  • une méthode pour une action, qui porte un verbe et 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.

throw interrompt le constructeur et signale une erreur. new ne renvoie aucun dé, et le programme s'arrête, sauf si quelqu'un attrape l'erreur. 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. 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.");
}
new Hero('   ');       → InvalidArgumentException: Un héros a un nom.
new Hero('Arthur', 0); → 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 trois 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, 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 10 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. Le get de isFullHealth calcule sa valeur à 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 sept 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.

  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. Le squelette déclare la propriété 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. Poussez, et vérifiez que la tâche encapsulation est verte dans l'onglet Actions, à côté de niveau-1.

Comptez 30 minutes. 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, c'est-à-dire un Item ou rien du tout, ce qui s'écrit ?Item : 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.

Bonus. 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.