Une méthode de recherche qui ne trouve rien renvoie souvent null. Chaque fonction qui l’appelle doit alors s’en souvenir, et rien, dans son propre code, ne le lui rappelle.

Dans eMush, un jeu multijoueur open source écrit en PHP, une fonction d’une dizaine de lignes suffit à le montrer. Elle ne présente rien d’anormal à la lecture, et peut pourtant faire planter le jeu en production. Sa dernière version ne contient plus aucune vérification de null, alors qu’un joueur peut toujours ne pas avoir la compétence qu’elle cherche.

Entre les deux versions, il y a la question de ce que ce null coûte à chaque appelant, et de la façon de le traiter lorsque l’absence de la valeur fait partie des règles.

Ces versions viennent d’une discussion sur le Discord du projet, en juillet 2024 : un contributeur se demandait si l’on cherchait toujours à se passer de null, et un autre pensait que j’étais revenu sur cette décision. Absolument pas.

Une fonction sans rien d’anormal

Cette fonction renvoie le coût en points de compétence d’une action, ou null si l’action n’en coûte pas :

private function getSkillPointCost(Player $currentPlayer, ActionConfig $action, ActionSkillPointRule $skillPointCostRule): ?int
{
    $skill = $currentPlayer->getSkillByName($skillPointCostRule->skill);
    if ($skill->getSkillPoints() > 0) {
        return 1;
    }

    return null;
}

À la lecture, ce code ne présente aucun problème. Sauf que getSkillByName renvoie null quand le joueur n’a pas la compétence demandée : l’appel à getSkillPoints() échoue, et le jeu plante en production. Ce genre de plantage est déjà arrivé sur eMush.

C’est le premier coût de null : l’absence sort de getSkillByName et voyage jusqu’à l’appelant, sans que rien, dans ces dix lignes, ne la signale.

Vérifier null dans l’appelant

Un analyseur statique comme Psalm détecte cette erreur. La correction la plus directe consiste à vérifier que la compétence n’est pas null :

private function getSkillPointCost(Player $currentPlayer, ActionConfig $action, ActionSkillPointRule $skillPointCostRule): ?int
{
    $skill = $currentPlayer->getSkillByName($skillPointCostRule->skill);
    if ($skill != null && $skill->getSkillPoints() > 0) {
        return 1;
    }

    return null;
}

Depuis PHP 8, l’opérateur nullsafe ?-> écrit la même vérification de façon un peu plus lisible : si l’objet est null, l’appel renvoie null au lieu d’échouer.

private function getSkillPointCost(Player $currentPlayer, ActionConfig $action, ActionSkillPointRule $skillPointCostRule): ?int
{
    $skill = $currentPlayer->getSkillByName($skillPointCostRule->skill);
    if ($skill?->getSkillPoints() > 0) {
        return 1;
    }

    return null;
}

Ici, la vérification n’est pas trop gênante à la lecture. Selon les règles du jeu, un joueur peut très bien ne pas avoir la compétence demandée : $skill est parfois null, et le code le dit.

Le plantage a disparu. La vérification, elle, reste à écrire et à relire dans chaque fonction qui appelle getSkillByName : Psalm l’impose, ?-> la raccourcit, et aucun des deux ne la supprime. Que l’outil force à traiter le cas n’est d’ailleurs pas mon argument principal. Ce qui compte, c’est de cantonner ces vérifications aux seuls endroits où elles sont indispensables, pour que le code reste simple à comprendre.

Lorsque null n’est pas censé arriver, la vérification coûte davantage.

Quand l’absence n’est pas prévue

Si l’on ne s’attend pas à null, il faut lever soi-même une exception, pour déboguer ou pour informer l’utilisateur. C’est ce que fait l’action Land, qui fait atterrir un patrouilleur dans le vaisseau :

protected function applyEffect(ActionResult $result): void
{
    $patrolShip = $this->target;

    $daedalus = $patrolShip->getDaedalus();

    $patrolShipMechanic = $patrolShip->getMechanicByName(EquipmentMechanicEnum::PATROL_SHIP);
    if ($patrolShipMechanic === null) {
        throw new \RuntimeException('Patrol ship mechanic not found');
    }

    $patrolShipDockingPlace = $daedalus->getPlaceByName($patrolShipMechanic->getDockingPlace());
    if ($patrolShipDockingPlace === null) {
        throw new \RuntimeException('Docking place not found');
    }

    // ...make patrol ship land
}

