Skip to main content

Prog fonct en java - immutabilité

· 8 min read

Dans le post précédent, on a vu les fondations de la programmation fonctionnelle. Ici, on va s'intéresser un peu plus en détail à l'immutabilité en java.

L'instant musical

L'immutabilité

Une façon de ne pas galérer avec les fonctions pures, c'est d'avoir des variables immuables. Mais comment ça se passe en java ?

Il existe le mot clé final qui permet d'assurer qu'une variable ne pourra pas être réaffectée. Il peut être utilisé :

  • Pour des variables :
final String foo = "bar";
  • Pour des attributs de classe :
class Foo {
final String bar;
}
  • Pour des paramètres de méthode :
public String foo(final String bar) {
...
}
  • Pour la définition d'une classe pour s'assurer que la classe ne sera pas étendue
final class Foo {
...
}

Avec des langages un peu plus récents comme kotlin ou scala, où l'immutabilité a été pris en compte dès le départ, on aura une syntaxe simplifiée avec les mots clés var et val :

val fooImmutable = "Bar"
var fooMutable = "Bar"

Les structures de données

Traditionnellement en java, on représente les structures de données par des bean. Un bean déclare des attributs privés mutables et on propose des getters et des setters pour respectivement accéder aux attributs et modifier les attributs.

public class User {

private String nom;
private String email;

public String getNom() {
return nom;
}

public void setNom(String nom) {
this.nom = nom;
}

public String getEmail() {
return email;
}

public void setEmail(String email) {
this.email = email;
}
}

En plus de la verbosité, ici, on est loins de l'immutabilité souhaitée !

Une première approche serait de déclarer les attributs final, et tant qu'on y est, on va les déclarer public, comme ça : pas besoin de getter !

public class User {

public final String nom;
public final String email;

public User(String nom, String email) {
this.nom = nom;
this.email = email;
}
}

On peut encore faire plus concis, en remplaçant la classe par un record :

public record User(String nom, String email) {}

Les attributs du record sont immuables par défaut.

Avoir des structures immuables ajoute une certaine sécurité et diminue la charge mentale lors du développement mais, elle vient avec un certain nombre de contraintes.

Premièrement, comme il n'y a plus de setter, il devient plus compliqué de modifier un attribut. Pour ça, on va devoir créer une nouvelle instance :

public record User(String nom, String email) {
public User withNom(String nom) {
return new User(nom, email);
}
public User withEmail(String email) {
return new User(nom, email);
}
}

Pour modifier l'email d'un user, il faudra passer par une nouvelle variable :

var user = new User("Milo", "milo@gmail.com");
var userWithNameUpdated = user.withEmail("milo.aukerman@gmail.com");

Dans les futures versions de java, on devrait directement avoir le mot clé with :

var user = new User("Milo", "milo@gmail.com");
var userWithNameUpdated = user with { email = "milo.aukerman@gmail.com" };

Malheureusement, il va falloir attendre un peu...

En attendant, il faudra écrire les with à la main ou utiliser la lib dont personne ne prononce le nom, qui commence par lom et qui finit par bok.

En utilisant des structures avec des attributs immuables, on peut vite se rendre compte que ça devient assez difficile de modifier la feuille d'une structure un peu plus complèxe :

var userUpdated = user
.withAddress(user.address
.withCity(
user.address.city.withZipCode("79000")
)
);

En programmation fonctionnelle, il existe ce qu'on appelle les optics pour modifier facilement des structures arborescentes (peut être le sujet d'un article futur !). Malheureusement, il n'existe aucune implémentation en java.

L'immutabilité dans le jdk

Nous venons de voir comment créer nos propres structures immuables, mais que propose java en terme d'immutabilité dans le jdk ?

Historiquement, java n'était pas trop porté sur l'immutabilité, même si les choses changent petit à petit (comme on l'a vu avec les records). Pourtant, certaines classes sont immuables par défaut, et depuis très longtemps, on peut penser à java.lang.String.

Ça ne nous viendrait pas à l'esprit d'écrire ce code :

String monTexte = "Un texte";
monTexte.append(" avec une suite");

Tout le monde a bien intégré que String est immuable et que la concatenation produira une nouvelle valeur :

String monTexte = "Un texte";
String textComplet = monTexte + " avec une suite";

On est très loin de ça dans beaucoup de structures largement utilisées comme par ex : java.util.List et plus largement les collections.

Les collections

La gestion de l'immutabilité dans l'API de collection n'est clairement pas au niveau. Java propose des utilitaires pour vérrouiller des collections, mais pas vraiment d'outils pour pouvoir les modifier.

Il existe par exemple Collections.unmodifiableList :

ArrayList<String> list = new ArrayList<>();
list.add("a");
List<String> immutableList = Collections.unmodifiableList(list);
immutableList.add("b");

Ici, on va obtenir 💥🧨💣

java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1091)

