Aller au contenu

Relations entre objets : posséder, partager, pointer

Séance 2 · ~3 périodes

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, une flèche : chacun a sa traduction en code. Et le choix se joue presque toujours sur une seule ligne, celle où l'objet est fabriqué.

Ce chapitre règle aussi un malentendu qui coûte des heures de débogage : en PHP, deux variables peuvent désigner le même objet. Tant que ce point n'est pas clair, certains résultats restent incompréhensibles.

À 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 (code smell : un détail d'écriture qui 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);

Le reste du chapitre développe cette réponse.

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 être des objets. C'est même le cœur de la POO : des objets qui en contiennent d'autres.

Voici une salle du Donjon, avec l'objet posé sur son sol :

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

private ?Item $loot se lit ainsi : cette propriété contient un objet Item, ou null. Si vous lui affectez une chaîne de caractères, PHP arrête le programme avec une TypeError, une erreur de type. 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 Room o-- "0..1" Item du chapitre 3, é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. On l'écrit donc en commentaire, dans une forme que l'éditeur et l'analyseur savent 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->, et l'analyse statique signale certains usages faux avant l'exécution. PHP, lui, ne lit pas ce commentaire : ce qui protège vraiment le tableau, c'est le type du paramètre de la méthode qui remplit ce tableau. 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(
        private readonly string $name,
        private readonly float $weight,
    ) {}

    public function name(): string
    {
        return $this->name;
    }

    public function weight(): float
    {
        return $this->weight;
    }
}

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

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

    /** Le poids maximum transportable. */
    public function maxWeight(): float
    {
        return $this->maxWeight;
    }

    /** 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;
    }
}

Trois détails comptent.

  • $this->items[] = $item; ajoute l'objet à la fin du tableau. C'est la seule écriture possible dans $items, parce que 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 5, 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() : ce sont les deux méthodes que vous écrirez au kata.

Faisons tourner l'ensemble :

$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 décide

Le chapitre 3 a posé les deux relations. Rappel en une phrase : 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. Mais une fois la décision prise, elle se lit à un seul endroit : qui appelle new ?

L'arbre de décision : qui appelle new ?

Composition : le tout fabrique la partie

class Hero
{
    /** Le sac : créé ici, et nulle part ailleurs. */
    private Inventory $inventory;

    private int $hp;
    private int $maxHp;

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

    /** Le sac du héros. */
    public function inventory(): Inventory
    {
        return $this->inventory;
    }

    // takeDamage(), heal(), isAlive() et __toString() : inchangés depuis le chapitre 1.
}

C'est la seule classe Hero du chapitre : elle a gagné une propriété et une méthode depuis le chapitre 1, le reste ne bouge pas.

Le new Inventory() est dans le constructeur. Trois conséquences suivent. On ne peut pas 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 Hero *-- "1" Inventory, le losange plein.

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.

Une précision : « 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. */
    private ?Item $loot = null;

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

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

    /** L'objet au sol, ou null. */
    public function loot(): ?Item
    {
        return $this->loot;
    }

    /** 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. Losange vide.

Le donjon, lui, fabrique ses salles :

class Dungeon
{
    /** @var Room[] Les salles, fabriquées ici. */
    private 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];
    }

    /** @return Room[] */
    public function rooms(): array
    {
        return $this->rooms;
    }
}

Un Dungeon se construit avec une liste de noms, pas avec une liste de salles. Impossible de lui passer une salle déjà peuplée : composition, comme pour le sac.

Voici la démonstration :

$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. Le héros peut donc la ramasser 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 {
  +room(index: int): Room
  +rooms(): array
}
class Room {
  +name: string
  +drop(item: Item): void
  +take(): Item
  +describe(): string
}
class Hero {
  +name: string
  +inventory(): Inventory
}
class Inventory {
  -maxWeight: float
  +add(item: Item): bool
  +count(): int
  +totalWeight(): float
}
class Item {
  -name: string
  -weight: float
  +name(): string
  +weight(): float
}
Dungeon "1" *-- "1..*" Room : rooms
Room "1" o-- "0..1" Item : loot
Hero "1" *-- "1" Inventory : inventory
Inventory "1" o-- "0..*" Item : items
note right of Inventory : Créé dans le constructeur de Hero.
note right of Item : Reçu par drop() et par add().
@enduml

L'état mémoire du Donjon : ce que possède le héros, ce que la salle partage

