Adopter un raisonnement fonctionnel - Immutabilité

Pour ceux qui ont la possibilité de pratiquer des langages comme Java 8, Groovy ou Scala, ou même des APIs comme Guava, vous avez probablement pu manipuler des concepts provenant de la programmation fonctionnelle. Mais à force de manipuler ces concepts, vous vous êtes rendus compte que la mutabilité ne s’associe pas toujours très bien à des opérations comme filter(), transform() ou map(). Car l’immutabilité est un des pré-requis nécessaire si vous voulez faire de la programmation fonctionnelle et utiliser ces opérations.

Dans cet article, nous allons nous concentrer sur le principe d’immutabilité. Nous commencerons par en donner une première définition. Puis nous verrons la notion d’effet de bord que l’immutabilité cherche à palier. Nous verrons ensuite qu’immutabilité et mutabilité peuvent s’associer. Nous continuerons en observant comment mettre en place l’immutabilité dans Java et Scala et quels sont les pièges qui peuvent apparaître. Et nous verrons rapidement des exemples d’application de l’immutabilité en architecture, avant de conclure.

Immutabilité

“Immutability changes everything! I mean it changes nothing! Which changes everything!”

James Iry – 2015-08-24 – tweet

L’immutabilité est assez simple à comprendre. Elle consiste à ne pas permettre le changement d’état. Ainsi, l’exemple suivant est un cas simple d’immutabilité

int var2 = var1 + 1;

Par contre, l’exemple ci-dessous est un cas de mutabilité puisqu’on modifie var1

var1 = var1 + 1;

Cependant, les machines sur lesquelles tournent nos applications sont basées sur le paradigme impératif. Ce paradigme repose sur le fait qu’on fournit à la machine des instructions qui modifient au fur et à mesure l’état de la machine. Ainsi, dans l’absolu, l’immutabilité n’est pas possible sur ce type de machine. Par exemple

int var2 = var1 + 1;

Le fait d’arriver sur cette instruction fait modifier le pointeur d’instruction du processeur. Il faut aussi envoyer les ordres pour la réservation de l’espace mémoire de var2 (si l’opération ne peut pas se faire uniquement au niveau du processeur), passer par le bus pour lire var1 et modifier var2. Sans compter les modifications des registres et des flags du processeur, les interruptions matérielles, etc. Tout ceci est généralement transparent pour l’utilisateur. Bien qu’à certains moments, il y ait des conséquences visibles comme une insuffisance mémoire, un ralentissement de la machine ou un plantage imprévu de l’application.

Dans ce contexte, il faut revoir notre définition de l’immutabilité. Pour cela, commençons par ce qui nous intéresse dans l’immutabilité.

État mutable partagé

Ce qui nous intéresse lorsqu’on parle d’immutabilité, c’est avant tout d’éviter les effets de bord. C’est-à-dire qu’on veut éviter que l’appel d’une fonction ou l’évaluation d’une expression génère un changement d’état observable en dehors de cette fonction ou de cette expression. Il existe plusieurs causes aux effets de bord. Dans le cadre de l’immutabilité, on cherche surtout à éviter les états mutables partagés.

Les états mutables sont des états qui peuvent être modifiés dans le temps. C’est-à-dire qu’ils peuvent subir des opérations d’écriture à différents moments de leur cycle de vie. C’est le cas des variables en informatique. Elles sont partagés à partir du moment où ces états apparaissent dans plusieurs contextes d’appel. En Java, ça commence avec les variables d’instance non finales, quelle que soit leur visibilité, qui peuvent être partagées par plusieurs méthodes et pas nécessairement du même objet. Et ensuite, nous avons les variables de classe non finales, les objets distants (dans le cadre de RMI, par exemple), les services stateful et potentiellement les base de données. Toutes ces catégories d’état mutable créent du couplage fort au sein d’une ou plusieurs applications, dans la mesure où il est difficile de déterminer leurs effets sur le reste du code.

