POO en PHP : interface ou héritage avec méthode abstraite ?

Présentation de ces 2 architectures et considérations pour le choix à réaliser.

→ Corrigé du Challenge : L’entraînement de Peach et Mario

Dans le challenge MARIO_1 il était question de faire sauter de plateforme en plateforme Peach et Mario. Ils ont chacun des propriétés spécifiques mais ont surtout des actions communes ! On va donc voir dans ce corrigé comment structurer notre code à l’aide de 2 outils : les interfaces et les méthodes abstraites au travers de l’héritage.

Au programme :

Structures des classes en UML

On n’est pas ici sur un diagramme UML au sens strict mais il va permettre de visualiser comment les fichiers vont être organisés :

On retrouve donc à gauche l’organisation des fichiers avec une mécanique d’héritage et à droite l’organisation des fichiers avec une mécanique d’interface.

Quand on regarde de plus près, il y a très peu de différence. Il y a un construct côté heritage avec une propriété readonly public alors qu’il y a une méthode getName dans l’interface. On verra dans les explications ce qui justifie cette différence.

La classe Level, au milieu est identique pour les 2 mécaniques d’un point de vue structure. Dans le détails des méthodes il y aura (seulement) une différence.

Si jamais tu n’as pas encore résolu le challenge par toi même, tu peux te baser sur ce schéma pour tenter de le résoudre.

Méthode abstraite et héritage en PHP

Nos 2 classes Mario et Peach vont donc hériter d’une classe abstraite Jumper.

Une classe abstraite est une classe qui ne peut pas être instanciée, elle est utile dans le cadre de l’héritage, mais on pourrait la considérer comme « insuffisante » pour être instanciée elle même.

On ne pourra donc jamais faire « new Jumper() » au risque de déclencher une erreur fatale.

Voici le code de la classe abstraite Jumper :

abstract class Jumper
{
    public function __construct(
        public readonly string $name
    ) {}

    abstract public function canJump(int $gapLength): bool; 
}

Un peu d’explications :

  • J’ai le mot clé « abstract » avant le mot clé « class » dans la déclaration
  • Mon constructeur permet d’instancier la propriété public et readonly $name qui est une chaine de caractères
  • La méthode canJump a aussi ce mot clé « abstract ». On déclare son paramètre et son type de retour et rien d’autre. Je n’ai pas les accolades du corps de la méthode.

Maintenant la classe Mario :

class Mario extends Jumper
{
    public function __construct()
    {
        parent::__construct('M');
    }

    public function canJump(int $gapLength): bool
    {
        return $gapLength <= 3;
    }
}

Un peu d’explications :

  • La classe Mario étend donc la classe Jumper grâce au mot clé « extends ».
  • Mario a son propre constructeur, qui fait appel au constructeur de la classe parente, donc de Jumper, pour initialiser $name avec la valeur « M ».
  • Si je ne crée pas la méthode canJump dans Mario, mon IDE (puis mon code) m’affiche une erreur.
  • Je crée donc cette méthode en respectant scrupuleusement sa déclaration dans Jumper (paramètre et type de retour). Par contre je mets « ce que je veux » comme contenu. Ici je vérifie que la taille de l’espace est inférieur ou égal à 3 car Mario peut sauter les espaces de taille 1, 2 ou 3.

Voici le code de la classe Peach :

class Peach extends Jumper
{
    public function __construct()
    {
        parent::__construct('P');
    }

    public function canJump(int $gapLength): bool
    {
        return $gapLength >= 3 && $gapLength <= 5;
    }
}

Quelles sont les différences ?

  • Dans le constructeur, je retrouve un « P » au lieu du « M »
  • Le contenu de la méthode canJump est différent puisque pour Peach, les règles sont différentes, elle peut sauter les espaces de taille 3, 4 ou 5.

La classe abstraite Jumper nous a donc contraint à créer une méthode canJump dans chacune des classes qui en héritait. Mais le contenu de canJump est propre à chaque classe. Je pourrais par exemple avoir une classe Yoshi, avec un Yoshi qui ne saute que les espaces pairs :

public function canJump(int $gapLength): bool
{
    return $gapLength % 2 === 0;
}

Implémentation d’une interface en PHP

Nos 2 classes Mario et Peach vont cette fois-ci implémenter l’interface Jumper.

Qu’est ce qu’une interface ?

Une interface est un « contrat » que la classe qui l’implémente doit suivre absolument. C’est à dire que l’interface indique la ou les méthodes qui devront obligatoirement être déclarées dans les classes associées.

