Dernière modification : 29/08/2022

 Collections et Stream avec Java

Utilisation

 

Après avoir vu dans l'article précédent comment créer un Stream, nous allons maintenant aborder comment les manipuler.

1. Avant de commencer

Grossièrement, le Stream permet de parcourir et traiter un ensemble d'éléments (filtre, mapping, ...). Cependant, il est intéressant avant toute chose, d'expliciter quelques bases sur le Stream :

  • Il ne stock pas les données car elles sont obtenues "à la volée".
  • Il ne modifie pas les données en entrées (et on ne doit pas modifier les données en entrées sous peine de le perturber)
  • Comme vu dans l'article précédent, il est possible de créer des Stream infini.
  • Le Stream exécute deux types de traitements :
    • intermédiaires : map / filter
    • terminal : sum, max, foreach (reduce)
  • Il s'exécute uniquement quand cela est nécessaire : lors de l'appel aux traitements terminaux (lazyness).
  • Un Stream ne peut être utilisé qu'une seule fois.
  • Les traitements peuvent (non obligatoire) s'exécuter en parallèle.

Création de quelques classes utiles pour les futurs exemples. La classe Department représente le département et Person représente une personne. Un département a un id, un nom et une liste de personnes. Une personne a un id, un nom, un prénom et un âge.

import java.util.List;

class Department {
	private long id;
	private String name;
	private List persons;

	public Department(long id, String name, List persons) {
		super();
		this.id = id;
		this.name = name;
		this.persons = persons;
	}

	public long getId() {
		return id;
	}

	public String getName() {
		return name;
	}

	public List getPersons() {
		return persons;
	}
}

class Person {
	private long id;
	private String firstName;
	private String lastName;
	private int age;

	public Person(long id, String firstName, String lastName, int age) {
		super();
		this.id = id;
		this.firstName = firstName;
		this.lastName = lastName;
		this.age = age;
	}

	public long getId() {
		return id;
	}

	public String getFirstName() {
		return firstName;
	}

	public String getLastName() {
		return lastName;
	}
	
	public int getAge() {
		return age;
	}
}

 

2. Parcourir un Stream

Le parcours d'un Stream peut s'effectuer via la méthode forEach.

Définition de la méthode forEach dans java.util.stream.Stream :

default void forEach(Consumer action);

Exemple d'utilisation :

List firstName = Arrays.asList("Alex", "Claire", "Angel");
firstName.forEach(System.out::println); // Référence de méthode "::"
firstName.forEach(x -> System.out.println(x)); // Lambda expression

/* Output x 2 :
Alex
Claire
Angel */

 

3. Le mapping

Le mapping est la transformation d'un objet de type T en objet de type R. Ici, la méthode Stream.map permet de transformer un Stream en Stream. Définition de la méthode map dans java.util.stream.Stream :

 Stream map(Function mapper);

 

3.1 Récupération d'un objet A d'un objet B

Grâce au mapping et aux références des fonctions ( :: ), nous pouvons facilement récupérer un objet contenu dans un autre. Ici nous récupérons la valeur firstName contenu dans la liste d'objet Person.

List persons = Arrays.asList(new Person(0,"Alex", "Brown", 30), new Person(1, "Claire", "Jones", 29));
List firstNames = persons.stream()
    .map(Person::getFirstName) // ou .map(person -> person.getFirstName())
    .collect(Collectors.toList());

 

3.2 Transformation/manipulation d'un objet

La méthode map sert également à réaliser des transformations d'objets. Ici, nous allons transformer une liste de String en majuscule.

List firstName = Arrays.asList("Alex", "Claire", "Angel");

List firstNameUpperCase = firstName.stream()
    .map(String::toUpperCase)
    .collect(Collectors.toList());

System.out.println(firstNameUpperCase); // Output: [ALEX, CLAIRE, ANGEL]

 

3.3 Mapping primitif

