PHP

Utilisation des enums avec PHP 8.1

Expérimentation des enums, la nouvelle structure disponible depuis PHP 8.1

→ Corrigé du Challenge : Le train va t-il arriver à l’heure ?

Dans ce corrigé, basé sur le challenge TRAIN_1, je vais tester la nouvelle structure disponible depuis PHP 8.1 : les enums.

On commencera par corriger le challenge en implémentant un enum. Puis je donnerais mon avis sur cette structure ainsi que quelques exemples d’utilisation.

Mise en pratique d’un enum

Pour faciliter la lecture de l’article, le code complet peut être consulté sur Github.

Le challenge consistait à calculer le temps d’un trajet d’un train selon les évènements qu’il rencontrait. Pour tester les enums, je vais représenter les évènements (Events) de 2 façons :

  • Avec une classe « classique »
  • Avec un enum

case VS constantes de classes

enum Events: string
{
    case TRAIN_STATION = 'T';
    case POWER_BREAK = 'P';
    case NATURAL_INCIDENT = 'N';
}
class Events
{
    public const TRAIN_STATION = 'T';
    public const POWER_BREAK = 'P';
    public const NATURAL_INCIDENT = 'N';
}

Là j’ai choisis d’utiliser un enum typé, en attribuant une valeur à chaque case. Mais cela n’est pas obligatoire. Le fait de déclarer les différents case peut suffire. Important, si on utilise un enum typé comme ici, chaque case doit avoir une valeur du même type.

Dans ma class, j’ai créé des constantes publiques.

Jusque là, pas trop de différence.

Méthodes

Chaque évènement a une distance et une vitesse associées. Je dois chercher combien de temps dure un évènement. Je vais créer 3 méthodes. D’abord pour structurer les valeurs de l’énoncé, et une pour faire le calcul.

public function distance(): int
{
    return match($this) {
        self::TRAIN_STATION => 10,
        self::POWER_BREAK => 10,
        self::NATURAL_INCIDENT => 5,
        default => 0
    };
}

public function speed(): int
{
    return match($this) {
        self::TRAIN_STATION => 50,
        self::POWER_BREAK => 5,
        self::NATURAL_INCIDENT => 10,
        default => 1
    };
}

public function timeInSeconds(): float
{
    return $this->distance() / $this->speed() * 3600;
}
public static function distance(string $event): int
{
    $distances = [
        self::TRAIN_STATION => 10,
        self::POWER_BREAK => 10,
        self::NATURAL_INCIDENT => 5
    ];

    return $distances[$event] ?? 0;
}

public static function speed(string $event): int
{
    $speeds = [
        self::TRAIN_STATION => 50,
        self::POWER_BREAK => 5,
        self::NATURAL_INCIDENT => 10
    ];

    return $speeds[$event] ?? 1;
}

public static function timeInSeconds(string $event): float
{
    return self::distance($event) / self::speed($event) * 3600;
}

Les méthodes se ressemblent.

Dans la classe, j’utilise des tableaux, dans l’enum, j’utilise match($this). La logique est la même.

En y regardant de plus près, la principale différence se trouve dans le fait que dans la class, chaque méthode a un argument, alors que celles de l’enum n’en ont pas. En effet, quand on est dans un enum, on est forcément sur un des cas déclarés au départ. Un peu comme si j’avais conservé le type d’évènement (TRAIN_STATION, POWER_BREAK ou NATURAL_INCIDENT) dans une propriété dédiée.

On aurait pu créer cette propriété privée dans la classe, mais on aurait perdu en comparaison avec l’enum. On va le voir avec l’utilisation dans le code principal du challenge.

Résolution du challenge

class Travel
{
    private const SPEED_TRAIN_REGULAR = 200;
    private const LINE_REGULAR = '_';

    private int $nbTrainStations;
    private int $nbPowerBreaks;
    private int $nbNaturalIncidents;

    private string $events;
    private int $totalDistance;

    public function __construct(int $totalDistance, string $events)
    {
        $this->events = $events;
        $this->totalDistance = $totalDistance;

        $this->nbTrainStations = substr_count($events, Events::TRAIN_STATION->value);
        $this->nbTrainStations--; // Pour gérer la gare de début et la gare de fin

        $this->nbPowerBreaks = substr_count($events, Events::POWER_BREAK->value);
        $this->nbNaturalIncidents = substr_count($events, Events::NATURAL_INCIDENT->value);
    }

