Aller au contenu

Objets et classes

Séance 1 · ~2 périodes

Slides du chapitre (PDF)PDF

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

  1. définir un objet comme la réunion d'un état et d'un comportement ;
  2. écrire une classe PHP moderne : propriétés typées, constructeur avec promotion, readonly, declare(strict_types=1) ;
  3. expliquer pourquoi une classe garde ses données pour elle et expose des actions comme takeDamage() ;
  4. distinguer une méthode d'instance d'une méthode statique et écrire une fabrique Dice::d6().

1. Le problème : un héros en tableau associatif

On veut un héros de jeu de rôle : un nom, des points de vie, une force. Il encaisse des coups, il se soigne, il peut mourir. Avec ce que vous savez déjà, cela donne un tableau et trois fonctions.

<?php

declare(strict_types=1);

$hero = ['name' => 'Arthur', 'hp' => 10, 'maxHp' => 10, 'strength' => 2];

function takeDamage(array $hero, int $amount): array
{
    $hero['hp'] = max(0, $hero['hp'] - $amount);

    return $hero;
}

function heal(array $hero, int $amount): array
{
    $hero['hp'] = min($hero['maxHp'], $hero['hp'] + $amount);

    return $hero;
}

function describe(array $hero): string
{
    return "{$hero['name']} ({$hero['hp']}/{$hero['maxHp']} PV)";
}

$hero = takeDamage($hero, 3);
$hero = heal($hero, 1);
echo describe($hero), PHP_EOL;

$hero['hp'] = 9999;
$hero['maxHP'] = 12;
echo describe($hero), PHP_EOL;
Arthur (8/10 PV)
Arthur (9999/10 PV)

Le résultat est correct. Ce code a pourtant quatre défauts. Ce sont les quatre que la POO corrige.

  1. Les données et les fonctions qui les manipulent sont séparées. Rien n'indique que heal() concerne le héros. Avec quarante fonctions dans le fichier, plus personne ne sait ce qui va avec quoi.
  2. Il faut réassigner à chaque appel : $hero = takeDamage($hero, 3);. Si vous oubliez $hero =, les points de vie ne changent pas et rien ne le signale.
  3. Rien n'est protégé. La ligne $hero['hp'] = 9999; passe. Les règles du jeu sont : jamais sous 0, jamais au-dessus de maxHp. Elles ne tiennent que si tout le monde passe par ces trois fonctions.
  4. Rien n'est vérifié. Une affectation à $hero['maxHP'], avec un H majuscule, ajoute une clé au tableau au lieu de modifier maxHp. Le bug apparaîtra plus tard, ailleurs.

2. La même chose, en objet

Un tableau accepte tout, une classe garde la main

Deux mots avant de lire le code qui suit. Une propriété est une variable qui appartient à l'objet : elle remplace ici une case du tableau. Une méthode est une fonction qui appartient à l'objet : elle remplace ici une des trois fonctions. Le reste n'est que de la syntaxe.

<?php

declare(strict_types=1);

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 isAlive(): bool
    {
        return $this->hp > 0;
    }

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

La classe Hero annotée pièce par pièce

On s'en sert comme ceci :

$hero = new Hero('Arthur');
$hero->takeDamage(3);
$hero->heal(1);
echo $hero, PHP_EOL;

$hero->takeDamage(50);
echo $hero, PHP_EOL;
echo $hero->isAlive() ? 'vivant' : 'mort', PHP_EOL;
Arthur (8/10 PV)
Arthur (0/10 PV)
mort

Même résultat qu'avec le tableau. Mais $hero est maintenant un seul bloc : ses données et les méthodes qui les modifient sont au même endroit. On ne réassigne plus, car $hero->takeDamage(3) modifie le héros lui-même.

Note

Un objet réunit un état et un comportement. L'état, ce sont ses propriétés : name, hp, maxHp, strength. Le comportement, ce sont ses méthodes : takeDamage(), heal(), isAlive(). Tout le vocabulaire qui suit sert à décrire cette phrase.

3. Le vocabulaire

class : le moule, new : la pièce

class Hero { … } ne crée aucun héros. C'est un moule : la description des données et des méthodes que possède tout héros. new Hero('Arthur') fabrique une instance, c'est-à-dire un héros concret, avec ses propres valeurs, en mémoire.

