[NewCrafts 2019] Workshop: Refactoring to Immutability

François Hyvrier
YounitedTech
Published in
9 min readAug 14, 2019
Méduse, Carole Raddato (CC BY-SA 2.0)

En préambule de l’édition 2019 de la conférence NewCrafts, l’atelier animé par Kevlin Henney proposait de mettre en application quelques techniques de refactoring, en particulier certaines permettant de limiter les effets de bord de son code.

Cet atelier prolongeait le talk du même nom donné l’année précédente à NewCrafts, et reprenait une bonne partie de son contenu.

“Immutability changes everything”

On dit d’un objet qu’il est immutable lorsque son état ne peut pas être changé après sa création. L’immutabilité n’est pas un concept propre à la programmation fonctionnelle, des mécanismes existent dans d’autres types de langages pour définir des objets dont l’état ne peut pas être modifié.

Chaque programme a en réalité des effets de bord car des variables sont créées en mémoire, l’état du processeur change au cours de son exécution : l’immutabilité est en fait l’illusion que rien ne change. L’approche de la programmation fonctionnelle consistant à évaluer des fonctions pures a donc elle aussi des effets de bord, mais chaque brique qui compose un programme écrit dans ce paradigme, chaque fonction, a l’apparence de l’immutabilité. Le résultat calculé par une fonction est passé à une autre de façon “fire and forget”, peu importe ce qu’il advient du résultat, que son état soit modifié ou non, car la fonction qui l’a calculé n’a pas gardé de référence vers lui.

En tant que développeur, on préfère que le code sur lequel on travaille soit raisonnable. Pas “raisonnable” dans le sens où il se couche tôt, ne dépasse pas les limites de vitesse ou finit toujours son gratin de choux de Bruxelles, mais plutôt dans le sens où il est simple de raisonner à son sujet. Pour comprendre un code qui manipule des objets mutables, il faut connaître tous les états possibles dans lesquels peuvent être les objets ainsi que toutes les transitions possibles entre ces états, et il peut rapidement y avoir beaucoup dans un programme non trivial.

A large fraction of the flaws in software development are due to programmers not fully understanding all the possible states their code may execute in.

John Carmack — In-depth: Functional programming in C++

Les effets de bord d’un programme sont encore plus difficiles à maîtriser dans un contexte d’exécution multi-threads. Une solution consiste à utiliser des mécanismes de synchronisation comme les locks, mais la programmation concurrente est complexe et peut conduire à des bugs difficiles à reproduire et corriger (des deadlocks par exemple). Elle pose aussi problème en cas de montée en charge, car plus l’application sera sollicitée, plus elle passera du temps à attendre la libération de locks.

Une explication à cette situation où la plupart des architectures proposent nativement des mécanismes de programmation multi-threads à base de locks est historique. Les premiers programmes étaient exécutés par un seul thread, un mode de fonctionnement que les développeurs avaient intégré dans leurs habitudes, dans leur façon de penser et de concevoir des logiciels. D’autre part, à une époque où les ressources matérielles des ordinateurs étaient bien plus limitées qu’aujourd’hui, le partage de données au sein d’un même programme à l’aide de pointeurs ou de références était un moyen efficace de limiter son empreinte mémoire et d’arriver à ses fins. C’est pourquoi lorsque le multi-threading s’est démocratisé, au lieu de remettre en cause leurs habitudes, les développeurs l’ont utilisé dans leurs programmes auxquels ils ont rajoutés des locks.

Lorsqu’on ne maîtrise pas complètement tous les états possibles de notre programme, l’exécuter dans un modèle multi-threads est risqué. Ce risque disparaît si on n’utilise pas de pointeurs ni de références, c’est-à-dire si aucun état n’est partagé. C’est aussi le cas si notre programme travaille avec des objets immutables car ils peuvent être partagés sans problème : il devient donc inutile de poser des verrous pour empêcher leurs mises à jour concurrentes puisqu’on ne peut pas les mettre à jour.

Une définition du refactoring

Kevlin Henney explique que le code d’un programme peut évoluer selon trois axes :

  • L’axe fonctionnel, qui mesure à quel point le programme est correct et calcule correctement ce pour quoi il a été conçu.
  • L’axe opérationnel, qui évalue ses performances (ses temps de réponse, son utilisation mémoire).
  • L’axe “développemental”, qui permet d’évaluer la simplicité de maintenance du code.