    public function calculate(): float
    {
        $totalDistanceWithoutEvents = $this->totalDistance;
        $totalTime = 0;

        // TRAIN STATION
        $totalDistanceWithoutEvents -= $this->nbTrainStations * Events::TRAIN_STATION->distance();
        $totalTime += $this->nbTrainStations * Events::TRAIN_STATION->timeInSeconds();

        // POWER BREAKS
        $totalDistanceWithoutEvents -= $this->nbPowerBreaks * Events::POWER_BREAK->distance();
        $totalTime += $this->nbPowerBreaks * Events::POWER_BREAK->timeInSeconds();

        // NATURAL INCIDENT
        $totalDistanceWithoutEvents -= $this->nbNaturalIncidents * Events::NATURAL_INCIDENT->distance();
        $totalTime += $this->nbNaturalIncidents * Events::NATURAL_INCIDENT->timeInSeconds();

        // LE RESTE
        $totalTime += $totalDistanceWithoutEvents / self::SPEED_TRAIN_REGULAR * 3600;

        return $totalTime;
    }
}
class Travel
{
    private const SPEED_TRAIN_REGULAR = 200;
    private const LINE_REGULAR = '_';

    private int $nbTrainStations;
    private int $nbPowerBreaks;
    private int $nbNaturalIncidents;

    private string $events;
    private int $totalDistance;

    public function __construct(int $totalDistance, string $events)
    {
        $this->events = $events;
        $this->totalDistance = $totalDistance;

        $this->nbTrainStations = substr_count($events, Events::TRAIN_STATION);
        $this->nbTrainStations--; // Pour gérer la gare de début et la gare de fin
        
        $this->nbPowerBreaks = substr_count($events, Events::POWER_BREAK);
        $this->nbNaturalIncidents = substr_count($events, Events::NATURAL_INCIDENT);
    }

    public function calculate(): float
    {
        $totalDistanceWithoutEvents = $this->totalDistance;
        $totalTime = 0;

        // TRAIN STATION
        $totalDistanceWithoutEvents -= $this->nbTrainStations * Events::distance(Events::TRAIN_STATION);
        $totalTime += $this->nbTrainStations * Events::timeInSeconds(Events::TRAIN_STATION);

        // POWER BREAKS
        $totalDistanceWithoutEvents -= $this->nbPowerBreaks * Events::distance(Events::POWER_BREAK);
        $totalTime += $this->nbPowerBreaks * Events::timeInSeconds(Events::POWER_BREAK);

        // NATURAL INCIDENT
        $totalDistanceWithoutEvents -= $this->nbNaturalIncidents * Events::distance(Events::NATURAL_INCIDENT);
        $totalTime += $this->nbNaturalIncidents * Events::timeInSeconds(Events::NATURAL_INCIDENT);

        // LE RESTE
        $totalTime += $totalDistanceWithoutEvents / self::SPEED_TRAIN_REGULAR * 3600;

        return $totalTime;
    }
}

Dans le constructeur, je cherche à compter combien d’évènements de chaque type le train rencontre.

Avec la classe, j’utilise :

Events::TRAIN_STATION

Et avec l’enum :

Events::TRAIN_STATION->value

On a donc ce petit « ->value » qui fait partie de la structure enum, je n’ai pas eu besoin de préciser « value » dans mon enum pour que cette propriété soit disponible. On verra qu’il y a d’autres éléments disponibles de base dans les enums.

Dans la méthode calculate, je cherche, pour chaque type d’évènement, la distance parcourue, et le temps associé.

Avec la classe, j’utilise les méthodes de cette façon :

Events::timeInSeconds(Events::TRAIN_STATION)

Et avec l’enum :

Events::TRAIN_STATION->timeInSeconds()

Dans la classe, il m’est nécessaire de préciser l’évènement en paramètre et donc de rappeler « Events:: ». Dans l’enum, on précise aussi l’évènement, mais je trouve que les choses s’enchaînent de façon + naturelle.

Les « cases » d’un enum

On va voir qu’on peut passer en revue tous les « case » d’un enum.

Pour mettre en pratique, on va créer une méthode qui retourne les noms des évènements.

public function name(): string
    {
        return match($this) {
            self::TRAIN_STATION => 'TrainStation',
            self::POWER_BREAK => 'PowerBreak',
            self::NATURAL_INCIDENT => 'NaturalIncident',
            default => ''
        };
    }
public static function names(): array
    {
        return [
            self::TRAIN_STATION => 'TrainStation',
            self::POWER_BREAK => 'PowerBreak',
            self::NATURAL_INCIDENT => 'NaturalIncident'
        ];
    }

Important : ces méthodes sont différentes. Pour l’enum, je retourne le nom correspondant. Pour la classe, je retourne la liste des noms. Le typage est d’ailleurs différent.

