Aller au contenu

Relations entre objets : posséder, partager, pointer

Séance 2 · ~3 périodes

Slides du chapitre (PDF)PDF

Au chapitre précédent, vous avez dessiné des traits entre des boîtes. Ce chapitre écrit ces traits en PHP. Un losange plein, un losange vide, un trait : chacun a sa traduction en code. Et le choix se joue presque toujours sur une seule ligne, celle où l'objet est fabriqué.

En PHP, deux variables peuvent désigner le même objet. La section 4 le montre sur Hero.

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

  1. écrire une propriété typée par une classe et un tableau d'objets correctement annoté ;
  2. traduire en PHP une composition et une agrégation, et justifier votre choix ;
  3. expliquer pourquoi deux variables peuvent modifier le même objet, et le prouver ;
  4. manipuler une propriété qui peut valoir null, avec ?Type et ?-> ;
  5. comparer deux objets avec == et ===.

Le code qui sent mauvais

Cinq minutes, en binôme. Ce code tourne, et il sent mauvais : un détail d'écriture y trahit un défaut de conception. Lisez-le et répondez à une seule question : qu'affiche la dernière ligne ?

<?php

declare(strict_types=1);

class Inventory
{
    /** @var string[] */
    public array $items = [];
}

class Hero
{
    public function __construct(
        public readonly string $name,
        public Inventory $inventory,
    ) {}
}

$sac = new Inventory();
$arthur = new Hero('Arthur', $sac);
$morgane = new Hero('Morgane', $sac);

$arthur->inventory->items[] = 'Épée courte';

print_r($morgane->inventory->items);

1. Une propriété typée par une classe

Jusqu'ici, vos propriétés étaient des int, des string, des float. Elles peuvent aussi contenir un objet. C'est le cœur de la POO.

Voici une salle du Donjon, réduite à l'objet posé sur son sol et à la méthode qui le pose :

class Room
{
    /** L'objet posé au sol. Au plus un, d'où le point d'interrogation. */
    public private(set) ?Item $loot = null;

    public function __construct(
        public readonly string $name,
    ) {}

    /** Pose un objet au sol. */
    public function drop(Item $item): void
    {
        $this->loot = $item;
    }
}

?Item $loot se lit ainsi : cette propriété contient un objet Item, ou null. Le paramètre de drop() porte le même type, sans le point d'interrogation : poser null au sol n'aurait pas de sens. Essayez de poser une chaîne de caractères à la place d'un objet :

$tresor = new Room('Trésor');
$tresor->drop('Épée courte');
Fatal error: Uncaught TypeError: Room::drop(): Argument #1 ($item) must be of type Item, string given

La TypeError arrête le programme à la ligne fautive

PHP arrête le programme sur la ligne fautive. Le bug ne va pas plus loin.

Une propriété typée par une classe est une relation. Le type dit avec quelle classe, le point d'interrogation dit combien. Ici, c'est le trait entre Room et Item du chapitre 4, le losange vide {uml:agregation} avec 0..1 de chaque côté, écrit en PHP.

2. Une liste d'objets

Quand la multiplicité passe à 0..*, la propriété devient un tableau. Le type array ne précise pas ce que ce tableau contient. Vous l'écrivez en commentaire, dans une forme que l'éditeur sait lire :

/** @var Item[] Les objets transportés. */
private array $items = [];

Ce commentaire indique à l'éditeur que le tableau contient des Item. L'éditeur propose alors leurs méthodes dès que vous tapez $item->. L'analyse statique, l'outil qui relit le code sans le lancer, signale certains usages faux avant l'exécution. PHP, lui, ne lit pas ce commentaire. Ce qui protège le tableau, c'est le type du paramètre d'add(). Prenez quand même l'habitude de l'écrire à chaque tableau d'objets.

Voici le cœur de l'Inventory, le sac du héros, avec l'Item qu'il transporte :

<?php

declare(strict_types=1);

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

class Inventory
{
    /** @var Item[] Les objets transportés. */
    private array $items = [];

    public function __construct(
        public readonly float $maxWeight = 20.0,
    ) {}

