Perché i flussi Java sono una tantum?


239

A differenza di C # IEnumerable, in cui una pipeline di esecuzione può essere eseguita tutte le volte che vogliamo, in Java un flusso può essere "ripetuto" solo una volta.

Qualsiasi chiamata a un'operazione terminale chiude il flusso, rendendolo inutilizzabile. Questa "caratteristica" toglie molta potenza.

Immagino che la ragione di ciò non sia tecnica. Quali erano le considerazioni progettuali alla base di questa strana restrizione?

Modifica: per dimostrare di cosa sto parlando, considera la seguente implementazione di Quick-Sort in C #:

IEnumerable<int> QuickSort(IEnumerable<int> ints)
{
  if (!ints.Any()) {
    return Enumerable.Empty<int>();
  }

  int pivot = ints.First();

  IEnumerable<int> lt = ints.Where(i => i < pivot);
  IEnumerable<int> gt = ints.Where(i => i > pivot);

  return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt));
}

Ora, per essere sicuro, non sto sostenendo che questa è una buona implementazione di tipo rapido! È tuttavia un ottimo esempio del potere espressivo dell'espressione lambda combinato con il funzionamento del flusso.

E non può essere fatto in Java! Non posso nemmeno chiedere a uno stream se è vuoto senza renderlo inutilizzabile.


4
Potresti fare un esempio concreto in cui la chiusura del flusso "toglie energia"?
Rogério,

23
Se si desidera utilizzare i dati da uno stream più di una volta, è necessario scaricarli in una raccolta. Questo è praticamente come deve funzionare: o devi ripetere il calcolo per generare il flusso o devi memorizzare il risultato intermedio.
Louis Wasserman,

5
Ok, ma ripetere lo stesso calcolo sullo stesso flusso sembra sbagliato. Uno stream viene creato da una determinata sorgente prima dell'esecuzione di un calcolo, proprio come gli iteratori vengono creati per ogni iterazione. Vorrei ancora vedere un esempio concreto concreto; alla fine, scommetto che esiste un modo chiaro per risolvere ogni problema con i flussi use-once, supponendo che esista un modo corrispondente con gli enumerabili di C #.
Rogério,

2
Questo all'inizio mi ha confuso, perché pensavo che questa domanda avrebbe messo in relazione C # IEnumerablecon i flussi dijava.io.*
SpaceTrucker,

9
Si noti che l'utilizzo di IEnumerable più volte in C # è un modello fragile, quindi la premessa della domanda potrebbe essere leggermente imperfetta. Molte implementazioni di IEnumerable lo consentono ma alcune non lo fanno! Gli strumenti di analisi del codice tendono a metterti in guardia dal fare una cosa del genere.
Sander,

Risposte:


368

Ho alcuni ricordi del primo progetto dell'API Streams che potrebbero far luce sulla logica del design.

Nel 2012 stavamo aggiungendo lambda al linguaggio e volevamo un insieme di operazioni orientate alle raccolte o "bulk data", programmate usando lambda, che facilitassero il parallelismo. L'idea di concatenare pigramente operazioni insieme è stata ben consolidata a questo punto. Inoltre, non volevamo che le operazioni intermedie memorizzassero i risultati.

I problemi principali che dovevamo decidere erano l'aspetto degli oggetti nella catena nell'API e il modo in cui si collegavano alle origini dati. Le fonti erano spesso raccolte, ma volevamo anche supportare dati provenienti da un file o dalla rete o dati generati al volo, ad esempio da un generatore di numeri casuali.