Devant une relation nouvelle, deux questions suffisent. Qui fabrique l'objet ? Si le tout disparaît, la partie a-t-elle encore un sens ? Un héros **est fait d'**un sac : deux fois la même réponse, donc composition. Une salle a une épée au sol : l'épée existait avant, donc agrégation.

4. Deux variables, un seul objet

Avec les types de base, PHP copie la valeur :

$a = 5;
$b = $a;
$b = 10;
// $a vaut toujours 5

Avec les objets, non. Une variable ne contient pas l'objet : elle contient de quoi le retrouver. L'affectation recopie ce lien, pas l'objet.

Deux variables pointent vers le même objet ; clone en crée un second

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

Attention : clone recopie les propriétés telles quelles. Si l'une d'elles contient un objet, l'original et la copie le partagent. Nous n'en aurons pas besoin dans ce cours.

== ou === sur des objets

La distinction découle de ce qui précède.

  • == 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 ?
$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. On l'écrit avec un point d'interrogation devant le nom de la classe : ?Item. La salle du Donjon en a une, private ?Item $loot = null, et sa méthode take() renvoie un ?Item.

Lire une propriété nullable demande une précaution. $room->loot()->name() échoue dès que le sol est vide, parce qu'on ne peut appeler aucune méthode sur null. L'opérateur 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 appelée. L'opérateur ?? prend alors le relais et fournit la valeur de remplacement. En deux caractères, vous évitez quatre lignes de vérification.

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 trois lignes de plus.

À 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 5. 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 deux semaines, vous écrirez ceci :

$post = Post::find(1);

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

$post->comments donne les objets Comment liés à cet article. C'est la structure de ce chapitre : une liste d'objets détenue par un objet, comme $items dans Inventory.

Deux différences avec votre code. Laravel va chercher ces commentaires dans la base de données, souvent au premier accès, au lieu que vous écriviez les new vous-même. Et find() renvoie null quand l'article n'existe pas : $post est donc nullable, exactement comme le ?Item de la salle, et le ?-> de ce chapitre vous servira dès la première page.

Vous retrouverez ces relations dans le vocabulaire d'Eloquent : hasMany, belongsTo, hasOne. Quand la documentation dira « un Post has many Comment », vous saurez que c'est un trait dans un diagramme, une propriété dans une classe et une clé étrangère dans une table.

À retenir

  • Une propriété typée par une classe est une relation : ?Item $loot écrit la multiplicité 0..1, et /** @var Item[] */ sur un tableau renseigne l'éditeur et l'analyseur.
  • 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 de quoi le retrouver. $b = $a ne fabrique pas de second objet ; clone le fait.
  • == 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.

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 name(): string, weight(): float renvoient le nom et le poids passés au constructeur
Inventory __construct(float $maxWeight = 20.0) un sac neuf : count() vaut 0, totalWeight() vaut 0.0, 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 l'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 inventory(): Inventory l'inventaire existe dès la construction du héros, et deux héros n'ont jamais le même

Attention à maxWeight : c'est une méthode, $bag->maxWeight(), pas une propriété publique. Comme pour hp() et maxHp() au niveau 1, la propriété reste privée et une méthode permet de lire sa valeur.

Deux remarques sur le squelette fourni. Item y est déjà déclarée abstract et son constructeur prend un troisième paramètre Rarity $rarity : c'est sa forme des niveaux suivants, et pour le niveau 2 vous n'avez que name() et weight() à écrire. Comme une classe abstraite ne s'instancie pas, les tests passent par Tests\Support\SimpleItem, une petite sous-classe fournie dans tests/Support/. Vous n'y touchez pas.

Le test « deux héros ont chacun leur propre sac » est le seul piège du niveau. Il construit Arthur et Morgane, ajoute une épée au sac d'Arthur, puis exige 1 d'un côté et 0 de l'autre. 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 weapon(): ?Weapon, une relation 0..1 comme l'épée au sol. Il demande aussi l'héritage : il attend donc le chapitre 5.

Exercices sur papier

Trois questions à traiter sans ordinateur, avant le kata.

  1. Un Dungeon reçoit ses salles en paramètre au lieu de les fabriquer. Quel losange dessine-t-on alors, et que se passe-t-il si deux donjons reçoivent la même salle ?
  2. Écrivez la propriété PHP qui traduit chacune de ces trois relations : Room o-- "0..*" Item, Hero *-- "1" Inventory, Room o-- "0..1" Item.
  3. Le héros expose son sac par 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 ?