Voici notre interface :

interface Jumper
{
    public function canJump(int $gapLength): bool;
    public function getName(): string;
}

Un peu d’explications :

  • On utilise donc le mot clé « interface » pour créer une interface ! Il arrive que les interfaces soit suffixées « Interface » ou qu’elles aient un nom en « …able ». On aurait pu l’appeler ici « Jumpable » par exemple => « Sautable », « Sautant »
  • Une interface ne contient que les déclarations des méthodes. Ici on devra donc créer au moins 2 méthodes : canJump (comme précédemment) et getName, une méthode sans paramètre qui retourne une chaine de caractères.

Maintenant la classe Mario :

class Mario implements Jumper
{
    public function canJump(int $gapLength): bool
    {
        return $gapLength <= 3;
    }

    public function getName(): string
    {
        return 'M';
    }
}

Un peu d’explications :

  • La méthode canJump est la même que précédemment, rien à signaler
  • On a remplacé le constructeur qui gérait $name par une méthode getName. On reviendra sur ce point un peu plus tard.

Voici le code de la classe Peach :

class Peach implements Jumper
{
    public function canJump(int $gapLength): bool
    {
        return $gapLength >= 3 && $gapLength <= 5;
    }

    public function getName(): string
    {
        return 'P';
    }
}

Important à noter :

  • Peach et Mario implémentent donc la même interface, les méthodes canJump et getName ont exactement la même déclaration dans les 2 classes mais leur contenu sont différents.

On pourrait imaginer une classe Wario, avec un Wario qui ne peut sauter que les espaces correspondant à des nombres premiers :

class Wario implements Jumper
{
    public function canJump(int $gapLength): bool
    {
        // Code spécifique au comportement de Wario
        $possibleGaps = [1, 3, 5, 7, 11, 13, 17];

        return in_array($gapLength, $possibleGaps);
    }

    public function getName(): string
    {
        return 'W'; // Retour spécifique à Wario
    }
}

La classe Level, différences selon les 2 implémentations

La classe Level va se décomposer en plusieurs sections :

  • constantes et propriétés
  • constructeur et promotion de propriétés
  • méthode « run » qui va parcourir $platforms
  • un petit getteur pour récupérer la réponse

Voici la classe (dans sa version liée à l’héritage) :

class Level
{
    private const PLATFORM = 'P';

    private string $sequence = '';
    private Jumper $currentJumper;

    public function __construct(
        private string $platfoms,
        private Jumper $jumper1,
        private Jumper $jumper2
    ) {
        $this->currentJumper = $jumper1;
    }

    public function run(): void
    {
        $gaps = explode(self::PLATFORM, $this->platfoms);

        foreach ($gaps as $gap) {

            $gapLength = strlen($gap);

            if ($gapLength === 0) {
                // Bords, personne ne saute
                continue;
            }
            
            // Si les 2 peuvent sauter
            if ($this->jumper1->canJump($gapLength) && $this->jumper2->canJump($gapLength)) {
                // On incrémente la séquence avec le jumper courant
                $this->sequence .= $this->currentJumper->name;

                // On change de currentJumper
                if ($this->currentJumper->name === $this->jumper1->name) {
                    $this->currentJumper = $this->jumper2;
                } else {
                    $this->currentJumper = $this->jumper1;
                }

                continue;
            }

            if ($this->jumper1->canJump($gapLength)) {
                $this->sequence .= $this->jumper1->name;

                continue;
            }

            if ($this->jumper2->canJump($gapLength)) {
                $this->sequence .= $this->jumper2->name;

                continue;
            }
        }
    }

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

Un peu d’explications de la méthode « run » :