Avec un seul moule, vous fabriquez autant d'objets que vous voulez. Chacun a son propre état.

$arthur = new Hero('Arthur');
$morgane = new Hero('Morgane', maxHp: 8, strength: 3);

$arthur->takeDamage(3);

echo $arthur, PHP_EOL;
echo $morgane, PHP_EOL;
Arthur (7/10 PV)
Morgane (8/8 PV)

Les points de vie de Morgane n'ont pas changé : chaque héros possède ses propres valeurs.

Au passage, maxHp: 8 est un argument nommé : on écrit le nom du paramètre, puis sa valeur. Le constructeur de Hero attend trois paramètres. Le premier, $name, est obligatoire. Les deux autres ont une valeur par défaut : $maxHp = 10 et $strength = 2. Si on ne les donne pas, PHP prend ces valeurs. Arthur n'a reçu que son nom : il a donc 10 points de vie et 2 de force. Pour Morgane, on veut 8 et 3, on nomme les deux. Et si on ne voulait changer que la force, new Hero('Morgane', strength: 3) suffit : maxHp garde sa valeur par défaut, sans qu'on ait à écrire 10 au milieu.

Classe et instances

$this : l'objet en cours

Dans une méthode, $this désigne l'objet sur lequel la méthode a été appelée. Quand vous écrivez $arthur->takeDamage(3), PHP exécute le corps de takeDamage avec $this qui vaut $arthur. C'est ce qui permet d'écrire la méthode une seule fois pour tous les héros du jeu.

Propriétés typées et declare(strict_types=1)

private int $hp; déclare une propriété qui n'accepte qu'un entier. La ligne declare(strict_types=1), en tête de chaque fichier, dit à PHP de refuser les conversions silencieuses.

declare(strict_types=1);

function takeDamage(int $amount): int
{
    return $amount;
}

takeDamage('3');
Fatal error: Uncaught TypeError: takeDamage(): Argument #1 ($amount)
must be of type int, string given

Sans cette ligne, PHP accepterait '3' et le convertirait en 3 sans rien dire. Le typage strict fait apparaître l'erreur là où elle est, pas cent lignes plus loin.

En PHP, les types ne sont pas obligatoires. Vous pouvez écrire function takeDamage($amount) et private $hp;, et ça tourne. Mais alors rien n'empêche de passer une chaîne, un tableau ou null à la place d'un entier. L'erreur ne se voit pas à l'appel : elle se voit plus tard, quand un calcul donne un résultat absurde ou qu'un null fait planter une autre fonction, parfois dans un autre fichier. Un type, c'est une vérification gratuite que PHP fait pour vous à chaque appel, et une information pour celui qui lit : int $amount dit ce que la fonction attend sans qu'on ait à ouvrir son code. Votre éditeur s'en sert aussi pour vous prévenir avant même de lancer le programme. Dans ce cours, la règle est simple : un type sur chaque propriété, chaque paramètre et chaque valeur de retour, partout où c'est possible. Laravel est écrit comme ça.

Le constructeur, et la promotion

Le constructeur est la méthode appelée automatiquement par new. Il initialise les propriétés de l'objet. Avant PHP 8, il fallait écrire chaque propriété trois fois.

// L'ancienne façon, celle que vous croiserez dans du code plus vieux.
class Hero
{
    private string $name;

    public function __construct(string $name)
    {
        $this->name = $name;
    }
}

Avant PHP 8, le nom est écrit trois fois ; depuis PHP 8.0, une seule ligne

Note

Histoire de PHP. Le langage s'est équipé par étapes. PHP 5.4, en 2012, ajoute la syntaxe courte des tableaux : [] remplace array(), que vous verrez encore dans du code ancien. PHP 7.0, en 2015, ajoute les types scalaires sur les paramètres et les retours, ainsi que declare(strict_types=1). PHP 7.4, en 2019, ajoute les types sur les propriétés. PHP 8.0, en 2020, ajoute la promotion de constructeur et les arguments nommés. PHP 8.1, en 2021, ajoute readonly. Un fichier sans types ni promotion n'est pas du mauvais code : il est plus ancien que ces versions.

La promotion de constructeur fait les trois opérations en une.

