POO en PHP et concepts avancés : enum, Value Object, array_map

En s’appuyant sur la programmation orienté objet, on met en pratique des concepts avancés sur un challenge débutant.

→ Corrigé du Challenge : Team Pokemon #2

Encore une fois, on va partir d’un challenge de code débutant pour mettre en pratique des concepts plus avancés. On va travailler en programmation orientée objet et passer en revue les concepts suivants :

Le tout en conservant un niveau de contrôle du code avec PHPStan au niveau max.

Conception : découpage du problème en différentes classes

Dans ce challenge, il est question de constituer la meilleure équipe de pokemons et de retourner la puissance totale de cette meilleure équipe.

En lisant la phrase ci-dessus, on voit que 2 termes se dégagent « Pokemon » et « Equipe ».

Ensuite les Pokemons sont définis par leur type (Herbe, Feu, Eau, etc.). Ces types sont connus et bien définis. L’utilisation d’un enum est appropriée ici.

On va donc avoir ces 3 fichiers :

  • Pokemon.php qui contiendra les informations d’un pokémon
  • PokemonTeam.php qui contiendra tous les pokémons et les opérations nécessaires de tri
  • PokemonType.php, l’enum qui contiendra tous les types de pokémon

Création d’un enum en PHP

Les enums permettent de définir un type avec un ensemble de valeurs possibles. Cette structure permet vraiment d’améliorer la lisibilité du code et d’éviter les erreurs car tous les choix possibles sont prédéfinis.

Voici le code de notre enum :

// Notre enum est ici typé. Tous les cas possibles seront des chaines de caractères
enum PokemonType: string
{
    // Types classiques
    case HERBE = 'Herbe';
    case FEU = 'Feu';
    case EAU = 'Eau';
    
    // Types rares
    case GLACE = 'Glace';
    case AIR = 'Air';
    case POISON = 'Poison';
    case PSYCHIQUE = 'Psychique';
    case INSECTE = 'Insecte';
}

Mais ce n’est pas tout ! A la manière d’une classe, un enum peut contenir des méthodes. On va lui rajouter 2 méthodes qui vont permettre de retourner les catégories de types :

/**
 * @return PokemonType[]
 */
public static function rares(): array
{
    return [self::GLACE, self::AIR, self::POISON, self::PSYCHIQUE, self::INSECTE];
}

/**
 * @return PokemonType[]
 */
public static function classiques(): array
{
    return [self::HERBE, self::FEU, self::EAU];
}

Ces 2 méthodes retournent chacune un tableau contenant des cas de notre enum.

Création du Value Object en PHP

Un Value Object est une classe qui permet d’encapsuler des propriétés spécifiques, sans « identité propre ». Ici un Pokemon est défini par ses propriétés, dont son type. De plus dans un Value Object, les données seront immuables, c’est à dire qu’elles n’évolueront pas dans le temps. Une très bonne occasion d’utiliser la propriété readonly ! Voici la classe :

class Pokemon
{
    public function __construct(
        public readonly PokemonType $type,
        public readonly int $power
    ) {}
    
    public static function createFromText(string $informations): Pokemon
    {
        $data = explode(':', $informations);
        
        return new self(
            PokemonType::from($data[0]),
            (int) $data[1]
        );
    }
}

Un peu d’explications :

  • On utilise la promotion de propriétés dans le constructeur pour définir les propriétés
  • On utilise la méthode « from » de PokemonType qui est une méthode native à la structure enum qui permet d’instancier notre enum selon un cas passé en paramètre. Une erreur sera déclenchée si le cas n’existe pas dans l’enum.
  • Les 2 propriétés sont en readonly, soit « lecture seule ». Elles ne peuvent être instanciées qu’une seule fois. Et de préférence dans le constructeur.
  • On type $type avec l’enum PokemonType. Il est tout à fait possible, et fortement conseillé, d’utiliser les enums pour le typage, de la même manière qu’une classe.
  • La méthode statique createFromText réalise le parsing des données et renvoie une nouvelle instance de Pokemon. On verra dans la section suivante comment elle est utilisée.

Utilisation de array_map en PHP #1

La classe PokemonTeam va avoir beaucoup de boulot ! Elle doit tout d’abord stocker les pokemons puis disposer d’une méthode pour les ajouter. Voici le début de cette classe :

class PokemonTeam
{
    /**
     * @var Pokemon[] $pokemons
     */
    private array $pokemons = [];

    public function addPokemon(string $informations): void
    {
        $this->pokemons[] = Pokemon::createFromText($informations);
    }
}

