Introduction aux objets immutables
Aujourd’hui, on va parler d’objet immutable (ou d’objet immuable). L’immutabilité, quand on l’a compris, c’est très simple ! Mais le comprendre, c’est compliqué.
J’ai d’abord essayé d’expliquer l’immutabilité en faisant comme tout le monde. En disant que l’immutabilité était un concept de programmation fonctionnelle et objet, en parlant de mathématiques et de références en PHP. Tout ça en essayant de rester simple, car je voulais écrire un article accessible à tous pour que les débutants puissent commencer leur carrière avec de bonnes bases. Car l’immutabilité, c’est simple, pas besoin d’avoir 5 ans d’expérience pour l’utiliser.
Puis, un de mes collègues a relu mon article en m’expliquant qu’il était trop compliqué. Qu’il fallait aborder le sujet différemment, en parlant d’expériences personnelles par exemple. Alors, j’ai réécrit l’article en vous racontant mon expérience avec l’immutabilité.
Dans cet article, on va voir une feature que j’ai développée dans le cadre d’un ancien projet, sans utiliser l’immutabilité. Puis, on verra comment je le ferais aujourd’hui en utilisant l’immutabilité.
Une feature sans immutabilité
Sur le projet, on devait générer des entités avec une certaine récurrence. Pour simplifier tout ça on va prendre l’exemple d’un événement récurrent.
class RecurrentEvent {
public function __construct(
public \DateTime $startAt,
public \DateTimeInterval $recurrenceInterval,
public \DateTime $endAt,
public ?RecurrentEvent $parentEvent = null,
) {
}
}
$recurrenceInterval = \DateTimeInterval('P3M');
$event = new RecurrentEvent(
new \DateTime(),
$recurrenceInterval,
(new \DateTime())->add($recurrenceInterval),
);
Jusque-là, pas de problème ça fonctionne, continuons et ajoutons un cron
qui va chercher toutes les RecurrentEvent
qui arrivent à échéance dans les 3 jours et qui va les renouveler. Sachant qu’on ne sait pas si le cron de la veille est passé ou non.
class CronCommand {
public function execute() {
foreach ($this->getEventsToRenew() as $recEvent) {
$newEvent = $this->renew($recEvent);
// save $newEvent in database or something else
}
}
private function renew(RecurrentEvent $recEvent): RecurrentEvent {
return new RecurrentEvent(
$recEvent->endAt,
$recEvent->recurrenceInterval,
$recEvent->endAt->add($recEvent->recurrenceInterval),
$recEvent,
);
}
}
Le code est plutôt clair et assez lisible. Ça a l’air bon, mais si on exécute ce code, on peut voir qu’on a des dates étranges. La propriété $newEvent->startDate
est égale à $newEvent->endDate
. On a même la propriété $recEvent->endDate
qui est aussi égale à $newEvent->endDate
, alors qu’on n’a pas touché au $recEvent
.
Voyons le code de plus près :
class CronCommand {
// ...
private function renew(RecurrentEvent $recEvent): RecurrentEvent {
return new RecurrentEvent( // [1]
$recEvent->endAt, // [2]
$recEvent->recurrenceInterval, // [3]
$recEvent->endAt->add($recEvent->recurrenceInterval), // [4]
$recEvent,
);
}
}
[1] On crée un nouveau RecurrentEvent
qui sera notre futur $newEvent
[2] On set la propriété $newEvent->startDate
avec $recEvent->endDate
.
[3] On set la propriété $newEvent->recurrenceInterval
avec $recEvent->recurrenceInterval
.
[4] On prend notre $recEvent->endDate
, on lui ajoute $recEvent->recurrenceInterval
et on set le résultat dans $newEvent->endDate
.
Et si on regarde d’encore de plus près, au [2], on passe l’objet $recEvent->endDate
pour set le $newEvent->startDate
. Mais en PHP, les “objets sont passés par référence par défaut” (en réalité, on nous a menti, mais ce n’est pas le sujet ici). Donc, le $newEvent->startDate
et $recEvent->endDate
sont le même objet. Si on modifie l’un, on modifie aussi l’autre. Déjà c’est bizarre.
Mais ça ne s’arrête pas là, si on regarde [4], et qu’on lit la documentation PHP du DateTime::add
on voit ceci:
Description:
Ajoute la durée de l’objet DateInterval à l’objet DateTime.
Valeurs de retour:
Retourne l’objet modifié DateTime pour chainer les méthodes.
Ça veut donc dire qu’on prend $recEvent->endDate
et on lui ajoute $recEvent->recurrenceInterval
. C’est donc ici qu’on modifie notre $recEvent->endDate
et notre $newEvent->startDate
car ils sont liés comme expliqué plus haut.
Selon la documentation, le retour du DateTime::add
c’est aussi $recEvent->endDate
, et on le set en tant que $newEvent->endDate
. Et comme expliqué plus haut, les “objets sont passés par référence”, donc le $newEvent->endDate
et $recEvent->endDate
sont le même objet.
On a donc le $newEvent->startDate
et le $recEvent->endDate
qui sont identiques, mais aussi $newEvent->endDate
et le $recEvent->endDate
qui sont identiques. Donc, si l’un mute les deux autres mutent aussi. C’est embêtant ça. Qu’allons-nous faire ?
Une “solution” toujours sans immutabilité
Ceci n’est pas une vraie solution et je vous dirai pourquoi.
Le problème est qu’on a le même objet partout. Et là, vous vous dites “Je clone le $recEvent->endDate et c’est tout”. Si vous ne vous dites pas ça, imaginez la même situation avec un peu plus de stress, parce que le client attend, il faut une solution rapide et maintenant ! Arrêtons de réfléchir, on connaît la solution, c’est facile. On clone la date comme ceci.
class CronCommand{
// ...
private function renew(RecurrentEvent $recEvent): RecurrentEvent {
$newEventStartAt = clone $recEvent->endAt;
$newEventEndAt = (clone $recEvent->endAt)->add($recEvent->recurrenceInterval);
return new RecurrentEvent(
$newEventStartAt,
$recEvent->recurrenceInterval,
$newEventEndAt,
$recEvent,
);
}
}
Ça marche, on livre, le client est content, on est content. C’est top !
Pourquoi ce n’est pas une solution ?
Vous avez vu qu’en quelques lignes seulement une date peut en modifier deux autres. Si on ne fait pas attention ça se propage et ça part facilement en cascade. Imaginez ça sur tout un projet. Dans le projet on manipulait et remanipulait les dates partout. On passe ces objets RecurrentEvent
à des services, ces services vont faire des traitements sur les dates et parfois enregistrer dans d’autres objets qui vont passer dans d’autres services.
Avec toutes les dates sur le projet, j’ai eu des effets de bords. J’ai cloné à nouveau les mêmes dates pour corriger des bugs dans une autre feature du projet, et encore une fois cloné ailleurs, et j’ai cloné sans arrêt. À tout moment, je peux avoir un bug de date qui part en production et qui m’écrase mes dates en base de données. J’étais sous stress constant, j’ai fini par devenir paranoïaque et tout le temps cloner les dates.
Même si je testais tout aujourd’hui, demain il suffit d’un nouveau développeur qui ne fait pas attention, il suffit d’un collègue distrait, ou moi-même par inadvertance et c’est reparti.
Mes premiers contacts avec l’immutabilité
La solution était simple, mais j’ai quitté le projet, puis même changé d’entreprise avant même de savoir qu’il y avait une solution simple, l’immutabilité.
La première fois que j’ai vu le mot immutabilité, c’est en arrivant chez ekino, dans une ligne ajoutée par Nicolas Perussel dans un tableur qui listait des idées de sujets intéressants à traiter pour des talks internes à l’agence. Le mot m’a intrigué, je me suis dit “pourquoi ne pas prendre le sujet et en profiter pour apprendre de nouvelles choses”.
J’ai fait des recherches, et je n’ai rien compris, même l’article Wikipédia sur les objets immuables je ne l’ai pas compris, l’article en anglais Immutable Object est plus détaillé, mais pareil je ne comprends pas. J’ai écouté des conférences, j’en ai discuté avec mes collègues j’ai suivi les bonnes pratiques chez ekino, j’ai compris l’immutabilité en pratiquant sans même chercher à comprendre.
Puis on m’a proposé d’écrire un article sur l’immutabilité. Décidément ça me suit partout. Cette fois-ci je connaissais déjà l’immutabilité en pratique seulement, j’ai refait des recherches et j’ai compris tout ce qu’ils veulent dire ! J’ai re-regardé les conférences. J’en ai encore parlé avec mes collègues qui sont beaucoup plus expérimentés que moi sur le sujet.
Les dates immutables
Revenons à nos RecurrentEvent
. La solution de cloner ne fait que soulager les symptômes, elle ne guérit pas la maladie.
Reprenons notre exemple du début et on va faire un léger changement. Il existe une class DateTimeImmutable
identique à la class DateTime
, mais DateTimeImmutable
ne change pas après sa création. Chacune de ses méthodes va retourner un nouvel objet de type DateTimeImmutable
avec la modification demandée. On remplace donc nos DateTime
et voilà ce que ça donne :
class RecurrentEvent {
public function __construct(
public \DateTimeImmutable $startAt,
public \DateTimeInterval $recurrenceInterval,
public \DateTimeImmutable $endAt,
public ?RecurrentEvent $nextEvent = null,
) {
}
}
$recurrenceInterval = \DateTimeInterval('P3M');
$event = new RecurrentEvent(
new \DateTimeImmutable(),
$recurrenceInterval,
(new \DateTimeImmutable())->add($recurrenceInterval),
);
// ...
class CronCommand{
// ...
private function renew(RecurrentEvent $recEvent): RecurrentEvent {
return new RecurrentEvent(
$recEvent->endAt,
$recEvent->recurrenceInterval,
$recEvent->endAt->add($recEvent->recurrenceInterval),
$recEvent,
);
}
}
Maintenant, on peut faire autant de fois $recEvent->endDate->add($recEvent->recurrenceInterval)
il retournera une nouvelle date avec la durée de l’intervalle en plus et $recEvent->endDate
ne changera pas. Notre problème est résolu à la source, la manipulation de ces dates dans les services n’impactera plus notre objet.
Note: en réalité, DateTimeImmutable
n’est pas vraiment immutable, vous pouvez par exemple rappeler __construct
et faire muter votre objet, donc faites quand même attention.
Les objets immutables
Dans l’introduction je ne vous ai pas dit qu’on allait parler de date immutable, mais bien d’objet immutable. Nous ne nous arrêterons pas en si bon chemin.
Au final, la classe DateTime
de PHP c’est un objet. Et ce qu’on a eu avec les dates, on pourrait très bien l’avoir avec un objet ! En passant un objet à un service qui en voulant faire un simple calcul sur l’objet, il le fait muter et c’est reparti pour tout cloner. Je vous montre.
Si par exemple les RecurrentEvent
étaient payants, et qu’on travaillait à l’international avec des devises. Plutôt qu’enregistrer la valeur du prix et la devise directement sur notre RecurrentEvent
, on va utiliser un Value Object Price
qui va contenir la valeur du prix, la devise et éventuellement autre chose (si vous voulez faire des méthodes Price::add
ou Price::sub
par exemple, profiter de votre Value Object). Ce qui est logique l’événement n’a pas de devise, pourquoi on enregistrerait une devise sur notre entité ?
class Price {
public function __construct(
public int $value,
public Currency $currency, // enum
public int $precision = 6,
) {
}
}
class RecurrentEvent {
public function __construct(
public \DateTimeImmutable $startAt,
public \DateTimeInterval $recurrenceInterval,
public \DateTimeImmutable $endAt,
public Price $price,
public ?RecurrentEvent $nextEvent = null,
) {
}
}
$recurrenceInterval = \DateTimeInterval('P3M');
$event = new RecurrentEvent(
new \DateTimeImmutable(),
$recurrenceInterval,
(new \DateTimeImmutable())->add($recurrenceInterval),
new Price(15, Currency::EURO),
);
// ...
class CronCommand{
public function execute() {
foreach ($this->getEventsToRenew() as $recEvent) {
$newEvent = $this->renew($recEvent);
// save in database
}
}
private function renew(RecurrentEvent $recEvent): RecurrentEvent {
return new RecurrentEvent(
$recEvent->endAt,
$recEvent->recurrenceInterval,
$recEvent->endAt->add($recEvent->recurrenceInterval),
$recEvent->price,
$recEvent,
);
}
}
Très bien ça marche, maintenant disons qu’après chaque renouvellement, le prix doit être mis à jour.
class CronCommand{
public function execute() {
foreach ($this->getEventsToRenew() as $recEvent) {
$newEvent = $this->renew($recEvent);
$this->updatePrice($newEvent);
// save in database
}
}
//...
private function updatePrice(RecurrentEvent $recEvent): void {
$recEvent->price->value = $this->getLastPriceValueFor($recEvent);
}
}
Comme pour la DateTime
, on vient de modifier le prix de notre nouvel RecurrentEvent
, mais aussi celui de notre événement parent, et celui du parent du parent aussi. Ça part en cascade.
Encore une fois, il y a une solution “rapide”. On clone le Price
où on refait un new Price
, mais comme pour les dates, on ne fera que soulager les symptômes d’une maladie et on risque d’avoir des effets de bords.
La solution est la même que pour les dates, il faut utiliser un objet immutable. Cette fois-ci, c’est different, on est sur un objet que l’on a déclaré nous-même. Il va falloir le rendre immutable nous-même.
Reprenons le fameux article Wikipedia, ce qui est important à retenir c’est :
Un objet immuable, en programmation orientée objet et fonctionnelle, est un objet dont l’état ne peut pas être modifié après sa création.
Il dit qu’il faut faire en sorte qu’une fois créé, notre objet ne puisse plus être modifié. En PHP 8.2 il suffit de mettre la class en readonly
. Avant PHP 8.2, c’est simple, mais un peu plus long, je vous mets un exemple dans le code mettez vos propriétés en private
avec des getters, mais SANS setters et mettez votre __construct
en private
et créez un constructeur nommé create
ça bloquera la mutation de l’objet en rappelant __construct
.
precision// In PHP 8.2
readonly class Price {
public function __construct(
public int $value,
public Currency $currency, // enum
public int $precision = 6,
) {
}
}
// In PHP 8.0
class Price {
// Private constructor to block mutation by recalling __construct
private function __construct(
// Private properties
private int $value,
private Currency $currency, // enum
private int $precision = 6,
) {
}
// Named Constructor to allow creation of object
public static function create(
int $value,
Currency $currency, // enum
int $precision = 6,
): self {
return new self($value, $currency, $precision);
}
// Getters only. WITHOUT SETTERS !
public function getValue(): int {
return $this->value;
}
public function getCurrency(): Currency {
return $this->currency;
}
public function getPrecision(): int {
return $this->precision;
}
}
Pour plus de simplicité lors de l’article, on va rester en PHP 8.2, mais il y a des équivalents pour les autres versions de PHP.
Une fois les propretés définies, elles ne pourront plus être modifiées. Votre objet ne mutera plus. Il est donc immutable. Si par exemple vous voulez modifier le prix de votre événement, vous devez créer un nouvel objet Price
, avec la modification voulue. D’un point de vue métier c’est logique, si 2 événements ont le même prix 2 €, ils pourraient se partager le même Price
car ils ont la même valeur, mais si j’augmente le prix d’un seul événement à 3 €, je ne vais pas redéfinir la valeur du 2 € à 3 € sur tout mon projet. Je vais créer un nouveau prix à 3 € et donner ce prix à mon événement. On aura le code suivant :
class CronCommand{
public function execute() {
foreach ($this->getEventsToRenew() as $recEvent) {
$newEvent = $this->renew($recEvent);
$this->updatePrice($recEvent);
// save in database
}
}
//...
private function updatePrice(RecurrentEvent $recEvent): void {
$recEvent->price = new Price(
$this->getLastPriceFor($recEvent),
$recEvent->price->currency,
$recEvent->price->precision,
);
}
}
C’est bon ça marche, on a notre objet immutable, on crée une copie modifiée, et on utilise la copie. Avant de conclure, nous allons améliorer la DX. On nous a appris les “getters” et “setters”, maintenant on va faire des “withers”. Avec ces withers, on va centraliser en un même endroit la création d’une copie modifiée et permettre de chaîner les modifications.
class Price {
public function __construct(
public readonly int $value,
public readonly Currency $currency, // enum
public int $precision = 6,
) {
}
public function withValue(int $value): Price {
return new Price($value, $this->currency, $this->precision);
}
public function withCurrency(Currency $currency): Price {
return new Price($this->value, $currency, $this->precision);
}
public function withPrecision(int $precision): Price {
return new Price($this->value, $this->currency, $precision);
}
}
//...
class CronCommand{
//...
private function updatePrice(RecurrentEvent $recEvent): void {
$recEvent->price = $recEvent->price->withValue(
$this->getLastPriceFor($recEvent),
);
}
}
Maintenant on est serein avec nos dates et nos prix sur notre projet !
Quand est-ce qu’il faut rendre un objet immutable ?
La vraie question devrait être “Quand est-ce qu’il faut rendre un objet mutable ?”.
Quand vous faites le plan d’une maison, vous placez les murs extérieurs ensuite vous faites des ouvertures là où il y a besoin (porte, fenêtre, cheminée, ventilation …). Vous ne vous dites pas “Je vais jamais entrer par là, donc je mets un morceau de mur ici”, jusqu’à faire tout le tour de la maison en laissant que les ouvertures.
Faites la même chose en développement, posez les murs extérieurs, mettez votre classe en final readonly
et toutes les propriétés en private
. Et là où vous avez besoin, ouvrez les accès, mettez votre propriété en public
ou donnez-lui un getter. Enlever le readonly
et rendez votre objet partiellement mutable si vous en avez besoin, pourquoi pas ?
Par son design l’immutabilité rendra votre code plus fiable, plus sûr. Il vous évitera des bugs et des effets de bords. Partir d’un objet immutable, et le rendre mutable en fonction du besoin rendra votre code plus défensif (cf Defensive Programming).
Lorsqu’on développe, on modélise souvent la vie réelle. Comme pour l’exemple du Price
. Si on modifie Price::value
on a un prix different, ce prix n’est pas l’ancien prix il doit donc rester immutable. Par contre, si on modifie RecurrentEvent::price
, si le prix de l’événement change, mais l’évenement reste le même, il a juste un prix différent. On doit donc rendre mutable RecurrentEvent
.
Développer avec une approche DDD (Domain-Driven Design) rendra les choix un peu plus évidents car vos objets modélisent souvent quelque chose de réel. Une entité User
par exemple. Si un utilisateur “John Doe” change d’adresse email, l’utilisateur reste “John Doe”, on ne va pas créer un nouvel utilisateur avec le nouvel email, c’est donc mutable.
Cet article n’est qu’une introduction, on y a vu comment l’immutabilité peut nous éviter des bugs. Elle peut aussi être utilisée à d’autres fins, comme l’amélioration des performances, ou de la sécurité de votre projet.