Advent of code 2022 : sandbox et expérimentations PHP

Tu as participé à #AdventOfCode et tu cherches des corrigés passionnants en PHP ? Alors notre repo sur GitHub est fait pour toi ! Découvre nos solutions originales et améliore tes compétences en programmation !

Advent of code est une série de challenges de programmation sous la forme d’un calendrier de l’avent. Du 1er au 24 décembre, un challenge est mis à disposition chaque jour. Il existe depuis 2015. Pour être complètement transparent, Advent of code a inspiré Tainix. Il y a un fonctionnement similaire :

  • Un énoncé
  • Un jeu de données unique par participant
  • Une réponse à apporter (unique également)

Il n’y a pas d’IDE en ligne pour réaliser le challenge, chacun est libre de le réaliser comme il le souhaite.

Dans cet article, nous présentons une sandbox en PHP et des corrigés pour les 5 premières journées. Si tu as lu le manifeste de Tainix, tu comprendras que le but de ces corrigés est d’expérimenter des bonnes pratiques (tests unitaires, PHPStan, etc.), des fonctionnalités nouvelles du langage (PHP 8.1, etc.), en gardant un code le + lisible et organisé possible.

L’ensemble du code est disponible sur Github.

On ne présente pas chaque corrigé en détails, l’idée est de mentionner quelques points intéressants de chaque corrigé. Libre à toi ensuite de farfouiller dans le code sur Github pour aller + en détails, et bien sûr, d’utiliser la sandbox pour réaliser à ton tour les challenges !

On n’a pas non plus la prétention de dire que les solutions présentées ici sont les meilleures ou sont parfaites en tout point. Tu verras que le day 2 n’a pas de tests par exemple (Bouhhhh !!). Si tu as envie de réagir, demander une précision, proposer quelques choses de différent, n’hésite pas à le faire depuis Github ou Twitter.

Sommaire

Jour 1 : création de la sandbox, script via composer, et lire de données des challenges

Le premier jour, j’en ai profité pour structurer le projet et être dans la capacité de réaliser les challenges chaque jour efficacement. Pour cela, j’ai créé un projet avec composer, dans lequel j’ai mis quelques dépendances intéressantes :

{
    "name": "tainix/aoc-2022",
    "description": "Resolution des challenges Advent Of Code 2022",
    "type": "project",
    "require": {
        "php": ">=8.1",
        "pestphp/pest": "1.*",
        "phpstan/phpstan": "1.*",
        "illuminate/collections": "*",
        "larapack/dd": "1.*"
    },
    "autoload": {
        "files": ["Reader.php"],
        "psr-4" : {
            "Solutions\\" : "solutions/"
        }
    },
    "scripts": {
        "day": [
            "php newday.php"
        ]
    }
}

Quelques explications :

  • Pour tester les dernières fonctionnalités de PHP, on travaillera avec PHP 8.1
  • Pour les tests on s’appuiera sur Pest, mais il embarque PHPUnit si tu préfères
  • On validera le code avec PHPStan
  • On se permettra d’utiliser les collections pour manipuler les tableaux
  • On charge le débugger dd => debug + die toujours pratique en phase de développement
  • L’autoload va charger un fichier Reader.php ainsi que la totalité du dossier /solutions
  • Enfin, on a paramétrer un petit script « day » (cf. ci-dessous)

Le projet va être structuré de la façon suivante :

Organisation des fichiers

Chaque dossier data, solutions et tests auront autant de sous dossier day1, day2, day3 que nécessaire.

Script newday.php

Pour ne pas avoir à créer plusieurs dossiers et fichiers manuellement chaque jour, j’ai créé un script newday.php qui se charge de créer les dossiers et les fichiers.

Je peux appeler ce script de cette façon :

php newday.php 6

Et je peux aussi paramétrer le script dans mon fichier composer.json pour l’appeler via composer :

composer day 6

Ici le gain est léger, mais pour des scripts + compliqués, avec des paramètres par défaut, cela peut être intéressant. On aurait pu par exemple créer un raccourci pour appeler les tests d’un jour précis, avec :

composer test-day 6

Pour info, GPT-Chat m’a aidé pour générer ce script 😉

Lire les données avec Reader.php

Dans Advent of code, la première étape consiste toujours à lire les données proposées. Elles sont au format texte, contenues dans (beaucoup) de lignes.