Un peu d’explications :

  • On a une propriétés $pokemons qui est un tableau de Pokemon
  • On n’a pas besoin particulièrement de constructeur ici
  • La méthode addPokemon va faire appel à la méthode statique vue précédemment, pour parser les données, puis retourner une instance de Pokemon, qu’on range directement dans $this->pokemons.

Maintenant, j’ai envie d’ajouter tous mes pokemons d’un coup, c’est à dire de passer en paramètre tout un tableau d’informations à parser (ce qui est aussi en passant le jeu de données du challenge 😉 ).

Je vais pouvoir le faire de 2 façons :

Avec un foreach :

/**
 * @param string[] $informations
 */
public function addPokemons(array $informations): void
{
    foreach ($informations as $informationsPokemons) {
        $this->addPokemon($informationsPokemons);
    }
}

Avec array_map :

/**
 * @param string[] $informations
 */
public function addPokemons(array $informations): void
{
    array_map([$this, 'addPokemon'], $informations);
}

Dans cette utilisation de array_map, on n’utilise pas le retour de array_map, on n’en a pas besoin. Comme les objets fonctionnent par référence, on appelle dynamiquement la méthode de la classe en passant un tableau comme paramètre de callback à array_map avec [$this, ‘addPokemon’].

Utilisation de array_map en PHP #2

Maintenant on va chercher le meilleur pokémon selon un ou plusieurs types. Soit je veux par exemple le meilleur pokémon de type « Herbe », soit je veux le meilleur pokémon rare, avec donc un ensemble de types.

On crée une méthode qui pourra prendre en paramètre les 2 cas de figure : un seul type ou un tableau de types (de pokemon) :

Voici la déclaration de cette méthode :

/**
 * @param PokemonType|PokemonType[] $pokemonTypes
 */
public function getBestPokemonFromTypes(PokemonType|array $pokemonTypes): int

On utilise le caractère | pour définir une union de type et donc la possibilité d’avoir 2 types de paramètres différents en entrée.

Pour contrôler la correspondance du ou des types, on va travailler avec la fonction in_array. Celle-ci prend en paramètre un tableau. Même si j’ai un seul type, il me faut un tableau. On va pouvoir faire comme ça, grâce à la fonction PHP is_array :

if (! is_array($pokemonTypes)) {
    $pokemonTypes = [$pokemonTypes]; // On met l'élément seul dans un tableau
}

Et voici la méthode avec un foreach et une recherche de maximum classique :

/**
 * @param PokemonType|PokemonType[] $pokemonTypes
 */
public function getBestPokemonFromTypes(PokemonType|array $pokemonTypes): int
{
    if (! is_array($pokemonTypes)) {
        $pokemonTypes = [$pokemonTypes];
    }

    $max = 0;

    foreach ($this->pokemons as $pokemon) {
        if (! in_array($pokemon->type, $pokemonTypes)) {
            continue;
        }

        if ($pokemon->power > $max) {
            $max = $pokemon->power;
        }
    }

    return $max;
}

On retrouve le in_array utilisé sur $pokemonTypes qui sera bien toujours un tableau.

Et maintenant avec le array_map, avec au passage l’utilisation d’un ternaire à la place du if :

/**
 * @param PokemonType|PokemonType[] $pokemonTypes
 */
public function getBestPokemonFromTypes(PokemonType|array $pokemonTypes): int
{
    if (! is_array($pokemonTypes)) {
        $pokemonTypes = [$pokemonTypes];
    }

    return max(
        array_map(function (Pokemon $pokemon) use ($pokemonTypes) {
            return in_array($pokemon->type, $pokemonTypes) ? $pokemon->power : 0;
        }, $this->pokemons)
    );
}

Un peu d’explications :

  • Les fonctions sont imbriquées les unes dans les autres, il est important de bien utiliser l’indentation pour s’y retrouver ! On aurait aussi pu créer des variables intermédiaires pour garder en lisibilité.
  • Le array_map va transformer chaque Pokemon de 2 façons possibles :
    • Sa puissance si le type du pokemon est présent dans $pokemonTypes
    • 0 si ce n’est pas le cas
  • On applique la fonction max sur toutes ces valeurs. On peut considérer que les 0 ne sont donc pas pris en compte

Utilisation de array_map en PHP #3

Enfin, il reste à faire la somme des puissances. Cette dernière étape est l’occasion d’utiliser les méthodes créées dans l’enum.

Voici le code :

Avec un foreach :

