Guide des collectionneurs de Java 8

1. Vue d'ensemble

Dans ce didacticiel, nous passerons en revue les collecteurs de Java 8, qui sont utilisés à la dernière étape du traitement d'un flux .

Si vous souhaitez en savoir plus sur l' API Stream elle-même, consultez cet article.

Si vous voulez savoir comment tirer parti de la puissance des collecteurs pour le traitement parallèle, consultez ce projet.

2. Le Stream.collect () Méthode

Stream.collect () est l'une des méthodes de terminal de l' API Stream de Java 8 . Il nous permet d'effectuer des opérations de repli mutables (reconditionner des éléments dans certaines structures de données et appliquer une logique supplémentaire, les concaténer, etc.) sur des éléments de données détenus dans une instance Stream .

La stratégie de cette opération est fournie via l' implémentation de l'interface Collector .

3. Collectionneurs

Toutes les implémentations prédéfinies se trouvent dans la classe Collectors . Il est courant d'utiliser l'importation statique suivante avec eux pour optimiser la lisibilité:

import static java.util.stream.Collectors.*;

ou juste des collecteurs d'importation uniques de votre choix:

import static java.util.stream.Collectors.toList; import static java.util.stream.Collectors.toMap; import static java.util.stream.Collectors.toSet;

Dans les exemples suivants, nous réutiliserons la liste suivante:

List givenList = Arrays.asList("a", "bb", "ccc", "dd");

3.1. Collectors.toList ()

Le collecteur ToList peut être utilisé pour collecter tous les éléments Stream dans une instance List . La chose importante à retenir est le fait que nous ne pouvons pas supposer une implémentation de List particulière avec cette méthode. Si vous souhaitez avoir plus de contrôle sur cela, utilisez plutôt toCollection .

Créons une instance Stream représentant une séquence d'éléments et rassemblons-les dans une instance List :

List result = givenList.stream() .collect(toList());

3.1.1. Collectors.toUnmodifiableList ()

Java 10 a introduit un moyen pratique d'accumuler les éléments Stream dans une liste non modifiable :

List result = givenList.stream() .collect(toUnmodifiableList());

Si nous essayons maintenant de modifier la liste des résultats , nous obtiendrons une UnsupportedOperationException :

assertThatThrownBy(() -> result.add("foo")) .isInstanceOf(UnsupportedOperationException.class);

3.2. Collectors.toSet ()

Le collecteur ToSet peut être utilisé pour collecter tous les éléments Stream dans une instance Set . La chose importante à retenir est le fait que nous ne pouvons pas supposer une implémentation Set particulière avec cette méthode. Si nous voulons avoir plus de contrôle sur cela, nous pouvons utiliser toCollection à la place.

Créons une instance Stream représentant une séquence d'éléments et rassemblons-les dans une instance Set :

Set result = givenList.stream() .collect(toSet());

Un ensemble ne contient pas d'éléments en double. Si notre collection contient des éléments égaux les uns aux autres, ils n'apparaissent qu'une seule fois dans l' ensemble résultant :

List listWithDuplicates = Arrays.asList("a", "bb", "c", "d", "bb"); Set result = listWithDuplicates.stream().collect(toSet()); assertThat(result).hasSize(4);

3.2.1. Collectors.toUnmodifiableSet ()

Depuis Java 10, nous pouvons facilement créer un ensemble non modifiable à l' aide du collecteur toUnmodifiableSet () :

Set result = givenList.stream() .collect(toUnmodifiableSet());

Toute tentative de modification du jeu de résultats aboutira à UnsupportedOperationException :

assertThatThrownBy(() -> result.add("foo")) .isInstanceOf(UnsupportedOperationException.class);

3.3. Collectors.toCollection ()

Comme vous l'avez probablement déjà remarqué, lorsque vous utilisez les collecteurs toSet et toList , vous ne pouvez faire aucune hypothèse sur leurs implémentations. Si vous souhaitez utiliser une implémentation personnalisée, vous devrez utiliser le collecteur toCollection avec une collection fournie de votre choix.

Créons une instance Stream représentant une séquence d'éléments et rassemblons-les dans une instance LinkedList :

List result = givenList.stream() .collect(toCollection(LinkedList::new))

Notez que cela ne fonctionnera pas avec les collections immuables. Dans ce cas, vous devez soit écrire une implémentation de Collector personnalisée, soit utiliser collectAndThen .

3.4. Collectionneurs . toMap ()

Le collecteur ToMap peut être utilisé pour collecter des éléments Stream dans une instance Map . Pour ce faire, nous devons fournir deux fonctions:

  • keyMapper
  • valueMapper

keyMapper sera utilisé pour extraire une clé Map d'un élément Stream , et valueMapper sera utilisé pour extraire une valeur associée à une clé donnée.