public function __construct(
    public readonly string $name,
    int $maxHp = 10,
    public readonly int $strength = 2,
) {
    $this->maxHp = $maxHp;
    $this->hp = $maxHp;
}

Un paramètre est une valeur reçue par une méthode, le temps de son exécution. Une propriété, elle, reste dans l'objet. Un mot de visibilité devant un paramètre, comme public devant $name, crée la propriété correspondante et lui donne la valeur reçue. C'est le cas de name et de strength. Sans ce mot, $maxHp reste un simple paramètre, et le constructeur doit le recopier lui-même.

Le corps du constructeur sert à ce qui se calcule. Ici, hp reçoit la valeur de maxHp : le héros commence avec tous ses points de vie. C'est une règle du jeu, pas un paramètre. hp et maxHp sont l'affaire de la classe : tout le monde peut les lire, $hero->hp, mais seule la classe les écrit. C'est ce que dit public private(set), expliqué juste après.

readonly : ce qui ne change jamais

Un héros ne change pas de nom en cours de partie. readonly l'écrit dans le code : la propriété reçoit sa valeur une fois, dans le constructeur, et ne peut plus être modifiée ensuite. Toute tentative est refusée par PHP, avec une erreur claire.

À quoi ça sert ? À rendre impossible une catégorie de bugs : la valeur qui change par accident, quelque part au milieu du programme, sans que personne ne l'ait voulu. Avec readonly, vous n'avez plus à vous demander « qui a modifié le nom ? » : personne, c'est garanti. Cela rend aussi la propriété publique sans risque. name est public readonly : tout le monde peut la lire avec $arthur->name, personne ne peut l'écrire. Réflexe à prendre : dès qu'une valeur est fixée à la création et ne doit plus bouger, elle est readonly. Le nombre de faces d'un dé, le nom d'un héros, la date de création d'une commande.

Visibilité : public, private, protected

Chaque propriété et chaque méthode porte un mot de visibilité. Il répond à une question : qui a le droit d'y toucher ?

« L'intérieur de la classe », c'est le code écrit entre les accolades de class Hero { … }, donc le corps de ses méthodes, là où on écrit $this->hp. « L'extérieur », c'est tout le reste : le script qui fait new Hero('Arthur'), une autre classe, un test.

class Hero
{
    public string $name = 'Arthur';
    private int $hp = 10;

    public function takeDamage(int $amount): void
    {
        $this->hp = max(0, $this->hp - $amount); // intérieur : autorisé
    }
}

$arthur = new Hero();
echo $arthur->name, PHP_EOL; // extérieur, public : autorisé
$arthur->takeDamage(3);     // extérieur, méthode publique : autorisé
echo $arthur->hp;           // extérieur, private : refusé
Arthur
Fatal error: Uncaught Error: Cannot access private property Hero::$hp
  • public : accessible de partout, à l'intérieur comme à l'extérieur. C'est le cas de name et de takeDamage().
  • private : accessible uniquement depuis l'intérieur de la classe. hp se lit et s'écrit dans les méthodes de Hero, nulle part ailleurs.
  • protected : comme private, plus les classes qui héritent de celle-ci. On en reparle au chapitre 6, quand ces classes existeront.

Il manque un cas. L'affichage a besoin de lire hp : la barre de vie, le Arthur (8/10 PV). Mais personne ne doit l'écrire. Depuis PHP 8.4, une propriété peut avoir deux visibilités, une pour la lecture et une pour l'écriture :

public private(set) int $hp;

Se lit « publique en lecture, privée en écriture » : $arthur->hp fonctionne partout, $arthur->hp = 9999 ne fonctionne que dans les méthodes de Hero. C'est la déclaration utilisée pour hp et maxHp dans la classe du chapitre. Le chapitre 3 y revient en détail.

Encapsulation : la classe garde la main sur ses données

C'est le point le plus important du chapitre. Reprenez le tableau de la partie 1 : n'importe quelle ligne du programme pouvait écrire $hero['hp'] = 9999;, et la règle « jamais au-dessus de maxHp » ne tenait que si tout le monde pensait à passer par heal(). Dans un vrai projet, tout le monde n'y pense pas.