J’ai donc créé une classe Reader.php chargée de lire les données et de les mettre dans un tableau. Une ligne vide aura une valeur de null. Dans chaque dossier /data/dayX il y aura 2 fichiers :

  • sample.txt qui reprend l’échantillon de données pour expliquer l’énoncé
  • data.txt (non versionné) dans lequel on viendra copier/coller les données pour réaliser le challenge

En changeant un paramètre du constructeur, on pourra récupérer les données de sample.txt ou de data.txt :

// Données de test du jour 1
$calories = Reader::getDataForDay(1, Reader::SAMPLE);

// Données pour réaliser le challenge du jour 1
$calories = Reader::getDataForDay(1);
$calories = Reader::getDataForDay(1, Reader::DATA); // Alternative

Résolution du day 1

Pour résoudre le day 1, j’ai donc plusieurs fichiers :

  • 2 fichiers : sample.txt et data.txt dans /data/day1
  • 1 classe dans solutions/day1
  • 1 fichier de tests dans tests/day1
  • 1 fichier de résolution, à la racine, day_1.php :
<?php

require './vendor/autoload.php';

use Data\Reader;
use Solutions\Day1\CaloriesCounter;

$calories = Reader::getDataForDay(1, Reader::DATA);
$counter = new CaloriesCounter($calories);

echo $counter->getMax();
echo PHP_EOL;
echo $counter->getTopThree();

Et en respectant cette structure, je suis prêt pour tous les challenges !

Utilisation des collections

Pour résoudre le day 1 partie 2, pour récupérer la somme du top 3, je me suis appuyé sur les méthodes des collections :

public function getTopThree(): mixed
{
    return collect($this->groups)->sortDesc()->slice(0, 3)->sum();
}

Jour 2 : une interface et pas de else

Ce jour 2 était l’occasion d’une partie de Pierre-Feuille-Ciseaux (tiens ça me rappelle quelque chose 🙂 ). La partie 1 et la partie 2 présentait 2 stratégies différentes à mettre en oeuvre. J’ai donc décidé de créer une interface :

interface StrategyInterface
{
    public static function play(string $vilain, string $me): int;
}

En quelques mots, une interface en PHP est un moyen de définir un contrat pour une classe ou un objet. Elle spécifie quelles méthodes doivent être implémentées par les classes qui implémentent cette interface.

De la même manière qu’une classe parente, je vais aussi pouvoir définir des constantes dans mon interface :

public const ROCK = 'R';
public const PAPER = 'P';
public const SCISSORS = 'S';

Mes classes qui implémentent mon interface devront donc implémenter une méthode play et accèderont aux constantes :

// Part 1
class StrategyFollowInstructions implements StrategyInterface
{ }

// Part 2
class StrategyHaveTo implements StrategyInterface
{ }

Dans une classe Party, je vais gérer un attribut « strategy » qui contient une chaine de caractères faisant référence à l’une ou l’autre de mes 2 stratégies. A la manière d’une Factory, je peux donc appeler dynamiquement la bonne classe :

public function play(): void
{
    foreach ($this->data as $play) {
        $strategy = 'Solutions\\Day2\\Strategy' . ucfirst($this->strategy);
        $this->score += $strategy::play(...explode(' ', $play));
    }
}

Pas de else

Si l’adversaire fait Pierre, je dois faire Feuille, sinon s’il fait Ciseaux, je dois faire Pierre, sinon…

A première vue, on peut avoir envie d’imbriquer tout plein de if/else if/else pour résoudre cette problématique.

Mais en fait, en créant des tableaux des différentes combinaisons et en utilisant par moment des return anticipé, on peut coder tout ce challenge sans un seul else.

D’ailleurs, voici d’autres techniques pour se passer du else, voir du if.

Les différentes classes du jour 2 sont sur Github.

Jour 3 : des intersections, une fonction fléchée, un opérateur de décomposition, des tests avec Pest

Ce jour 3 était une histoire de sacs à dos remplis d’items et d’elfes qui font (encore ?) n’importe quoi ! Il fallait tout d’abord chercher des redondances de caractères entre 2 portions. Puis subdiviser des groupes, pour rechercher des redondances entre 3 élements.

En PHP, quand on recherche des redondances d’élements, on peut dire qu’on recherche des « intersections », c’est à dire des portions qui sont communes. Et il y a une méthode spéciale pour ça : array_intersect. Cette méthode prend en paramètre X tableaux et retournent un tableau comprenant les éléments qui se répètent.