Collectons ces éléments dans une carte qui stocke les chaînes en tant que clés et leurs longueurs en tant que valeurs:

Map result = givenList.stream() .collect(toMap(Function.identity(), String::length))

Function.identity () est juste un raccourci pour définir une fonction qui accepte et renvoie la même valeur.

Que se passe-t-il si notre collection contient des éléments en double? Contrairement à toSet , toMap ne filtre pas silencieusement les doublons. C'est compréhensible - comment devrait-il déterminer quelle valeur choisir pour cette clé?

List listWithDuplicates = Arrays.asList("a", "bb", "c", "d", "bb"); assertThatThrownBy(() -> { listWithDuplicates.stream().collect(toMap(Function.identity(), String::length)); }).isInstanceOf(IllegalStateException.class);

Notez que toMap n'évalue même pas si les valeurs sont également égales. S'il voit des clés en double, il lève immédiatement une IllegalStateException .

Dans de tels cas avec collision de clés, nous devrions utiliser toMap avec une autre signature:

Map result = givenList.stream() .collect(toMap(Function.identity(), String::length, (item, identicalItem) -> item));

Le troisième argument ici est un BinaryOperator , où nous pouvons spécifier comment nous voulons que les collisions soient gérées. Dans ce cas, nous choisirons simplement l'une de ces deux valeurs en collision car nous savons que les mêmes chaînes auront toujours la même longueur.

3.4.1. Collectors.toUnmodifiableMap ()

De même que pour les listes et les ensembles , Java 10 a introduit un moyen simple de collecter des éléments Stream dans une carte non modifiable :

Map result = givenList.stream() .collect(toMap(Function.identity(), String::length))

Comme nous pouvons le voir, si nous essayons de mettre une nouvelle entrée dans un résultat Map , nous obtiendrons UnsupportedOperationException :

assertThatThrownBy(() -> result.put("foo", 3)) .isInstanceOf(UnsupportedOperationException.class);

3.5. Collecteurs .c ollectingAndThen ()

CollectingAndThen is a special collector that allows performing another action on a result straight after collecting ends.

Let's collect Stream elements to a List instance and then convert the result into an ImmutableList instance:

List result = givenList.stream() .collect(collectingAndThen(toList(), ImmutableList::copyOf))

3.6. Collectors.joining()

Joining collector can be used for joining Stream elements.

We can join them together by doing:

String result = givenList.stream() .collect(joining());

which will result in:

"abbcccdd"

You can also specify custom separators, prefixes, postfixes:

String result = givenList.stream() .collect(joining(" "));

which will result in:

"a bb ccc dd"

or you can write:

String result = givenList.stream() .collect(joining(" ", "PRE-", "-POST"));

which will result in:

"PRE-a bb ccc dd-POST"

3.7. Collectors.counting()

Counting is a simple collector that allows simply counting of all Stream elements.

Now we can write:

Long result = givenList.stream() .collect(counting());

3.8. Collectors.summarizingDouble/Long/Int()

SummarizingDouble/Long/Int is a collector that returns a special class containing statistical information about numerical data in a Stream of extracted elements.

We can obtain information about string lengths by doing:

DoubleSummaryStatistics result = givenList.stream() .collect(summarizingDouble(String::length));

In this case, the following will be true:

assertThat(result.getAverage()).isEqualTo(2); assertThat(result.getCount()).isEqualTo(4); assertThat(result.getMax()).isEqualTo(3); assertThat(result.getMin()).isEqualTo(1); assertThat(result.getSum()).isEqualTo(8);

3.9. Collectors.averagingDouble/Long/Int()

AveragingDouble/Long/Int is a collector that simply returns an average of extracted elements.

We can get average string length by doing:

Double result = givenList.stream() .collect(averagingDouble(String::length));

3.10. Collectors.summingDouble/Long/Int()

SummingDouble/Long/Int is a collector that simply returns a sum of extracted elements.

We can get a sum of all string lengths by doing:

Double result = givenList.stream() .collect(summingDouble(String::length));

3.11. Collectors.maxBy()/minBy()

MaxBy/MinBy collectors return the biggest/the smallest element of a Stream according to a provided Comparator instance.

We can pick the biggest element by doing:

Optional result = givenList.stream() .collect(maxBy(Comparator.naturalOrder()));

Notice that returned value is wrapped in an Optional instance. This forces users to rethink the empty collection corner case.

3.12. Collectors.groupingBy()

GroupingBy collector is used for grouping objects by some property and storing results in a Map instance.

We can group them by string length and store grouping results in Set instances:

Map
    
      result = givenList.stream() .collect(groupingBy(String::length, toSet()));
    

This will result in the following being true:

assertThat(result) .containsEntry(1, newHashSet("a")) .containsEntry(2, newHashSet("bb", "dd")) .containsEntry(3, newHashSet("ccc")); 

Notice that the second argument of the groupingBy method is a Collector and you are free to use any Collector of your choice.

