Java 8 Streams: raccolta e riduzione


143

Quando useresti collect()vs reduce()? Qualcuno ha buoni esempi concreti di quando è decisamente meglio andare in un modo o nell'altro?

Javadoc menziona che collect () è una riduzione mutabile .

Dato che si tratta di una riduzione mutabile, suppongo che richieda la sincronizzazione (internamente) che, a sua volta, può essere dannosa per le prestazioni. Presumibilmente reduce()è più facilmente parallelizzabile al costo di dover creare una nuova struttura di dati per il ritorno dopo ogni passo nella riduzione.

Le affermazioni di cui sopra sono comunque congetture e mi piacerebbe che un esperto suonasse qui.


1
Il resto della pagina a cui lo hai collegato lo spiega: Come per riduci (), un vantaggio nell'esprimere raccogli in questo modo astratto è che è direttamente suscettibile alla parallelizzazione: possiamo accumulare risultati parziali in parallelo e poi combinarli, purché le funzioni di accumulo e combinazione soddisfano i requisiti appropriati.
JB Nizet,

1
vedi anche "Streams in Java 8: Reduce vs. Collect" di Angelika Langer - youtube.com/watch?v=oWlWEKNM5Aw
MasterJoe

Risposte:


115

reduceè un'operazione " fold ", applica un operatore binario a ciascun elemento nel flusso in cui il primo argomento per l'operatore è il valore di ritorno dell'applicazione precedente e il secondo argomento è l'elemento corrente del flusso.

collectè un'operazione di aggregazione in cui viene creata una "raccolta" e ogni elemento viene "aggiunto" a quella raccolta. Le raccolte in diverse parti del flusso vengono quindi aggiunte insieme.

Il documento che hai collegato fornisce la ragione per avere due approcci diversi:

Se volessimo prendere un flusso di stringhe e concatenarle in un'unica lunga stringa, potremmo raggiungere questo obiettivo con una normale riduzione:

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

Otterremmo il risultato desiderato e funzionerebbe anche in parallelo. Tuttavia, potremmo non essere contenti della performance! Un'implementazione del genere farebbe molta copia di stringhe e il tempo di esecuzione sarebbe O (n ^ 2) nel numero di caratteri. Un approccio più performante sarebbe quello di accumulare i risultati in StringBuilder, che è un contenitore mutabile per accumulare stringhe. Possiamo usare la stessa tecnica per parallelizzare la riduzione mutabile come facciamo con la riduzione ordinaria.

Quindi il punto è che la parallelizzazione è la stessa in entrambi i casi, ma nel reducecaso applichiamo la funzione agli elementi del flusso stessi. Nel collectcaso applichiamo la funzione a un contenitore modificabile.


1
Se questo è il caso di collect: "Un approccio più performante sarebbe quello di accumulare i risultati in un StringBuilder", allora perché dovremmo mai usare la riduzione?
jimhooker2002,

2
@ Jimhooker2002 rileggilo. Se, ad esempio, stai calcolando il prodotto, la funzione di riduzione può essere semplicemente applicata ai flussi divisi in parallelo e poi combinati insieme alla fine. Il processo di riduzione comporta sempre il tipo come flusso. La raccolta viene utilizzata quando si desidera raccogliere i risultati in un contenitore modificabile, ovvero quando il risultato è di tipo diverso rispetto allo stream. Ciò ha il vantaggio che una singola istanza del contenitore può essere utilizzata per ogni flusso diviso, ma lo svantaggio che i contenitori devono essere combinati alla fine.
Boris the Spider,

1
@ jimhooker2002 nell'esempio del prodotto, intè immutabile, quindi non è possibile utilizzare prontamente un'operazione di raccolta. Potresti fare un trucco sporco come usare una AtomicIntegero qualche abitudine IntWrapperma perché dovresti? Un'operazione di piega è semplicemente diversa da un'operazione di raccolta.
Boris the Spider,

17
Esiste anche un altro reducemetodo, in cui è possibile restituire oggetti di tipo diverso dagli elementi del flusso.
damluar,

1
un altro caso in cui utilizzeresti raccogliere anziché ridurre è quando l'operazione di riduzione comporta l'aggiunta di elementi a una raccolta, quindi ogni volta che la tua funzione di accumulatore elabora un elemento, crea una nuova raccolta che include l'elemento, che è inefficiente.
Raghu,

40