Les Stream permettent de réaliser des mapping avec des types primitifs int, long et double nativement. Définition des méthodes de mapping primitifs dans java.util.stream.Stream :

IntStream mapToInt(ToIntFunction mapper);
LongStream mapToLong(ToLongFunction mapper);
DoubleStream mapToDouble(ToDoubleFunction mapper);

Exemple d'utilisation :

List persons = Arrays.asList(new Person(0, "Alex", "Brown", 30), new Person(1, "Claire", "Jones", 29));
Long maxAge = persons.stream().mapToLong(Person::getAge).max().orElse(0);
System.out.println(maxAge); // Output: 30

Attention, pour transformer un LongStream (IntStream ou DoubleStream) en List, il est nécessaire au préalable d'utiliser la méthode boxed() qui converti l'objet primitif en sa version objet Stream. Exemple d'utilisation :

List persons = Arrays.asList(new Person(0, "Alex", "Brown", 30), new Person(1, "Claire", "Jones", 29));
List ages = persons.stream().mapToInt(Person::getAge).boxed().collect(Collectors.toList());
System.out.println(ages); // Output: [30, 29]

Un des intérêt d'utiliser ces Stream est qu'ils implémentent déjà plusieurs méthodes comme max / min par exemple.

 

3.4 Aplatissement de Stream

La méthode Stream.flatMap permet d'aplatir un Stream :

 Stream flatMap(Function> mapper);

Exemples d'utilisation :

List myList1 = Arrays.asList("Alex", "Claire", "Angel");
List myList2 = Arrays.asList("John", "Miller", "Brown");

// Liste de liste
List> myListOfList = Arrays.asList(myList1, myList2);
List myResult = myListOfList.stream().flatMap(List::stream).collect(Collectors.toList());
System.out.println(myResult); // Output: [Alex, Claire, Angel, John, Miller, Brown]

// Optional.OfNullable (Java 9)
List myResult2 = Optional.ofNullable(myList1).stream().flatMap(List::stream).collect(Collectors.toList());
System.out.println(myResult2); // Output: [Alex, Claire, Angel]

// Stream.OfNullable (Java 9)
List myResult3 = Stream.ofNullable(myList1).flatMap(List::stream).collect(Collectors.toList());
System.out.println(myResult3); // Output: [Alex, Claire, Angel]

 

4. Réduction

Afin d'agréger un Stream à un seul élément en appliquant une fonction, la méthode Stream.reduce peut être appelée. Le reduce est un traitement terminal, c'est à dire qu'il va déclencher l'exécution du Stream. Les méthode min et max par exemple appellent cette méthode.

Définitions des méthodes reduce :

T reduce(T identity, BinaryOperator accumulator);
Optional reduce(BinaryOperator accumulator);
 U reduce(U identity, BiFunction accumulator, BinaryOperator combiner);

Exemples d'utilisation :

Integer maxAge = persons.stream().reduce((p1, p2) -> p1.getAge() > p2.getAge() ? p1 : p2).map(Person::getAge).orElse(0);
System.out.println(maxAge); // Output: 30

System.out.println(IntStream.range(0, 5).reduce((x, y) -> x + y)); // Output: OptionalInt[10] <= (1+2+3+4)

System.out.println(IntStream.range(0, 5).reduce(5, (x, y) -> x + y)); // Output: 15 <= 5 + (1+2+3+4)

System.out.println(Stream.of(1, 2, 3, 4).reduce(5, (a, b) -> a + b, (a, b) -> { // combiner not called, only in parralele. Output: 15 <= 5 + (1+2+3+4)
    return a + b;
}));

System.out.println(Stream.of(1, 2, 3, 4).parallel().reduce(5, (a, b) -> a + b, (a, b) -> { // Combiner B Combiner B. Output: 30 <= (((5+1) + (5+2)) + ((5+3) + (5+4)))
    return a + b;
}));

Attention : La fonction de combinaison appelée en mode parallèle peut facilement induire en erreur (voir les sommes ci-dessus : 30 et non 15) à cause des étapes de combinaisons.

 