Ci sono state molte influenze del lavoro esistente sul design. Tra i più influenti vi furono la biblioteca Guava di Google e la biblioteca delle collezioni Scala. (Se qualcuno è sorpreso dell'influenza di Guava, nota che Kevin Bourrillion , sviluppatore principale di Guava, faceva parte del gruppo di esperti Lambda JSR-335 .) Nelle collezioni Scala, abbiamo trovato questo discorso di Martin Odersky di particolare interesse: Future- Proofing Scala Collections: da Mutable a Persistent to Parallel . (Stanford EE380, 1 giugno 2011)

Il nostro progetto di prototipo all'epoca era basato su Iterable. Le operazioni di familiari filter, mape così via erano Metodi di estensione (default) su Iterable. Chiamare uno ha aggiunto un'operazione alla catena e ne ha restituita un'altra Iterable. Un'operazione terminale come quella countrichiamerebbe iterator()la catena alla fonte e le operazioni sarebbero state implementate all'interno dell'iteratore di ogni fase.

Poiché si tratta di Iterable, è possibile chiamare il iterator()metodo più di una volta. Cosa dovrebbe succedere allora?

Se l'origine è una raccolta, funziona principalmente bene. Le raccolte sono Iterabili e ogni chiamata a iterator()produce un'istanza Iterator distinta che è indipendente da qualsiasi altra istanza attiva e ciascuna attraversa la raccolta in modo indipendente. Grande.

E se la sorgente fosse a colpo singolo, come leggere le righe da un file? Forse il primo Iteratore dovrebbe ottenere tutti i valori ma il secondo e quelli successivi dovrebbero essere vuoti. Forse i valori dovrebbero essere intercalati tra gli Iteratori. O forse ogni Iteratore dovrebbe ottenere tutti gli stessi valori. Quindi, se hai due iteratori e uno si allontana dall'altro? Qualcuno dovrà bufferizzare i valori nel secondo Iteratore fino a quando non saranno letti. Peggio ancora, se ottieni un Iteratore e leggi tutti i valori, e solo allora ottieni un secondo Iteratore. Da dove vengono i valori adesso? C'è un requisito per cui tutti devono essere bufferizzati nel caso in cui qualcuno voglia un secondo Iteratore?

Chiaramente, consentire più Iteratori su una fonte one-shot solleva molte domande. Non avevamo buone risposte per loro. Volevamo un comportamento coerente e prevedibile per quello che succede se chiami iterator()due volte. Questo ci ha spinto a non consentire più attraversamenti, rendendo le condutture un colpo solo.

Abbiamo anche osservato che altri si sono imbattuti in questi problemi. Nel JDK, la maggior parte degli Iterable sono raccolte o oggetti simili a raccolte, che consentono l'attraversamento multiplo. Non è specificato da nessuna parte, ma sembrava esserci un'aspettativa non scritta che Iterables consentisse l'attraversamento multiplo. Un'eccezione notevole è l' interfaccia NIO DirectoryStream . Le sue specifiche includono questo avviso interessante:

Mentre DirectoryStream estende Iterable, non è un Iterable per uso generico in quanto supporta solo un Iterator; invocare il metodo iteratore per ottenere un secondo o successivo iteratore genera IllegalStateException.

[grassetto in originale]

Sembrava abbastanza insolito e spiacevole che non volevamo creare un sacco di nuovi Iterable che potevano essere una sola volta. Questo ci ha allontanato dall'uso di Iterable.

In quel periodo apparve un articolo di Bruce Eckel che descriveva un punto di problemi che aveva avuto con Scala. Aveva scritto questo codice:

// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)

È abbastanza semplice. Analizza righe di testo inRegistrant oggetti e le stampa due volte. Solo che in realtà li stampa solo una volta. Si scopre che pensava che registrantsfosse una collezione, quando in realtà è un iteratore. La seconda chiamata a foreachincontrare un iteratore vuoto, dal quale tutti i valori sono stati esauriti, quindi non stampa nulla.

Questo tipo di esperienza ci ha convinto che era molto importante ottenere risultati chiaramente prevedibili se si tentava un attraversamento multiplo. Ha inoltre messo in luce l'importanza di distinguere tra strutture pigre simili a pipeline da raccolte effettive che archiviano dati. Questo a sua volta ha spinto la separazione delle operazioni della pipeline pigra nella nuova interfaccia Stream e mantenendo solo le operazioni mutanti e desiderose direttamente sulle Collezioni.Brian Goetz ha spiegato la logica di ciò.

Che ne dite di consentire l'attraversamento multiplo per condutture basate su raccolta ma non consentirlo per condotte non basate su raccolta? È incoerente, ma è ragionevole. Se stai leggendo valori dalla rete, ovviamente non puoi attraversarli di nuovo. Se vuoi attraversarli più volte, devi trascinarli in una raccolta in modo esplicito.

Esploriamo tuttavia la possibilità di consentire l'attraversamento multiplo da pipeline basate su raccolte. Diciamo che hai fatto questo:

Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);

