Java 8 Streams - Collectez vs Réduisez

143

Quand utiliseriez-vous collect()vs reduce()? Quelqu'un a-t-il de bons exemples concrets de moments où il vaut vraiment mieux aller dans un sens ou dans l'autre?

Javadoc mentionne que collect () est une réduction mutable .

Étant donné qu'il s'agit d'une réduction modifiable, je suppose qu'elle nécessite une synchronisation (en interne) qui, à son tour, peut nuire aux performances. Il reduce()est vraisemblablement plus facilement parallélisable au prix d'avoir à créer une nouvelle structure de données pour le retour après chaque étape de la réduction.

Les déclarations ci-dessus sont cependant des conjectures et j'aimerais qu'un expert intervienne ici.

jimhooker2002
la source
1
Le reste de la page à laquelle vous avez lié l'explique: comme pour réduire (), un avantage d'exprimer collect de cette manière abstraite est qu'il est directement accessible à la parallélisation: nous pouvons accumuler des résultats partiels en parallèle puis les combiner, tant que les fonctions d'accumulation et de combinaison satisfont aux exigences appropriées.
JB Nizet
1
voir aussi "Streams in Java 8: Reduce vs. Collect" par Angelika Langer - youtube.com/watch?v=oWlWEKNM5Aw
MasterJoe

Réponses:

115

reduceest une opération " fold ", elle applique un opérateur binaire à chaque élément du flux où le premier argument de l'opérateur est la valeur de retour de l'application précédente et le second argument est l'élément courant du flux.

collectest une opération d'agrégation où une "collection" est créée et chaque élément est "ajouté" à cette collection. Les collections dans différentes parties du flux sont ensuite ajoutées ensemble.

Le document que vous avez lié donne la raison d'avoir deux approches différentes:

Si nous voulions prendre un flux de chaînes et les concaténer en une seule longue chaîne, nous pourrions y parvenir avec une réduction ordinaire:

 String concatenated = strings.reduce("", String::concat)  

Nous obtiendrions le résultat souhaité, et cela fonctionnerait même en parallèle. Cependant, nous ne serons peut-être pas satisfaits de la performance! Une telle implémentation ferait beaucoup de copie de chaînes, et le temps d'exécution serait O (n ^ 2) dans le nombre de caractères. Une approche plus performante consisterait à accumuler les résultats dans un StringBuilder, qui est un conteneur modifiable pour accumuler des chaînes. Nous pouvons utiliser la même technique pour paralléliser la réduction mutable que nous le faisons avec la réduction ordinaire.

Le fait est donc que la parallélisation est la même dans les deux cas, mais dans le reducecas où nous appliquons la fonction aux éléments de flux eux-mêmes. Dans le collectcas où nous appliquons la fonction à un conteneur mutable.

Boris l'araignée
la source
1
Si tel est le cas pour collect: "Une approche plus performante consisterait à accumuler les résultats dans un StringBuilder", alors pourquoi utiliserait-on une fois réduire?
jimhooker2002
2
@ Jimhooker2002 l'a relu. Si, par exemple, vous calculez le produit, la fonction de réduction peut simplement être appliquée aux flux fractionnés en parallèle, puis combinée ensemble à la fin. Le processus de réduction aboutit toujours au type comme flux. La collecte est utilisée lorsque vous souhaitez collecter les résultats dans un conteneur mutable, c'est-à-dire lorsque le résultat est d'un type différent du flux. Cela présente l'avantage qu'une seule instance du conteneur peut être utilisée pour chaque flux fractionné, mais l'inconvénient que les conteneurs doivent être combinés à la fin.
Boris the Spider
1
@ jimhooker2002 dans l'exemple de produit intest immuable , vous ne pouvez donc pas utiliser facilement une opération de collecte. Vous pourriez faire un sale hack comme utiliser une AtomicIntegerou une coutume, IntWrappermais pourquoi le feriez-vous? Une opération de pliage est simplement différente d'une opération de collecte.
Boris the Spider
17
Il existe également une autre reduceméthode, où vous pouvez renvoyer des objets de type différent des éléments du flux.
damluar
1
un autre cas où vous utiliseriez collect au lieu de réduire est lorsque l'opération de réduction implique l'ajout d'éléments à une collection, puis chaque fois que votre fonction d'accumulateur traite un élément, elle crée une nouvelle collection qui inclut l'élément, ce qui est inefficace.
raghu
40

