Java 8 Collectors: Comment faire des « groupy by », agrégations ou Collect?

Ce billet présente les collectors liés à la (belle) nouvelle API Collections de Java 8.
Hélas, peu de rappel théorique sera fait ici.
Incontestablement, l'API Collection de java 8 apporte un design totalement remanié en abandonnant l'iterator au profit des streams (programmation fonctionnelle).
Nous donnons ici un certain nombre de cas pratiques avec détails permettant de saisir la notion collector.
Et nous enchaînons dans la seconde partie sur des exemples avancés.

Prérequis: avoir des connaissances sur la notion stream dans java 8.

 

Stream.collect est une méthode qui modifie une valeur existante (voir Oracle); donc, elle ne crée rien.

Elle retourne néanmoins une seule valeur.

Sommaire des notions abordées:

  • Exemples simples de collect,
  • Group by un attribut d'un bean,
  • Group by plusieurs attributs,
  • Exemples avancés de collect,
  • Personnaliser un collector.

 

Présentation de l'interface Collector

 

Démarrons avec l'interface riche de Collector dont voici le code:

/* @see Stream#collect(Collector)
* @see Collectors
* @since 1.8
*/
public interface Collector<T, A, R> {
/**
* A function that creates and returns a new mutable result container.
* @return a function which returns a new, mutable result container
*/
Supplier<A> supplier();
/**
* A function that folds a value into a mutable result container.
* @return a function which folds a value into a mutable result container
*/
BiConsumer<A, T> accumulator();
/**
* A function that accepts two partial results and merges them.  The
* combiner function may fold state from one argument into the other and
* return that, or may return a new result container.
* @return a function which combines two partial results into a combined
* result
*/
BinaryOperator<A> combiner();
/**
* Perform the final transformation from the intermediate accumulation type
* {@code A} to the final result type {@code R}.
* <p>If the characteristic {@code IDENTITY_TRANSFORM} is
* set, this function may be presumed to be an identity transform with an
* unchecked cast from {@code A} to {@code R}.
* @return a function which transforms the intermediate result to the final
* result
*/
Function<A, R> finisher();
}

Cette interface contient quatre méthodes: supplier, accumulator, combiner, finisher.

La fonction combiner est utilisée dans le contexte parallèle.

 

Avant de passer à une implémentation, voyons ce que dit la javadoc:

Interface Collector<T,A,R>

Type Parameters:
T - the type of input elements to the reduction
A - the mutable accumulation type of the reduction
R - the result type of the reduction