Ce qu’on appelle “refactoring” consiste alors à faire évoluer le code d’un programme le long de l’axe “développemental” sans qu’il bouge le long de l’axe fonctionnel : on améliore la qualité du code sans en changer les fonctionnalités, les performances peuvent en revanche rester identiques ou évoluer de façon positive ou négative. Le terme refactoring ne sert donc pas à décrire le simple fait de changer du code, ce serait trop général.

Lorsqu’on travaille sur un aspect du code (fonctionnel, opérationnel, ou “développemental”), l’un des deux autres ne change pas tandis que le troisième peut soit s’améliorer, soit se dégrader. Dans le cas d’un travail d’optimisation par exemple, c’est l’axe opérationnel qui est privilégié tout en gardant l’axe fonctionnel inchangé, parfois au détriment de la qualité.

Le cas du Singleton

John Singleton, Canadian Film Centre (CC BY 2.0)

Le singleton est un pattern régulièrement utilisé pour implémenter le fait qu’un d’objet ne doit exister… qu’en un seul exemplaire. Ce design pattern n’est pas spécialement bon ou mauvais, il est une réponse à un besoin (s’assurer qu’il n’existe qu’une seule instance d’un objet donné dans tout le programme). C’est plutôt la façon dont il est utilisé dans le code qui peut poser problème.

Dans l’exemple ci-dessus le singleton est accédé via l’appel à la méthode statique GetInstance, ce qui est normal car il a été conçu pour être récupéré de cette façon. En raison de cet accès statique la méthode DisplayJohnSingletonMovies et la classe où elle est déclarée se retrouvent fortement liées à la classe Singleton. Tester unitairement la méthode risque d’être compliqué, et le fait qu’elle dépende du singleton est masqué, car il faut avoir accès au corps de la méthode pour le constater.

En l’état, la méthode DisplayJohnSingletonMovies “sait” que la classe JohnSingleton est un singleton car elle appelle sa méthode GetInstance. Mais dans les faits la méthode a seulement besoin d’une instance de la classe, peu importe que cette instance soit unique ou non. Un premier refactoring consiste alors à passer une instance de la classe nécessaire à la méthode via un nouveau paramètre :

Avec ce changement, nous avons rendu visible la dépendance de la méthode à la classe JohnSingleton, mais surtout la méthode est devenue ignorante du mode d’instanciation de la classe et de l’origine de son paramètre. En poussant cette idée un peu plus loin, on réalise que c’est en fait la liste des films qui intéresse la méthode, d’où l’idée de lui passer un moyen plus direct de l’obtenir, sous la forme d’une fonction retournant la liste des films :

Avec cette nouvelle version, la méthode n’a plus aucun lien avec la classe JohnSingleton, elle attend juste qu’on lui fournisse un moyen de récupérer les films qu’elle doit afficher. Mais on peut encore la simplifier, en lui passant directement les films en question :

Avec ces modifications successives nous avons rendu notre code :

  • Plus ignorant : il ne connaît plus l’origine des données avec lesquelles il travaille.
  • Plus apathique, l’origine des données n’a strictement aucun intérêt pour lui.
  • Et enfin plus égoïste car la nouvelle version de la méthode est à présent centrée sur ses propres besoins (disposer d’une liste de films), la responsabilité d’obtenir les données nécessaires est laissée au code appelant.

Dans cet exemple, le singleton n’était qu’un prétexte. Le but du refactoring n’était pas de transformer le singleton mais de s’en abstraire, de ne plus avoir besoin de connaître son existence ou de savoir qu’il s’agit d’un singleton, ce qui peut après tout être parfaitement justifié.

Refactorer un objet pour le rendre immutable

La transformation d’un objet mutable en un objet immutable se base sur quelques principes simples que nous allons illustrer à l’aide de la classe TimeOfDay suivante.

Dans cette version de la classe TimeOfDay, l’état d’une instance peut être modifiée via ses propriétés Hour et Minute, mais aussi les méthodes NextHour et NextMinute.

Par définition, l’état d’un objet immutable ne peut pas être modifié après sa création. Ce comportement s’implémente concrètement en appliquant les changements suivants à la classe :

  • Modifier ses propriétés pour que leurs valeurs ne puissent plus être changées: elles doivent devenir read-only.
  • Transformer les méthodes qui modifient les propriétés de l’objet pour qu’elles créent plutôt une nouvelle instance correspondant au résultat de l’opération effectuée et la retourne en résultat.

