PHP : Utiliser les Collections sur des tableaux d’objets

Les Collections permettent de manipuler efficacement les tableaux en PHP. Cela fonctionne sur les objets aussi !

→ Corrigé du Challenge : Petits monstres mignons #3

On avait déjà fait une brève présentation des Collections en PHP dans un précédent corrigé. Pour rappel, les Collections sont des outils permettant d’homogénéiser la manipulation des tableaux en PHP, là où les fonctions disponibles nativement sont un peu « disparates » en terme d’organisation et de fonctionnement (ordre des paramètres, par référence ou pas, etc.). Les Collections viennent aussi avec des fonctionnalités supplémentaires et une syntaxe objet qui permet de « chainer » les méthodes, un peu comme on le ferait en javascript.

Les Collections permettent de manipuler des tableaux. Mais on va voir dans ce corrigé, que l’on peut surtout s’en servir pour manipuler des tableaux d’objets. On va s’appuyer pour cela sur le challenge « Petits Monstres #3« .

Au programme, en avançant pas à pas :

Résolution du challenge en POO, sans les Collections

Dans le challenge « Petits Monstres #3 », il s’agit de regrouper les monstres en fonction d’une de leurs caractéristiques, à savoir leur type de nourriture (4 types différents).

Une fois qu’on a constitué les 4 groupes, on va ordonner les monstres selon une autre caractéristique, à savoir leur poids. Ils seront rangés soit dans l’ordre croissant, soit dans l’ordre décroissant (encore une fois selon le type de nourriture).

Voyons voir comment on peut coder tout ça !

Pour commencer, on a un enum pour stocker le type de nourriture, et une classe pour gérer les monstres et le parsing de leurs informations :

enum FoodType: string
{
    case FRUITS = 'F';
    case GRASS = 'G';
    case ROCK = 'R';
    case WOOD = 'W';
}
class Monster
{
    public function __construct(
        public readonly string $name,
        public readonly FoodType $foodType,
        public readonly int $weight,
    ) {}

    public static function createFromText(string $informations): Monster
    {
        $data = explode(':', $informations);

        return new self(
            name: $data[0], 
            foodType: FoodType::from($data[1]),
            weight: (int) $data[2]
        );
    }
}

Un peu d’explications :

  • Dans l’enum, je n’ai pas mis mes différents cas dans un ordre quelconque, j’ai repris l’ordre attendu pour la réponse du challenge… (spoiler : ça nous servira à la fin)
  • Comme dans d’autres corrigés, j’ai une méthode statique dans Monster, chargée de faire le parsing et de retourner une instance de Monster. Ce n’est pas le constructeur qui gère le parsing. Et mes propriétés sont définies dans le constructeur grâce à la promotion de propriétés.

Maintenant créons une classe MonstersLines qui représente ces lignes de petits monstres (trop) mignons :

class MonstersLines
{
    /**
     * @var Monster[]
     */
    private array $monsters = [];

    public function addMonsterFromText(string $informations): void
    {
        $this->monsters[] = Monster::createFromText($informations);
    }

    public function getMonstersFromFoodType(FoodType $foodType, int $nbMonsters = 3): string
    {
        // 1. Initialisation d'un tableau vide
        $monsters = [];

        // 2. Je filtre selon le type de nourriture
        foreach ($this->monsters as $monster) {
            if ($monster->foodType === $foodType) {
                $monsters[] = $monster;
            }
        }

        // 3. Je trie dans un sens ou dans l'autre, selon le type de nourriture
        // grâce à la fonction usort, en comparant les poids
        if ($foodType === FoodType::FRUITS || $foodType === FoodType::GRASS) {
            usort($monsters, function (Monster $m1, Monster $m2) {
                return $m1->weight <=> $m2->weight;
            });
        } else {
            // Sens inverse
            usort($monsters, function (Monster $m1, Monster $m2) {
                return $m2->weight <=> $m1->weight;
            });
        }

        // 4. Je garde les N premiers monstres
        $monsters = array_slice($monsters, 0, $nbMonsters);

        // 5. J'extraie les noms des monstres
        $names = array_column($monsters, 'name');

        // 6. Je lie les noms avec un "-"
        return implode('-', $names);
    }