(L' intooperazione è ora scritta collect(toList()).)

Se l'origine è una raccolta, la prima into()chiamata creerà una catena di iteratori sull'origine, eseguirà le operazioni della pipeline e invierà i risultati nella destinazione. La seconda chiamata a into()creerà un'altra catena di iteratori ed eseguirà nuovamente le operazioni della pipeline . Questo non è ovviamente sbagliato, ma ha l'effetto di eseguire tutte le operazioni di filtro e mappa una seconda volta per ogni elemento. Penso che molti programmatori sarebbero stati sorpresi da questo comportamento.

Come accennato in precedenza, abbiamo parlato con gli sviluppatori di Guava. Una delle cose interessanti che hanno è un cimitero di idee in cui descrivono caratteristiche che hanno deciso di non implementare insieme ai motivi. L'idea di collezioni pigre sembra piuttosto interessante, ma ecco cosa hanno da dire al riguardo. Considera List.filter()un'operazione che restituisce a List:

La preoccupazione maggiore qui è che troppe operazioni diventano costose, proposte a tempo lineare. Se si desidera filtrare un elenco e ottenere un elenco indietro, e non solo una raccolta o un Iterable, è possibile utilizzare ImmutableList.copyOf(Iterables.filter(list, predicate)), che "indica in anticipo" cosa sta facendo e quanto costa.

Per fare un esempio specifico, qual è il costo di get(0)o size()su un elenco? Per le classi comunemente usate come ArrayList, sono O (1). Ma se si chiama uno di questi in un elenco filtrato pigramente, deve eseguire il filtro sull'elenco di supporto e all'improvviso queste operazioni sono O (n). Peggio ancora, deve attraversare la lista di supporto su ogni operazione.

Questo ci è sembrato troppo pigrizia. Una cosa è impostare alcune operazioni e rimandare l'esecuzione effettiva fino a quando non "Go". È un altro modo di sistemare le cose in modo tale da nascondere una quantità potenzialmente elevata di ricalcolo.

Nel proporre di non consentire flussi non lineari o di "non riutilizzo", Paul Sandoz ha descritto le potenziali conseguenze del consentire loro di dare origine a "risultati inattesi o confusi". Ha anche detto che l'esecuzione parallela renderebbe le cose ancora più complicate. Infine, aggiungerei che un'operazione di pipeline con effetti collaterali porterebbe a bug difficili e oscuri se l'operazione venisse eseguita inaspettatamente più volte, o almeno un numero di volte diverso da quello previsto dal programmatore. (Ma i programmatori Java non scrivono espressioni lambda con effetti collaterali, vero? FANNO ??)

Quindi questa è la logica di base per la progettazione dell'API Java 8 Streams che consente l'attraversamento one-shot e che richiede una pipeline rigorosamente lineare (senza diramazione). Fornisce un comportamento coerente su più origini flusso diverse, separa chiaramente le operazioni pigre da quelle desiderose e fornisce un modello di esecuzione semplice.


Per quanto riguarda IEnumerable, sono ben lungi dall'essere un esperto di C # e .NET, quindi apprezzerei essere corretto (delicatamente) se trarre conclusioni errate. Sembra, tuttavia, che IEnumerablepermetta a più attraversamenti di comportarsi diversamente con fonti diverse; e consente una struttura ramificata di IEnumerableoperazioni nidificate , che può comportare una ricompilazione significativa. Anche se apprezzo il fatto che sistemi diversi facciano diversi compromessi, queste sono due caratteristiche che abbiamo cercato di evitare nella progettazione dell'API Java 8 Streams.

L'esempio di quicksort fornito dall'OP è interessante, sconcertante, e mi dispiace dirlo, alquanto terrificante. La chiamata QuickSortprende un IEnumerablee restituisce un IEnumerable, quindi non viene fatto alcun ordinamento fino a quando non IEnumerableviene attraversato il finale . Ciò che la chiamata sembra fare, tuttavia, è costruire una struttura ad albero IEnumerablesche rifletta il partizionamento che farebbe Quicksort, senza farlo effettivamente. (Dopotutto, questo è un calcolo pigro.) Se la fonte ha N elementi, l'albero sarà N elementi larghi nella sua larghezza più ampia e avrà livelli di lg (N) profondi.

Mi sembra - e ancora una volta, non sono un esperto di C # o .NET - che questo causerà alcune chiamate dall'aspetto innocuo, come la selezione del pivot via ints.First(), più costose di quanto sembri. Al primo livello, ovviamente, è O (1). Ma considera una partizione in profondità nell'albero, sul bordo destro. Per calcolare il primo elemento di questa partizione, è necessario attraversare l'intera sorgente, un'operazione O (N). Ma poiché le partizioni sopra sono pigre, devono essere ricalcolate, richiedendo confronti O (lg N). Quindi selezionare il perno sarebbe un'operazione O (N lg N), che è costosa come un intero ordinamento.

Ma in realtà non riordiniamo fino a quando non attraversiamo il reso IEnumerable. Nell'algoritmo standard quicksort, ogni livello di partizionamento raddoppia il numero di partizioni. Ogni partizione ha solo la metà delle dimensioni, quindi ogni livello rimane alla complessità O (N). L'albero delle partizioni è O (lg N) alto, quindi il lavoro totale è O (N lg N).

Con l'albero di IEnumerables pigro, nella parte inferiore dell'albero ci sono N partizioni. Il calcolo di ogni partizione richiede un attraversamento di N elementi, ognuno dei quali richiede confronti lg (N) sull'albero. Per calcolare tutte le partizioni nella parte inferiore dell'albero, quindi, sono necessari confronti O (N ^ 2 lg N).

(È vero? Non riesco quasi a crederci. Qualcuno per favore controlla questo per me.)

In ogni caso, è davvero bello che IEnumerablepossa essere usato in questo modo per costruire complicate strutture di calcolo. Ma se aumenta la complessità computazionale tanto quanto penso, sembrerebbe che programmare in questo modo sia qualcosa che dovrebbe essere evitato se non si è estremamente attenti.


35
Prima di tutto, grazie per la risposta eccezionale e non condiscendente! Questa è di gran lunga la spiegazione più accurata e precisa che ho ottenuto. Per quanto riguarda l'esempio di QuickSort, sembra che tu abbia ragione sugli ints. Prima gonfia quando il livello di ricorsione cresce. Credo che questo possa essere facilmente risolto calcolando 'gt' e 'lt' avidamente (raccogliendo i risultati con ToArray). Detto questo, sicuramente supporta il tuo punto di vista sul fatto che questo stile di programmazione potrebbe comportare un prezzo inaspettato delle prestazioni. (Continua nel secondo commento)
Vitaliy,

18
D'altra parte, dalla mia esperienza con C # (più di 5 anni) posso dire che sradicare i calcoli 'ridondanti' non è così difficile una volta che si è verificato un problema di prestazioni (o è stato proibito, se qualcuno ha reso l'impensabile e introdotto un effetto collaterale lì). Mi è sembrato troppo compromesso per garantire la purezza dell'API, a scapito di possibilità simili a C #. Mi hai sicuramente aiutato a modificare il mio punto di vista.
Vitaliy,

7
@Vitaliy Grazie per lo scambio equo di idee. Ho imparato un po 'di C # e .NET dall'investigare e scrivere questa risposta.
Stuart segna il