4.1 Les méthodes de réductions utiles

Stream :

Méthode Description
min Retourne l'élément minimale du stream. Comparateur à fournir
max Retourne l'élément maximale du stream. Comparateur à fournir
count Retourne le nombre d'éléments du stream
collect Renvoie un conteneur mutable contenant la transformation du stream contenant le résultat des traitements
toArray Renvoie un tableau contenant la transformation du stream contenant le résultat des traitements

 

Types Stream primitifs (IntStream ...) :

Méthode Description
min Retourne l'élément minimal du stream.
max Retourne l'élément maximal du stream.
count Retourne le nombre d'élément du stream
sum Somme
average Moyenne
summaryStatistics Retourne un SummaryStatistics encapsulant différentes statistiques calculées du Stream
collect Renvoie un conteneur mutable contenant la transformation du stream contenant le résultat des traitements
toArray Renvoie un tableau contenant la transformation du stream contenant le résultat des traitements

 

5. La méthode collect

La méthode collect permet la transformation d'un Stream dans un conteneur (Collection, List, Set, Map) mutable (modifiable) contenant les traitements de réduction (reduce). Ses définitions sont les suivantes :

 R collect(Supplier supplier, BiConsumer accumulator, BiConsumer combiner);
 R collect(Collector collector);

 

5.1 Convertir un Stream en une Collection, une List ou un Set

Stream myStream = Stream.of("Alex", "Claire", "Angel");
List myList = myStream.map(String::toUpperCase).collect(Collectors.toList()); // Java >= 8
List myList = myStream.map(String::toUpperCase).toList(); // Java >= 16

 

5.2 Convertir un Stream en String

La méthode Collectors.joining concatène le contenu du Stream. Ses définitions sont les suivantes :

public static Collector joining();
public static Collector joining(CharSequence delimiter);
public static Collector joining(CharSequence delimiter, CharSequence prefix, CharSequence suffix);

Exemple d'utilisation :

Stream myStream = Stream.of("Alex", "Claire", "Angel");
String myString = myStream.map(String::toUpperCase).collect(Collectors.joining(",", "{", "}"));
System.out.println(myString); // Output: {ALEX,CLAIRE,ANGEL}

 

5.3 Extraire des somme, des moyennes et des statistiques

La classe Collectors offre pour les trois types primitifs int, long et double des méthodes pour sommer (Collectors.summarizingXXX), réaliser des moyennes (Collectors.averagingXXX) et obtenir des statistiques (Collectors.summarizingXXX) (XXX étant soit Int, Long ou Double).

Exemples d'utilisation :

// init
List persons = Arrays.asList(new Person(0, "Alex", "Brown", 30), new Person(1, "Claire", "Jones", 29));
// somme
System.out.println(persons.stream().collect(Collectors.summingInt(Person::getAge))); // Output: 59
// moyenne
System.out.println(persons.stream().collect(Collectors.averagingInt(Person::getAge))); // Output: 29.5
// stat
System.out.println(persons.stream().collect(Collectors.summarizingInt(Person::getAge))); // Output: IntSummaryStatistics{count=2, sum=59, min=29, average=29,500000, max=30}

 

5.4 Regrouper les données

Comme en SQL, il est possible de réaliser des regroupements sur les Stream. Ils sont réalisés via méthode Collectors.groupingBy.

Exemples d'utilisations :

// group by age
// Map result : {29=[tools.Person@4eec7777], 30=[tools.Person@3b07d329, tools.Person@41629346, tools.Person@404b9385, tools.Person@6d311334]}
Map> myList1 = persons.stream().collect(Collectors.groupingBy(Person::getAge));
Map> myList1_2 = persons.stream().collect(Collectors.groupingBy(Person::getAge, Collectors.toSet())); // Pareil pour avoir une liste

