Depuis Java 8, le tant attendu, il y a les lambdas et l'API stream : Java rattrape enfin son retard sur nombre de langages qui offrent déjà depuis bien longtemps des mécanismes de programmation fonctionnelle. Il était temps.
Alors, comme tout Javaman qui se respecte, je me suis attelé à la tâche : essayer de domestiquer ces nouveaux compagnons. Il y a tout un tas de fonctionnalités géniales prêtes à l'emploi : on peut générer des streams à partir de tout et n'importe quoi, on peut obtenir des stream bornés ou infinis, séquentiels ou parallélisables, et on les consomme dans un mode "lazy", c'est à dire que chaque étape dans un pipeline demande à l'étape précédente la donnée à traiter qui lui-même fournira à l'étape suivante la donnée utile, ni plus ni moins : c'est du pur pull-pipeline bourrin, économe et efficace.
Mais comment produire des données dans un mode "lazy" ? Pratiquement dès le début, je me suis retrouvé comme une poule devant un violon face à cette épineuse question.
Voyons de quoi il s'agit dans un exemple concret.
Produire juste ce qu'il faut : un enjeu de chaque instant |
Un exemple concret
Il s'agit de fournir un utilitaire qui, à partir d'une classe donnée, parcours l'ensemble des types (classes et interfaces) qui la composent (sauf Object, à la racine). Je disposais d'une implémentation d'avant Java 8 qui faisait le boulot dans un mode "lazy", c'est à dire qui fournissait un GOI (Good Old Iterator, ne cherchez pas, c'est une création...) : un itérateur qui permettait à un consommateur de ne parcourir qu'une partie de ses éléments, et qui par conséquent se devait d'être économe, c'est à dire qui n'allait pas lire à l'avance toutes les interfaces à chaque étage de la hiérarchie des types, mais seulement si on lui demandait "passe moi le suivant".
En Java 8, c'est simplissime, voici le code :
public class GreedyClassStream { private static Stream<Class<?>> getClassStream(Class<?> clazz) { if (clazz == null || clazz.getSuperclass() == null) { return Stream.empty(); } else { return Stream.concat( Stream.of(clazz.getInterfaces()), Stream.concat( Stream.of(clazz), getClassStream(clazz.getSuperclass()) ) ); } } public static void main(String[] args) { getClassStream(HashMap.class).forEach(System.out::println); } }
Dans cette première version, j'affiche chaque type dont est composé une classe. Un seul regret : la méthode
concat()
ne prend que 2 paramètres, ni plus ni moins.
En testant le code sur, par exemple, un
HashMap
, on obtient la sortie suivante :interface java.util.Map interface java.lang.Cloneable interface java.io.Serializable class java.util.HashMap interface java.util.Map class java.util.AbstractMap
Mission accomplie, je retrouve toute la hiérarchie et toutes les interfaces.
Une classe avide en traitement
Il y a juste un petit défaut : l'interface
Map
apparaît deux fois (car elle est explicitement mentionnée dans deux classes de la hiérarchie), mais ce n'est pas bien méchant à corriger, il suffit de garder la mémoire
des interfaces trouvées dans un Set<Class>
.
Il y a aussi un gros défaut : toute la hiérarchie est parcourue à l'avance, contrairement à ce qui est souhaité. Certes, les items sont traités avec économie, mais ils sont générés avec gaspillage.
Pour s'en rendre compte, on va utiliser ce code, qui corrige le petit défaut, et fait intervenir un wrapper autour d'une classe pour voir quand les méthodes
getSuperclass()
et surtout getInterfaces()
sont appelées :
public class GreedyClassStream { private static Stream<Class<?>> getClassStream(WClass clazz, Set<Class<?>> interfaces ) { if (clazz == null || clazz.getSuperclass() == null) { return Stream.empty(); } else { return Stream.concat( Stream.of(clazz.getInterfaces()) .filter(c -> interfaces.add(c)), Stream.concat( Stream.of(clazz.clazz), getClassStream( new WClass(clazz.getSuperclass()), interfaces) ) ); } } public static Stream<Class<?>> getClassStream(Class<?> clazz) { return getClassStream(new WClass(clazz), new HashSet<Class<?>>()); } public static void main(String[] args) { getClassStream(HashMap.class).forEach(System.out::println); } }
Voici le code du wrappeur, qui n'a rien de vraiment intéressant (je sais, une solution AOP aurait été plus élégante, mais allons à l'essentiel) :
public class WClass { Class<?> clazz; public WClass(Class<?> clazz) { this.clazz = clazz; } public Class<?> getSuperclass() { System.out.println(" " + this.clazz.getName() + ".getSuperClass()"); return this.clazz.getSuperclass(); } public Class<?>[] getInterfaces() { System.out.println(" " + this.clazz.getName() + ".getInterfaces()"); return this.clazz.getInterfaces(); } }
Mais quand on lance le bousin, on obtient la trace suivante :
java.util.HashMap.getSuperClass() java.util.HashMap.getInterfaces() java.util.HashMap.getSuperClass() java.util.AbstractMap.getSuperClass() java.util.AbstractMap.getInterfaces() java.util.AbstractMap.getSuperClass() java.lang.Object.getSuperClass() interface java.util.Map interface java.lang.Cloneable interface java.io.Serializable class java.util.HashMap class java.util.AbstractMap
L'interface
Map
qui apparaissait en double a bien été filtrée (avec une déconcertante facilité).
Mais pour le reste c'est sans appel, toute la hiérarchie est parcourue AVANT d'avoir accès au premier item ; et si on souhaite s'arrêter au premier item, toute la hiérarchie aura été parcourue inutilement. Cela était prévisible, rien qu'en regardant le nom de la classe (
GreedyClassStream
)
Une classe économe en traitement
Le moins que je puisse dire, c'est que ça n'a pas été immédiat pour trouver comment fournir des données en mode "lazy" ; il y a bien un "
Supplier
"
de disponible, je vois bien comment fournir tous mes items, mais comment arrêter ?
D'ailleurs, la méthode Stream.generate(Supplier s)
mentionne qu'il n'y a pas de dispositif inhérent à ce fournisseur de données pour indiquer
quand c'est terminé : on est susceptible de retourner une infinité de valeur.
La méthode Stream.limit(long maxSize)
n'est pas satisfaisante car elle suppose
de connaître à l'avance le nombre d'items, ce qui est loin d'être toujours le cas.
Ce dont j'ai besoin, c'est d'un composant qui puisse être initialisé au premier appel, et qui me donne un moyen de dire "y'en a plus", comme on peut faire avec un GOI et sa méthode
hasNext()
.
La solution se trouve dans une combinaison de classes :
StreamSupport
qui permet de créer un stream à partir d'unSpliterator
Spliterators.AbstractSpliterator
qui permet de créer unSpliterator
Le
Spliterator
n'est pas un
dinosaure de l'ère du Crétacé, mais un hybride de "split" et d'"iterator" de l'ère Java 8. "Split" car il permet de partitionner
les traitements en vue d'une parallélisation, "iterator" car il permet de parcourir les items d'une collection. Contrairement
à son ancêtre GOI (Good Old Iterator, essayez de suivre un peu, c'est pénible de devoir se répéter...) qui offrait deux méthodes (hasNext()
et next()
), le Spliterator
offre une méthode deux-en-un tryAdvance()
qui retourne un booléen.
En se balladant dans la Javadoc, on comprend assez vite comment utiliser ces classes :
public class LazyClassStream { static final int FLAGS = Spliterator.CONCURRENT | Spliterator.DISTINCT | Spliterator.IMMUTABLE | Spliterator.NONNULL | Spliterator.ORDERED | Spliterator.SIZED; private static Stream<Class<?>> getClassStream(WClass clazz, Set<Class<?>> interfaces ) { if (clazz == null || clazz.getSuperclass() == null) { return Stream.empty(); } else { Spliterator<Class<?>> parent = new Spliterators.AbstractSpliterator<Class<?>> (Long.MAX_VALUE, FLAGS) { Spliterator<Class<?>> classes; @Override public boolean tryAdvance( Consumer<? super Class<?>> action) { if (classes == null) { classes = getClassStream( new WClass(clazz.getSuperclass()), interfaces ).spliterator(); } return classes.tryAdvance(action); } }; return Stream.concat( Stream.of(clazz.getInterfaces()) .filter(c -> interfaces.add(c)), Stream.concat( Stream.of(clazz.clazz), StreamSupport.stream(parent, false) ) ); } } public static Stream<Class<?>> getClassStream(Class<?> clazz) { return getClassStream(new WClass(clazz), new HashSet<Class<?>>()); } public static void main(String[] args) { getClassStream(HashMap.class).forEach(System.out::println); } }
Il ne reste plus qu'à lancer tout ça :
java.util.HashMap.getSuperClass() java.util.HashMap.getInterfaces() interface java.util.Map interface java.lang.Cloneable interface java.io.Serializable class java.util.HashMap java.util.HashMap.getSuperClass() java.util.AbstractMap.getSuperClass() java.util.AbstractMap.getInterfaces() class java.util.AbstractMap java.util.AbstractMap.getSuperClass() java.lang.Object.getSuperClass()
Parfait ! La hiérarchie des types est résolue quand on la demande : si je n'ai besoin que du premier item, les appels suivants ne seront pas fait, je suis bel et bien en mode "lazy" !
Conclusion
Je ne peux m'empêcher de revenir sur la phrase d'introduction de cet article : "Avant, pour produire les items à consommer dans une boucle for-each en Java, on pouvait utiliser un itérateur." Et bien, c'est toujours le cas ! Cependant, je reconnais que c'est plus aisé dans une seule méthode
tryAdvance()
de fournir des items
tout en ayant la possibilité d'indiquer qu'il n'y en a plus.
J'apprécie également l'élégance et la facilité avec laquelle on peut écrire du nouveau code. Synthétiser un maximum d'opérations très communes (map, filter, reduce, etc) dans l'API Stream est un boulot formidable de la part des concepteurs de Java 8. Enjoy !
Barrack Obama témoigne : "Après une longue journée de boulot, j'adore me détendre sur Semantic Mismatch"
Aucun commentaire:
Enregistrer un commentaire