10
Piccolo commento: ReSharper è un'estensione di Visual Studio che aiuta con C #. Con il codice QuickSort sopra riportato ReSharper aggiunge un avviso per ogni utilizzoints : "Possibile enumerazione multipla di IEnumerable". L'uso dello stesso IEenumerablepiù di una volta è sospetto e dovrebbe essere evitato. Indicherei anche questa domanda (a cui ho risposto), che mostra alcune delle avvertenze con l'approccio .Net (oltre alle scarse prestazioni): Elenco <T> e IEnumerable differenza
Kobi

4
@Kobi Molto interessante che ci sia un tale avviso in ReSharper. Grazie per il puntatore alla tua risposta. Non conosco C # /. NET, quindi dovrò esaminarlo attentamente, ma sembra che mostri problemi simili alle preoccupazioni di progettazione che ho menzionato sopra.
Stuart segna il

122

sfondo

Mentre la domanda sembra semplice, la risposta effettiva richiede un po 'di background per avere un senso. Se vuoi saltare alla conclusione, scorri verso il basso ...

Scegli il tuo punto di confronto - Funzionalità di base

Usando concetti di base, il concetto di C # IEnumerableè più strettamente correlato a quello di JavaIterable , che è in grado di creare tutti gli Iteratori che desideri. IEnumerablescreare IEnumerators. Java IterablecreaIterators

La storia di ciascun concetto è simile, in quanto entrambi IEnumerablee Iterablehanno una motivazione di base per consentire lo stile "per ciascuno" in loop sui membri delle raccolte di dati. Questa è una semplificazione eccessiva in quanto entrambi consentono più di questo e sono anche arrivati ​​a quel punto tramite diverse progressioni, ma è una caratteristica comune significativa a prescindere.

Confrontiamo questa caratteristica: in entrambe le lingue, se una classe implementa il IEnumerable/ Iterable, quella classe deve implementare almeno un singolo metodo (per C #, è GetEnumeratore per Java è iterator()). In ogni caso, l'istanza restituita da quella ( IEnumerator/ Iterator) consente di accedere ai membri correnti e successivi dei dati. Questa funzione è utilizzata nella sintassi per ogni lingua.

Scegli il tuo punto di confronto - Funzionalità avanzata

IEnumerablein C # è stato esteso per consentire una serie di altre funzionalità linguistiche ( principalmente correlate a Linq ). Le funzionalità aggiunte includono selezioni, proiezioni, aggregazioni, ecc. Queste estensioni sono fortemente motivate dall'uso nella teoria degli insiemi, simile ai concetti SQL e Relational Database.

Java 8 ha anche aggiunto funzionalità per consentire un certo grado di programmazione funzionale usando Streams e Lambdas. Si noti che i flussi Java 8 non sono principalmente motivati ​​dalla teoria degli insiemi, ma dalla programmazione funzionale. Indipendentemente da ciò, ci sono molti parallelismi.

Quindi, questo è il secondo punto. I miglioramenti apportati a C # sono stati implementati come miglioramento del IEnumerableconcetto. In Java, tuttavia, i miglioramenti apportati sono stati implementati creando nuovi concetti di base di Lambdas e Streams, e quindi creando anche un modo relativamente banale per convertire da Iteratorse Iterablesin stream e viceversa.

Quindi, confrontando IEnumerable con il concetto di Stream di Java è incompleto. È necessario confrontarlo con le API combinate di stream e collezioni in Java.

In Java, gli stream non sono gli stessi di Iterables o Iterators

Gli stream non sono progettati per risolvere i problemi allo stesso modo degli iteratori:

  • Gli iteratori sono un modo per descrivere la sequenza di dati.
  • Gli stream sono un modo per descrivere una sequenza di trasformazioni di dati.

Con un Iterator, ottieni un valore di dati, lo elabori e quindi ottieni un altro valore di dati.

Con gli stream, concatenate una sequenza di funzioni, quindi inviate un valore di input allo stream e ottenete il valore di output dalla sequenza combinata. Nota, in termini Java, ogni funzione è incapsulata in una singola Streamistanza. L'API Streams ti consente di collegare una sequenza di Streamistanze in modo da concatenare una sequenza di espressioni di trasformazione.

Per completare il Streamconcetto, è necessaria una fonte di dati per alimentare il flusso e una funzione terminale che consuma il flusso.

Il modo in cui inserisci i valori nel flusso può in effetti provenire da un Iterable, ma la Streamsequenza stessa non è una Iterable, è una funzione composta.

A Streamvuole anche essere pigro, nel senso che funziona solo quando si richiede un valore da esso.

Nota queste ipotesi e caratteristiche significative di Stream:

  • A Streamin Java è un motore di trasformazione, trasforma un elemento di dati in uno stato, in un altro stato.
  • i flussi non hanno alcun concetto di ordine o posizione dei dati, semplicemente trasformano tutto ciò che viene loro richiesto.
  • i flussi possono essere forniti con dati provenienti da molte fonti, inclusi altri flussi, Iteratori, Iterabili, Collezioni,
  • non puoi "resettare" uno stream, sarebbe come "riprogrammare la trasformazione". Il ripristino dell'origine dati è probabilmente quello che desideri.
  • c'è logicamente 1 solo elemento di dati 'in volo' nello stream in qualsiasi momento (a meno che lo stream non sia uno stream parallelo, a quel punto, c'è 1 elemento per thread). Ciò è indipendente dall'origine dati che potrebbe avere più degli elementi correnti "pronti" per essere forniti allo stream o dal raccoglitore di stream che potrebbe essere necessario aggregare e ridurre più valori.
  • Gli stream possono essere non associati (infiniti), limitati solo dall'origine dati o dal collector (che può essere anche infinito).
  • Gli stream sono "concatenabili", l'output del filtro di uno stream è un altro stream. I valori immessi e trasformati da un flusso possono a loro volta essere forniti a un altro flusso che effettua una trasformazione diversa. I dati, nel suo stato trasformato, fluiscono da uno stream all'altro. Non è necessario intervenire ed estrarre i dati da un flusso e collegarli al successivo.