La moitié de la méthode vérifie des cas qui ne devraient jamais arriver, et c’est ce qui me gêne : on ne voit plus l’atterrissage. Voici la même action, lorsque les méthodes de recherche lèvent elles-mêmes l’exception :

protected function applyEffect(ActionResult $result): void
{
    $patrolShip = $this->target;

    $daedalus = $patrolShip->getDaedalus();

    $patrolShipMechanic = $patrolShip->getMechanicByNameOrThrow(EquipmentMechanicEnum::PATROL_SHIP);
    $patrolShipDockingPlace = $daedalus->getPlaceByNameOrThrow($patrolShipMechanic->getDockingPlace());

    // ...make patrol ship land
}

getMechanicByNameOrThrow fait la même recherche que getMechanicByName, mais lève une exception au lieu de renvoyer null. L’échec reste immédiat et visible, comme le veut le principe du fail fast. Ce qui change, c’est l’endroit où l’absence est traitée : une fois, dans la méthode qui cherche la valeur, et plus dans chaque action qui l’utilise.

Cette solution convient à une absence anormale. Elle ne convient pas à getSkillPointCost, où un joueur sans la compétence est un cas prévu : lever une exception y interromprait une action parfaitement légitime.

Revenir à la compétence absente

Dans beaucoup de cas, null est inutile. Plutôt que null, le joueur pourrait renvoyer une compétence « par défaut », qui remplit la même fonction sans ses inconvénients. C’est le patron de conception Null Object :

public function getSkillByNameOrDefault(SkillEnum $skillName): Skill
{
    return $this->skills->filter(fn (Skill $skill) => $skill->getName() === $skillName)->first() ?? new Skill(SkillEnum::NULL); // ou Skill::createDefault() / Skill::createNull()
}

getSkillPointCost devient :

private function getSkillPointCost(Player $currentPlayer, ActionConfig $action, ActionSkillPointRule $skillPointCostRule): ?int
{
    $skill = $currentPlayer->getSkillByNameOrDefault($skillPointCostRule->skill);
    if ($skill->getSkillPoints() > 0) {
        return 1;
    }

    return null;
}

On peut imaginer que les valeurs par défaut de Skill lui donnent zéro point de compétence. Tout continue alors de fonctionner, sans risque de plantage en production et sans vérification de null. C’est, à un nom de méthode près, la toute première version : le code qu’on voulait écrire au départ.

L’absence est toujours là, mais elle est traitée une fois, dans getSkillByNameOrDefault, et plus aucun appelant n’en paie le prix.

Choisir la valeur par défaut dès l’entité

Le même raisonnement vaut plus en amont, lorsque l’on définit les attributs d’une entité : il faut souvent se demander si null est la seule valeur par défaut possible. Le nom d’une compétence, par exemple, peut être une chaîne vide ('') plutôt que null.

Après quelques tests, je ne vois que les attributs qui pointent vers d’autres entités pour lesquels null est obligatoire, à cause de l’ORM.

Où garder null

Vingt minutes après la discussion, le contributeur qui avait posé la question annonçait avoir remplacé ce qu’il aurait mis à null par une entité avec des paramètres par défaut.

Je pense donc que null doit être évité le plus en amont possible, en cherchant des valeurs par défaut non null pour les entités. Lorsqu’une recherche peut échouer, l’absence se traite dans la méthode de recherche elle-même : une exception si elle est anormale, un objet par défaut si les règles la prévoient.

Si l’on utilise null, il faut le cantonner aux parties techniques de l’application, comme l’infrastructure ou les relations qu’impose l’ORM, et le tenir à l’écart du code métier, qui est déjà suffisamment complexe comme ça.

À gauche, le code technique (base de données, entités, repositories), où null est admis. À droite, le code métier (services, actions, contrôleurs), où il est interdit. Les méthodes getOrThrow et getOrDefault font passer les valeurs de l’un à l’autre.

Les méthodes ...OrThrow et ...OrDefault se trouvent exactement sur cette frontière : ce sont elles qui transforment une valeur éventuellement null en une valeur que le code métier peut utiliser sans vérification.

Si vous voulez échanger sur la gestion de null dans votre code, sur l’ingénierie logicielle, le ML engineering ou l’IA générative, vous pouvez me contacter sur LinkedIn.

Références