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->x = 2;
$pointA->y = 3;
$pointB = new Point();
$pointB->x = 4;
$pointB->y = 6;
$pointC = new Point();
$pointC->x = 8;
$pointC->y = 12;
$pointD = new Point();
$pointD->x = 16;
$pointD->y = 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 = $x;
$this->y = $y;
}
}
$pointA = new Point(2, 3);
$pointB = new Point(4, 6);
$pointC = new Point(8, 12);
$pointD = new Point(16, 24);
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 = $x;
$this->y = $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 $x, int $y)
{
$this->x = $x;
$this->y = $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 $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
}
$p = new Point(2, 3);
echo $p->x, ':', $p->y; // 2:3
// ... du code ici
$p->x = 4;
$p->y = 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 $x, int $y)
{
$this->x = $x;
$this->y = $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 !
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 $x, int $y)
{
$this->x = $x;
$this->y = $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 = $x;
}
public function setY(): void
{
$this->y = $y;
}
}
$p = new PointHacked(2, 3);
// ... 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é !
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 $x, int $y)
{
$this->x = $x;
$this->y = $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 !
declare(strict_types=1);
final class Point
{
private int $x;
private int $y;
public function __construct(int $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
public function getX(): int
{
return $this->x;
}
public function getY(): int
{
return $this->y;
}
}
$p = new Point(2, 3);
// ... du code ici
$p->__construct(4, 6);
// ... 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 $x, int $y)
{
$this->x = $x;
$this->y = $y;
}
}
$p = new Point(2, 3);
// ... du code ici
$p->__construct(4, 6); // 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". 🙃