Confronto C #

Se si considera che un flusso Java è solo una parte di un sistema di approvvigionamento, flusso e raccolta e che Streams e Iteratori sono spesso usati insieme alle Collezioni, non c'è da meravigliarsi che sia difficile collegarsi agli stessi concetti che sono quasi tutti integrati in un unico IEnumerableconcetto in C #.

Parti di IEnumerable (e concetti vicini) sono evidenti in tutti i concetti Java Iterator, Iterable, Lambda e Stream.

Ci sono piccole cose che i concetti Java possono fare che sono più difficili in IEnumerable e viceversa.


Conclusione

  • Non c'è nessun problema di progettazione qui, solo un problema nell'abbinamento dei concetti tra le lingue.
  • I flussi risolvono i problemi in modo diverso
  • I flussi aggiungono funzionalità a Java (aggiungono un modo diverso di fare le cose, non tolgono la funzionalità)

L'aggiunta di stream ti dà più possibilità di scelta quando risolvi i problemi, il che è giusto classificarlo come "potenziamento del potere", non "riduzione", "rimozione" o "limitazione".

Perché i flussi Java sono una tantum?

Questa domanda è sbagliata, perché i flussi sono sequenze di funzioni, non dati. A seconda dell'origine dati che alimenta il flusso, è possibile ripristinare l'origine dati e alimentare lo stesso flusso o flusso diverso.

A differenza di IEnumerable di C #, in cui una pipeline di esecuzione può essere eseguita tutte le volte che vogliamo, in Java un flusso può essere "ripetuto" solo una volta.

Il confronto tra an e IEnumerablea Streamè errato. Il contesto che stai usando per dire IEnumerablepuò essere eseguito tutte le volte che vuoi, è meglio se paragonato a Java Iterables, che può essere ripetuto tutte le volte che vuoi. Un Java Streamrappresenta un sottoinsieme del IEnumerableconcetto, e non il sottoinsieme che fornisce dati, e quindi non può essere "rieseguito".

Qualsiasi chiamata a un'operazione terminale chiude il flusso, rendendolo inutilizzabile. Questa "caratteristica" toglie molta potenza.

La prima affermazione è vera, in un certo senso. L'affermazione "toglie potere" non lo è. Stai ancora confrontando Stream it IEnumerables. L'operazione terminale nel flusso è come una clausola 'break' in un ciclo for. Sei sempre libero di avere un altro flusso, se lo desideri, e se riesci a fornire nuovamente i dati di cui hai bisogno. Ancora una volta, se si considera IEnumerableche è più simile a un Iterable, per questa affermazione, Java lo fa bene.

Immagino che la ragione di ciò non sia tecnica. Quali erano le considerazioni progettuali alla base di questa strana restrizione?

Il motivo è tecnico e per la semplice ragione che uno Stream è un sottoinsieme di ciò che pensa che sia. Il sottoinsieme di stream non controlla la fornitura di dati, pertanto è necessario reimpostare la fornitura, non lo stream. In quel contesto, non è così strano.

Esempio QuickSort

Il tuo esempio quicksort ha la firma:

IEnumerable<int> QuickSort(IEnumerable<int> ints)

Stai trattando l'input IEnumerablecome un'origine dati:

IEnumerable<int> lt = ints.Where(i => i < pivot);

Inoltre, anche il valore restituito IEnumerableè una fornitura di dati e poiché si tratta di un'operazione di ordinamento, l'ordine di tale fornitura è significativo. Se consideri la Iterableclasse Java come la corrispondenza appropriata per questo, in particolare la Listspecializzazione di Iterable, poiché List è una fornitura di dati che ha un ordine o iterazione garantita, allora il codice Java equivalente al tuo codice sarebbe:

Stream<Integer> quickSort(List<Integer> ints) {
    // Using a stream to access the data, instead of the simpler ints.isEmpty()
    if (!ints.stream().findAny().isPresent()) {
        return Stream.of();
    }

    // treating the ints as a data collection, just like the C#
    final Integer pivot = ints.get(0);

    // Using streams to get the two partitions
    List<Integer> lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList());
    List<Integer> gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList());

    return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt));
}    

Si noti che esiste un bug (che ho riprodotto), in quanto l'ordinamento non gestisce i valori duplicati con garbo, è un ordinamento "valore univoco".

Nota anche come il codice Java utilizza l'origine dati ( List) e i concetti di flusso in punti diversi e che in C # queste due "personalità" possono essere espresse in giusto IEnumerable. Inoltre, anche se ho usato Listcome tipo di base, avrei potuto usare il più generale Collection, e con una piccola conversione da iteratore a stream, avrei potuto usare anche il più generaleIterable