La raison en est simplement que:

  • collect() ne peut fonctionner qu'avec des objets de résultat mutables .
  • reduce()est conçu pour fonctionner avec des objets de résultat immuables .

reduce()Exemple " avec immuable"

public class Employee {
  private Integer salary;
  public Employee(String aSalary){
    this.salary = new Integer(aSalary);
  }
  public Integer getSalary(){
    return this.salary;
  }
}

@Test
public void testReduceWithImmutable(){
  List<Employee> list = new LinkedList<>();
  list.add(new Employee("1"));
  list.add(new Employee("2"));
  list.add(new Employee("3"));

  Integer sum = list
  .stream()
  .map(Employee::getSalary)
  .reduce(0, (Integer a, Integer b) -> Integer.sum(a, b));

  assertEquals(Integer.valueOf(6), sum);
}

collect()Exemple " avec mutable"

Par exemple, si vous souhaitez calculer manuellement une somme en utilisant, collect()il ne peut pas travailler avec BigDecimalmais uniquement avec MutableIntde org.apache.commons.lang.mutablepar exemple. Voir:

public class Employee {
  private MutableInt salary;
  public Employee(String aSalary){
    this.salary = new MutableInt(aSalary);
  }
  public MutableInt getSalary(){
    return this.salary;
  }
}

@Test
public void testCollectWithMutable(){
  List<Employee> list = new LinkedList<>();
  list.add(new Employee("1"));
  list.add(new Employee("2"));

  MutableInt sum = list.stream().collect(
    MutableInt::new, 
    (MutableInt container, Employee employee) -> 
      container.add(employee.getSalary().intValue())
    , 
    MutableInt::add);
  assertEquals(new MutableInt(3), sum);
}

Cela fonctionne car l' accumulateur container.add(employee.getSalary().intValue()); n'est pas censé renvoyer un nouvel objet avec le résultat mais changer l'état du mutable containerde type MutableInt.

Si vous souhaitez utiliser à la BigDecimalplace pour le, containervous ne pouvez pas utiliser la collect()méthode car container.add(employee.getSalary());cela ne changerait pas le containercar BigDecimalil est immuable. (A part cela BigDecimal::newne fonctionnerait pas car BigDecimalil n'y a pas de constructeur vide)

Sandro
la source
2
Notez que vous utilisez un Integerconstructeur ( new Integer(6)), qui est obsolète dans les versions ultérieures de Java.
MC Emperor
1
Bonne prise @MCEmperor! Je l'ai changé enInteger.valueOf(6)
Sandro
@Sandro - Je suis confus. Pourquoi dites-vous que collect () ne fonctionne qu'avec des objets mutables? Je l'ai utilisé pour concaténer des chaînes. Chaîne allNames = Employees.stream () .map (Employee :: getNameString) .collect (Collectors.joining (",")) .toString ();
MasterJoe
1
@ MasterJoe2 C'est simple. En bref - l'implémentation utilise toujours le StringBuilderqui est mutable. Voir: hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/…
Sandro
30

La réduction normale est censée combiner deux valeurs immuables telles que int, double, etc. et en produire une nouvelle; c'est une réduction immuable . En revanche, la méthode collect est conçue pour muter un conteneur pour accumuler le résultat qu'il est censé produire.

Pour illustrer le problème, supposons que vous souhaitiez réaliser en Collectors.toList()utilisant une simple réduction comme

List<Integer> numbers = stream.reduce(
        new ArrayList<Integer>(),
        (List<Integer> l, Integer e) -> {
            l.add(e);
            return l;
        },
        (List<Integer> l1, List<Integer> l2) -> {
            l1.addAll(l2);
            return l1;
        });

C'est l'équivalent de Collectors.toList(). Cependant, dans ce cas, vous modifiez le fichier List<Integer>. Comme nous le savons, le ArrayListn'est pas thread-safe, et il n'est pas sûr d'ajouter / supprimer des valeurs pendant l'itération, de sorte que vous obtiendrez soit une exception simultanée, ArrayIndexOutOfBoundsExceptionsoit tout type d'exception (en particulier lorsqu'il est exécuté en parallèle) lorsque vous mettez à jour la liste ou le combineur essaie de fusionner les listes parce que vous faites muter la liste en y accumulant (en ajoutant) les nombres entiers. Si vous voulez rendre ce thread-safe, vous devez passer une nouvelle liste à chaque fois, ce qui nuirait aux performances.