On va donc pouvoir l’utiliser dans la classe « Rucksack » pour trouver l’intersection des 2 compartiments :

public function getDouble(): string
{
    $intersect = array_intersect(
        str_split($this->compartiment1),
        str_split($this->compartiment2)
    );

    return $intersect[array_key_first($intersect)];
}

Comme les compartiments sont des chaines de caractères, j’utilise str_split pour les transformer en tableau. Et j’applique donc array_intersect sur ces 2 tableaux.

Le tableau retourné conserve les clés des tableaux passés en paramètre. Le premier élément de ce tableau ne sera donc pas forcément indexé à 0. Pour récupérer le premier élément, j’utilise la méthode array_key_first.

Ensuite, pour les groupes d’elfes (groupes de 3 Rucksack), j’ai fait les choses d’une façon un peu différente. J’ai considéré que mon groupe d’elfes avait comme attribut un tableau de Rucksack, sans spécifier forcément qu’il y en avait 3. Il fallait donc que mon code fonctionne qu’il y ait 1, 2, 3 ou 1000 Rucksacks.

Je commence donc par utiliser un array_map pour appliquer str_split sur tous mes Rucksacks :

/**
 * @var array<int, array<int, string>> $itemsInArrays
 */
$itemsInArrays = array_map(
    fn($r) => str_split($r->items),
    $this->rucksacks
);

J’ai dur rajouter un peu de PHPDoc pour spécifier à VSCode ce que contenait $itemsInArrays.

Puis j’utilise un opérateur de décomposition dans mon fonction array_intersect :

$similar = array_intersect(
    ...$itemsInArrays
);

array_intersect prend en paramètre plusieurs tableaux, et non un tableau de tableau (ça ne donnerait pas le même résultat). L’opérateur de décomposition, permet justement de décomposer notre tableau en plusieurs paramètres !

Tests avec Pest PHP

Dans la suite de notre introduction à Pest PHP, les tests de ce jour 3 ont été réalisés avec Pest PHP. On s’appuie sur les données proposées pour comprendre l’énoncé et les réponses associées.

Dans un projet structuré avec un framework, je préfère utiliser PHPUnit que je trouve plus structurant. Dans des challenges comme ça, utiliser Pest PHP est très pratique et permet d’aller un peu + vite je trouve.

Ces tests sont l’occasion de passer en revue quelques points intéressants de l’écriture du tests, avec Pest PHP comme les dataprovider avec la méthode with. Les tests sont écrits dans l’ordre logique de résolution du challenge. Le code est sur Github.

Jour 4 : Utilisation de readonly et des tests générés automatiquement

Ce jour 4 était une histoire de section de caractères à analyser, quelques points communs avec le jour précédent, car on a utilisé encore array_intersect pour la partie 2.

Pour représenter ces sections, j’ai créé une petite classe section avec des propriétés readonly :

class Section
{
    public function __construct(
        public readonly int $start,
        public readonly int $end,
    ) {}
}

Les proprités readonly, cumulées avec la promotion de propriétés dans le constructeur permettent de créer des dataobject très efficacement. C’est à dire de petits objets qui permettent de structurer proprement les données. Jadis, on aurait pu être tenté de structurer Section avec un tableau :

$section = ['start' => 1, 'end' => 2]; 

Techniquement, ce petit tableau « fait le job », mais la classe permet de structurer davantage, s’assurer des types notamment. La propriété readonly stipule que la valeur de la propriété ne peut être allouée qu’une seule fois, dans le constructeur. On accédera donc de cette façon à start et end :

// Pas besoin de getteurs
$section->start;
$section->end;

Tests générés automatiquement

Tu as sûrement entendu parler de Chat-GPT, ce chat basé sur le moteur d’intelligence artificielle OpenAI. Sans rentrer dans les détails (n’hésite pas à faire un tour sur les réseaux sociaux pour découvrir plein d’exemples d’utilisation), son utilisation sur le code est puissante.

Pour illustrer, les tests unitaires du fichier day4Test.php ont été générés automatiquement en lui passant, juste, le contenu du fichier Assignement.php.

Je lui ai demandé, en français : « Ecris les tests unitaires, basés sur Pest PHP, pour s’assurer du bon fonctionnement de cette classe : [coller tout le code ici] »

Et Paf ! Ca a fait des tests unitaires 😀

Jour 5 : Un Parser dédié, et du code ajusté pour valider PHPStan

