Accueil / Articles PiApplications. / La plate-forme Java / Le langage

Introduction aux streams Java.

Les streams Java permettent la programmation fonctionnelle. La programmation traditionnelle (hors stream) se nomme programmation impérative.

Différences entre programmation fonctionnelle et programmation impérative.

Dans la programmation impérative :

Une séquence d'instructions a pour objectif l'assignation de valeurs à une ou plusieurs variables qui peuvent être liées entres elles.

Contrairement à la programmation impérative, dans la programmation fonctionnelle :

Il n'y a donc pas de concept de répétition ou de boucle en programmation fonctionnelle.

Les streams.

Un stream représente l'abstraction d'un agrégat de traitements : ce n'est pas une structure de données et il peut être infini. Ils autorisent les fusions, les découplages et le parallélisme.

Les streams peuvent être organisés sous la forme d'un tuyau (pipeline) ayant deux extrémités (source et opération terminale) séparées par un certains nombre d'opérations intermédiaires chainées les unes aux autres de façon séquentielle. Un stream traite les données aux travers de fonctions de la source à l'opération terminale.

La totalité du "tuyau" n'est évalué que lors de l'appel de l'opération terminale. Les opérations peuvent être exécutées de façon séquentielle ou en parallèle (utilisation du modèle fork-join introduit par le JDK7).

Les opérations intermédiaires peuvent être fusionnées ce qui supprime les redondances d'exécution, autorise les raccourcis (findfirst par exemple) et les évaluations indépendantes les unes des autres. Certaines caractéristiques des streams aide à identifier les optimisations (fonction distinct par exemple).

Streams, objets et types primitifs.

Pour améliorer les performances, Java dispose de types primitifs (byte, short, int, long, double, float et char) qui ne sont pas des classes. La plate-forme permet le passage d'un type primitif à un objet de sa classe équivalent (Byte pour byte par exemple) via une opération appelée boxing. L'opération inverse qui permet de passer de l'objet à son type primitif est appelé unboxing. De nombreuses conversions s'opèrent automatiquement.

Les listes et les collections en supportent pas les types primitifs et exécutent des opérations d'auto-boxing lorsque nécessaire. Cela peut être contre-performant lors d eleur emploi avec les streams. Prenons par exemple l'instruction int iHighScore = students.stream().filter(std -> std.graduationYear() == 2015).map(std -> std.getScore()).max();.

La fonction map converti chaque score en un objet de classe Integer tandis que la fonction max doit les reconvertir en int puisqu'elle s'applique à des types primitifs. cette double conversion est dommageable aux performances. Pour l'éviter, il existe des streams dédiés aux types primitifs : IntStream, LongStream et DoubleStream. Ces streams peuvent être issus de fonctions particulières comme mapToInt, mapToLong ou mapToDouble.

Nous pouvons alors modifier notre exemple pour éviter la double conversion :

int iHighScore = students.stream().filter(std -> std.graduationYear() == 2015).mapToInt(std -> std.getScore()).max();.

Sources.

Il y a 23 classes qui disposent de 95 méthodes pour initier la source d'un stream dans le JDK8. Beaucoup d'entres elles le font via une opération intermédiaire de l'interface Stream. On distingue les méthodes :

  1. stream() qui permettent un exécution séquentielle du flux ;
  2. parallelStream() qui permettent un exécution parallèle du flux.

De nombreuses classes permettent d'initier des streams :

En tant qu'interface du JDK8, l'interface Stream disposent de quelques méthodes statiques :

Notez que dans le JDK8, seules les collections fournissent directement un stream d'exécution parallèle.

Opérations intermédiaires.

Un stream agit sur une séquence d'éléments. La plupart des opérations intermédiaires acceptent un paramètre qui en décrit le comportement. Ce paramètre est typiquement une expression lambda qui n'interfère pas sur le flux et qui est généralement sans état. Il est possible de modifier l'ensemble des traitements pour les exécuter de manière parallèle ou séquentielle : le dernier mode invoqué est celui qui est appliqué.

Des opérations intermédiaires permettent le filtrage ou la transposition : distinct(), filter(Predicate p), map(Function f), mapToInt(), mapToLong(), mapToDouble().

La transposition est exécutée via les opérations map. Une opération particulière nommée flatMap permet de décomposer chaque élément d'un flux en plusieurs autres éléments. Elle restitue ensuite un nouveau flux avec l'ensemble des sous-éléments issus de la décomposition de chaque élément du flux initial. Par exemple :

List<String> output = reader
 .lines()
 . flatMap(line -> Stream.of(line.split(REGEXP)))
 .filter(word -> word.length() > 0)
 .collect(Collectors.toList());

Cette opération lit toute les lignes issues d'un "lecteur" en les poussant dans un même flux. Chaque ligne est ensuite décomposée en "mots" via la méthode String.split. Un filtre est ensuite appliqué pour éliminer les mots "vides". L'ensemble des mots filtrés est alors collationné dans une liste. Le passage des lignes aux mots impose l'emploi de l'opération flatMap.

Il est possible de restreindre la taille d'un stream via les opérations skip(long l) [on ne traite pas les l premiers éléments] et limit(long l) [on interrompt la lecture du flux sortant au bout de l éléments].

Il est possible de trier ou non les éléments d'un flux via les opérations sorted(Comparator c) et unordered().

Il est même possible de placer un observateur sur le flux sans que cela le modifie grâce à l'opération peek(Consummer c).

Opérations terminales.

Une opération terminale achève l'enchainement des opérations. Ce n'est que lors de son invocation que l'ensemble des traitements est invoqué ce qui permet au compilateur, en observant l'ensemble du "tuyau", de réaliser des évaluations lâches, des opérations de fusion/séparation, l'élimination des redondances et des exécutions en parallèle.

Une opération terminale produit un résultat explicite ou un effet de bord comme :

La classe Optional.

Certaines opérations terminales retournent un objet de classe Optional. Cette classe a été conçue notamment pour éviter d'avoir à retourner un objet nul. En programmation impérative, le fait qu'un retour de méthode puisse être un objet nul entraîne le test systématique de l'objet retourné ce qui alourdit le code.

Par exemple, que se passerait-il si les opérations min ou max étaient invoquées sur un flux ne contenant pas d'élément ? C'est pour cela que la classe Optional comporte plusieurs méthodes qui permettent d'agir sur le résultat :

L'usage de la classe Optional n'est donc pas réservé à son emploi en tant que résultat d'opérations terminales mais peut être utilisé dans toute classe où le résultat de la méthode peut être un flux d'éléments même vide.

(c) PiApplications 2016