// group by age and firstName
// Map result : {29={Claire=[tools.Person@4eec7777]}, 30={Alex=[tools.Person@3b07d329], Angel=[tools.Person@41629346, tools.Person@404b9385, tools.Person@6d311334]}}
Map>> myList2 = persons.stream().collect(Collectors.groupingBy(Person::getAge, Collectors.groupingBy(Person::getFirstName)));

// group by age and gettint the average
// Map result : {29=29.0, 30=30.0}
Map myList3 = persons.stream().collect(Collectors.groupingBy(Person::getAge, Collectors.averagingInt(Person::getAge)));

 Il est également possible de récupérer certains éléments issus de la comparaison :

// Init
List persons2 = Arrays.asList(new Person(0, "Alex", "Brown", 30),
		new Person(0, "Claire", "Jones", 29), 
		new Person(1, "Angel", "Mike", 30),
		new Person(1, "Angel", "Mike1", 31));

// Récupération de la personne ayant l'age maximale regroupé par ID
Map> myList4 = persons2.stream().collect(Collectors.groupingBy(Person::getId, Collectors.maxBy(Comparator.comparingInt(Person::getAge))));
myList4.entrySet().stream()
.forEach(entry -> System.out.println(entry.getKey() + " : " + entry.getValue().get().getAge()));
/**
 * Output:
 * 0 : 30
 * 1 : 31
 */

 

6. Les filtres

Les filtres sont effectués via la méthode Stream.filter qui a la définition suivante :

Stream filter(Predicate predicate);

Exemple d'utilisation :

// Initialisation
Department departement1 = new Department(13, "PACA", Arrays.asList(new Person(0, "Alex", "Brown", 30), new Person(1, "Claire", "Jones", 29)));
Department departement2 = new Department(03, "Alpes-de-Haute-Provence", Arrays.asList(new Person(0, "Angel", "Davies", 30), new Person(1, "Wilson", "Miller", 60)));
List departements = Arrays.asList(departement1, departement2);

// Filtre des personne de moins de 40 ans
List yongFirstNames = departements.stream().map(Department::getPersons).flatMap(List::stream)
                              .filter(person -> person.getAge() < 40).map(Person::getFirstName).collect(Collectors.toList());

// Affichage
System.out.println("FirstName de toutes les personnes agées de moins de 40 ans : " + yongFirstNames);

 

7. Les Stream parallèles

Pour paralléliser un Stream, Il est nécessaire d'utiliser la méthode Collection.parallelStream ou parallel.

Exemples d'utilisations :

// Creer directement un stream parallele
List firstName = Arrays.asList("Alex", "Claire", "Angel");
List firstNameUpperCase = firstName.parallelStream().map(String::toUpperCase).collect(Collectors.toList());
System.out.println(firstNameUpperCase);
// Output: [ALEX, CLAIRE, ANGEL]

// Modifier un stream pour qu'il devienne parallele
List firstName = Arrays.asList("Alex", "Claire", "Angel");
List firstNameUpperCase = firstName.stream().parallel().map(String::toUpperCase).collect(Collectors.toList());
System.out.println(firstNameUpperCase);
// Output: [ALEX, CLAIRE, ANGEL]

// Parallele pour un type primitif
List firstNameUpperCase = IntStream.range(0, 5).parallel().map(x -> x + 1).boxed().collect(Collectors.toList());
System.out.println(firstNameUpperCase);
// Output: [1, 2, 3, 4, 5]

// Parallele vers sequentiel
IntStream myStream = IntStream.range(0, 5).parallel();
System.out.println(myStream.isParallel()); // true
myStream = myStream.sequential();
System.out.println(myStream.isParallel()); // false

 

8. Conclusion

L'API Stream est élégante et permet de gagner du temps dans l'écriture d'un programme. De plus, lorsqu'ils sont correctement utilisés, les Stream rendent la lecture du code plus claire. Cependant, cela est à double tranchant. Ils peuvent aussi le rendre illisible (surtout pour trouver l'origine d'une anomalie) si l'API n'est pas appliquée correctement.