Il motivo è semplicemente che:

  • collect() può funzionare solo con oggetti risultato mutabili .
  • reduce()è progettato per funzionare con oggetti risultato immutabili .

" reduce()con immutabile" esempio

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);
}

esempio " collect()con mutevole"

Ad esempio, se si vuole calcolare manualmente una somma utilizzando collect()non può funzionare con BigDecimalma solo con MutableIntda org.apache.commons.lang.mutable, per esempio. Vedere:

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);
}

Questo funziona perché non si suppone che l' accumulatore container.add(employee.getSalary().intValue()); restituisca un nuovo oggetto con il risultato, ma cambi lo stato del mutabile containerdi tipo MutableInt.

Se si desidera utilizzare BigDecimalinvece per il containernon è possibile utilizzare il collect()metodo in quanto container.add(employee.getSalary());non cambierebbe containerperché BigDecimalè immutabile. (A parte questo BigDecimal::newnon funzionerebbe perché BigDecimalnon ha un costruttore vuoto)


2
Si noti che si sta utilizzando un Integercostruttore ( new Integer(6)), che è obsoleto nelle versioni Java successive.
MC Emperor

1
Buona cattura @MCEmperor! L'ho cambiato inInteger.valueOf(6)
Sandro il

@Sandro - Sono confuso. Perché dici che collect () funziona solo con oggetti mutabili? L'ho usato per concatenare le stringhe. String allNames = employee.stream () .map (Employee :: getNameString) .collect (Collectors.joining (",")) .toString ();
MasterJoe

1
@ MasterJoe2 È semplice. In breve: l'implementazione utilizza ancora il StringBuilderparametro mutabile. Vedi: hg.openjdk.java.net/jdk8/jdk8/jdk/file/687fd7c7986d/src/share/…
Sandro

30

La riduzione normale ha lo scopo di combinare due valori immutabili come int, double, ecc. E produrne uno nuovo; è una riduzione immutabile . Al contrario, il metodo di raccolta è progettato per mutare un contenitore per accumulare il risultato che dovrebbe produrre.

Per illustrare il problema, supponiamo che tu voglia ottenere Collectors.toList()usando una semplice riduzione come

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;
        });

Questo è l'equivalente di Collectors.toList(). Tuttavia, in questo caso muti il List<Integer>. Come sappiamo, ArrayListnon è thread-safe, né è possibile aggiungere / rimuovere valori da esso durante l'iterazione, in modo da ottenere un'eccezione simultanea ArrayIndexOutOfBoundsExceptiono qualsiasi tipo di eccezione (specialmente se eseguita in parallelo) quando si aggiorna l'elenco o il combinatore cerca di unire gli elenchi perché stai mutando l'elenco accumulando (aggiungendo) i numeri interi ad esso. Se vuoi rendere questo thread sicuro, devi passare ogni volta un nuovo elenco che comprometterebbe le prestazioni.

Al contrario, le Collectors.toList()opere funzionano in modo simile. Tuttavia, garantisce la sicurezza del thread quando si accumulano i valori nell'elenco. Dalla documentazione per il collectmetodo :

Esegue un'operazione di riduzione mutabile sugli elementi di questo flusso utilizzando un servizio di raccolta. Se il flusso è parallelo e Collector è simultaneo e il flusso non è ordinato o il collector non è ordinato, verrà eseguita una riduzione simultanea. Se eseguiti in parallelo, è possibile creare istanze, compilare e unire più risultati intermedi in modo da mantenere l'isolamento delle strutture di dati mutabili. Pertanto, anche se eseguito in parallelo con strutture dati non thread-safe (come ArrayList), non è necessaria alcuna sincronizzazione aggiuntiva per una riduzione parallela.

Quindi per rispondere alla tua domanda:

Quando useresti collect()vs reduce()?

se si dispone di valori immutabili, come ints, doubles, Stringsquindi riduzione normale funziona bene. Tuttavia, se devi dire i reducetuoi valori in una List(struttura di dati mutabili), allora devi usare la riduzione mutabile con il collectmetodo.


Nello snippet di codice penso che il problema sia che prenderà l'identità (in questo caso una singola istanza di una ArrayList) e assumerò che sia "immutabile" in modo che possano avviare xdiscussioni, ognuna delle quali "si aggiunge all'identità" e poi si combina. Buon esempio.
rogerdpack