  • On explode $platforms selon le caractère « P » pour extraire chaque « gap »
  • Pour chacun de ces gaps, on calcule la longueur avec strlen
  • On commence par vérifier si les 2 peuvent sauter en même temps, et dans ce cas là, on fera sauter en premier le currentJumper (sauteur courant) puis on inversera, en se basant sur le name
  • On s’appuie sur « continue » pour passer à l’itération suivante dès qu’on a complété « sequence »
  • On vérifie ensuite jumper1 puis jumper2
  • Il est nécessaire de commencer par le cas où les 2 peuvent sauter en même temps. L’ordre ensuite entre le controle sur jumper1 et jumper2 n’a pas d’importance

Dans la version liée à l’interface, la différence se trouve sur ->name et ->getName(). Dans un premier temps, j’avais gardé une propriété $name dans les classes Mario et Peach. Et tout fonctionnait très bien. Mais lors de mon analyse statique avec PHPStan, j’ai eu l’erreur suivante :

« Access to an undefined property Challenges\MARIO_1_interfaces\Jumper::$name. »

En effet, dans ma classe Level, jumper1 et jumper2 sont typés « Jumper », c’est à dire que le code attend une classe qui implémente cette interface. Mais avec seulement cette déclaration, on ne peut pas deviner que les classes Mario et Peach ont une propriété $name, d’où cette alerte PHPStan.

Cette alerte peut être corrigée en spécifiant à l’interface qu’on aura une propriété $name, grâce à la PHPDoc :

/**
 * @property string $name
 */
interface Jumper
{
    public function canJump(int $gapLength): bool;
    // Et donc plus besoin de getName()
}

Mais je ne trouvais pas ça terrible… Bizarre de « rajouter » la déclaration d’une propriété qui pourrait ne pas être là… Rien ne m’y contraint dans mon code. Une interface ne pouvant pas contenir la déclaration d’une propriété, j’ai donc préféré passer par cette méthode getName(), qui ressemble à un getteur mais qui n’en est pas tout à fait un car on retourne une chaine de caractères directement et non la valeur d’une propriété.

Comment choisir entre l’implémentation d’une interface et l’utilisation d’une classe abstraite en PHP ?

Multiples interfaces

Il est important de noter qu’une classe ne peut hériter que d’une seule autre classe. Alors qu’une classe peut implémenter autant d’interfaces que nécessaires. Les interfaces seront donc à favoriser quand les méthodes à produire ont des contextes différents, on pourra ranger les méthodes dans des interfaces dédiés.

Par exemple :

interface Walkable {
    public function walk();
}

interface Jumpable {
    public function jump();
}

// Mario peut marcher ET voler, on a donc plusieurs interfaces
class Mario implements Walkable, Jumpable {
    // Implémentation des méthodes walk() et jump() ici.
}

L’héritage permet plus de choses

On l’a vu avec la mutualisation du constructeur et d’une propriété dans notre exemple. Là où une interface ne permet que des déclarations de méthode, avec l’héritage, la classe parente peut contenir d’autres éléments :

  • Propriétés
  • Méthodes précises et complètes
  • Déclaration de méthodes spécifiques à produire

Par exemple :

abstract class Enemy {
    protected int $health;

    abstract public function attack();

    public function takeDamage(int $amount) {
        // Réduire la santé de l'ennemi ici.
        $this->health -= $amount; // La même mécanique pour tous les enfants
    }
}

class Koopa extends Enemy {
    public function attack() {
        // Attaque spécifique de Koopa ici.
    }
}

class PiranhaPlant extends Enemy {
    public function attack() {
        // Attaque spécifique de PiranhaPlant ici.
    }
}

On peut cumuler héritage et interface(s)

Et oui, on n’a pas toujours besoin de choisir ! On peut cumuler l’héritage (avec classes abstraites ou non) et l’implémentation de une ou plusieurs interfaces.

Un dernier exemple avec le fameux poisson volant du monde de Mario.

C’est un ennemi, qui peut voler et nager :

// Interfaces
interface Flyable {
    public function fly();
}

interface Swimable {
    public function swim();
}

// Classe parente
// Cf. exemple précédent

// Classe qui hérite et implémente des interfaces
class FlyingFish extends Enemy implements Flyable, Swimable {
    
    public function attack() {
        // Attaque spécifique du FlyingFish.
    }

    public function fly() {
        // Implémentation de la capacité de voler.
    }

    public function swim() {
        // Implémentation de la capacité de nager.
    }
}

Conclusion

Quelques éléments de réflexion peut choisir la bonne architecture :

  • Pensez au contexte ! Si les méthodes à implémenter sont décorrélées du contexte de base de la classe qui va les implémenter, et que ces méthodes peuvent se retrouver plein de classes différentes, alors les interfaces sont la bonne solution.
  • Au contraire, si le contexte est lié, et qu’il y a à la fois des méthodes à implémenter, mais surtout des mécaniques à mutualiser, l’héritage est la solution logique à utiliser.

Dans cet article on a parlé de :

Et n’hésite pas à tester tout ça sur un autre challenge de code 😉

Le code complet sur Github :


Qui a codé ce superbe contenu ?

Keep learning

Autres contenus à découvrir


Ta newsletter chaque mois

Corrigés, challenges, actualités, veille technique... aucun spam.