    /** Ajoute un objet, sauf si le sac déborde. */
    public function add(Item $item): bool
    {
        if ($this->totalWeight() + $item->weight > $this->maxWeight) {
            return false;
        }

        $this->items[] = $item;

        return true;
    }

    /** Le nombre d'objets dans le sac. */
    public function count(): int
    {
        return count($this->items);
    }

    /** La somme des poids, en kilos. */
    public function totalWeight(): float
    {
        $total = 0.0;
        foreach ($this->items as $item) {
            $total += $item->weight;
        }

        return $total;
    }
}
  • $this->items[] = $item; ajoute l'objet à la fin du tableau. Aucune écriture dans $items ne vient de l'extérieur : la propriété est private.
  • add() renvoie false quand le sac déborde, et n'ajoute rien. C'est provisoire. Si le programme ne regarde pas cette valeur, le refus passe inaperçu. Au chapitre 6, une exception signalera l'échec à la place.
  • Le contrôle du poids vit dans add(), à un seul endroit. Personne ne peut le contourner : il n'y a pas d'autre chemin vers la liste.

Il manque has() et remove(). Vous les écrirez au kata.

Le héros fabrique son sac dans son constructeur, à la section 3. Essayez :

$arthur = new Hero('Arthur');
$arthur->inventory->add(new Item('Épée courte', 2.0));
$arthur->inventory->add(new Item('Potion', 0.5));

echo 'Enclume acceptée ? ', var_export($arthur->inventory->add(new Item('Enclume', 80.0)), true), "\n";
echo $arthur->inventory->count(), ' objets, ', $arthur->inventory->totalWeight(), " kg\n";
Enclume acceptée ? false
2 objets, 2.5 kg

L'enclume est refusée : 2,5 plus 80 dépasse les 20 kg du sac.

3. Créer ou recevoir : la ligne qui trahit le choix

Le chapitre 4 a posé les deux relations. La composition dit que la partie est fabriquée par le tout et n'appartient qu'à lui. L'agrégation dit que la partie vient d'ailleurs et lui survit. C'est le modèle du jeu qui tranche, pas le code. Une fois la décision prise, un indice la donne : qui appelle new ?

Composition ou agrégation : new dans le constructeur, ou un paramètre

Composition : le tout fabrique la partie

class Hero
{
    /** Le sac : créé ici, et nulle part ailleurs. Lisible de partout, jamais remplacé. */
    public readonly Inventory $inventory;

    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 = 10,
        public readonly int $strength = 2,
    ) {
        $this->maxHp = $maxHp;
        $this->hp = $maxHp;
        $this->inventory = new Inventory();
    }

    // isFullHealth, les deux validations du constructeur, takeDamage(), heal(),
    // isAlive() et __toString() : inchangés depuis le chapitre 3.
}

Cette classe Hero est celle du chapitre 3, avec une propriété de plus : inventory. Le hook de hp borne toujours les points de vie.

Le new Inventory() est dans le constructeur. Trois conséquences suivent. Personne ne peut donner à un héros un sac déjà rempli. Deux héros ne peuvent pas recevoir le même sac. Et le sac n'a aucune raison d'exister ailleurs que chez son héros. C'est le losange plein {uml:composition} entre Hero et Inventory du chapitre 4 : un héros porte un sac, et un seul.

Le code d'ouverture faisait l'inverse : il recevait le sac en paramètre. D'où deux héros avec la même épée.

« La partie meurt avec le tout » est une règle du modèle, pas une garantie du langage. PHP libère un objet quand plus aucune variable ne le désigne. Si vous écrivez $sac = $arthur->inventory; puis que vous oubliez Arthur, le sac reste accessible par $sac. Le losange plein dit une intention. C'est à votre code de la tenir.

Agrégation : le tout reçoit la partie

Une épée posée au sol n'appartient pas à la salle. Elle a été fabriquée ailleurs, elle passera dans le sac du héros, et ce sera toujours la même épée :

class Room
{
    /** L'objet posé au sol : reçu de l'extérieur, et parfois absent. */
    public private(set) ?Item $loot = null;