perché dovremmo ottenere un'eccezione di modifica simultanea, la chiamata di stream riavvierà semplicemente il flusso seriale e ciò significa che verrà elaborata dal singolo thread e la funzione combinatrice non viene affatto chiamata?
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); } }ho provato e non ho ricevuto l'eccezione
CCm

@amarnathharish il problema si verifica quando si tenta di eseguirlo in parallelo e più thread tentano di accedere allo stesso elenco
george

11

Lascia che lo stream sia a <- b <- c <- d

In riduzione,

avrai ((a # b) # c) # d

dove # è quell'interessante operazione che vorresti fare.

In collezione,

il tuo collezionista avrà una sorta di struttura di raccolta K.

K consuma a. K quindi consuma b. K quindi consuma c. K quindi consuma d.

Alla fine, chiedi a K quale sia il risultato finale.

K poi te lo dà.


2

Sono molto diversi nel potenziale footprint di memoria durante il runtime. Mentre collect()raccoglie e inserisce tutti i dati nella raccolta, reduce()ti chiede esplicitamente di specificare come ridurre i dati che li hanno fatti attraverso il flusso.

Ad esempio, se si desidera leggere alcuni dati da un file, elaborarli e inserirli in alcuni database, si potrebbe finire con un codice java stream simile al seguente:

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

In questo caso, utilizziamo collect()per forzare java per lo streaming dei dati e per salvare il risultato nel database. Senza collect()i dati non vengono mai letti e mai archiviati.

Questo codice genera felicemente un java.lang.OutOfMemoryError: Java heap spaceerrore di runtime, se la dimensione del file è abbastanza grande o la dimensione dell'heap è abbastanza bassa. La ragione ovvia è che tenta di impilare tutti i dati che lo hanno fatto attraverso il flusso (e, in effetti, è già stato archiviato nel database) nella raccolta risultante e questo fa esplodere l'heap.

Tuttavia, se lo sostituisci collect()con reduce()- non sarà più un problema in quanto quest'ultimo ridurrà e eliminerà tutti i dati che lo hanno superato.

Nell'esempio presentato, basta sostituire collect()con qualcosa con reduce:

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

Non è nemmeno necessario preoccuparsi di fare in modo che il calcolo dipenda dal fatto resultche Java non è un linguaggio FP (programmazione funzionale) puro e non è possibile ottimizzare i dati che non vengono utilizzati nella parte inferiore dello stream a causa dei possibili effetti collaterali .


3
Se non ti interessano i risultati del tuo salvataggio in db, dovresti usare per ogni ... non devi usare riduci. A meno che questo non fosse a scopo illustrativo.
DaveEdelstein l'

2

Ecco l'esempio di codice

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 (somma);

Ecco il risultato di esecuzione:

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 funzione di riduzione gestisce due parametri, il primo parametro è il valore di ritorno precedente nello stream, il secondo parametro è il valore di calcolo corrente nel flusso, somma il primo valore e il valore corrente come primo valore nella successiva caculazione.


0

Secondo i documenti

I collettori riducenti () sono più utili se utilizzati in una riduzione a più livelli, a valle del raggruppamento Per o del partizionamento Per. Per eseguire una semplice riduzione su uno stream, utilizzare invece Stream.reduce (BinaryOperator).

Quindi in pratica useresti reducing()solo se forzato all'interno di una raccolta. Ecco un altro esempio :

 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))));

Secondo questo tutorial, ridurre è talvolta meno efficiente

L'operazione di riduzione restituisce sempre un nuovo valore. Tuttavia, la funzione accumulatore restituisce anche un nuovo valore ogni volta che elabora un elemento di un flusso. Supponiamo di voler ridurre gli elementi di un flusso a un oggetto più complesso, come una raccolta. Ciò potrebbe ostacolare le prestazioni dell'applicazione. Se l'operazione di riduzione comporta l'aggiunta di elementi a una raccolta, ogni volta che la funzione di accumulatore elabora un elemento, crea una nuova raccolta che include l'elemento, che è inefficiente. Invece sarebbe più efficiente aggiornare una raccolta esistente. Puoi farlo con il metodo Stream.collect, che la sezione successiva descrive ...

Quindi l'identità viene "riutilizzata" in uno scenario ridotto, quindi un po 'più efficiente .reducese possibile.

Utilizzando il nostro sito, riconosci di aver letto e compreso le nostre Informativa sui cookie e Informativa sulla privacy.
Licensed under cc by-sa 3.0 with attribution required.