3.13. Collectors.partitioningBy()

PartitioningBy is a specialized case of groupingBy that accepts a Predicate instance and collects Stream elements into a Map instance that stores Boolean values as keys and collections as values. Under the “true” key, you can find a collection of elements matching the given Predicate, and under the “false” key, you can find a collection of elements not matching the given Predicate.

You can write:

Map
    
      result = givenList.stream() .collect(partitioningBy(s -> s.length() > 2))
    

Which results in a Map containing:

{false=["a", "bb", "dd"], true=["ccc"]} 

3.14. Collectors.teeing()

Let's find the maximum and minimum numbers from a given Stream using the collectors we've learned so far:

List numbers = Arrays.asList(42, 4, 2, 24); Optional min = numbers.stream().collect(minBy(Integer::compareTo)); Optional max = numbers.stream().collect(maxBy(Integer::compareTo)); // do something useful with min and max

Here, we're using two different collectors and then combining the result of those two to create something meaningful. Before Java 12, in order to cover such use cases, we had to operate on the given Stream twice, store the intermediate results into temporary variables and then combine those results afterward.

Fortunately, Java 12 offers a built-in collector that takes care of these steps on our behalf: all we have to do is provide the two collectors and the combiner function.

Since this new collector tees the given stream towards two different directions, it's called teeing:

numbers.stream().collect(teeing( minBy(Integer::compareTo), // The first collector maxBy(Integer::compareTo), // The second collector (min, max) -> // Receives the result from those collectors and combines them ));

This example is available on GitHub in the core-java-12 project.

4. Custom Collectors

If you want to write your Collector implementation, you need to implement Collector interface and specify its three generic parameters:

public interface Collector {...}
  1. T – the type of objects that will be available for collection,
  2. A – the type of a mutable accumulator object,
  3. R – the type of a final result.

Let's write an example Collector for collecting elements into an ImmutableSet instance. We start by specifying the right types:

private class ImmutableSetCollector implements Collector
    
      {...}
    

Since we need a mutable collection for internal collection operation handling, we can't use ImmutableSet for this; we need to use some other mutable collection or any other class that could temporarily accumulate objects for us.

In this case, we will go on with an ImmutableSet.Builder and now we need to implement 5 methods:

  • Supplier supplier()
  • BiConsumer accumulator()
  • BinaryOperator combiner()
  • Function finisher()
  • Set characteristics()

The supplier()method returns a Supplier instance that generates an empty accumulator instance, so, in this case, we can simply write:

@Override public Supplier
    
      supplier() { return ImmutableSet::builder; } 
    

The accumulator() method returns a function that is used for adding a new element to an existing accumulator object, so let's just use the Builder‘s add method.

@Override public BiConsumer
    
      accumulator() { return ImmutableSet.Builder::add; }
    

The combiner()method returns a function that is used for merging two accumulators together:

@Override public BinaryOperator
    
      combiner() { return (left, right) -> left.addAll(right.build()); }
    

The finisher() method returns a function that is used for converting an accumulator to final result type, so in this case, we will just use Builder‘s build method:

@Override public Function
    
      finisher() { return ImmutableSet.Builder::build; }
    

La méthode features () est utilisée pour fournir à Stream des informations supplémentaires qui seront utilisées pour les optimisations internes. Dans ce cas, nous ne prêtons pas attention à l'ordre des éléments dans un Set afin d'utiliser Characteristics.UNORDERED . Pour obtenir plus d'informations sur ce sujet, consultezJavaDoc de Characteristics .

@Override public Set characteristics() { return Sets.immutableEnumSet(Characteristics.UNORDERED); }

Voici l'implémentation complète avec l'utilisation:

public class ImmutableSetCollector implements Collector
    
      { @Override public Supplier
     
       supplier() { return ImmutableSet::builder; } @Override public BiConsumer
      
        accumulator() { return ImmutableSet.Builder::add; } @Override public BinaryOperator
       
         combiner() { return (left, right) -> left.addAll(right.build()); } @Override public Function
        
          finisher() { return ImmutableSet.Builder::build; } @Override public Set characteristics() { return Sets.immutableEnumSet(Characteristics.UNORDERED); } public static ImmutableSetCollector toImmutableSet() { return new ImmutableSetCollector(); }
        
       
      
     
    

et ici en action:

List givenList = Arrays.asList("a", "bb", "ccc", "dddd"); ImmutableSet result = givenList.stream() .collect(toImmutableSet());

5. Conclusion

Dans cet article, nous avons exploré en profondeur les collecteurs de Java 8 et montré comment en implémenter un. Assurez-vous de vérifier l'un de mes projets qui améliore les capacités de traitement parallèle en Java.

Tous les exemples de code sont disponibles sur le GitHub. Vous pouvez lire des articles plus intéressants sur mon site.