9
Se stai pensando di "iterare" un flusso, lo stai facendo in modo sbagliato. Un flusso rappresenta lo stato dei dati in un determinato momento nel tempo in una catena di trasformazioni. I dati entrano nel sistema in una sorgente di flusso, quindi fluiscono da un flusso a quello successivo, cambiando stato mentre procede, fino a quando non vengono raccolti, ridotti o scaricati alla fine. A Streamè un concetto temporizzato, non un '"operazione a ciclo" .... (seguito)
rolfl,

7
Con uno Stream, hai dati che entrano nello stream come X, ed escono dallo stream come Y. Esiste una funzione che lo stream esegue quella trasformazione. f(x)Lo stream incapsula la funzione, non incapsula i dati che fluiscono attraverso
rolfl,

4
IEnumerablepuò anche fornire valori casuali, non essere associato e diventare attivo prima che i dati esistano.
Arturo Torres Sánchez,

6
@Vitaliy: molti metodi che ricevono si IEnumerable<T>aspettano che rappresenti una raccolta finita che può essere ripetuta più volte. Alcune cose che sono iterabili ma che non soddisfano tali condizioni implementano IEnumerable<T>perché nessun'altra interfaccia standard è adatta al conto, ma i metodi che prevedono raccolte finite che possono essere ripetute più volte sono soggetti a crash se vengono fornite cose iterabili che non rispettano tali condizioni .
supercat,

5
Il tuo quickSortesempio potrebbe essere molto più semplice se restituisse a Stream; risparmierebbe due .stream()chiamate e una .collect(Collectors.toList())chiamata. Se poi lo sostituisci Collections.singleton(pivot).stream()con Stream.of(pivot)il codice diventa quasi leggibile ...
Holger,

22

Streams sono costruiti attorno a Spliterators che sono oggetti stateful, mutabili. Non hanno un'azione di "ripristino" e, in effetti, richiedere di supportare tale azione di riavvolgimento "toglierebbe molto potere". Come dovrebbe Random.ints()essere gestita una simile richiesta?

D'altra parte, per le Streams che hanno un'origine retrattile, è facile costruire un equivalente Streamda riutilizzare. Basta mettere i passaggi fatti per costruire il Streammetodo riutilizzabile. Tieni presente che ripetere questi passaggi non è un'operazione costosa poiché tutti questi passaggi sono operazioni pigre; il lavoro effettivo inizia con l'operazione del terminale e, a seconda dell'operazione effettiva del terminale, potrebbe essere eseguito un codice completamente diverso.

Spetterebbe a te, autore di tale metodo, specificare cosa implica chiamare due volte il metodo: riproduce esattamente la stessa sequenza, come fanno i flussi creati per un array o una raccolta non modificati, oppure produce un flusso con un semantica simile ma elementi diversi come un flusso di ints casuali o un flusso di linee di input della console, ecc.


Tra l'altro, per evitare confusione, un'operazione terminale consuma il Streamdistinto da chiudere il Streamquale chiamando close()sul flusso non (che è richiesto per i flussi aver associato risorse come, ad esempio, prodotto da Files.lines()).


Sembra che molta confusione derivi da un confronto errato di IEnumerablecon Stream. Un IEnumerablerappresenta la capacità di fornire un reale IEnumerator, quindi è come un IterableJava. Al contrario, a Streamè un tipo di iteratore e paragonabile a un IEnumeratorquindi è sbagliato affermare che questo tipo di tipo di dati può essere utilizzato più volte in .NET, il supporto per IEnumerator.Resetè facoltativo. Gli esempi discussi qui usano piuttosto il fatto che un IEnumerablepuò essere usato per recuperare nuovi IEnumerator se funziona anche con quelli di Java Collection; puoi averne uno nuovo Stream. Se gli sviluppatori Java hanno deciso di aggiungere direttamente le Streamoperazioni Iterable, con operazioni intermedie che ne restituiscono un'altraIterable, era davvero paragonabile e poteva funzionare allo stesso modo.