Et on va maintenant gérer nos propriétés dynamiquement.

Remarque : le code qui suit est assez particulier, le but est de présenter la fonctionnalité « cases » et de jouer avec des propriétés dynamiques. Mais il est trop complexe pour pas grand chose. Je remets aussi le constructeur (inchangé) car il est important pour visualiser toute la logique d’un coup.

class Travel
{
    private const SPEED_TRAIN_REGULAR = 200;
    private const LINE_REGULAR = '_';

    private int $nbTrainStations;
    private int $nbPowerBreaks;
    private int $nbNaturalIncidents;

    private string $events;
    private int $totalDistance;

    public function __construct(int $totalDistance, string $events)
    {
        $this->events = $events;
        $this->totalDistance = $totalDistance;

        $this->nbTrainStations = substr_count($events, Events::TRAIN_STATION->value);
        $this->nbTrainStations--; // Pour gérer la gare de début et la gare de fin

        $this->nbPowerBreaks = substr_count($events, Events::POWER_BREAK->value);
        $this->nbNaturalIncidents = substr_count($events, Events::NATURAL_INCIDENT->value);
    }

    public function calculate2(): float
    {
        $totalDistanceWithoutEvents = $this->totalDistance;
        $totalTime = 0;

        foreach (Events::cases() as $event) {
            $name = 'nb' . $event->name() . 's';

            $totalDistanceWithoutEvents -= $this->$name * $event->distance();
            $totalTime += $this->$name * $event->timeInSeconds();
        }
        
        // LE RESTE
        $totalTime += $totalDistanceWithoutEvents / self::SPEED_TRAIN_REGULAR * 3600;

        return $totalTime;
    }
}
class Travel
{
    private const SPEED_TRAIN_REGULAR = 200;
    private const LINE_REGULAR = '_';

    private int $nbTrainStations;
    private int $nbPowerBreaks;
    private int $nbNaturalIncidents;

    private string $events;
    private int $totalDistance;

    public function __construct(int $totalDistance, string $events)
    {
        $this->events = $events;
        $this->totalDistance = $totalDistance;

        $this->nbTrainStations = substr_count($events, Events::TRAIN_STATION);
        $this->nbTrainStations--; // Pour gérer la gare de début et la gare de fin
        
        $this->nbPowerBreaks = substr_count($events, Events::POWER_BREAK);
        $this->nbNaturalIncidents = substr_count($events, Events::NATURAL_INCIDENT);
    }

    public function calculate2(): float
    {
        $totalDistanceWithoutEvents = $this->totalDistance;
        $totalTime = 0;

        foreach (Events::names() as $event => $eventName) {
            $name = 'nb' . $eventName . 's';

            $totalDistanceWithoutEvents -= $this->$name * Events::distance($event);
            $totalTime += $this->$name * Events::timeInSeconds($event);
        }

        // LE RESTE
        $totalTime += $totalDistanceWithoutEvents / self::SPEED_TRAIN_REGULAR * 3600;

        return $totalTime;
    }
}

ça va ? Tu es toujours avec moi ? 🙂

Pour parcourir les évènements, j’ai donc fait :

Avec la classe :

foreach (Events::names() as $event => $eventName) {
    $name = 'nb' . $eventName . 's';
    // ...
}

Avec l’enum :

foreach (Events::cases() as $event) {
    $name = 'nb' . $event->name() . 's';
    // ...
}

Tout comme « ->value » plus haut, je dispose d’une méthode « cases » qui va retourner un tableau contenant tous les cas disponibles. Dans la classe, la méthode names renvoie bien un tableau de chaines de caractères. Alors que dans l’enum, la méthode cases renvoie un tableau d’enum Events. C’est pour ça que je peux appeler la méthode name sur chaque $event.

Méthode « from » d’un enum

Une autre possibilité consiste à « rechercher » un cas disponible dans un enum. On utilisera pour ça la méthode from(), disponible par défaut, comme « ->value » ou « cases() ».

Si on avait un code plutôt complexe juste au dessus, on va ici le simplifier. Events ne change pas mais la class Travel oui.

class Travel
{
    private const SPEED_TRAIN_REGULAR = 200;
    private const LINE_REGULAR = '_';

    private string $events;
    private int $totalDistance;

    public function __construct(int $totalDistance, string $events)
    {
        $this->events = $events;
        $this->totalDistance = $totalDistance;
    }