Ce jour 5 était une histoire de paquets à déplacer de pile en pile. La structure des données était complexifiée car elle contenait 2 jeux de données en 1. D’abord les piles, puis les opérations à réaliser.

Fort heureusement, le Reader créé le jour 1 permet de repérer facilement la ligne vide qui sépare les 2 jeux de données 😉 Pour autant le découpage des données étant plus complexe que les autres jours, j’ai décidé de créer une classe dédiée au parsing des données, pour séparer ces opérations de la logique de résolution du challenge.

Voici le constructeur de ce Parser.php (code complet sur Github) :

public function __construct(array $data)
{
    $separatorIndex = array_search(null, $data);


    $columnsData = array_slice($data, 0, $separatorIndex);
    $operationsData = array_slice($data, $separatorIndex + 1);

    $this->setPiles($columnsData);
    $this->setOperations($operationsData);
}

Et 2 méthodes spécifiques pour parser les piles et les opérations.

Important : j’ai eu un soucis sur le parsing des piles. Les premiers espaces du fichier n’étaient pas pris en compte. Ca ne m’a pas posé de problème pour la résolution du challenge car la première ligne mettait bien un paquet dans la première pile. Si ton jeu de données n’est pas constitué de cette façon, le parsing peut ne pas fonctionner correctement.

Validation PHPStan

Dans la suite de notre article sur PHPStan, j’ai dédié un commit complet à la mise en place de la PHPDoc sur le code de ce jour 5, avec comme objectif une validation au level max.

Quelques points importants :

Dans le constructeur du parser, j’ai dû rajouter un controle sur $separatorIndex pour m’assurer qu’il n’était pas false. S’il est à false, le reste du code ne fonctionne pas, PHPStan nous avertit. Avec le déclenchement d’une exception, PHPStan comprend que la suite du code s’execute dans de bonnes conditions :

if ($separatorIndex === false) {
    throw new \Exception('La ligne vide n\'a pas été trouvée');
}

Pour les opérations, j’ai créé un dataobject dédié :

class Operation
{
    public function __construct(
        public readonly int $nb,
        public readonly int $pileToRemove,
        public readonly int $pileToAdd
    ) {}
}

Le constructeur a donc 3 paramètres. Ces 3 paramètres sont issus du parsing de la ligne « move 3 from 1 to 2 ». En utilisant un opérateur de décomposition, on peut initialiser l’objet comme ça :

new Operation(
    ...sscanf($operation, 'move %d from %d to %d')
);

Cf. notre article sur le parsing pour la fonctions sscanf.

Mais PHPStan n’aime pas cette ligne… Si $operation n’est pas formaté exactement comme attendu, la construction de Operation ne va pas fonctionner. Pour résoudre ça j’ai rajouté un contrôle avec une expression régulière :

$regex = '/^move \d+ from \d+ to \d+$/';

if (! preg_match($regex, $operation)) {
    throw new \Exception('La ligne est mal formatée');
}

Mais ça ne suffit pas… les 3 erreurs PHPStan (1 par paramètre) sont toujours présentes dans le rapport. Je n’ai pas poussé plus loin… J’ai laissé les erreurs s’afficher au level max. J’aurais pu ignorer les erreurs (cf. point suivant).

Le Parser possède des propriétés readonly. Ces propriétés doivent être définies dans le constructeur. Pour clarifier mon code, elles sont définies dans des méthodes privées, appelées dans le constructeur (setPiles et setOperations). PHPStan remonte une erreur car elles ne sont pas définies dans le constructeur. J’ai donc décidé d’ignorer ces erreurs, pour cela, je rajoute cette ligne :

/** @phpstan-ignore-next-line */
$this->operations = $operations;

Mais il y a peut être moyen de faire autrement ?

Conclusion

J’espère que cet article t’auras donné envie de réaliser les challenges Advent of Code et de farfouiller dans notre repo pour voir comment on a fait et que tu pourras apprendre des choses au passage. On va essayer de tenir la cadence jusqu’au bout ! On mettra les corrigés en ligne avec toujours un peu décalage pour te laisser le temps de faire les challenges par toi même en premier !

Si tu as fini tout Advent of code, les challenges de Tainix sont là pour prendre le relais 😉

Et pour échanger, tu peux nous rejoindre sur Twitter.

A très bientôt, et toute l’équipe de Tainix te souhaite du bon code et de très bonnes fêtes de fin d’année 🙂


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.