    public function __construct(
        public readonly string $name,
    ) {}

    /** Pose un objet au sol. Vue à la section 1. */
    public function drop(Item $item): void
    {
        $this->loot = $item;
    }

    /** Retire l'objet du sol et le rend. */
    public function take(): ?Item
    {
        $item = $this->loot;
        $this->loot = null;

        return $item;
    }

    public function describe(): string
    {
        return $this->name.' : '.($this->loot?->name ?? 'rien au sol');
    }
}

Aucun new Item dans cette classe. La salle reçoit un objet fabriqué ailleurs, elle le garde un moment, elle le rend. C'est le losange vide.

Le donjon, lui, fabrique ses salles :

class Dungeon
{
    /** @var Room[] Les salles, fabriquées ici. Lisibles de partout, modifiées seulement ici. */
    public private(set) array $rooms = [];

    /** @param string[] $names */
    public function __construct(array $names)
    {
        foreach ($names as $name) {
            $this->rooms[] = new Room($name);
        }
    }

    public function room(int $index): Room
    {
        return $this->rooms[$index];
    }
}

Un Dungeon se construit avec une liste de noms, pas avec une liste de salles. Personne ne peut lui passer une salle déjà peuplée. C'est une composition, comme le sac.

Le code :

$dungeon = new Dungeon(['Entrée', 'Salle des gardes', 'Trésor']);

$epee = new Item('Épée courte', 2.0);
$dungeon->room(1)->drop($epee);

foreach ($dungeon->rooms as $room) {
    echo $room->describe(), "\n";
}

echo 'même objet que $epee ? ', var_export($dungeon->room(1)->loot === $epee, true), "\n";
Entrée : rien au sol
Salle des gardes : Épée courte
Trésor : rien au sol
même objet que $epee ? true

La dernière ligne est le point important. La salle ne détient pas une copie de l'épée : elle détient votre épée. Sa propriété loot ne contient pas l'objet lui-même, mais une référence : de quoi retrouver l'objet en mémoire. Le héros peut donc ramasser l'épée sans qu'elle change d'identité :

$arthur = new Hero('Arthur');
$ramasse = $dungeon->room(1)->take();

if ($ramasse !== null) {
    $arthur->inventory->add($ramasse);
}

echo $dungeon->room(1)->describe(), "\n";
echo $arthur->inventory->count(), " objet dans le sac\n";
echo 'toujours la même épée ? ', var_export($ramasse === $epee, true), "\n";
Salle des gardes : rien au sol
1 objet dans le sac
toujours la même épée ? true

Le modèle du Donjon, tel qu'il est codé ci-dessus :

@startuml
class Dungeon {
  -rooms: Room [1..*]
  +room(index: int): Room
}
class Room {
  +name: string
  -loot: Item [0..1]
  +drop(item: Item): void
  +take(): Item [0..1]
  +describe(): string
}
class Hero {
  +name: string
  +inventory: Inventory
}
class Inventory {
  +maxWeight: float
  -items: Item [0..*]
  +add(item: Item): bool
  +count(): int
  +totalWeight(): float
}
class Item {
  +name: string
  +weight: float
}
Dungeon "1 -dungeon" *-- "1..* -rooms" Room : contient >
Room "0..1 -floor" o-- "0..1 -loot" Item : expose >
Hero "1 -owner" *-- "1 -inventory" Inventory : porte >
Inventory "0..1 -bag" o-- "0..* -items" Item : contient >
note right of Inventory : Créé dans le constructeur de Hero.
note right of Item : Reçu par drop() et par add().
@enduml

Chaque relation se lit deux fois : sur le trait, et comme propriété dans la boîte du côté qui la porte. rooms, loot et items sont notées - parce que seule leur classe les écrit. La boîte Hero ne montre que name et inventory. Ses points de vie et ses méthodes du chapitre 3 existent toujours, le diagramme les laisse de côté pour ne garder que les relations.

L'épée change de propriétaire : la salle, puis le sac d'Arthur