public function getPowerOfBestTeam(): int
{
    $total = 0;

    foreach (PokemonType::classiques() as $pokemonTypeClassique) {
        $total += $this->getBestPokemonFromTypes($pokemonTypeClassique);
    }

    $total += $this->getBestPokemonFromTypes(PokemonType::rares());

    return $total;
}

Avec array_map :

public function getPowerOfBestTeam(): int
{
    return array_sum(
        array_map([$this, 'getBestPokemonFromTypes'], PokemonType::classiques())
    ) 
    + $this->getBestPokemonFromTypes(PokemonType::rares());
}

On a ici un mélange des 2 premiers cas, on utilise à nouveau une méthode interne comme callback et on applique array_sum sur le retour de array_map (comme on utilisait max juste avant).

On voit là tout l’intérêt des méthodes de l’enum qui permettent un code propre et très explicite.

Cette dernière méthode cloture le challenge, voici comment le challenge peut être résolu :

$pokemonTeam = new PokemonTeam;
$pokemonTeam->addPokemons($pokemons);
echo $pokemonTeam->getPowerOfBestTeam();

Les tests unitaires avec Pest PHP

On commence par tester le parsing lors de la création d’un Pokemon :

test('Création d\'un pokemon', function() {

    $informations = 'Glace:34';

    $pokemon = Pokemon::createFromText($informations);

    expect($pokemon->type)->toBe(PokemonType::GLACE);
    expect($pokemon->power)->toBe(34);
});

Ensuite on peut tester les méthodes de PokemonTeam :

  • L’ajout d’un Pokemon
  • L’ajout de plusieurs Pokemon
  • La recherche du meilleur Pokemon selon 1 type
  • La recherche du meilleur Pokemon selon plusieurs types
  • Et enfin la puissance de la meilleure équipe

Voici la totalité de ces tests :

test('Ajout d\'un pokemon', function() {

    $informations = 'Feu:16';

    $pokemonTeam = new PokemonTeam;
    $pokemonTeam->addPokemon($informations);

    $pokemon = $pokemonTeam->getPokemons()[0]; // Un seul Pokemon

    expect($pokemon->type)->toBe(PokemonType::FEU);
    expect($pokemon->power)->toBe(16);
});

test('Ajout de plusieurs pokemons', function() {

    $informations =  ['Feu:16', 'Air:20', 'Herbe:31'];

    $pokemonTeam = new PokemonTeam;
    $pokemonTeam->addPokemons($informations);

    $pokemonFeu = $pokemonTeam->getPokemons()[0];
    $pokemonAir = $pokemonTeam->getPokemons()[1];
    $pokemonHerbe = $pokemonTeam->getPokemons()[2];

    expect($pokemonFeu->type)->toBe(PokemonType::FEU);
    expect($pokemonFeu->power)->toBe(16);
    
    expect($pokemonAir->type)->toBe(PokemonType::AIR);
    expect($pokemonAir->power)->toBe(20);
    
    expect($pokemonHerbe->type)->toBe(PokemonType::HERBE);
    expect($pokemonHerbe->power)->toBe(31);
});

test('Recherche du meilleur Pokemon selon 1 type', function() {

    $informations =  ['Feu:16', 'Air:20', 'Feu:35', 'Herbe:31', 'Feu:52', 'Insecte:17'];

    $pokemonTeam = new PokemonTeam;
    $pokemonTeam->addPokemons($informations);

    expect($pokemonTeam->getBestPokemonFromTypes(PokemonType::FEU))->toBe(52);
});

test('Recherche du meilleur Pokemon selon plusieurs types', function() {

    $informations =  ['Feu:16', 'Air:20', 'Feu:35', 'Herbe:31', 'Feu:52', 'Insecte:17'];

    $pokemonTeam = new PokemonTeam;
    $pokemonTeam->addPokemons($informations);

    expect($pokemonTeam->getBestPokemonFromTypes([PokemonType::HERBE, PokemonType::AIR]))->toBe(31);
});

test('Puissance meilleure équipe', function() {

    // Jeu de données issu de Tainix directement
    $informations = ['Psychique:52', 'Eau:48', 'Feu:41', 'Eau:20', 'Herbe:34', 'Herbe:14', 'Feu:48', 'Insecte:98', 'Psychique:76', 'Air:40', 'Herbe:31'];

    $pokemonTeam = new PokemonTeam;
    $pokemonTeam->addPokemons($informations);

    expect($pokemonTeam->getPowerOfBestTeam())->toBe(228);
});

Aller + loin

Tu peux aussi chercher un challenge de code pour mettre en application tous les concepts vus ici.


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.