D'ailleurs, dans certains cas, ce sont directement des listes immuables qui sont créées :

List<String> immutableList = List.of("a");
immutableList.add("b");

Même résultat 💥🧨💣

java.lang.UnsupportedOperationException
at java.base/java.util.Collections$UnmodifiableCollection.add(Collections.java:1091)

Perso, je trouve ce comportement assez angoissant, on ne sait jamais quand la manipulation d'une liste va nous péter à la gueule…

Dans le paragraphe précédent, quand on voulait modifier une structure, il fallait créer une nouvelle instance avec la valeur modifiée. Est-ce qu'on ne pourrait pas faire de même avec les listes ?

Et bien la solution, c'est d'utiliser la syntaxe suivante :

List<String> immutableList = List.of("a");
List<String> newList = Stream
.concat(
immutableList.stream(),
Stream.of("b")
)
.toList();

Ça fonctionne, mais ça reste assez verbeux.

Une autre option :

List<String> immutableList = List.of("a");
List<String> tempList = new ArrayList<>(immutableList);
tempList.add("b");
List<String> newList = Collections.unmodifiableList(tempList);

Pas vraiment mieux...

Quand on commence à vraiment utiliser des collections immuables, l'API de collections de la lib vavr est largement au-dessus du jdk.

Vavr a porté toute l'API de collection de scala en java, et les collections proposées sont immuables par défaut :

List<String> empty = List.empty();
List<String> unElement = empty.append("a");
List<String> deuxElements = unElement.append("b");

Ici, pas de mauvaises surprises ! Comme par exemple, des exceptions qui nous pètent à la gueule.

Impacts de l'immutabilité

Comme pour les structures immuables, l'immutabilité dans les collections vient avec son lot de manipulations un peu tricky.

Un cas d'école, incrémenter un compteur en parcourant une liste :

int i = 0;
for (String elt : list) {
i = i + elt.length();
}

N'est plus possible en version immuable. Le code suivant ne compile plus :

final int i = 0;
for (String elt : list) {
i = i + elt.length();
}

Il faudra écrire en java standard :

Integer res = list.stream()
.reduce(0, (acc, elt) -> acc + elt.length(), Integer::sum);

Ou cette version avec vavr (pas besoin de gérer le cas de la parallélisation comme avec java std) :

Integer res = list.foldLeft(0, (acc, elt) ->  acc + elt.length());

Sur plein d'aspect, l'API de collections de vavr permettra de gérer plus simplement tout un tas d'opérations. Elle vient avec plein de méthodes out of box, contrairement au jdk :

  • find, distinct, distinctBy, groupBy max, count ...

Les gatherer ont été introduits, dans la dernière version de java, ça rendra peut-être l'utilisation native des collections plus utilisable.

La récursivité

L'immutabilité peut aussi amener l'usage de la récursivité, qui au passage n'est pas très optimisé en java : attention aux StackOverflowError !

On va prendre un exemple avec un use case : une structure arborescente pour laquelle on souhaiterait compte le nombre de nœuds.

Ex :

record Node(String id, Node child) {}

En java classique, on écrirait :

static Integer count(Node node) {
Node tmp = node;
int count = 0;
while (tmp != null) {
count++;
tmp = tmp.child;
}
return count;
}

Si on veut se passer de mutabilité, il va falloir utiliser la récursivité :

static Integer count(Node node) {
if (node == null) return 0;

return 1 + count(node.child);
}

Conclusion

Il existe des outils dans le jdk pour gérer l'immutabilité mais, malheureusement, comme souvent avec java, même si les fondations sont là, on sera vite tenté d'ajouter des dépendances pour profiter d'API plus fluides.

Manipuler en permanences des objets immuables aura un coût d'apprentissage et il faudra probablement faire preuve d'ingéniosité pour résoudre certaines situations.

Une fois le coup de main pris, manipuler des objets immuables libère un peu la tête, pas de mauvaises surprises, pas de risques que l'objet ait été modifié hors du scope de la méthode courante.