PHP : faisons un point

Modéliser proprement un point en 2D en PHP n'est pas aussi simple qu'il n'y paraît ...

PHP : faisons un point

La nouvelle vient de tomber : le client veut une nouvelle feature qui fait intervenir des points sur un graphique en 2 dimensions.

Pour y arriver, l'équipe a décidé de représenter ces points sous forme d'objets PHP.

Voici comment j'aurais géré cette situation il y a quelques années :

Un point en 2D en PHP ? Je te fais ça en 10 secondes chrono ! Mate ça :

*tape frénétiquement sur le clavier*

class Point
{
    public 
$x;
    public 
$y;
}

Voilà, on a une classe Point, avec ses coordonnées x et y.

Merci bien, au revoir et enjoy !

Alors oui, techniquement cette classe fonctionne ... mais si c'était aussi facile que ça, tu te doutes que cet article n'existerait pas. 😁

Aujourd'hui, je vois au moins 3 choses qui pourraient être problématiques.

On ne dirait pas comme ça, mais cette classe peut faire dérailler le code de nombreuses manières. Et un développeur particulièrement malicieux* pourrait très bien utiliser cette classe n'importe comment !

* Développeur malicieux : un développeur qui a 15 minutes pour corriger un bug critique en prod et qui est prêt à tout pour y arriver (la fameuse "rustine" bien sale qu'on applique en attendant la "vraie correction", toi-même tu sais 😜).

Tous les développeurs seront un jour malicieux (ou bien ils l'ont déjà été par le passé, et je m'inclus dans ce groupe).

Alors, c'est quoi le problème de cette classe ?

Si elle est si sale que ça, comment la transformer en quelque chose de propre ? Comment empêcher qu'elle soit utilisée à mauvais escient par des personnes imprudentes pressées bien intentionnées ?

C'est ce qu'on va voir maintenant. On va partir de cette classe et la refactoriser pour la transformer progressivement en clean code.

Le code à rallonge

Premier problème : cette classe va augmenter la taille du reste du code.

Même si la classe en elle-même est minuscule, le code qui va l'utiliser aura tendance à devenir très long.

Par exemple, voici comment initialiser 4 points en utilisant cette classe :

class Point
{
    public 
$x;
    public 
$y;
}

$pointA = new Point();
$pointA->2;
$pointA->3;
$pointB = new Point();
$pointB->4;
$pointB->6;
$pointC = new Point();
$pointC->8;
$pointC->12;
$pointD = new Point();
$pointD->16;
$pointD->24;

On a juste créé 4 points, et le code est déjà devenu très lourd.

Il y a même de fortes chances pour que tu n'aies pas lu cet exemple jusqu'au bout. Si tu es comme moi, tes yeux ont probablement glissé rapidement jusqu'en bas du code, parce qu'il est assomant à lire.

Est-ce qu'on utilise les bonnes variables sur chaque ligne ? Est-ce que les valeurs sont correctes ? Est-ce qu'il manque un point-virgule ou un tiret quelque part ?

S'il y avait une faute de frappe ou un bug, il serait difficile de s'en rendre compte.

Heureusement, j'ai fait très attention en écrivant ce code, donc il ne bug pas 😇 ... ou peut-être que j'y ai laissé une faute quelque part. Tu ne pourras pas en être sûr tant que tu n'auras pas vérifié méticuleusement par toi-même, mouahaha ! 😈

C'est bien relou, n'est-ce pas ?

Clairement, cette classe crée du code que personne ne souhaite lire, ce qui rend la découverte de bugs compliquée et fatiguante.

La bonne nouvelle, c'est qu'on peut arranger ça facilement en ajoutant un constructeur à notre classe :

class Point
{
    public 
$x;
    public 
$y;

    public function 
__construct($x$y)
    {
        
$this->$x;
        
$this->$y;
    }
}

$pointA = new Point(23);
$pointB = new Point(46);
$pointC = new Point(812);
$pointD = new Point(1624);

Voilà ! Plus besoin de 3 lignes pour créer un point, une seule suffit. C'est déjà mieux, non ? 😉

On vient de régler notre premier problème : le code qui utilise notre Point ne sera pas inutilement long.

La première étape est maintenant terminée, mais on est loin d'avoir fini !

Est-ce vraiment un point ?

On peut détourner l'usage de cette classe pour en faire totalement autre chose.

Pour le moment, rien ne nous empêche d'écrire ceci :

class Point
{
    public 
$x;
    public 
$y;

    public function 
__construct($x$y)
    {
        
$this->$x;
        
$this->$y;
    }
}

$p1 = new Point("François""Baveye");
$p2 = new Point(null, []);
$p3 = new Point(new DateTime(), M_PI);

Et voilà notre Point transformé en tout, sauf en un point en 2D !

Si tu penses qu'une personne saine d'esprit ne ferait jamais une chose pareille, détrompe-toi.

L'exemple que j'ai choisi est simple, donc on peut facilement voir à quel point c'est bizarre d'écrire ce genre de code. Mais en pratique, les exemples sont souvent plus complexes et subtils.

Par exemple, combien de frameworks ne font pas la différence entre un Model object et un DTO ? Combien de scripts finissent par utiliser la même classe pour modéliser des concepts similaires, mais différents ? *tousse en prononcant le nom de frameworks connus*

"Confondre" un objet avec un autre et pervertir son utilisation, c'est une pratique encore très répandue.

Prenons donc nos précautions en forçant le code à utiliser au moins le bon type de données pour les coordonnées x et y. Ici, nos coordonnées sont des nombres entiers, alors on va préciser que la classe ne doit utiliser que des int.

Voilà ce que ça donne :

declare(strict_types=1);

class 
Point
{
    public 
int $x// Nécessite PHP 7.4 ou plus
    
public int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
}

new 
Point("François""Baveye"); // Fatal error

Maintenant, on est sûr que les coordonnées du point seront des nombres entiers. Le declare(strict_types=1) renforce encore plus cette précaution en empêchant de convertir implicitement de mauvaises valeurs en int (on ne sait jamais).

Dans son état actuel, la classe ressemble aux Data Transfer Objects qu'on retrouve dans le code de beaucoup d'entreprises : pas 100% clean, mais pas trop sale non plus.

Tout à fait utilisable ; on pourrait s'arrêter là.

Mais je t'ai promis un code bien propre, alors on continue.

Passons aux choses sérieuses :

L'immutabilité

Notre point n'est pas fixe, et c'est problématique.

Une fois qu'on a créé notre Point, on peut toujours modifier la valeur de x et y à la volée :

declare(strict_types=1);

class 
Point
{
    public 
int $x
    public int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
}

$p = new Point(23);
echo 
$p->x':'$p->y// 2:3

// ... du code ici

$p->4;
$p->6;

// ... du code ici

echo $p->x':'$p->y// 4:6 ... même pas sûr

Rien n'empêche notre point de changer subitement de coordonnées à tout moment.

Personnellement, si je crée un point, je veux pouvoir le retrouver plus tard et être sûr que ça reste le même point.

Si on ne peut pas en être sûr, cela ajoute beaucoup d'incertitude dans le code (et potentiellement de nouveaux bugs). Oui, tu as la variable $p sous les yeux, mais comment savoir qu'elle contient toujours ce que tu penses ?

Si jamais on doit utiliser un autre point ... eh bien on va fabriquer un autre point.

Dit comme ça, ça peut paraître bête, mais notre classe ne le gère pas (encore). Pour ça, il faudrait rendre notre objet immutable. C'est à dire qu'on ne devrait pas pouvoir modifier les valeurs qu'il contient.

Pour y arriver, on adopte en général la solution suivante :

declare(strict_types=1);

class 
Point
{
    protected 
int $x;
    protected 
int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
    
    public function 
getX(): int
    
{
        return 
$this->x;
    }
    
    public function 
getY(): int
    
{
        return 
$this->y;
    }
}

Les propriétés passent de public à protected pour qu'on ne puisse plus les modifier de l'extérieur, et on ajoute des getters pour pouvoir lire les coordonnées du point.

En général, c'est à ça que ressemblent les objets classiques dans les codebases d'entreprises.

Ça y est, on ne peut plus le modifier de l'extérieur : notre point est devenu immutable ! On a retiré les incertitudes du code, youpi !

... mais est-ce bien vrai ?

Est-ce qu'on vient vraiment de le rendre immutable, ou est-ce qu'on en a juste l'impression ?

Pour en être sûr, rien de tel que d'essayer de faire muter notre point.

Toujours pas immutable

On peut toujours modifier les coordonnées de notre point.

La prod est tombée à cause d'un bug en lien avec les coordonnées ? Il faudrait changer leur valeur dans certains cas pour éviter les problèmes ? Ton boss te met la pression pour livrer rapidement ?

On dirait que tu as besoin de rendre le point mutable à nouveau.

Pas de panique, soyons malicieux ! 😛

Faisons comme dans ces séries avec des hackeurs qui tentent de pénétrer dans un endroit sécurisé :

Si on ne peut pas passer par l'extérieur, on va passer par l'intérieur !

— citation d'une série cliché

Voici comment faire (attention, c'est pas très propre) :

declare(strict_types=1);

class 
Point
{
    protected 
int $x;
    protected 
int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
    
    public function 
getX(): int
    
{
        return 
$this->x;
    }
    
    public function 
getY(): int
    
{
        return 
$this->y;
    }
}

class 
PointHacked extends Point
{
    public function 
setX(int $x): void
    
{
        
$this->$x;
    }
    
    public function 
setY(): void
    
{
        
$this->$y;
    }
}

$p = new PointHacked(23);

// ... du code ici

$p->setX(4);
$p->setY(6);

// ... du code ici

echo $p->getX(), ':'$p->getY(); // 4:6 ... même pas sûr

En étendant la classe et en ajoutant des setters dans la classe fille, on a créé une version mutable de notre point.

Une fonction qui accepte un objet de type Point en paramètre va aussi accepter un objet de type PointHacked (car elle hérite de la classe Point).

On peut donc encore modifier les coordonnées de notre point. Nos données sont donc toujours aussi mutables et incertaines.

On est entré !

— citation d'une série cliché

Retour à la case départ. 😥

Heureusement, on peut arranger ça. Il est possible d'indiquer qu'une classe ne peut pas être étendue :

declare(strict_types=1);

final class 
Point
{
    private 
int $x;
    private 
int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
    
    public function 
getX(): int
    
{
        return 
$this->x;
    }
    
    public function 
getY(): int
    
{
        return 
$this->y;
    }
}

En passant la classe en final, on corrige cette vulnérabilité.

Un autre détail : dorénavant, il n'y a que notre Point qui pourra utiliser directement les coordonnées. On peut donc passer les coordonnées en private. Techniquement, ça ne change rien mais c'est plus propre. Alors on en profite.

Le constructeur

Alors, ça y est ? Notre classe est bien immutable ?

En bien non, on peut toujours modifier les coordonnées de notre point.

Attention ! Le hack qui arrive est beaucoup plus fourbe que tout ce que j'ai montré jusqu'à présent.

Si on ne peut passer ni par l'extérieur ni par l'intérieur, on va se cacher en pleine lumière !

— citation d'une série cliché
declare(strict_types=1);

final class 
Point
{
    private 
int $x;
    private 
int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
    
    public function 
getX(): int
    
{
        return 
$this->x;
    }
    
    public function 
getY(): int
    
{
        return 
$this->y;
    }
}

$p = new Point(23);

// ... du code ici

$p->__construct(46);

// ... du code ici

echo $p->getX(), ':'$p->getY(); // 4:6 ... même pas sûr

Eh oui, on a utilisé le constructeur de la classe pour modifier les coordonnées !

On n'y pense pas forcément, mais un constructeur c'est juste une méthode qui est appelée lors de la création d'un objet.

Si on met de côté cette particularité, le constructeur se comporte comme n'importe quelle autre méthode publique : on peut l'appeler encore et encore avec des valeurs différentes si on le souhaite pour changer les coordonnées du point.

Personnellement, je n'ai jamais vu ça en situation réelle. Mais cela veut quand même dire que notre Point n'est toujours pas réellement immutable.

Bon ... on fait quoi maintenant ?

En voyant ça, on peut se demander s'il est seulement possible de créer un objet immutable en PHP. Et comme dans la majorité des cas, la réponse à cette question est "ça dépend".

Ça dépend de quoi ? De la version de PHP que tu utilises.

PHP 8.1 à la rescousse !

En PHP 8.1, il est possible de créer facilement des objets réellement immutables.

Pour empêcher totalement la modification des coordonnées de notre point, voici la solution :

declare(strict_types=1);

final class 
Point
{
    public 
readonly int $x;
    public 
readonly int $y;
    
    public function 
__construct(int $xint $y)
    {
        
$this->$x;
        
$this->$y;
    }
}

$p = new Point(23);

// ... du code ici

$p->__construct(46); // Error: Cannot modify readonly property

Pour s'assurer que les coordonnées restent réellement en lecture seule, on précise que les propriétés sont readonly. Si on essaie de changer les coordonnées du point après qu'il ait été initialisé, PHP lèvera une erreur.

Ça y est, les coordonnées ne bougeront plus !

Un des avantages, c'est qu'en faisant ça, les propriétés peuvent repasser en visibilité public (de toute manière, on ne pourra pas les modifier). Ça nous permet donc de retirer les getters qui nous encombraient la vue depuis tout à l'heure.

Notre point qui avait beaucoup gagné en taille au fil de la refacto vient de faire un sacré régime !

Et le voilà devenu réellement immutable !

Un coup de nettoyage

Maintenant que notre point fonctionne comme on le voulait, on va le rendre un peu plus joli.

Depuis PHP 8, il est possible d'utiliser la promotion de propriétés de constructeur (constructor property promotion en anglais) pour rendre le code plus compact, mais toujours lisible.

Voilà à quoi ça ressemble :

declare(strict_types=1);

final class 
Point
{
    public function 
__construct(
        public 
readonly int $x,
        public 
readonly int $y
    
) {}
}

Plus besoin de déclarer et d'assigner les coordonnées sur les lignes différentes, tout se fait dans la signature du constructeur.

Je crois qu'on tient enfin la version finale de notre point !

Conclusion

Pas si simple de faire du clean code, n'est-ce pas ? Créer un simple point en 2D fait intervenir tellement de choses !

Il faut connaître la syntaxe du langage, son fonctionnement interne, savoir anticiper les problèmes les plus tordus ...

Bien sûr, avec le temps, on n'a plus besoin de passer par toutes ces étapes intermédiaires pour créer une version propre de ce genre de classes. On apprend à l'écrire directement dans sa version finale sans trop y réfléchir.

Au niveau du langage en lui-même, utiliser une version à jour facilite vraiment le boulot. Ici, c'est les fonctionnalités introduites en PHP 7.4, 8.0 et 8.1 qui nous ont permis de rendre notre point plus propre.

Pour PHP, je recommande d'utiliser au moins la version 7.4 : propriétés typées, covariance et contravariance des types, fonction fléchées ... que du bonheur !

Je te remercie d'avoir suivi ce nettoyage de code en ma compagnie, j'espère que tu as trouvé ça instructif.

Allez, c'est tout pour cette fois. The end. "Point final". 🙃

← Retour au blog