Tuttavia, gli sviluppatori hanno deciso di non farlo e la decisione è discussa in questa domanda . Il punto più grande è la confusione sulle operazioni di raccolta desiderose e sulle operazioni di flusso pigro. Osservando l'API .NET, io (sì, personalmente) la trovo giustificata. Mentre sembra ragionevole guardare da IEnumerablesolo, una particolare Collezione avrà molti metodi che manipolano direttamente la Collezione e molti metodi che restituiscono un pigro IEnumerable, mentre la natura particolare di un metodo non è sempre intuitivamente riconoscibile. L'esempio peggiore che ho trovato (nei pochi minuti in cui l'ho visto) è il List.Reverse()cui nome corrisponde esattamente al nome dell'ereditato (è questo il terminus giusto per i metodi di estensione?) Enumerable.Reverse()Pur avendo un comportamento totalmente contraddittorio.


Naturalmente, queste sono due decisioni distinte. Il primo a rendere Streamun tipo distinto da Iterable/ Collectione il secondo a rendere Streamuna specie di iteratore una volta piuttosto che un altro tipo di iterabile. Ma queste decisioni sono state prese insieme e potrebbe essere il caso che non sia mai stata presa in considerazione la separazione di queste due decisioni. Non è stato creato per essere paragonabile a .NET.

La vera decisione di progettazione dell'API è stata quella di aggiungere un tipo migliorato di iteratore, il Spliterator. Spliterators può essere fornito dalle vecchie Iterables (che è il modo in cui sono state adattate) o da implementazioni completamente nuove. Quindi, è Streamstato aggiunto come front-end di alto livello ai livelli piuttosto bassi Spliterators. Questo è tutto. Puoi discutere se un design diverso sarebbe migliore, ma non è produttivo, non cambierà, dato il modo in cui sono progettati ora.

C'è un altro aspetto dell'implementazione che devi considerare. nonStream sono strutture di dati immutabili. Ogni operazione intermedia può restituire una nuova istanza che incapsula quella precedente, ma può anche manipolare la propria istanza e restituire se stessa (ciò non preclude di fare entrambe le cose per la stessa operazione). Esempi comunemente noti sono operazioni come o che non aggiungono un altro passaggio ma manipolano l'intera pipeline). Avere una struttura di dati così mutevole e tentare di riutilizzare (o, peggio ancora, usarlo più volte contemporaneamente) non gioca bene ...Streamparallelunordered


Per completezza, ecco il tuo esempio quicksort tradotto Streamnell'API Java . Dimostra che non "toglie molto potere".

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {

  final Optional<Integer> optPivot = ints.get().findAny();
  if(!optPivot.isPresent()) return Stream.empty();

  final int pivot = optPivot.get();

  Supplier<Stream<Integer>> lt = ()->ints.get().filter(i -> i < pivot);
  Supplier<Stream<Integer>> gt = ()->ints.get().filter(i -> i > pivot);

  return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s);
}

Può essere usato come

List<Integer> l=new Random().ints(100, 0, 1000).boxed().collect(Collectors.toList());
System.out.println(l);
System.out.println(quickSort(l::stream)
    .map(Object::toString).collect(Collectors.joining(", ")));

Puoi scriverlo ancora più compatto come

static Stream<Integer> quickSort(Supplier<Stream<Integer>> ints) {
    return ints.get().findAny().map(pivot ->
         Stream.of(
                   quickSort(()->ints.get().filter(i -> i < pivot)),
                   Stream.of(pivot),
                   quickSort(()->ints.get().filter(i -> i > pivot)))
        .flatMap(s->s)).orElse(Stream.empty());
}

1
Bene, consuma o no, provare a consumarlo di nuovo genera un'eccezione che il flusso era già chiuso , non consumato. Per quanto riguarda il problema con il ripristino di un flusso di numeri interi casuali, come hai detto, spetta allo scrittore della libreria definire il contratto esatto di un'operazione di ripristino.
Vitaliy,

2
No, il messaggio è "lo stream è già stato gestito o chiuso" e non stavamo parlando di un'operazione di "reset", ma chiamando due o più operazioni di terminale ona, Streammentre il ripristino della sorgente Spliteratorsarebbe implicito. E sono abbastanza sicuro che se fosse possibile, c'erano domande su SO come "Perché chiamare count()due volte su Streamdà risultati diversi ogni volta", ecc ...
Holger,

1
È assolutamente valido che count () dia risultati diversi. count () è una query su un flusso e se il flusso è mutabile (o per essere più esatti, il flusso rappresenta il risultato di una query su una raccolta mutabile), è previsto. Dai un'occhiata all'API di C #. Affrontano tutti questi problemi con garbo.
Vitaliy,

4
Quello che chiami "assolutamente valido" è un comportamento controintuitivo. Dopotutto, è la motivazione principale per chiedere di utilizzare uno stream più volte per elaborare il risultato, che dovrebbe essere lo stesso, in modi diversi. Ogni domanda su SO circa la natura non riutilizzabile di Streams deriva finora dal tentativo di risolvere un problema chiamando più volte le operazioni del terminale (ovviamente, altrimenti non si nota) che ha portato a una soluzione silenziosa se l' StreamAPI lo ha permesso con risultati diversi su ogni valutazione. Ecco un bell'esempio .
Holger,

3
In realtà, il tuo esempio dimostra perfettamente cosa succede se un programmatore non capisce le implicazioni dell'applicazione di più terminali. Basti pensare a ciò che accade quando ciascuna di queste operazioni verrà applicata a un insieme di elementi completamente diverso. Funziona solo se l'origine del flusso ha restituito gli stessi elementi su ogni query, ma questo è esattamente il presupposto sbagliato di cui stavamo parlando.
Holger,

8

Penso che ci siano pochissime differenze tra i due quando si guarda abbastanza da vicino.

Alla sua faccia, un IEnumerablesembra essere un costrutto riutilizzabile:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

foreach (var n in numbers) {
    Console.WriteLine(n);
}

Tuttavia, il compilatore sta effettivamente facendo un po 'di lavoro per aiutarci; genera il seguente codice:

IEnumerable<int> numbers = new int[] { 1, 2, 3, 4, 5 };

IEnumerator<int> enumerator = numbers.GetEnumerator();
while (enumerator.MoveNext()) {
    Console.WriteLine(enumerator.Current);
}

Ogni volta che si esegue l'iterazione dell'enumerabile, il compilatore crea un enumeratore. L'enumeratore non è riutilizzabile; ulteriori chiamate a MoveNextrestituiranno semplicemente false e non c'è modo di ripristinarlo all'inizio. Se si desidera scorrere nuovamente i numeri, è necessario creare un'altra istanza di enumeratore.