public interface Collector<T,A,R>
/*
A mutable reduction operation that accumulates input elements into a mutable result container, 
optionally transforming the accumulated result into a final representation after all input elements have been processed.
Reduction operations can be performed either sequentially or in parallel.
Examples of mutable reduction operations include: 
  accumulating elements into a Collection;
  concatenating strings using a StringBuilder;
  computing summary information about  elements such as sum, min, max, or average;
 computing "pivot table" summaries such as "maximum valued transaction by seller", etc. The class Collectors provides implementations of many common mutable reductions. 
A Collector is specified by four functions that work together to accumulate entries into a mutable result container, and optionally perform a final transform on the result. They are: 
•creation of a new result container (supplier())
•incorporating a new data element into a result container (accumulator())
•combining two result containers into one (combiner())
•performing an optional final transform on the container (finisher())
In addition to the predefined implementations in Collectors, the static factory methods of(Supplier, BiConsumer, BinaryOperator, Characteristics...) can be used to construct collectors. For example, you could create a collector that accumulates widgets into a TreeSet with:
     Collector<Widget, ?, TreeSet<Widget>> intoSet =
         Collector.of(TreeSet::new, TreeSet::add,
                      (left, right) -> { left.addAll(right); return left; });
 
API Note:Performing a reduction operation with a Collector should produce a result equivalent to: 
     R container = collector.supplier().get();
     for (T t : data)
         collector.accumulator()
                .accept(container, t);
      return collector
                     .finisher()
                    .apply(container);

However, the library is free to partition the input, perform the reduction on the partitions, and then use the combiner function to combine the partial results to achieve a parallel reduction. 
Collectors are designed to be composed; many of the methods in Collectors are functions that take a collector and produce a new collector. For example, given the following collector that computes the sum of the salaries of a stream of employees: 

     Collector<Employee, ?, Integer> summingSalaries= Collectors.summingInt(Employee::getSalary))

If we wanted to create a collector to tabulate the sum of salaries by department, we could reuse the "sum of salaries" logic using Collectors.groupingBy(Function, Collector): 
     Collector<Employee, ?, Map<Department, Integer>> summingSalariesByDept
         = Collectors.groupingBy(
            Employee::getDepartment,
            summingSalaries); 
Since:1.8
See Also:Stream.collect(Collector), Collectors

 

Présentation de l'implémentation Collectors:

 

La classe java.util.stream.Collectors est une implémentation de l'interface Collector.

Nous allons utiliser ses méthodes dans les cas pratiques ci-dessous.

En particulier goupingBy:

/* Implementations of {@link Collector} that implement various useful reduction
* operations, such as accumulating elements into collections, summarizing
* elements according to various criteria, etc.
*/
public final class Collectors {
...

public static <T,K> Collector<T,?,Map<K,List<T>>>
 groupingBy(Function<? super T,? extends K> classifier)
/*
Returns a Collector implementing a "group by" operation on input elements of type T, grouping elements according to a classification function, and returning the results in a Map. 
The classification function maps elements to some key type K. The collector produces a Map<K, List<T>> whose keys are the values resulting from applying the classification function to the input elements, and whose corresponding values are Lists containing the input elements which map to the associated key under the classification function. 
There are no guarantees on the type, mutability, serializability, or thread-safety of the Map or List objects returned.
Implementation Requirements:This produces a result similar to: 
     groupingBy(classifier, toList());
Implementation Note:The returned Collector is not concurrent. For parallel stream pipelines, the combiner function operates by merging the keys from one map into another, which can be an expensive operation. If preservation of the order in which elements appear in the resulting Map collector is not required, using groupingByConcurrent(Function) may offer better parallel performance.
*/
}

Avant de passer à la démo, rappelons que nous utilisons la méthode (de reduction) java.util.stream.Stream.collect:

/**
* Performs a <a href="package-summary.html#MutableReduction">mutable
* reduction</a> operation on the elements of this stream using a
* {@code Collector}.  A {@code Collector}
* encapsulates the functions used as arguments to
* {@link #collect(Supplier, BiConsumer, BiConsumer)}, allowing for reuse of
* collection strategies and composition of collect operations such as
* multiple-level grouping or partitioning.
*
* <p>This is a <a href="package-summary.html#StreamOps">terminal
* operation</a>.
*
* @param <R> the type of the result
* @param <A> the intermediate accumulation type of the {@code Collector}
* @param collector the {@code Collector} describing the reduction
* @return the result of the reduction
* @see #collect(Supplier, BiConsumer, BiConsumer)
* @see Collectors
*/
<R, A> R collect(Collector<? super T, A, R> collector);

 

CAS PRATIQUES DE COLLECTOR

 

Les cas pratiques ci-après utilisent ces POJO simples créés facilement via le framework (optionnel) Lombok:

Donnees.java:

@AllArgsConstructor
@Data
public class Donnees {

private BigDecimal montant;

private String codeAvantage;

private String codeDepartement;
}

 

Couple.java:

 @AllArgsConstructor
@Data
@EqualsAndHashCode
public class Couple {

private String codeAvantage;
private String codeDepartement;
}

Note importante.
Notez la présence de l'annotation @EqualsAndHashCode nécessaire pour la suite. Ceci génère les surcharges des deux méthodes boolean Equals(Object obj) & int hashCode ().

Plusieurs méthodes utiles sont là afin de préparer les jeux de données pour l'ensemble des tests JUnit ci-après.
Ces méthodes sont regroupées dans cette classe utilitaire:

package demo;
import java.math.BigDecimal;
import java.util.*;
import demo.model.Donnees;