    public function getAllFirstThree(): string
    {
        // 0. Je précise l'ordre attendu 
        $foodTypes = [
            FoodType::FRUITS,
            FoodType::GRASS,
            FoodType::ROCK,
            FoodType::WOOD
        ];

        // 0. J'initialise un tableau vide en vue de mon implode final
        $result = [];

        // 1. Je parcours chaque type pour récupérer les 3 premiers monstres
        foreach ($foodTypes as $foodType) {
            $result[] = $this->getMonstersFromFoodType($foodType, 3);
        }

        // 2. Je lie les éléments avec un "-"
        return implode('-', $result);
    }
}

Un peu d’explications :

  • Il n’y a pas de constructeur, on ajoute les monstres un par un avec une méthode dédiée qui fait appel à la méthode statique de Monster.
  • 2 méthodes servent à trier les monstres, j’y ai décrit les étapes en les numérotant.
  • On remarque qu’on manipule à la fois plusieurs variables, il y a des boucles, des fonctions par référence (usort) et des fonctions qui retournent des valeurs (implode). Tout fonctionne bien mais l’ensemble est un peu « disparate » d’un point de vue structure.

On va voir comment améliorer tout ça avec les Collections…

Utilisation des Collections en PHP

Les collections proposent des fonctions « sortBy » et « sortByDesc » qui permettent de trier dans un ordre croissant ou décroissant selon une certaine propriété.

Utilisation d’une fonction variable

On va en profiter pour stocker dans une variable la fonction à utiliser. Et l’appeler dynamiquement lors du « chainage » des Collections.

Sans les Collections (même code que + haut)

public function getMonstersFromFoodType(FoodType $foodType, int $nbMonsters = 3): string
{
    $monsters = [];

    foreach ($this->monsters as $monster) {
        if ($monster->foodType === $foodType) {
            $monsters[] = $monster;
        }
    }

    if ($foodType === FoodType::FRUITS || $foodType === FoodType::GRASS) {
        usort($monsters, function (Monster $m1, Monster $m2) {
            return $m1->weight <=> $m2->weight;
        });
    } else {
        usort($monsters, function (Monster $m1, Monster $m2) {
            return $m2->weight <=> $m1->weight;
        });
    }

    $monsters = array_slice($monsters, 0, $nbMonsters);

    $names = array_column($monsters, 'name');

    return implode('-', $names);
}

Avec les Collections

public function getMonstersFromFoodType(FoodType $foodType, int $number = 3): string
{
    // Gestion du tri, par défaut, dans l'ordre croissant, décroissant pour ROCK et WOOD
    $sort = 'sortBy';
    if ($foodType === FoodType::ROCK || $foodType === FoodType::WOOD) {
        $sort = 'sortByDesc';
    }

    return collect($this->monsters)
        ->filter(function(Monster $item) use ($foodType) {
            return $item->foodType === $foodType;
        })
        ->$sort('weight')
        ->slice(0, $number)
        ->implode('name', '-');
}

Plus concis et mieux organisé, non ?!

Un peu d’explications pour la partie Collections :

  • On commence par créer une collection grâce à la fonction collect. On peut ensuite chainer toutes les fonctions qu’on souhaite appliquer, dans l’ordre d’éxécution.
  • Je commence avec la méthode filter, qui a pour rôle de réaliser un array_filter. J’utilise donc une fonction anonyme pour préciser ma condition de filtrage, à savoir une correspondance des types de nourriture.
  • Ensuite je trie selon « weight » dans un sens ou dans l’autre, dynamiquement. Plus besoin de if/else.
  • slice réalise le array_slice
  • implode prend en paramètre le nom d’une propriété, ce qui économise l’array_column pour récupérer les noms