L'encapsulation règle ce problème. Les données sont private, donc inaccessibles de l'extérieur. La classe expose à la place des actions : takeDamage(), heal(), isAlive(). Chaque action contient sa règle, écrite une seule fois. Trois avantages concrets :

  • les règles sont respectées partout, puisque le seul chemin vers hp passe par des méthodes qui les vérifient ;
  • une faute de frappe devient une erreur immédiate : $arthur->hpp n'existe pas, PHP le dit tout de suite, au lieu de créer une case en silence comme avec le tableau ;
  • vous pouvez changer l'intérieur sans casser l'extérieur : si demain les points de vie sont stockés autrement, seule la classe Hero change, pas les cinquante fichiers qui l'utilisent.

Voici deux écritures que PHP refuse maintenant.

$arthur->hp = 9999;      // propriété privée
$arthur->name = 'Kevin';  // propriété readonly

Deux écritures refusées : propriété privée, propriété readonly

Chaque essai doit être lancé seul, car la première erreur arrête le script. Voici les deux messages.

Fatal error: Uncaught Error: Cannot access private property Hero::$hp
Fatal error: Uncaught Error: Cannot modify readonly property Hero::$name

Le 9999 de la partie 1 est devenu impossible. La classe est le seul endroit où hp change : c'est cela, l'encapsulation.

Attention

Vous croiserez souvent deux mots. Un getter est une méthode qui se contente de lire une propriété, comme getHp() ou hp(). Un setter est une méthode qui se contente de l'écrire, comme setHp(int $hp). Le réflexe scolaire, hérité de Java, est d'en ajouter une paire pour chaque propriété. Ne le faites pas. Le getter est inutile depuis que private(set) ouvre la lecture sans ouvrir l'écriture. Le setter est dangereux : un setHp() sans vérification permettrait de redonner 9999 points de vie au héros, et on aurait recréé le tableau, avec plus de lignes.

// Mauvais : la règle « jamais sous 0 » n'est plus nulle part.
public function setHp(int $hp): void { $this->hp = $hp; }

// Bon : l'action du jeu, avec sa règle à l'intérieur.
public function takeDamage(int $amount): void { $this->hp = max(0, $this->hp - $amount); }

Ici, les méthodes publiques décrivent les actions du jeu : takeDamage, heal, plus tard equip et drink. En lisant vos méthodes publiques, on doit voir les règles du jeu, pas la liste de vos propriétés.

Lire une valeur de l'extérieur reste utile : l'affichage a besoin de la barre de vie. C'est pour cela que hp est public private(set) et pas private. Ce qu'on refuse, c'est l'écriture non contrôlée.

__toString : afficher un objet

echo $hero sur un objet est normalement une erreur. Déclarez __toString(): string et PHP l'appelle chaque fois que votre objet est utilisé comme du texte : echo, concaténation, interpolation dans une chaîne. C'est ce qui produit Arthur (8/10 PV) dans les sorties ci-dessus.

PHP réserve certains noms commençant par deux tirets bas, comme __construct et __toString, à des méthodes qu'il appelle automatiquement dans des situations précises. On les appelle les méthodes magiques. Ces deux-là suffisent pour l'instant.

static : parler à la classe plutôt qu'à un objet

Deuxième classe du Donjon, le dé.

<?php

declare(strict_types=1);

class Dice
{
    public function __construct(
        public readonly int $sides,
    ) {
    }

    public static function d6(): self
    {
        return new self(6);
    }

    public static function d20(): self
    {
        return new self(20);
    }

    public function roll(): int
    {
        return random_int(1, $this->sides);
    }
}

$d6 = Dice::d6();
echo $d6->sides, PHP_EOL;
echo Dice::d20()->sides, PHP_EOL;
6
20

roll() a besoin d'un dé précis : sans le nombre de faces, impossible de tirer. C'est une méthode d'instance, on l'appelle avec la flèche -> sur un objet. L'appel $d6->roll() renvoie un entier entre 1 et 6, différent à chaque exécution.

d6() n'a besoin d'aucun dé existant : elle en fabrique un. C'est une méthode statique, on l'appelle avec :: sur la classe. Ici, self désigne Dice : écrire new self(6) revient à écrire new Dice(6).

Pourquoi une fabrique plutôt que new Dice(6) partout ? Parce que Dice::d6() dit ce qu'on veut obtenir. Le d6 et le d20 sont des objets du jeu, pas deux nombres quelconques.

