Traiter des structures de données arborescentes, mettre à jour des paramètres tout au fond d’un arbre d’objets imbriqués en plus de la contrainte d'immutabilité, est une contrainte classique bien connue des développeurs Java.
Nous allons essayer tout au long de cet article d’exposer cette problématique, et par la suite de la résoudre au moyen des lentilles ("Lenses").
Domaine
Commençons par modéliser notre domaine par deux classes immutables : Personne et Adresse.
public class Person { private final String firstName; private final String lastName; private final Address address; public Person(String firstName, String lastName, Address address) { this.firstName = firstName; this.lastName = lastName; this.address = address; } // GETTERS ... } public class Address { private final String street; private final String city; private final String state; private final Integer zipCode; public Address(String street, String city, String state, Integer zipCode) { this.street = street; this.city = city; this.state = state; this.zipCode = zipCode; } // GETTERS ... }
La problématique est de mettre à jour le zipCode de l’adresse d’une personne et d’avoir en retour une nouvelle instance avec zipCode modifié.
Approche Classique
Person person = new Person("Jack", "Smith", new Address("Default", "Default", "Default", 0)); Address adress = new Address(person.getAddress().getStreet(), person.getAddress().getCity(),person.getAddress().getState(), person.getAddress().getZipCode() +1); Person p1 = new Person(person.getFirstName(), person.getLastName(), adress); assertEquals(p1.address.zipCode - 1, 0);
Que pensez-vous de la clarté du code si l'on a plusieurs niveaux d'emboîtement ?
Les Lentilles
La contrainte d’immutabilité nous a poussé à consulter l’avis de nos amis fonctionnels. La réponse était tout simplement : « utiliser les Lenses ! ».
Idée principale
L’idée principale est qu’au lieu de modifier les variables en accédant à l'objet (à travers ses mutateurs), nous allons inverser le sens d’accès, faire un zoom sur le paramètre en question et donner la main pour le modifier en dehors de l’objet via la lentille.
Définition
Une lentille Java se résume principalement en deux fonctions getter et setter :
- La fonction getter permet de récupérer la valeur d’un paramètre.
Function<A, B> getter;
- La fonction setter permet de produire une nouvelle instance de l’objet avec la valeur modifiée du paramètre.
BiFunction<A, B, A> setter;
Avec A le type de l’objet et B celui du paramètre.
public class Lens<A,B> { public final Function<A, B> getter; public final BiFunction<A, B, A> setter; public static <A, B> Lens<A, B> lens(Function<A, B> get, BiFunction<A, B, A> set){ return new Lens<>(get, set); } private Lens(Function<A, B> get, BiFunction<A, B, A> set) { this.getter = get; this.setter = set; } }
Par définition, une lentille ne traite qu’un paramètre à la fois (celui de ses descendants directs). De ce fait, pour modifier le zipCode d’une personne, on aura à déclarer deux lentilles : la première pour modifier l’adresse d’une personne et la seconde pour modifier le zipCode d’une adresse :
// Une lentille sur l’adresse Lens<Person, Address> personAddressLens = Lens.lens(Person::getAddress, (p,a) -> new Person(p.getFirstName(), p.getLastName(), a)); // Une lentille sur le zipCode Lens<Address, Integer> addressZipCodeLens = Lens.lens(Address::getZipCode, (a,z) -> new Address(a.getStreet(), a.getCity(), a.getState(), z));
Le Chaînage
Pour atteindre et modifier le zipCode d’une personne, nous allons renforcer notre lentille par la méthode de chaînage "andThen" de la façon suivante :
public <C> Lens<C, B> compose(final Lens<C, A> that) { return new Lens<>(c -> getter.apply(that.getter.apply(c)), (c,b) ->that.mod(c, a -> setter.apply(a, b) )); } public <C> Lens<A, C> andThen(Lens<B, C> that) { return that.compose(this); }
Cette méthode va nous permettre d’avoir la main directement sur le zipCode.
// Une lentille pour zoomer sur le zipCode de l’adresse d’une personne Lens<Person, Integer> personZipCodeLens = personAddressLens.andThen(addressZipCodeLens);
Pour incrémenter le zip code de l’adresse de Jack :
Person jack = new Person("Jack", "Smith", new Address("Default", "Default", "Default", 0));
Nous aurons :
Person p1 = personZipCodeLens.setter.apply(jack, personZipCodeLens.getter.apply(jack) + 1); assertEquals(p1.address.zipCode - 1, 0);
La fonction mod
Nous venons de modifier la valeur du zipcode par le biais du setter et avons aussitôt enchaîné avec la récupération de l’ancienne valeur par le biais du getter.
Il s'avère que ce cas d’usage - mise à jour d’un paramètre moyennant son ancienne valeur - est récurrent. Pour le simplifier, nous allons enrichir notre lentille par la méthode composite "mod" :
public A mod(A a, Function<B, B> updater) { return setter.apply(a, updater.apply(getter.apply(a))); }
Cette méthode prend en paramètre l’objet A en question, ainsi qu’une fonction updater (de B vers B et qui va mettre à jour le paramètre de type B).
L’exemple précédent peut se résumer en :
Person p1 = personZipCodeLens.mod(jack, z -> z + 1); assertEquals(p1.address.zipCode - 1, 0);
Lois
Plusieurs lois mathématiques découlent de la théorie des groupes et orbitent autour de nos lentilles. Nous pouvons citer les suivantes :
- La loi Set–Get (identité) : pour tout a, on a :
lens.setter.apply(a,lens.getter.apply(a)) → a
- La loi Get-Set (rétention) : pour tout a et b, on a :
lens.getter.apply(lens.setter.apply(a, b)) → b
- La loi Set-Set : pour tout tout a, b et c on a :
lens.getter.apply(lens.setter.apply(lens.setter.apply(a, b), c)) → c
Conclusion
L’objet de l’article était de simplifier la compréhension des lentilles, surtout pour ceux qui sont en train de suivre le chemin vers les langages et les bibliothèques fonctionnels.
Pour résumer, la logique des lentilles factorise et simplifie la manipulation des objets emboîtés; malgré cela, leur création en Java dans notre cas peut causer des lourdeurs, simplifiées dans d’autres langages et librairies comme Scalaz et Shapeless, à l’aide des Macros.
NB : Tout le code source de l'article est disponible dans ce dépôt GitHub.
Références
- Java-Lenses code et exemple : github.com/remeniuk/java-lenses
À propos de l'Auteur
Slim Ouertani est un Architecte logiciel avec une expérience dans le monde télécoms et systèmes d’information. Il a participé à la construction et la mise en place de plusieurs solutions, notamment au sein de multi-nationales. Certifié Java, Spring et MongoDB, Slim est passionné par Scala et JEE.
Vous pouvez en savoir plus sur ses récents travaux sur son blog et le suivre sur Twitter : @ouertani.