Dans le cadre des états immutables, la seule opération d’écriture permise est à l’initialisation. Dans le cas où ces états sont partagés, ils ne peuvent pas être responsables de changements de comportement, puisqu’ils ne varient pas. En Java, nous avons les variables finales et les entités n’ayant aucun setter. Dans une plus large mesure, nous avons les événements ou les logs qui sont des données factuelles.

Prenons les exemples suivants, où nous avons deux classes MutableThreshold et ImmutableThreshold, dont le but est de filtrer une liste d’entiers par rapport à un seuil (threshold).

class MutableThreshold {
    private int threshold;
    public void setThreshold(int t) {
        threshold = t;
    }
    public List filter(List ints) { /* loop over ints to filter according to threshold */ }
}
class ImmutableThreshold {
    private final int threshold;
    public ImmutableThreshold(int t) {
        threshold = t;
    }
    public List filter(List ints) { /* loop over ints to filter according to threshold */ }
}

On voit facilement que le cas de MutableThreshold posera problème dans un contexte multithread. En effet, si on modifie de threshold alors que la méthode filter est en cours d’exécution, le comportement résultant sera difficilement prévisible. Ceci est dû au fait qu’on a la liberté de changer threshold à n’importe quel moment par la méthode setThreshold. Un problème additionnel apparaît ici : dans son cycle de vie, une application subit un ensemble d’évolutions et de refactoring. À force, si nous reprenons notre exemple, on finit par facilement perdre la trace des appelants de setThreshold. On finit malheureusement par les retrouver dès qu’ils se sont manifestés au runtime ou lors d’un debug après l’apparition d’un bug (pour peu que la réflexion ne vienne pas interférer). Si nous rassemblons les deux difficultés vues précédemment, on se rend compte que les dommages que peuvent causer les états mutables partagés sont potentiellement assez importants.

Ces problèmes se posent moins pour ImmutableThreshold. Premièrement, on ne peut pas modifier threshold. Par le mot-clé final, cette variable ne peut être qu’initialisée, pas modifiée. Et on ne peut récupérer le contenu de threshold qu’à partir du moment où elle sera initialisée. Deuxièmement, il est difficile de perdre l’appelant qui fournit la valeur de threshold. En effet, pour réaliser cette opération, cet appelant doit créer une instance de ImmutableThreshold. Elle est donc propriétaire de l’instance de ImmutableThreshold et fournit exclusivement le threshold. Contrairement au cas mutable, où ce n’est pas forcément le propriétaire de l’instance qui peut fournit le threshold. Malgré cela, il est toujours possible de perdre les parties de l’application qui initialisent le threshold et celles qui appellent filter. Mais on voit bien que leur capacité d’action est beaucoup plus limité que dans le cas mutable.


Effet de bord par état mutable partagé avec une conséquence difficilement prévisible

Nous avons vu que la notion d’état mutable partagé est un réel problème et parfois un choix cornélien lors de la conception d’une application. En ça, l’immutabilité répond avec plus de prédictibilité en limitant les actions sur l’état. Voyons maintenant des cas où mutabilité et immutabilité se composent.

État mutable dans un contexte immutable ?

Dans un cadre immutable, la mutabilité est acceptée lorsque ses conséquences sont négligeables. Cette remarque est valable par exemple pour l’allocation mémoire, surtout si la quantité de mémoire allouée est particulièrement faible par rapport à la mémoire disponible. C’est le cas aussi, comme nous l’avons vu, des nombreux changement d’états du processeur.

Mutabilité non observable

En fait, la mutabilité est permise dès lors que ses effets ne sont pas observables en dehors d’une expression ou d’une fonction. Par exemple

int sumOf(List < Integer > values) {
    int sum = 0;
    for (Integer value: values) {
        sum += value;
    }
    return sum;
}

Ici, sum est une variable mutable. Mais comme elle n’est pas exposée hors de sumOf, on considère qu’il n’y a aucun effet de bord.

Mutabilité observable et immutabilité