public class UtilsTest {
final static BigDecimal ONE = BigDecimal.ONE;
final static BigDecimal TEN = BigDecimal.TEN;
public static List<Donnees> prepareData() {
final List<Donnees> inputSet=new ArrayList<>();
Donnees dcData = new Donnees(ONE,"Av1","Dpt1");
inputSet.add(dcData);
dcData = new Donnees(TEN,"Av1","Dpt1");
inputSet.add(dcData);
dcData = new Donnees(TEN,"Av1","Dpt1");
inputSet.add(dcData);
dcData = new Donnees(TEN,"Av2","Dpt2");
inputSet.add(dcData);
dcData = new Donnees(TEN,"Av2","Dpt2");
inputSet.add(dcData);
dcData = new Donnees(TEN,"Av2","Dpt2");
inputSet.add(dcData);
return inputSet;
}
public static List<Donnees> prepareDataForTest2() {
final List<Donnees> inputSet= prepareData();
Donnees dcData = new Donnees(TEN,"Av2","Dpt3");
inputSet.add(dcData);
return inputSet;
}
}

 

 

Premier exemple: Group by un attribut du bean

 

Pour faciliter la découverte des Collectors, voici un premier exemple détaillé. Ecrivons un test Junit comme suit:

package demo;
import static org.junit.Assert.*;
import java.util.*;
import java.util.Map;
import java.util.stream.Collectors;
import org.junit.Test;
import demo.model.Donnees;
public class Test1AgregateGroupBy {
@Test
public void test1GroupBy() {
List<Donnees> inputSet = UtilsTest.prepareData();
Map<String, List<Donnees>> map = inputSet.stream()
   .collect(
      Collectors
     .groupingBy(Donnees::getCodeAvantage)
);
assertEquals(2,map.keySet().size());
}
}

 

Si on affiche le contenu de la map, on obtiendrai ceci:

{
Av2=
[
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt2),
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt2),
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt2)
],
Av1=[
Donnees(montant=1, codeAvantage=Av1, codeDepartement=Dpt1),
Donnees(montant=10, codeAvantage=Av1, codeDepartement=Dpt1),
Donnees(montant=10, codeAvantage=Av1, codeDepartement=Dpt1)
]
}

Je vous laisse imaginer le code et l'énergie nécessaires (j'exagère à peine) pour ce "group by" en java 7 ou -.

 

Second exemple: Group by un attribut et adaptation des retours

 

Dans ce deuxième test, nous compliquons un petit chouïa les choses en ce sens:
Nous voulons regrouper par codeAvantage puis par codeDepartement afin d'obtenir ce résultat:

{

Av2=
{
Dpt2=[
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt2),
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt2),
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt2)
],
Dpt3=[
Donnees(montant=10, codeAvantage=Av2, codeDepartement=Dpt3)
]
},
Av1=
{
Dpt1=[
Donnees(montant=1, codeAvantage=Av1, codeDepartement=Dpt1),
Donnees(montant=10, codeAvantage=Av1, codeDepartement=Dpt1),
Donnees(montant=10, codeAvantage=Av1, codeDepartement=Dpt1)
]
}
}

Pour cela, nous avons préparé les jeux donnés via la méthode prepareDataForTest2 fournie précédemment.

Voici maintenant le code complet du second test Junit:

package demo;
import static demo.UtilsTest.*;
import static org.junit.Assert.*;
import java.util.*;
import java.util.stream.Collectors;
import org.junit.Test;
import demo.model.Donnees;

public class Test2AgregateGroupBy {

@Test
public void test2GroupBy() {
List<Donnees> inputSet = prepareDataForTest2();

//ici c est une map avec clé un String et value une autre map.
Map<String, Map<String, List<Donnees>>> map = inputSet.stream()
.collect(
   Collectors
   .groupingBy
     (
      Donnees::getCodeAvantage,
       Collectors.groupingBy
       (
        Donnees::getCodeDepartement
       )
   )
);
assertNotNull(map);
assertEquals(2,map.keySet().size());
Iterator<String> it = map.keySet().iterator();
Map<String, List<Donnees>> anotherMap= map.get(it.next());
assertEquals(2, anotherMap.keySet().size());
}
}

 