Per illustrare meglio che IEnumerable ha (può avere) la stessa 'caratteristica' di un flusso Java, prendere in considerazione un enumerabile la cui origine dei numeri non è una raccolta statica. Ad esempio, possiamo creare un oggetto enumerabile che genera una sequenza di 5 numeri casuali:

class Generator : IEnumerator<int> {
    Random _r;
    int _current;
    int _count = 0;

    public Generator(Random r) {
        _r = r;
    }

    public bool MoveNext() {
        _current= _r.Next();
        _count++;
        return _count <= 5;
    }

    public int Current {
        get { return _current; }
    }
 }

class RandomNumberStream : IEnumerable<int> {
    Random _r = new Random();
    public IEnumerator<int> GetEnumerator() {
        return new Generator(_r);
    }
    public IEnumerator IEnumerable.GetEnumerator() {
        return this.GetEnumerator();
    }
}

Ora abbiamo un codice molto simile al precedente enumerabile basato su array, ma con una seconda iterazione su numbers:

IEnumerable<int> numbers = new RandomNumberStream();

foreach (var n in numbers) {
    Console.WriteLine(n);
}
foreach (var n in numbers) {
    Console.WriteLine(n);
}

La seconda volta che passeremo in rassegna numbersavremo una diversa sequenza di numeri, che non è riutilizzabile nello stesso senso. In alternativa, avremmo potuto scrivere l' RandomNumberStreameccezione se si provasse a scorrere più volte su di essa, rendendo l'enumerabile effettivamente inutilizzabile (come un flusso Java).

Inoltre, cosa significa il tuo ordinamento rapido basato su enumerabile quando applicato a un RandomNumberStream?


Conclusione

Quindi, la differenza più grande è che .NET ti consente di riutilizzare un IEnumerablecreando implicitamente un nuovo IEnumeratorin background ogni volta che avrebbe bisogno di accedere agli elementi della sequenza.

Questo comportamento implicito è spesso utile (e 'potente' come dici tu), perché possiamo iterare ripetutamente su una raccolta.

Ma a volte, questo comportamento implicito può effettivamente causare problemi. Se la tua fonte di dati non è statica o è costosa per accedervi (come un database o un sito web), allora molte ipotesi su IEnumerabledevono essere scartate; il riutilizzo non è così diretto


2

È possibile bypassare alcune delle protezioni "esegui una volta" nell'API Stream; ad esempio, possiamo evitare java.lang.IllegalStateExceptioneccezioni (con il messaggio "stream è già stato gestito o chiuso") facendo riferimento e riutilizzando Spliterator(anziché Streamdirettamente).

Ad esempio, questo codice verrà eseguito senza generare un'eccezione:

    Spliterator<String> split = Stream.of("hello","world")
                                      .map(s->"prefix-"+s)
                                      .spliterator();

    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);


    replayable1.forEach(System.out::println);
    replayable2.forEach(System.out::println);

Tuttavia, l'output sarà limitato a

prefix-hello
prefix-world

piuttosto che ripetere due volte l'uscita. Questo perché l' ArraySpliteratorusato come Streamsorgente è stateful e memorizza la sua posizione corrente. Quando ripetiamo questo Stream, ricominciamo alla fine.

Abbiamo una serie di opzioni per risolvere questa sfida:

  1. Potremmo utilizzare un Streammetodo di creazione senza stato come Stream#generate(). Dovremmo gestire lo stato esternamente nel nostro codice e reimpostare tra Stream"replay":

    Spliterator<String> split = Stream.generate(this::nextValue)
                                      .map(s->"prefix-"+s)
                                      .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    this.resetCounter();
    replayable2.forEach(System.out::println);
  2. Un'altra soluzione (leggermente migliore ma non perfetta) è scrivere la nostra ArraySpliterator(o Streamfonte simile ) che includa una certa capacità per resettare il contatore corrente. Se lo usassimo per generare il Streampotenziale potremmo potenzialmente riprodurli con successo.

    MyArraySpliterator<String> arraySplit = new MyArraySpliterator("hello","world");
    Spliterator<String> split = StreamSupport.stream(arraySplit,false)
                                            .map(s->"prefix-"+s)
                                            .spliterator();
    
    Stream<String> replayable1 = StreamSupport.stream(split,false);
    Stream<String> replayable2 = StreamSupport.stream(split,false);
    
    
    replayable1.forEach(System.out::println);
    arraySplit.reset();
    replayable2.forEach(System.out::println);
  3. La migliore soluzione a questo problema (secondo me) è quella di creare una nuova copia di tutti gli stati Spliteratorusati nella Streampipeline quando vengono invocati nuovi operatori su Stream. Questo è più complesso e coinvolto da implementare, ma se non ti dispiace usare librerie di terze parti, cyclops-reazioni ha Streamun'implementazione che fa esattamente questo. (Divulgazione: sono lo sviluppatore principale di questo progetto.)

    Stream<String> replayableStream = ReactiveSeq.of("hello","world")
                                                 .map(s->"prefix-"+s);
    
    
    
    
    replayableStream.forEach(System.out::println);
    replayableStream.forEach(System.out::println);

Questo stamperà

prefix-hello
prefix-world
prefix-hello
prefix-world

come previsto.

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.