Un héros est fait d'un sac : il le fabrique, et le sac n'a pas de sens sans lui. Composition. Une salle a une épée : l'épée existait avant. Agrégation.

4. Deux variables, un seul objet

Avec les types de base, PHP copie la valeur :

$a = 5;
$b = $a;
$b = 10;

echo $a, "\n";
5

Avec les objets, non. Une variable ne contient pas l'objet : elle contient une référence, de quoi le retrouver. L'affectation recopie cette référence, pas l'objet.

$arthur = new Hero('Arthur');
$leHeros = $arthur;

$leHeros->takeDamage(3);

echo $arthur, "\n";
echo $leHeros, "\n";
Arthur (7/10 PV)
Arthur (7/10 PV)

Vous avez blessé Arthur en passant par $leHeros : il n'y a jamais eu qu'un seul héros. C'est l'explication du code d'ouverture, et c'est aussi ce qui rend l'agrégation possible. L'épée de la salle est celle de votre variable, pas une copie.

Pour obtenir un second objet, il faut le demander avec clone :

$jumeau = clone $arthur;
$jumeau->takeDamage(5);

echo $arthur, ' / ', $jumeau, "\n";
Arthur (7/10 PV) / Arthur (2/10 PV)

= recopie le lien, clone fabrique un second objet

clone $arthur recopie les propriétés une à une. Or inventory contient une référence : la copie reçoit la même référence, donc le même sac. Vérifiez :

$arthur = new Hero('Arthur');
$arthur->inventory->add(new Item('Épée courte', 2.0));

$jumeau = clone $arthur;
$jumeau->inventory->add(new Item('Potion', 0.5));

echo 'même sac ? ', var_export($jumeau->inventory === $arthur->inventory, true), "\n";
echo 'Arthur : ', $arthur->inventory->count(), ' / jumeau : ', $jumeau->inventory->count(), "\n";
même sac ? true
Arthur : 2 / jumeau : 2

La potion du jumeau a atterri chez Arthur. Pour que la copie ait son propre sac, la classe écrit la méthode __clone(). PHP l'appelle sur la copie, juste après l'avoir fabriquée :

class Hero
{
    // ... tout le reste ne change pas.

    /** Appelée sur la copie : le sac est recopié, pas partagé. */
    public function __clone(): void
    {
        $this->inventory = clone $this->inventory;
    }
}

Le même script affiche maintenant :

même sac ? false
Arthur : 1 / jumeau : 2

Cette copie s'appelle une copie profonde : chaque héros a son sac. Écrire dans la propriété readonly est permis ici, et seulement ici : __clone() termine la fabrication d'un objet neuf.

Et les objets dans le sac ? clone $this->inventory recopie le tableau $items, mais chaque case contient une référence : les deux sacs pointent vers la même épée. Ici, rien ne gêne. Un Item est entièrement readonly, personne ne peut le modifier, donc le partager ne change rien. Si vos objets changeaient avec le temps, une arme qui s'use, une potion à moitié bue, le partage deviendrait un bug. Inventory écrirait alors son propre __clone(), qui clone chaque objet du tableau. La copie profonde s'arrête là où les objets ne bougent plus.

== ou === sur des objets

  • == compare le contenu : même classe, et des propriétés qui se valent une à une.
  • === compare l'identité : est-ce le même objet ?
$arthur = new Hero('Arthur');
$leHeros = $arthur;
$leHeros->takeDamage(3);

$sosie = new Hero('Arthur');
$sosie->takeDamage(3);

echo 'arthur === leHeros : ', var_export($arthur === $leHeros, true), "\n";
echo 'arthur === sosie   : ', var_export($arthur === $sosie, true), "\n";
echo 'arthur == sosie    : ', var_export($arthur == $sosie, true), "\n";
arthur === leHeros : true
arthur === sosie   : false
arthur == sosie    : true

Le sosie porte le même nom et les mêmes points de vie, donc == répond true. C'est pourtant un autre objet, donc === répond false. Utilisez === pour vérifier que deux variables désignent le même objet : retrouver l'exemplaire emprunté par un membre, retirer cette épée-là du sol.