L’immutabilité n’a pas réponse à tout. Bien sûr, nous avons toujours besoin d’afficher des résultats sur un écran, par exemple, ou de persister un état dans une base. Néanmoins, il existe des moyens de les éviter ou de faire en sorte que ces effets soient parfaitement visibles dans le code. Pour les I/O, on se base notamment sur les monades IO. Mais nous ne l’aborderons pas dans cet article.

Il existe d’autres cas où nous allons chercher à faire ressortir dans le modèle les changements successifs de l’état. Voyons le cas suivant

import java.util.Random;
public class SequenceGenerator {
    private final static Random RANDOM = new Random();
    int getNextSequence() {
        return RANDOM.nextInt();
    }
}

Ici l’effet de bord est visible à chaque appel de getNextSequence(), puisqu’à chaque fois nous avons une valeur différente. Ceci est dû au fait que Random est mutable puisqu’il conserve la valeur actuelle du seed et ce de manière cachée (le seed étant une valeur sur laquelle se base Random pour calculer la valeur suivante). Ainsi, nous avons

id1 = generator.getNextSequence();
id2 = generator.getNextSequence();
id3 = generator.getNextSequence();
// id1 != id2 != id3

Même s’il s’agit de l’effet recherché, on voit très bien que le retour à un ancien état n’est pas possible. De plus, l’état à un instant donné n’est pas reproductible, car Random est initialisé selon l’état du système. Ce qui n’apparaît pas non plus dans le code tout comme la modification du seed. Tout ceci pose problème dans le cadre de tests ou lorsqu’il y a besoin de revenir à une ancienne valeur (c’est le cas par exemple des numéros de contrat d’assurance qui doivent absolument se suivre, dans le cas de numéros de séquence successivement augmentés de un).

Pour avoir la possibilité de reproduire l’état à un instant donné, il faudrait conserver toutes les étapes intermédiaires, c’est-à-dire toutes les valeurs intermédiaires du seed.

Pour cela, nous allons matérialiser (ou plutôt réifier) chaque état dans les instances d’une classe que nous allons appeler Sequence. Nous allons faire en sorte que l’état ne puisse pas changer pour une instance donnée. Toute demande de changement d’état devra générer un nouvel état et donc une nouvelle instance. Pour cela, créons une classe immutable et sans effet de bord

public class Sequence {
    public final long seed;
    public final int number;
    public Sequence(int seed) {
        this.seed = seed;
        this.number = new Random(seed).nextInt()
    }
    public Sequence next() { // always generate an equivalent instance each time next is called return new Sequence(new Random(seed).nextLong()); 
                            } 
                        }

Nous obtenons

sequence1 = new Sequence(0);
sequence2 = sequence1.next();
sequence3 = sequence2.next();
id1 = sequence1.number;
id2 = sequence2.number;
id3 = sequence3.number;

La seule particularité de ce type de représentation est qu’à chaque fois qu’on cherche à récupérer une nouvelle valeur, il faut aussi récupérer l’état suivant, pour propager les modifications de l’état. Par exemple, si je cherche à générer une liste d’IDs à partir d’une instance de Sequence donnée, il faut que je retourne à la fois la liste d’IDs, mais aussi le nouvel état obtenu à la fin de l’opération, afin de continuer à générer des IDs uniques par la suite.

List generateIds(int count, Sequence sequence) {
    Sequence currentSequence = sequence;
    List sequences = new ArrayList < > ();
    for (int i = 0; i < count; i++) {
        sequences.add(currentSequence);
        currentSequence = currentSequence.next();
    }
    return sequences;
}

En terme d’usage, nous obtenons par exemple

List result = generateIds(3, previousSequence);
id1 = result.get(0).number;
id2 = result.get(1).number;
id3 = result.get(2).number;
nextSequence = result.get(2).next();

Si pour une raison ou une autre, on invalide les entités qui ont été générées avec id3 ou id2, il est toujours possible de revenir à la séquence qui a permis de générer id1 pour retrouver les IDs suivant. Ceci est possible car nos séquences sont reproductibles.

L’immutabilité dans le langage, les APIs et l’architecture

Déclaration de constante

