jeudi 24 juillet 2014

Lazy Stream Generation with Java 8

Avant, pour produire les items à consommer dans une boucle for-each en Java, on pouvait utiliser un itérateur. Mais ça, c'était avant...

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 :

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