Attention

Sur deux objets, === ne compare pas les valeurs, contrairement à ce qu'il fait sur deux chaînes ou deux entiers. Deux héros identiques mais distincts donnent false. Pour comparer du contenu, comparez ce qui identifie vos objets, par exemple $a->name === $b->name.

5. Quand la relation est facultative

Une multiplicité 0..1 en UML devient un type nullable en PHP, c'est-à-dire un type qui accepte aussi la valeur null. Vous avez écrit ?Item $loot au chapitre 3. La salle du Donjon porte public private(set) ?Item $loot = null, et sa méthode take() renvoie un ?Item.

Lire une propriété nullable demande une précaution :

$room = new Room('Trésor');
var_dump($room->loot->name);
Warning: Attempt to read property "name" on null
NULL

$room->loot->name déclenche un avertissement dès que le sol est vide, et ne rend rien. L'opérateur ?->, appelé nullsafe, évite ce test :

return $this->name.' : '.($this->loot?->name ?? 'rien au sol');

$this->loot?->name se lit ainsi : si loot vaut null, l'expression entière vaut null et name n'est jamais lue. Il fonctionne aussi devant une méthode : $this->loot?->describe(). L'opérateur ?? prend alors le relais et fournit la valeur de remplacement. En trois caractères, vous évitez un if de trois lignes.

Note

Histoire de PHP. L'opérateur ?-> est arrivé avec PHP 8.0, en 2020. Avant, il fallait écrire le test à la main : if ($x !== null) { $x->m(); }, ou sa version courte $x ? $x->m() : null. Vous croiserez encore ces deux écritures dans les projets existants. Elles font le même travail, en plus long.

À l'usage :

$tresor = new Room('Trésor');
echo $tresor->loot?->name ?? 'rien au sol', "\n";

$tresor->drop(new Item('Potion', 0.5));
echo $tresor->loot?->name ?? 'rien au sol', "\n";
rien au sol
Potion

Astuce

?-> ne dispense pas de vérifier. Il convient pour un affichage ou une valeur de remplacement. Quand l'absence est une erreur du métier, un prêt sans exemplaire, une commande sans client, levez plutôt une exception : c'est le chapitre 6. Sinon l'erreur n'apparaît que beaucoup plus loin dans le programme, là où elle devient difficile à comprendre.

6. Le pont : ce que vous verrez dans Laravel

Dans la partie Framework du cours, vous écrirez ceci :

$post = Post::find(1);

foreach ($post->comments as $comment) {
    echo $comment->body;
}

Post::find(1) va chercher l'article numéro 1 dans la base de données et le rend sous forme d'objet. $post->comments donne la liste de ses objets Comment. C'est la structure de ce chapitre : une liste d'objets détenue par un objet, comme $items dans Inventory. Une différence : Laravel remplit cette liste depuis la base de données, vous n'écrivez aucun new.

Ce code a le défaut vu à la section 5. find() renvoie null quand aucun article ne porte ce numéro, et $post->comments lève alors l'avertissement Attempt to read property "comments" on null. Deux corrections possibles :

// Rien à afficher si l'article n'existe pas.
$post = Post::find(1);

foreach ($post?->comments ?? [] as $comment) {
    echo $comment->body;
}

// Ou : l'article doit exister, sinon Laravel renvoie une page 404.
$post = Post::findOrFail(1);

Dans la documentation de Laravel, ces relations portent des noms : hasMany, belongsTo, hasOne. « Un Post has many Comment » est la relation Inventory / Item de ce chapitre : un trait avec 0..* dans le diagramme, une propriété qui contient une liste dans la classe.

À retenir

  • Une propriété typée par une classe est une relation. ?Item $loot écrit la multiplicité 0..1, et /** @var Item[] */ renseigne l'éditeur sur un tableau d'objets.
  • Fabriqué dans le constructeur, c'est une composition : losange plein, exclusif. Reçu en paramètre, c'est une agrégation : losange vide, partageable.
  • Une variable ne contient pas l'objet, mais une référence vers lui. $b = $a ne fabrique pas de second objet. clone le fait, et __clone() décide ce que la copie partage.
  • == compare le contenu, === compare l'identité. Sur des objets, prenez ===.
  • ?-> remplace le test if ($x !== null) pour un affichage ou une valeur de remplacement, jamais pour cacher une erreur du métier.