    public function calculate3(): float
    {
        $totalDistanceWithoutEvents = $this->totalDistance;
        $totalTime = 0;

        // On retire la première gare
        foreach (str_split(substr($this->events, 1)) as $event) {

            if ($event === self::LINE_REGULAR) {
                continue;
            }

            $totalDistanceWithoutEvents -= Events::from($event)->distance();
            $totalTime += Events::from($event)->timeInSeconds();
        }

        // LE RESTE
        $totalTime += $totalDistanceWithoutEvents / self::SPEED_TRAIN_REGULAR * 3600;

        return $totalTime;
    }
}
class Travel
{
    private const SPEED_TRAIN_REGULAR = 200;
    private const LINE_REGULAR = '_';

    private string $events;
    private int $totalDistance;

    public function __construct(int $totalDistance, string $events)
    {
        $this->events = $events;
        $this->totalDistance = $totalDistance;
    }

    public function calculate3(): float
    {
        $totalDistanceWithoutEvents = $this->totalDistance;
        $totalTime = 0;

        // On retire la première gare
        foreach (str_split(substr($this->events, 1)) as $event) {

            if ($event === self::LINE_REGULAR) {
                continue;
            }

            $totalDistanceWithoutEvents -= Events::distance($event);
            $totalTime += Events::timeInSeconds($event);
        }

        // LE RESTE
        $totalTime += $totalDistanceWithoutEvents / self::SPEED_TRAIN_REGULAR * 3600;

        return $totalTime;
    }
}

Après tout, pourquoi chercher à compter les évènements quand on peut parcourir le trajet et compter au fur et à mesure. Important d’enlever la première gare cependant.

Les lignes qui diffèrent :

Avec la classe :

$totalDistanceWithoutEvents -= Events::distance($event);
$totalTime += Events::timeInSeconds($event);

Avec l’enum :

$totalDistanceWithoutEvents -= Events::from($event)->distance();
$totalTime += Events::from($event)->timeInSeconds();

Tout à l’heure on avait quelque chose de plus concis avec l’enum, cette fois-ci on rajoute cette méthode « from ».

Important :

  • Si la correspondance n’existe pas, from() renvoie une erreur.
  • Si la valeur passée dans from n’est pas typée comme l’enum, une erreur est également renvoyée.
  • Il existe une méthode « tryFrom » pour gérer manuellement l’erreur de correspondance.

Conclusion sur l’usage d’un enum

Note technique : je mets en opposition tout au long de cet article classe et enum. Pour autant, un enum peut être considéré comme une classe particulière. D’ailleurs, beaucoup des principes des classes s’appliquent aux enums (cf. note à la fin).

Au moment où j’écris cet article, je n’ai pas encore eu l’occasion d’utiliser cette structure dans un projet en production. Pour autant, j’ai des cas d’usage qui se dessinent, notamment pour tout ce qui concerne des éléments « statiques » d’un projet.

Par exemple, dans un projet avec un espace sécurisé, on a souvent une notion de « role ». Un role « user » et un role « admin » par exemple. On pourrait être tenté de ranger les informations associées dans une table « roles » qui aurait donc plusieurs champs et (seulement) 2 lignes. Sauf que cette table ne change pas tant que les développeurs ne l’ont pas décidé, que ce soit pour la structure bien sûr mais aussi les données qu’elle contient. Ce n’est pas un utilisateur, ni même un administrateur qui va pouvoir créer un nouveau rôle, car cela a souvent une répercussion sur le code, selon la façon dont les autorisations sont gérées. Du coup, je considère que tout ce qui peut faire évoluer le code n’a rien à faire dans la base de données.

Un autre exemple concerne la notion d’état :

  • Les états d’une commande
  • Les états d’une facture
  • Les états d’un projet
  • Etc.

Ces états sont définis dans un process métier, qui, s’il change, fait évoluer le code source. On les voit souvent rangés dans des tables qui du coup ne changent jamais. Je préfère les structurer dans le code.

Autres exemples, des « choses » immuables :

  • Les mois de l’année
  • Les 4 saisons
  • Les 4 couleurs d’un paquet de cartes
  • Etc.

Tous ces exemples font de bon cas d’usage pour l’utilisation des enums. Même si on a vu dans le code que d’un point de vue syntaxe, les différences sont fines, il faut prendre en considération l’utilisation dans un projet et tout ce que cela implique : typage, déclaration, structuration, auto-documentation (j’utilise un enum je suis donc dans tel cas de figure…), etc.

Je ne peux donc que te conseiller de te faire la main avec un de nos challenges pour tester les enums puis de réfléchir à son intégration dans un de tes (prochains ?) projets 😉

Aller + loin


Qui a codé ce superbe contenu ?


Ta newsletter chaque mois

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