Note

Retenez la règle de reconnaissance : :: parle à la classe, -> parle à un objet. Vous retrouverez cette notation dans Laravel, avec Post::latest() d'un côté et $post->title de l'autre. Le mécanisme derrière Post::latest() est plus subtil que notre fabrique, mais la notation se lit de la même façon.

Deux points parlent à la classe, la flèche parle à un objet

4. Pourquoi l'anglais dans le code ?

Les identifiants de ce cours sont en anglais, les commentaires et les explications en français. Laravel est écrit en anglais : ses classes Model, Controller, User, Request, ses méthodes hasMany, latest, firstOrFail, sa documentation et ses messages d'erreur. Il y a une raison plus concrète que le confort de lecture. Laravel devine beaucoup de choses à partir des noms : un modèle Post cherche une table posts, un modèle Category une table categories, une relation comments() une colonne post_id. Pour cela, il applique les règles du pluriel anglais. Un modèle Article deviendrait articles, ça passe par chance. Mais Cheval ne deviendra jamais chevaux, et Categorie donnera categories pendant que votre table s'appelle categorie. Résultat : des bugs entre le code et la base de données, difficiles à repérer. Une règle, pas de cas particulier : identifiants en anglais, commentaires et textes affichés en français.

5. Hero et Dice en UML

Un dessin se lit plus vite qu'un fichier. Voici les deux classes du chapitre en UML. Le chapitre 4 apprend à produire et à lire ces diagrammes.

@startuml
class Hero {
  +name: string
  +strength: int
  -hp: int
  -maxHp: int
  +takeDamage(amount: int): void
  +heal(amount: int): void
  +isAlive(): bool
  +__toString(): string
}
class Dice {
  +sides: int
  +{static} d6(): Dice
  +{static} d20(): Dice
  +roll(): int
}
note right of Dice : d6() est soulignée : c'est une méthode statique.
note left of Hero : hp est lisible de partout mais\nseule la classe l'écrit : en UML, un « - ».
@enduml

À retenir

  • Un objet réunit un état, ses propriétés, et un comportement, ses méthodes. La classe est le moule, new fabrique l'instance, $this désigne l'objet en cours.
  • declare(strict_types=1), types partout, promotion de constructeur, readonly : quatre habitudes qui transforment des bugs silencieux en erreurs immédiates.
  • Encapsulation : personne n'écrit dans les propriétés de l'extérieur (private, ou public private(set) quand il faut pouvoir lire). La classe expose les actions du jeu comme takeDamage(). Les règles sont écrites une fois, au seul endroit où les données changent.
  • :: parle à la classe, -> parle à un objet. Une fabrique statique comme Dice::d6() dit ce qu'on veut obtenir.
  • Identifiants en anglais, commentaires en français : c'est la langue de Laravel.

Exercices

Les trois exercices tiennent dans un seul fichier. Créez hero.php, recopiez-y la classe Hero du chapitre, et lancez le tout avec php hero.php. Aucun outil supplémentaire n'est nécessaire : Composer et les tests arrivent au chapitre 2.

  1. Deux héros indépendants. Créez Arthur avec ses valeurs par défaut et Morgane avec maxHp: 8 et strength: 3. Infligez 4 dégâts à Arthur seulement, soignez Morgane de 5, puis affichez les deux. Vérifiez que les points de vie de l'un n'ont pas bougé quand l'autre a été touché, et expliquez pourquoi en une phrase.
  2. Une action de plus. Ajoutez à Hero une méthode attack(): int qui renvoie la force du héros. Affichez le résultat pour Arthur et pour Morgane. Cette méthode deviendra intéressante au chapitre 6, quand le héros pourra équiper une arme.
  3. Deux fabriques de plus. Ajoutez la classe Dice dans le même fichier, puis écrivez deux fabriques Dice::d4() et Dice::d100() sur le modèle de d6(). Lancez chaque dé dix fois et affichez les résultats. Puis répondez en deux phrases : new Dice(4) fait exactement la même chose que Dice::d4(), alors pourquoi garder les fabriques ? Indice : relisez la fin de la partie 3, et pensez à celui qui lira Dice::d4() dans six mois.