EXEMPLES AVANCES

 

Group by plusieurs attributs et la somme (BigDecimal)

Dans cette première partie de cas pratiques avancés, on s'intèresse à un thème fort intéressant et peu traité.
Comment faire des "group by" à l'aide de plusieurs attributs (critères).

La première variante ci-dessous traite l'agrégation par deux attributs: codes d'avantage et de département.
Une fois l'agrégat est établi on enchaîne avec le total des montants (opération de sommation sur  BigDecimal).
La seconde variante, utile pour raison pédagogique, établit le total en double.

Voici le code complet du test Junit avec la première variante:

@Test public void testAv1GroupBy2AttributesThenSum(){
List<Donnees> inputSet = prepareData();
//step 1. agregate
Map<Couple, List<Donnees>> result =
inputSet.stream()
.collect(
  Collectors
  .groupingBy(
     dd ->new Couple(
     dd.getCodeAvantage(),
     dd.getCodeDepartement()
   )
)
);
//step 2: sum
BigDecimal total= ZERO;
for(Couple key:result.keySet()){
BigDecimal montant =
result.get(key)
.stream()
.map(Donnees::getMontant)
.reduce(ZERO,  (x,y)-> x.add(y));
total= total.add(montant);
}
Assert.assertEquals(
total.setScale(2, RoundingMode.HALF_UP)
.doubleValue(), 51.0,0.
);
}

 

Le code de la seconde variante :

@Test public void testGroupByTwoAttributesSumDouble(){
List<Donnees> inputSet = prepareData();

Map<Couple, List<Donnees>> result =
inputSet.stream()
 .collect(
      Collectors
           .groupingBy(
             dd -> new Couple(
               dd.getCodeAvantage(),
               dd.getCodeDepartement())
      )
);
Double somme=0.;
for(Couple key:result.keySet()){
   Double montant = result.get(key).stream()
    .map(Donnees::getMontant)
    .map(BigDecimal::doubleValue)
     .reduce(0., (x,y)->x+y);
     somme+=montant;
}
Assert.assertEquals(somme, 51.0,0.);
}

 

 

Personnaliser le collector

 

Voici enfin un test JUnit permettant de personnaliser un collector:

@Slf4j
public class TestAvance2ParallelAgregateFromJavadocExtended {
	static List<String> liste = Arrays.asList("A. ","CHINE ", "PARIS ", "FRANCE");

	@Test public void test2AvanceParallel(){
		MyClazz result = liste.parallelStream()
		.collect(
		     MyClazz::new,  
                     MyClazz::accept,
		     MyClazz::combine
		);
		assertNotNull(result);
		log.info("Result: "+Thread.currentThread().getName()+ " result: "+result);
	assertEquals(liste.size() ,result.getCount());
	}	
	@Data
	class MyClazz implements Consumer<String>{
		StringBuilder value=new StringBuilder();
		private int count=0;
		@Override
		public void accept(String t) {
			log.info("Consumer before: "+Thread.currentThread().getName()+ " value=> "+value+ " count="+count);
			value.append(t);
			count++;
			log.info("Consumer after: "+Thread.currentThread().getName()+ " value=> "+value+ " count="+count);
		}
	//only for parallel stream
	public void combine(MyClazz clazz){
	  log.info("Combiner before: "
         +Thread.currentThread().getName()
         + " value=> "+value
         + " count="+count);
	value.append(clazz.getValue());
	count+=clazz.getCount();
	log.info("Combiner after: "
       +Thread.currentThread().getName()
       +" value=> "+value
       + " count="+count);
  }
 }
}

 

La prochaine fois nous irons plus loin dans la personnalisation du collector.

Nous y reviendrons aussi pour des éclairages sur l'exécution parallèle des streams.

 

Un commentaire

Laisser un commentaire

Votre adresse de messagerie ne sera pas publiée. Les champs obligatoires sont indiqués avec *

Captcha *