Exercices

Sur papier

Trois questions à traiter sans ordinateur, avant d'ouvrir le kata.

  1. Un Dungeon reçoit ses salles en paramètre au lieu de les fabriquer. Quel losange dessinez-vous alors, et que se passe-t-il si deux donjons reçoivent la même salle ?
  2. Reprenez le diagramme de la section 3. Écrivez la propriété PHP qui traduit chacune de ses trois relations à losange : la salle et son butin {uml:agregation}, le héros et son sac {uml:composition}, le sac et ses objets {uml:agregation}. Donnez à chaque fois la visibilité, le type et la valeur de départ.
  3. Le héros expose son sac par la propriété inventory. N'importe quelle partie du programme peut donc écrire $arthur->inventory->add(...) sans passer par le héros. Est-ce compatible avec la composition ? Que changeriez-vous pour que seul le héros remplisse son sac ?

Kata : niveau 2

Dépôt : https://github.com/opmvpc/poo-katas-26. Lancez seulement ce niveau :

composer test -- --group=niveau-2

Ce que les tests attendent :

Classe Signature Comportement vérifié
Item __construct(string $name, float $weight, Rarity $rarity = Rarity::Common) $item->name et $item->weight rendent ce qui a été passé au constructeur. Un poids négatif lève une InvalidArgumentException
Inventory __construct(float $maxWeight = 20.0) un sac neuf : count() vaut 0, totalWeight() vaut 0.0, $bag->maxWeight vaut 20.0
Inventory add(Item $item): bool ajoute l'objet. Si le poids total dépassait maxWeight, n'ajoute rien et renvoie false
Inventory has(string $name): bool true pour un objet présent, false pour un objet absent
Inventory remove(string $name): void retire le premier objet portant ce nom. Ne fait rien si ce nom est absent, et count() reste juste
Inventory totalWeight(): float la somme des poids : 2.0 et 0.5 donnent 2.5
Hero public readonly Inventory $inventory $hero->inventory existe dès la construction du héros, et deux héros n'ont jamais le même

Le test du sac plein accepte aussi une exception, le comportement du niveau 3. Rien ne s'écrit de l'extérieur, comme au chapitre 3 : name, weight et maxWeight sont public readonly, inventory aussi. Vous les lisez, $bag->maxWeight, vous ne les écrivez jamais. Aucune méthode getName(), aucune méthode setName().

Le squelette d'Item commence ainsi :

abstract class Item implements \Stringable
{
    public function __construct(
        public readonly string $name,
        public readonly float $weight,
        public readonly Rarity $rarity = Rarity::Common,
    ) {
        throw new \LogicException('À implémenter');
    }

Item y est déjà abstract et son constructeur prend un troisième paramètre, Rarity $rarity : c'est sa forme des niveaux suivants. Au niveau 2, vous complétez ce constructeur avec la validation du poids, puis les cinq méthodes d'Inventory. Comme une classe abstraite ne s'instancie pas, les tests passent par Tests\Support\SimpleItem, une sous-classe fournie dans tests/Support/. Vous n'y touchez pas.

Le seul piège du niveau tient en un test :

$arthur = new Hero('Arthur');
$morgane = new Hero('Morgane');

$arthur->inventory->add(new SimpleItem('Épée courte', 2.0));

expect($arthur->inventory->count())->toBe(1);
expect($morgane->inventory->count())->toBe(0);

Si le constructeur reçoit l'inventaire en paramètre, ou si les deux héros le partagent d'une autre façon, le test passe au rouge.

Le niveau 3 reprendra les mêmes relations avec l'arme du héros, equip(Weapon $weapon): void et une propriété public private(set) ?Weapon $weapon. C'est une relation 0..1, comme l'épée au sol. Il demande aussi l'héritage : il attend donc le chapitre 6.