En revanche, les Collectors.toList()travaux de la même manière. Cependant, il garantit la sécurité des threads lorsque vous accumulez les valeurs dans la liste. De la documentation de la collectméthode :

Effectue une opération de réduction mutable sur les éléments de ce flux à l'aide d'un collecteur. Si le flux est parallèle et que le collecteur est simultané, et que le flux n'est pas ordonné ou que le collecteur n'est pas ordonné, une réduction simultanée sera effectuée. Lorsqu'ils sont exécutés en parallèle, de multiples résultats intermédiaires peuvent être instanciés, peuplés et fusionnés de manière à maintenir l'isolement des structures de données mutables. Par conséquent, même lorsqu'elle est exécutée en parallèle avec des structures de données non thread-safe (telles que ArrayList), aucune synchronisation supplémentaire n'est nécessaire pour une réduction parallèle.

Donc, pour répondre à votre question:

Quand utiliseriez-vous collect()vs reduce()?

si vous avez des valeurs immuables telles que ints, doubles, Stringspuis la réduction normale fonctionne très bien. Cependant, si vous avez reducevos valeurs dans disons une List(structure de données mutable), vous devez utiliser la réduction mutable avec la collectméthode.

George
la source
Dans l'extrait de code, je pense que le problème est qu'il prendra l'identité (dans ce cas, une seule instance d'un ArrayList) et supposera qu'il est "immuable" afin qu'ils puissent démarrer des xthreads, chacun "ajoutant à l'identité" puis se combinant. Bon exemple.
rogerdpack
pourquoi nous obtiendrions une exception de modification concurrente, l'appel des flux ne fera que retransmettre le flux série et ce qui signifie qu'il sera traité par un seul thread et que la fonction de combinaison n'est pas du tout appelée?
amarnath harish
public static void main(String[] args) { List<Integer> l = new ArrayList<>(); l.add(1); l.add(10); l.add(3); l.add(-3); l.add(-4); List<Integer> numbers = l.stream().reduce( new ArrayList<Integer>(), (List<Integer> l2, Integer e) -> { l2.add(e); return l2; }, (List<Integer> l1, List<Integer> l2) -> { l1.addAll(l2); return l1; });for(Integer i:numbers)System.out.println(i); } }J'ai essayé et je n'ai pas eu d'exception
CCm
@amarnathharish le problème se produit lorsque vous essayez de l'exécuter en parallèle et que plusieurs threads essaient d'accéder à la même liste
george
11

Soit le flux a <- b <- c <- d

En réduction,

