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
, map
e 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 count
richiamerebbe 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 registrants
fosse una collezione, quando in realtà è un iteratore. La seconda chiamata a foreach
incontrare 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' into
operazione è 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 IEnumerable
permetta a più attraversamenti di comportarsi diversamente con fonti diverse; e consente una struttura ramificata di IEnumerable
operazioni 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 QuickSort
prende un IEnumerable
e restituisce un IEnumerable
, quindi non viene fatto alcun ordinamento fino a quando non IEnumerable
viene attraversato il finale . Ciò che la chiamata sembra fare, tuttavia, è costruire una struttura ad albero IEnumerables
che 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 IEnumerable
possa 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.