Quelques remarques sur cette nouvelle version de la classe TimeOfDay :

  • Le mot-clé set a été supprimé des définitions des propriétés pour les rendre read-only et pour permettre leur initialisation, un constructeur est apparu. Le fait qu’une classe ait beaucoup de propriétés en écriture peut être un signe qu’il manque un constructeur, un builder ou une factory pour l’initialiser.
  • Les méthodes NextHour et NextMinute sont devenues des fonctions pures sans effet de bord dont le rôle est de calculer un nouveau TimeOfDay et de le retourner, ce qui permet également de chaîner les appels à ces méthodes et de rendre le code plus expressif.

Des structures de données immutables

L’exercice suivant illustre le fait que l’immutabilité peut être implémentée par n’importe quel type d’objets, en particulier ceux dont on pourrait penser que leur état doit forcément pouvoir être modifié : les structures de données comme les listes, les files et les piles.

De telles structures ayant la propriété d’être immutables sont appelées structures de données persistentes. Elles reposent sur le principe vu précédemment que chaque opération d’ajout ou de suppression d’un élément retourne une nouvelle instance de la structure.

Exemple d’utilisation d’une pile immutable

Dans l’animation ci-dessus, chaque pile créée est le résultat d’une opération appliquée à une autre pile immutable :

  • stack2 est égale à stack1 qui lui a été affectée, elles référencent le même objet.
  • stack3 est le résultat de l’appel à la méthode pop sur stack1. La variable stack1 est toujours dans le même état.
  • stack4 est le résultat de l’appel à push sur stack1.
  • stack5 est le résultat d’un push sur stack3.

Ainsi une pile peut être vue comme constituée d’un élément (le dernier qui y a été ajouté), et d’une référence à une autre pile qui contient les éléments suivants. La pile vide ne contient ni élément ni référence vers une autre pile, et une pile à un seul élément contient l’élément mais pas de référence vers une autre pile (ou alors une référence vers la pile vide). Cette façon de faire limite la duplication de données car un même élément peut appartenir à plusieurs piles différentes.

Plus concrètement, voici une implémentation de pile en Java que nous devions refactorer pour la rendre immutable :

Et voici un exemple de transformation de cette classe pour en garantir l’immutabilité :

Cette nouvelle implémentation met en pratique l’idée qu’une pile peut être représentée par un élément (le dernier ajouté) associé à une autre pile représentant ses autres éléments : d’où les attributs head (le dernier élément) et rest (la pile contenant le reste des éléments).

Comme on l’a déjà vu dans l’exemple de la classe TimeOfDay, les méthodes pop et push qui avaient un effet de bord sur l’objet ont été modifiées pour retourner une nouvelle instance de la classe Stack, permettant de chaîner leurs appels et de rendre le code les utilisant plus expressif.

Polymorphisme Vs. Structures conditionnelles

Certaines instances d’une classe immutable représentent des cas limites qui ont des comportements différents des autres. Pour reprendre l’exemple précédent, la pile vide est une instance particulière de la classe Stack car pour elle, les méthodes top (qui retourne l’élément au sommet de la pile) et pop (qui le supprime) n’ont pas de sens, c’est pourquoi elles lèvent une IllegalStateException lorsqu’elles sont appelées sur une pile d’une profondeur égale à zéro.

Une autre façon intéressante d’implémenter cela consiste à transformer la classe Stack en une interface et à l’implémenter dans deux classes : une première représentant une pile non vide (NonEmptyStack), et une autre représentant une pile vide (EmptyStack).

En séparant les comportements différents dans des classes distinctes, nous avons rendu notre code plus expressif et réduit la complexité de la classe Stack initiale : aucune des nouvelles méthodes ne contient de bloc if (un changement susceptible de réjouir les membres de la campagne Anti-If).

That’s all folks!

Contrairement à ce que son titre laissait penser, cet atelier ne concernait pas uniquement l’immutabilité mais était surtout l’occasion de réfléchir à ce que nous autres développeurs produisons au quotidien et de quelles manières nous pouvons l’améliorer : en cherchant des réponses dans le passé, en questionnant en permanence le code que nous écrivons et les caractéristiques profondes que nous voulons lui donner, tout en nous inspirant des autres paradigmes de programmation.

Refactoring to Immutability, Kevlin HenneyNewCrafts 2018

--

--