Tenho algumas lembranças do design inicial da API do Streams que podem lançar alguma luz sobre a lógica do design.
Em 2012, estávamos adicionando lambdas ao idioma e queríamos um conjunto de operações orientadas a coleções ou "dados em massa", programadas usando lambdas, que facilitassem o paralelismo. A idéia de encadear operações preguiçosamente juntas estava bem estabelecida nesse ponto. Também não queríamos que as operações intermediárias armazenassem resultados.
Os principais problemas que precisávamos decidir eram como eram os objetos na cadeia na API e como eles se conectavam às fontes de dados. As fontes eram frequentemente coleções, mas também queríamos suportar dados provenientes de um arquivo ou da rede ou dados gerados em tempo real, por exemplo, a partir de um gerador de números aleatórios.
Havia muitas influências do trabalho existente no design. Entre os mais influentes estavam a biblioteca Guava do Google e a biblioteca de coleções Scala. (Se alguém é surpreendido sobre a influência de goiaba, nota que Kevin Bourrillion , goiaba desenvolvedor líder, estava na JSR-335 Lambda . Grupo de peritos) em coleções Scala, encontramos essa conversa por Martin Odersky ser de particular interesse: futuro- Prova de coleções de Scala: de mutável a persistente a paralela . (Stanford EE380, 1º de junho de 2011)
Nosso design de protótipo na época era baseado em torno Iterable
. As operações familiares filter
, map
etc., foram métodos de extensão (padrão) ativados Iterable
. Chamar um adicionou uma operação à cadeia e retornou outro Iterable
. Uma operação terminal count
chamaria iterator()
a cadeia até a fonte e as operações foram implementadas no Iterador de cada estágio.
Como esses são iteráveis, você pode chamar o iterator()
método mais de uma vez. O que deveria acontecer então?
Se a fonte é uma coleção, isso geralmente funciona bem. As coleções são Iteráveis, e cada chamada iterator()
produz uma instância Iterator distinta, independente de quaisquer outras instâncias ativas, e cada uma percorre a coleção independentemente. Ótimo.
Agora, e se a fonte for única, como ler linhas de um arquivo? Talvez o primeiro iterador deva obter todos os valores, mas o segundo e os subsequentes devem estar vazios. Talvez os valores devam ser intercalados entre os iteradores. Ou talvez cada iterador deva obter os mesmos valores. Então, e se você tiver dois iteradores e um ficar mais à frente do outro? Alguém terá que armazenar em buffer os valores no segundo Iterador até que sejam lidos. Pior, e se você obtiver um Iterator e ler todos os valores, e somente então obter um segundo Iterator. De onde vêm os valores agora? Existe um requisito para que todos sejam armazenados em buffer, caso alguém queira um segundo iterador?
Claramente, permitir vários Iteradores sobre uma fonte de uma só vez levanta muitas questões. Não tínhamos boas respostas para eles. Queríamos um comportamento consistente e previsível para o que acontece se você ligar iterator()
duas vezes. Isso nos levou a proibir várias travessias, tornando os oleodutos de uma só vez.
Também observamos outros esbarrando nessas questões. No JDK, a maioria dos iteráveis são coleções ou objetos do tipo coleção, que permitem travessias múltiplas. Ele não está especificado em nenhum lugar, mas parecia haver uma expectativa não escrita de que os iteráveis permitem travessias múltiplas. Uma exceção notável é a interface NIO DirectoryStream . Sua especificação inclui este aviso interessante:
Embora o DirectoryStream estenda o Iterable, ele não é um Iterable de uso geral, pois suporta apenas um único Iterator; invocar o método iterador para obter um segundo ou um iterador subsequente lança IllegalStateException.
[negrito no original]
Isso parecia incomum e desagradável o suficiente para que não quiséssemos criar um monte de novos iteráveis que poderiam ser únicos. Isso nos afastou do uso do Iterable.
Naquela época, apareceu um artigo de Bruce Eckel que descrevia um certo problema que ele teve com Scala. Ele escreveu este código:
// Scala
val lines = fromString(data).getLines
val registrants = lines.map(Registrant)
registrants.foreach(println)
registrants.foreach(println)
É bem direto. Ele analisa linhas de texto em Registrant
objetos e as imprime duas vezes. Só que, na verdade, eles são impressos apenas uma vez. Acontece que ele pensou que registrants
era uma coleção, quando na verdade é um iterador. A segunda chamada para foreach
encontrar um iterador vazio, do qual todos os valores foram esgotados, portanto, ele não imprime nada.
Esse tipo de experiência nos convenceu de que era muito importante ter resultados claramente previsíveis se tentássemos várias travessias. Ele também destacou a importância de distinguir entre estruturas preguiçosas do tipo pipeline e coleções reais que armazenam dados. Por sua vez, isso levou à separação das operações de pipeline lento na nova interface Stream e manteve apenas operações mutantes e ansiosas diretamente nas coleções. Brian Goetz explicou a justificativa para isso.
Que tal permitir travessia múltipla para pipelines baseados em coleção, mas não permitir para pipelines não baseados em coleção? É inconsistente, mas é sensato. Se você está lendo valores da rede, é claro que não pode atravessá-los novamente. Se você deseja atravessá-los várias vezes, é necessário atraí-los para uma coleção explicitamente.
Mas vamos explorar a possibilidade de atravessar vários pipelines baseados em coleções. Digamos que você fez isso:
Iterable<?> it = source.filter(...).map(...).filter(...).map(...);
it.into(dest1);
it.into(dest2);
(A into
operação está agora escrita collect(toList())
.)
Se a origem for uma coleção, a primeira into()
chamada criará uma cadeia de iteradores de volta à origem, executará as operações do pipeline e enviará os resultados para o destino. A segunda chamada para into()
criará outra cadeia de iteradores e executará as operações do pipeline novamente . Obviamente, isso não está errado, mas tem o efeito de executar todas as operações de filtro e mapa uma segunda vez para cada elemento. Eu acho que muitos programadores ficariam surpresos com esse comportamento.
Como mencionei acima, estávamos conversando com os desenvolvedores do Guava. Uma das coisas legais que eles têm é um Idea Cemitério, onde descrevem recursos que decidiram não implementar juntamente com os motivos. A idéia de coleções preguiçosas parece bem legal, mas aqui está o que elas têm a dizer sobre isso. Considere uma List.filter()
operação que retorna a List
:
A maior preocupação aqui é que muitas operações se tornam caras, proposições em tempo linear. Se você deseja filtrar uma lista e recuperar uma lista, e não apenas uma coleção ou um iterável, pode usar o ImmutableList.copyOf(Iterables.filter(list, predicate))
que "informa antecipadamente" o que está fazendo e o quanto é caro.
Para dar um exemplo específico, qual é o custo de get(0)
ou size()
em uma lista? Para classes comumente usadas como ArrayList
, elas são O (1). Mas se você chamar um deles em uma lista filtrada lentamente, ele precisará executar o filtro na lista de suporte e, de repente, essas operações serão O (n). Pior, ele precisa percorrer a lista de suporte em todas as operações.
Isso nos parecia preguiça demais . Uma coisa é configurar algumas operações e adiar a execução real até você "Ir". Outra é configurar as coisas de tal maneira que oculte uma quantidade potencialmente grande de recomputação.
Ao propor a proibição de fluxos não lineares ou "sem reutilização", Paul Sandoz descreveu as possíveis consequências de permitir que elas originassem "resultados inesperados ou confusos". Ele também mencionou que a execução paralela tornaria as coisas ainda mais complicadas. Por fim, acrescentaria que uma operação de pipeline com efeitos colaterais levaria a erros difíceis e obscuros se a operação fosse executada inesperadamente várias vezes, ou pelo menos um número diferente de vezes que o programador esperava. (Mas os programadores Java não escrevem expressões lambda com efeitos colaterais, fazem? Eles fazem?)
Portanto, essa é a lógica básica do design da API do Java 8 Streams que permite a passagem de um tiro e requer um pipeline estritamente linear (sem ramificação). Ele fornece um comportamento consistente em várias fontes de fluxo diferentes, separa claramente as operações preguiçosas das ansiosas e fornece um modelo de execução simples.
No que diz respeito a IEnumerable
, estou longe de ser um especialista em C # e .NET, por isso gostaria de ser corrigido (suavemente) se tirar conclusões incorretas. Parece, no entanto, que IEnumerable
permite que a travessia múltipla se comporte de maneira diferente com fontes diferentes; e permite uma estrutura ramificada de IEnumerable
operações aninhadas , o que pode resultar em alguma recomputação significativa. Embora eu aprecie o fato de que sistemas diferentes fazem trocas diferentes, essas são duas características que procuramos evitar no design da API Java 8 Streams.
O exemplo do quicksort dado pelo OP é interessante, intrigante e, lamento dizer, um pouco horrível. A chamada QuickSort
recebe IEnumerable
e retorna uma IEnumerable
, portanto, nenhuma classificação é realmente feita até que a final IEnumerable
seja percorrida. O que a chamada parece fazer, no entanto, é construir uma estrutura em árvore IEnumerables
que reflita o particionamento que o quicksort faria, sem realmente fazê-lo. (Afinal, é uma computação preguiçosa.) Se a fonte tiver N elementos, a árvore terá N elementos de largura na sua largura mais ampla e níveis de lg (N) de profundidade.
Parece-me - e mais uma vez, não sou especialista em C # ou .NET - que isso fará com que certas chamadas de aparência inócua, como a seleção de pivô ints.First()
, sejam mais caras do que parecem. No primeiro nível, é claro, é O (1). Mas considere uma partição no fundo da árvore, na borda direita. Para calcular o primeiro elemento desta partição, toda a fonte deve ser atravessada, uma operação O (N). Porém, como as partições acima são preguiçosas, elas devem ser recalculadas, exigindo comparações de O (lg N). Portanto, selecionar o pivô seria uma operação O (N lg N), que é tão cara quanto uma classificação inteira.
Mas na verdade não classificamos até atravessarmos o retornado IEnumerable
. No algoritmo quicksort padrão, cada nível de particionamento dobra o número de partições. Cada partição tem apenas metade do tamanho, portanto, cada nível permanece com a complexidade O (N). A árvore de partições tem O (lg N) de altura, portanto o trabalho total é O (N lg N).
Com a árvore de IEnumerables preguiçosos, na parte inferior da árvore há N partições. O cálculo de cada partição requer uma travessia de N elementos, cada um dos quais requer comparações de lg (N) na árvore. Para calcular todas as partições na parte inferior da árvore, é necessário comparações O (N ^ 2 lg N).
(Está certo? Mal posso acreditar nisso. Alguém por favor verifique isso por mim.)
De qualquer forma, é realmente interessante que IEnumerable
possa ser usado dessa maneira para construir estruturas complicadas de computação. Mas se isso aumenta a complexidade computacional tanto quanto eu penso, parece que programar dessa maneira é algo que deve ser evitado, a menos que se seja extremamente cuidadoso.