vous aurez ((a # b) # c) # d

où # est cette opération intéressante que vous aimeriez faire.

En collection,

votre collectionneur aura une sorte de structure de collecte K.

K consomme un. K consomme alors b. K consomme alors c. K consomme alors d.

À la fin, vous demandez à K quel est le résultat final.

K vous le donne ensuite.

Yan Ng
la source
2

Ils sont très différents dans l'empreinte mémoire potentielle pendant l'exécution. Alors que collect()collecte et place toutes les données dans la collection, reduce()vous demande explicitement de spécifier comment réduire les données qui ont traversé le flux.

Par exemple, si vous souhaitez lire certaines données d'un fichier, les traiter et les placer dans une base de données, vous pouvez vous retrouver avec un code de flux Java similaire à celui-ci:

streamDataFromFile(file)
            .map(data -> processData(data))
            .map(result -> database.save(result))
            .collect(Collectors.toList());

Dans ce cas, nous utilisons collect()pour forcer java à diffuser des données et à enregistrer le résultat dans la base de données. Sans collect()les données ne sont jamais lues et jamais stockées.

Ce code génère heureusement une java.lang.OutOfMemoryError: Java heap spaceerreur d'exécution, si la taille du fichier est suffisamment grande ou si la taille du tas est suffisamment faible. La raison évidente est qu'il essaie d'empiler toutes les données qui ont traversé le flux (et, en fait, ont déjà été stockées dans la base de données) dans la collection résultante, ce qui fait exploser le tas.

Cependant, si vous remplacez collect()par reduce()- ce ne sera plus un problème car ce dernier réduira et supprimera toutes les données qui l'ont traversé.

Dans l'exemple présenté, remplacez simplement collect()par quelque chose par reduce:

.reduce(0L, (aLong, result) -> aLong, (aLong1, aLong2) -> aLong1);

Vous n'avez même pas besoin de vous soucier de faire dépendre le calcul du resultcar Java n'est pas un pur langage FP (programmation fonctionnelle) et ne peut pas optimiser les données qui ne sont pas utilisées au bas du flux en raison des effets secondaires possibles. .

Averasko
la source
3
Si vous ne vous souciez pas des résultats de votre sauvegarde db, vous devriez utiliser forEach ... vous n'avez pas besoin d'utiliser réduire. À moins que ce ne soit à des fins d'illustration.
DaveEdelstein
2

Voici l'exemple de code

List<Integer> list = Arrays.asList(1,2,3,4,5,6,7);
int sum = list.stream().reduce((x,y) -> {
        System.out.println(String.format("x=%d,y=%d",x,y));
        return (x + y);
    }).get();

System.out.println (somme);

Voici le résultat de l'exécution:

x=1,y=2
x=3,y=3
x=6,y=4
x=10,y=5
x=15,y=6
x=21,y=7
28

La fonction de réduction gère deux paramètres, le premier paramètre est la valeur de retour précédente dans le flux, le deuxième paramètre est la valeur de calcul actuelle dans le flux, il additionne la première valeur et la valeur actuelle en tant que première valeur dans le calcul suivant.

JetQin
la source
0

Selon la documentation

Les collecteurs reduction () sont les plus utiles lorsqu'ils sont utilisés dans une réduction multi-niveaux, en aval de groupingBy ou partitioningBy. Pour effectuer une réduction simple sur un flux, utilisez plutôt Stream.reduce (BinaryOperator).

Donc, fondamentalement, vous ne l'utiliseriez reducing()que lorsque vous êtes forcé dans une collecte. Voici un autre exemple :

 For example, given a stream of Person, to calculate the longest last name 
 of residents in each city:

    Comparator<String> byLength = Comparator.comparing(String::length);
    Map<String, String> longestLastNameByCity
        = personList.stream().collect(groupingBy(Person::getCity,
            reducing("", Person::getLastName, BinaryOperator.maxBy(byLength))));

D'après ce tutoriel, réduire est parfois moins efficace

L'opération de réduction renvoie toujours une nouvelle valeur. Cependant, la fonction d'accumulateur renvoie également une nouvelle valeur à chaque fois qu'elle traite un élément d'un flux. Supposons que vous souhaitiez réduire les éléments d'un flux à un objet plus complexe, tel qu'une collection. Cela peut nuire aux performances de votre application. Si votre opération de réduction implique l'ajout d'éléments à une collection, chaque fois que votre fonction d'accumulateur traite un élément, elle crée une nouvelle collection qui inclut l'élément, ce qui est inefficace. Il serait plus efficace pour vous de mettre à jour une collection existante à la place. Vous pouvez le faire avec la méthode Stream.collect, que la section suivante décrit ...

L'identité est donc «réutilisée» dans un scénario de réduction, donc légèrement plus efficace à utiliser .reducesi possible.

rogerdpack
la source