En Java et en Groovy, il existe des aides pour introduire l’immutabilité dans le code. Cela se fait en utilisant le mot-clé final, comme nous l’avons vu pour Java dans l’exemple précédent. Pour Scala, on utilise le mot-clé val. Ces aides permettent de s’assurer que la valeur ou la référence contenue dans la variable ne changera pas une fois l’assignation faite. Néanmoins, final ou val ne suffisent pas si l’objet pointé est mutable.

Cas des collections

En général, les collections disponibles dans java.util sont mutables. Mais il est possible de les rendre immutables en utilisant les méthodes Collections.unmodifiable[*] disponibles dans le même paquetage. Du coup, les opérations de modification comme add, remove ou sort génèrent une UnsupportedOperationException.

Les streams de Java 8 sont par contre des objets mutables, car une fois consommés, on ne peut plus réaliser d’opération dessus. Si vous essayez, vous aurez : java.lang.IllegalStateException: stream has already been operated upon or closed. Il est donc fortement déconseillé de partager un stream. Par exemple

IntStream ints = IntStream.of(1, 2, 3);
System.out.println(ints.sum()); // display: 6 
System.out.println(ints.map(i -> i * 2).sum()); // throw: IllegalStateException

En Guava, le paquetage collect contient un bon nombre de collections de type Immutable[*] et qui héritent des Java Collection. On peut les générer grâce à des builders. Toutes les opérations de modification sur ces collections immutables sont marquées deprecated et génèrent une UnsupportedOperationException.

En Scala, les collections sont immutables par défaut. Les opérations de modification sur ces collections créent une nouvelle collection avec la nouvelle configuration. Quand aux streams de Scala, ils sont immutables et il est possible mettre en place de nouvelles opérations sur un stream partagé.

val ints = Stream(1, 2, 3) println(ints.sum) // display: 6 
println(ints.map(i => i * 2).sum) // display: 12

Closure

La closure (aussi appelée fermeture) est une fonction qui capture la référence à une variable extérieure définie dans son scope. Dans la fonction suivante, les variables a et v0 sont des variables extérieures (aussi appelées variables libres) et t est une variable interne à la fonction (aussi appelée variable liée).

t -> a * t + v0

En Java 8, dans le cadre des lambda expressions, les variables extérieures sont implicitement déclarées final. Alors que dans les versions précédentes, dans le cadre des anonymous inner class, le compilateur demandait au développeur d’ajouter final aux variables extérieures.

En Scala, les variables extérieures peuvent être mutables.

Architecture logicielle

Il existe des architectures basées sur l’immutabilité. Il s’agit des architectures CQRS et tout autre architecture intégrant l’event sourcing. Ce type d’architecture est basée sur la notion d’événement. Un événement est un objet par essence immutable, car il représente un fait passé et ne peut être modifié. Les logs sont aussi des objets immutables par essence. Cette propriété fait qu’on s’en sert pour des analyses à travers Elasticsearch et Logstash, avec un front-end Kibana.

Conclusion

Nous avons vu dans cet article que l’immutabilité permet d’apporter dans l’application plus de prédictibilité et de reproductibilité. Cette catégorie d’état n’est possible que si on utilise des opérations dont les effets de bord sont négligeables. Nous avons ensuite vu que l’immutabilité n’est pas incompatible avec les notions de mutabilité et d’effet de bord. De plus, l’immutabilité permet d’exposer dans le code les effets de bord et de les rendre là encore reproductibles et prévisibles. Nous avons ensuite terminé en regardant comment mettre en place l’immutabilité dans Java et Scala et d’une manière générale comment elle émerge dans certaines architectures.

L’immutabilité apporte beaucoup d’avantages. C’est aussi un prérequis à la programmation fonctionnelle. Mais ce n’est pas le seul, car il y a aussi la transparence référentielle, l’idempotence ou la composition (et comment s’en assurer), avant de parler de pureté fonctionnelle.

Et si vous n’êtes toujours pas convaincu par l’immutabilité…

Back To The Furure - Immutability