Aller au contenu

Objets et classes

Séance 1 · ~2 périodes

À 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. justifier pourquoi une classe expose takeDamage() plutôt que setHp() ;
  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

Tableau contre objet : l'état et le comportement réunis

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

    public function hp(): int
    {
        return $this->hp;
    }

    public function maxHp(): int
    {
        return $this->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é. Il donne la valeur d'un paramètre par son nom, ce qui permet de sauter ceux qui ont une valeur par défaut.

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.

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

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 : on les lit par hp() et maxHp(), on ne les écrit jamais de l'extérieur.

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.

Visibilité : public, private, protected

public : accessible de partout. private : accessible uniquement depuis l'intérieur de la classe. protected : comme private, mais aussi depuis les classes filles. On en reparle au chapitre 5, quand les classes filles existeront.

Encapsulation : pourquoi il n'y a pas de setHp()

C'est le point le plus important du chapitre. 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

Le réflexe scolaire est d'ajouter un getter et un setter pour chaque propriété. Ne le faites pas. Un setHp() sans vérification permettrait de redonner 9999 points de vie au héros.

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

Une méthode qui lit une valeur sans permettre de l'écrire, comme hp(), reste utile : l'affichage a besoin de la barre de vie. 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 interne d'Eloquent est plus subtil que notre fabrique ; seule la notation est la même.

Statique contre instance

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. Mélanger deux langues dans un même fichier oblige à se demander, à chaque ligne, dans quelle langue on écrit. Une règle, pas de cas particulier : identifiants en anglais, commentaires 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 3 apprend à produire et à lire ces diagrammes.

@startuml
class Hero {
  +name: string
  +strength: int
  -hp: int
  -maxHp: 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.
@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 : les propriétés sont private, la classe expose les actions du jeu comme takeDamage(), pas des setters comme setHp().
  • :: 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 5, quand le héros pourra équiper une arme.
  3. Deux fabriques de plus. Ajoutez la classe Dice dans le même fichier, puis écrivez Dice::d4() et Dice::d100(). Comptez les lignes ajoutées. En deux phrases, dites ce qu'il faudrait changer pour créer n'importe quel dé sans écrire une méthode de plus.