Utilisation de la méthode native « cases » des enums

Pour la refonte de la méthode getAllFirstThree, on va utiliser la méthode natives « cases » des enums. C’est pour ça qu’on a fait attention à l’ordre défini dans l’enum.

Sans les Collections (même code que + haut)

public function getAllFirstThree(): string
{
    $foodTypes = [
        FoodType::FRUITS,
        FoodType::GRASS,
        FoodType::ROCK,
        FoodType::WOOD
    ];

    $result = [];

    foreach ($foodTypes as $foodType) {
        $result[] = $this->getMonstersFromFoodType($foodType, 3);
    }

    return implode('-', $result);
}

Avec les Collections

public function getAllFirstThree(): string
{
    return collect(FoodType::cases())
        ->map( fn(FoodType $foodType) => $this->getMonstersFromFoodType($foodType, 3) )
        ->implode('-');
}

Convaincu ?!

Un peu d’explications :

  • collect attend un tableau en paramètres. La méthode cases() des enums retourne un tableau avec tous les cas. C’est exactement ce qu’il nous faut ! Attention, ici ça fonctionne car on a le même ordre dans l’enum que celui attendu dans l’énoncé du challenge. Ca ne fonctionnerais plus pour un ordre différent.
  • La méthode map va réaliser un array_map. Ici je peux utiliser une fonction fléchée avec « fn () => » car le contenu de la fonction est constituée d’une seule instruction.
  • Un petit implode pour finir encore une fois.

Code final et tests unitaires avec Pest PHP

Pour réaliser le challenge, je lance ce code :

$monsterLine = new MonstersLines;

foreach ($monsters as $monsterInformations) {
    $monsterLine->addMonsterFromText($monsterInformations);
}

$solution = $monsterLine->getAllFirstThree();

On a donc vu dans ce corrigé que l’utilisation des Collections sur les tableaux d’objets est tout à fait fonctionnelle et pratique, notamment pour trier selon une propriété, récupérer les valeurs d’une propriété, etc.

Les Collections proposent encore de nombreuses fonctionnalités, que vous pouvez découvrir dans la documentation officielle des Collections en PHP (dans le doc de Laravel).

L’ensemble du code peut être retrouvé sur Github, ainsi que les tests unitaires réalisés avec Pest PHP. Ils ont d’ailleurs été très pratiques pour refactoriser le code avec les Collections. J’ai suivi ce process :

  1. Première solution sans les Collections
  2. Ecriture des tests unitaires pour verrouiller le bon fonctionnement
  3. Refactorisation avec les Collections
  4. Lancement des tests au fil du process pour vérifier que tout est toujours OK !

Pour tester les 2 classes, j’ai créé des tests avec des dataset. Chaque dataset contient les noms des classes pour pouvoir les instancier dynamiquement. Voici ce que ça donne :

test('Tri des monstres ROCK', function(string $className) {

    $monsters = ['Drex29:R:16', 'Traz65:W:74', 'Blit86:R:27', 'Brux18:F:83', 'Cobi82:R:72', 'Brux61:F:56', 'Draz68:G:97'];

    $monsterLine = new $className;

    foreach ($monsters as $monsterInformations) {
        $monsterLine->addMonsterFromText($monsterInformations);
    }

    expect($monsterLine->getMonstersFromFoodType(FoodType::ROCK, 3))->toBe('Cobi82-Blit86-Drex29');
})->with([
    [MonstersLinesWC::class],
    [MonstersLines::class]
]);

Ce n’est pas forcément l’approche « parfaite ». On pourrait à minima créer une interface pour s’assurer que les 2 classes ont bien les mêmes méthodes avant de faire ça. Ou sinon se diriger vers un pattern… comme le design pattern Strategy 😉


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.