Por que o Java Streams é único?


239

Diferentemente dos C # IEnumerable, em que um pipeline de execução pode ser executado quantas vezes quisermos, em Java, um fluxo pode ser 'iterado' apenas uma vez.

Qualquer chamada para uma operação do terminal fecha o fluxo, tornando-o inutilizável. Esse 'recurso' tira muito poder.

Imagino que o motivo disso não seja técnico. Quais foram as considerações de design por trás dessa estranha restrição?

Editar: para demonstrar o que estou falando, considere a seguinte implementação do Quick-Sort em 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));
}

Agora, com certeza, não estou defendendo que esta seja uma boa implementação de classificação rápida! No entanto, é um ótimo exemplo do poder expressivo da expressão lambda combinada com a operação de fluxo.

E isso não pode ser feito em Java! Não posso nem perguntar a um fluxo se ele está vazio sem torná-lo inutilizável.


4
Você poderia dar um exemplo concreto em que o fechamento do fluxo "tira o poder"?
Rogério

23
Se você quiser usar dados de um fluxo mais de uma vez, precisará despejá-los em uma coleção. Isso é muito bonito como ele tem para o trabalho: ou você tem que refazer o cálculo para gerar o fluxo, ou você tem que armazenar o resultado intermediário.
Louis Wasserman

5
Ok, mas refazer a mesma computação no mesmo fluxo parece errado. Um fluxo é criado a partir de uma determinada fonte antes de uma computação ser executada, assim como os iteradores são criados para cada iteração. Eu ainda gostaria de ver um exemplo concreto real; no final, aposto que existe uma maneira limpa de resolver cada problema com fluxos de uso único, assumindo que exista uma maneira correspondente com os enumeráveis ​​do C #.
Rogério

2
Este foi confuso no início para mim, porque eu pensei que esta questão se relacionam C # s IEnumerablepara os fluxos dejava.io.*
SpaceTrucker

9
Observe que o uso de IEnumerable várias vezes em C # é um padrão frágil; portanto, a premissa da pergunta pode ser um pouco falha. Muitas implementações do IEnumerable permitem, mas outras não! As ferramentas de análise de código tendem a alertá-lo contra essa ação.
Sander

Respostas:


368

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, mapetc., 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 countchamaria 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 Registrantobjetos e as imprime duas vezes. Só que, na verdade, eles são impressos apenas uma vez. Acontece que ele pensou que registrantsera uma coleção, quando na verdade é um iterador. A segunda chamada para foreachencontrar 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 intooperaçã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 IEnumerablepermite que a travessia múltipla se comporte de maneira diferente com fontes diferentes; e permite uma estrutura ramificada de IEnumerableoperaçõ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 QuickSortrecebe IEnumerablee retorna uma IEnumerable, portanto, nenhuma classificação é realmente feita até que a final IEnumerableseja percorrida. O que a chamada parece fazer, no entanto, é construir uma estrutura em árvore IEnumerablesque 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 IEnumerablepossa 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.


35
Antes de tudo, obrigado pela ótima e não condescendente resposta! Essa é, de longe, a explicação mais exata e objetiva que recebi. No que diz respeito ao exemplo do QuickSort, parece que você está certo quanto às ints.Primeiro inchaço à medida que o nível de recursão aumenta. Acredito que isso possa ser facilmente corrigido computando 'gt' e 'lt' avidamente (coletando os resultados com o ToArray). Dito isto, certamente apoia seu argumento de que esse estilo de programação pode incorrer em preços de desempenho inesperados. (Continue no segundo comentário)
Vitaliy

18
Por outro lado, da minha experiência com C # (mais de 5 anos), posso dizer que não é tão difícil encontrar os cálculos 'redundantes' quando você atinge um problema de desempenho (ou é proibido, se alguém fez o impensável e apresentou um lado afeta lá). Pareceu-me que havia sido comprometido demais para garantir a pureza da API, às custas de possibilidades do tipo C #. Você definitivamente me ajudou a ajustar meu ponto de vista.
Vitaliy

7
@Vitaliy Obrigado pela troca de ideias justa. Eu aprendi um pouco sobre C # e .NET investigando e escrevendo esta resposta.
Stuart Marks

10
Pequeno comentário: ReSharper é uma extensão do Visual Studio que ajuda no C #. Com o código QuickSort acima, o ReSharper adiciona um aviso para cada usoints : "Possível enumeração múltipla de IEnumerable". Usar o mesmo IEenumerablemais de uma vez é suspeito e deve ser evitado. Eu também apontaria para esta pergunta (que eu respondi), que mostra algumas das advertências com a abordagem .Net (além do desempenho fraco): List <T> e IEnumerable diferença
Kobi

4
@ Kobi Muito interessante que exista um aviso no ReSharper. Obrigado pelo ponteiro para sua resposta. Como não conheço C # /. NET, terei de selecioná-lo com cuidado, mas parece exibir problemas semelhantes às preocupações de design que mencionei acima.
Stuart Marks

122

fundo

Embora a pergunta pareça simples, a resposta real requer alguns antecedentes para fazer sentido. Se você quiser pular para a conclusão, role para baixo ...

Escolha seu ponto de comparação - Funcionalidade básica

Usando conceitos básicos, o conceito do C # IEnumerableestá mais estreitamente relacionado ao JavaIterable , que é capaz de criar quantos Iteradores você desejar. IEnumerablescriar IEnumerators. Do Java IterablecriarIterators

A história de cada conceito é semelhante, em que tanto IEnumerablee Iterabletêm uma motivação básica para permitir que 'for-each' estilo looping sobre os membros de coletas de dados. Isso é uma simplificação excessiva, pois ambos permitem mais do que apenas isso, e eles também chegaram a esse estágio por meio de diferentes progressões, mas é uma característica comum significativa, independentemente.

Vamos comparar esse recurso: nas duas linguagens, se uma classe implementa o IEnumerable/ Iterable, essa classe deve implementar pelo menos um método único (para C #, é GetEnumeratore para Java, é iterator()). Em cada caso, a instância retornada disso ( IEnumerator/ Iterator) permite acessar os membros atuais e subseqüentes dos dados. Esse recurso é usado na sintaxe de cada idioma.

Escolha seu ponto de comparação - Funcionalidade aprimorada

IEnumerableem C # foi estendido para permitir vários outros recursos de idioma ( principalmente relacionados ao Linq ). Os recursos adicionados incluem seleções, projeções, agregações, etc. Essas extensões têm uma forte motivação do uso na teoria de conjuntos, semelhante aos conceitos SQL e Banco de Dados Relacional.

O Java 8 também teve a funcionalidade adicionada para permitir um certo grau de programação funcional usando Streams e Lambdas. Observe que os fluxos do Java 8 não são motivados principalmente pela teoria dos conjuntos, mas pela programação funcional. Independentemente disso, existem muitos paralelos.

Então, este é o segundo ponto. Os aprimoramentos feitos no C # foram implementados como um aprimoramento do IEnumerableconceito. Em Java, no entanto, as melhorias feitas foram implementadas criando novos conceitos básicos de Lambdas e Streams, e também criando uma maneira relativamente trivial de converter de Iteratorse Iterablespara Streams, e vice-versa.

Portanto, a comparação do IEnumerable com o conceito de Stream do Java está incompleta. Você precisa compará-lo com as APIs Streams e Collections combinadas em Java.

Em Java, o Streams não é o mesmo que Iterables ou Iterators

Os fluxos não são projetados para resolver problemas da mesma maneira que os iteradores:

  • Iteradores são uma maneira de descrever a sequência de dados.
  • Os fluxos são uma maneira de descrever uma sequência de transformações de dados.

Com um Iterator, você obtém um valor de dados, processa-o e, em seguida, obtém outro valor de dados.

Com o Streams, você encadeia uma sequência de funções, alimenta um valor de entrada no fluxo e obtém o valor de saída da sequência combinada. Observe que, em termos de Java, cada função é encapsulada em uma única Streaminstância. A API do Streams permite vincular uma sequência de Streaminstâncias de maneira que encadeie uma sequência de expressões de transformação.

Para concluir o Streamconceito, você precisa de uma fonte de dados para alimentar o fluxo e de uma função terminal que consome o fluxo.

A maneira como você alimenta valores no fluxo pode ser de Iterable, mas a Streamsequência em si não é Iterable, é uma função composta.

A Streamtambém se destina a ser preguiçoso, no sentido de que só funciona quando você solicita um valor.

Observe estas premissas e recursos significativos do Streams:

  • A Streamem Java é um mecanismo de transformação, ele transforma um item de dados em um estado para outro.
  • os fluxos não têm conceito da ordem ou posição dos dados, simplesmente transformam tudo o que são solicitados.
  • os fluxos podem ser fornecidos com dados de várias fontes, incluindo outros fluxos, iteradores, iteráveis, coleções,
  • você não pode "redefinir" um fluxo, isso seria como "reprogramar a transformação". Redefinir a fonte de dados é provavelmente o que você deseja.
  • logicamente, há apenas 1 item de dados 'em voo' no fluxo a qualquer momento (a menos que o fluxo seja um fluxo paralelo; nesse ponto, há 1 item por segmento). Isso é independente da fonte de dados que pode ter mais do que os itens atuais 'prontos' para serem fornecidos ao fluxo ou do coletor de fluxo que pode precisar agregar e reduzir vários valores.
  • Os fluxos podem ser ilimitados (infinitos), limitados apenas pela fonte de dados ou coletor (que também pode ser infinito).
  • Os fluxos são 'encadeados', a saída da filtragem de um fluxo, é outro fluxo. Os valores introduzidos e transformados por um fluxo podem, por sua vez, ser fornecidos para outro fluxo que faz uma transformação diferente. Os dados, em seu estado transformado, fluem de um fluxo para o próximo. Você não precisa intervir e extrair os dados de um fluxo e conectá-lo ao próximo.

Comparação de C #

Quando você considera que um Java Stream é apenas parte de um sistema de fornecimento, fluxo e coleta, e que Streams e Iterators são frequentemente usados ​​em conjunto com o Collections, não é de admirar que seja difícil se relacionar com os mesmos conceitos que são quase todos incorporados a um único IEnumerableconceito em C #.

Partes do IEnumerable (e conceitos próximos) são aparentes em todos os conceitos de Java Iterator, Iterable, Lambda e Stream.

Existem pequenas coisas que os conceitos de Java podem fazer que são mais difíceis no IEnumerable e vice-versa.


Conclusão

  • Não há nenhum problema de design aqui, apenas um problema na correspondência de conceitos entre os idiomas.
  • Os fluxos resolvem os problemas de uma maneira diferente
  • Os fluxos adicionam funcionalidade ao Java (eles adicionam uma maneira diferente de fazer as coisas, não tiram a funcionalidade)

A adição de Streams oferece mais opções para a solução de problemas, o que é justo classificar como 'aprimorando o poder', não 'reduzindo', 'retirando' ou 'restringindo-o'.

Por que o Java Streams é único?

Esta pergunta é equivocada, porque fluxos são sequências de funções, não dados. Dependendo da fonte de dados que alimenta o fluxo, é possível redefinir a fonte de dados e alimentar o mesmo fluxo ou outro fluxo.

Diferentemente do IEnumerable do C #, onde um pipeline de execução pode ser executado quantas vezes quisermos, em Java, um fluxo pode ser 'iterado' apenas uma vez.

Comparar um IEnumerablea um Streamé equivocado. O contexto que você está usando para dizer IEnumerablepode ser executado quantas vezes quiser, é melhor comparado ao Java Iterables, que pode ser iterado quantas vezes você desejar. Um Java Streamrepresenta um subconjunto do IEnumerableconceito, e não o subconjunto que fornece dados e, portanto, não pode ser executado novamente.

Qualquer chamada para uma operação do terminal fecha o fluxo, tornando-o inutilizável. Esse 'recurso' tira muito poder.

A primeira afirmação é verdadeira, em certo sentido. A declaração 'tira o poder' não é. Você ainda está comparando Streams it IEnumerables. A operação do terminal no fluxo é como uma cláusula 'break' em um loop for. Você está sempre livre para ter outro fluxo, se quiser e se puder fornecer novamente os dados necessários. Novamente, se você considerar o IEnumerablemais parecido com um Iterable, para esta declaração, o Java faz muito bem.

Imagino que o motivo disso não seja técnico. Quais foram as considerações de design por trás dessa estranha restrição?

O motivo é técnico e pelo simples motivo de um Stream ser um subconjunto do que ele pensa que é. O subconjunto de fluxo não controla o fornecimento de dados, portanto, você deve redefinir o fornecimento, não o fluxo. Nesse contexto, não é tão estranho.

Exemplo do QuickSort

Seu exemplo do quicksort tem a assinatura:

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

Você está tratando a entrada IEnumerablecomo uma fonte de dados:

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

Além disso, o valor de retorno IEnumerabletambém é , que é um fornecimento de dados e, como se trata de uma operação de Classificação, a ordem desse fornecimento é significativa. Se você considerar a Iterableclasse Java a correspondência apropriada para isso, especificamente a Listespecialização de Iterable, como List é um fornecimento de dados que possui uma ordem ou iteração garantida, o código Java equivalente ao seu código seria:

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

Observe que há um erro (que eu reproduzi), em que a classificação não lida com valores duplicados normalmente, é uma classificação de 'valor exclusivo'.

Observe também como o código Java usa fonte de dados ( List) e transmite conceitos em pontos diferentes, e que em C # essas duas 'personalidades' podem ser expressas apenas IEnumerable. Além disso, embora eu tenha usado Listcomo o tipo base, eu poderia ter usado o mais geral Collectione, com uma pequena conversão de iterador para fluxo, eu poderia ter usado o ainda mais geralIterable


9
Se você está pensando em 'iterar' um fluxo, está fazendo errado. Um fluxo representa o estado dos dados em um determinado momento no tempo em uma cadeia de transformações. Os dados entram no sistema em uma fonte de fluxo e, em seguida, fluem de um fluxo para o outro, mudando de estado à medida que avançam, até serem coletados, reduzidos ou descartados no final. Um Streamé um conceito de ponto no tempo, não uma 'operação de circuito' .... (cont.)
rolfl

7
Com um Stream, você tem dados entrando no stream com aparência de X e saindo do stream com aparência de Y. Há uma função que o stream executa que executa essa transformação f(x)O stream encapsula a função, não encapsula os dados que fluem através de
rolfl

4
IEnumerabletambém pode fornecer valores aleatórios, ser desvinculados e tornar-se ativo antes que os dados existam.
Arturo Torres Sánchez

6
@Vitaliy: Muitos métodos que recebem uma IEnumerable<T>expectativa de que ela represente uma coleção finita que pode ser repetida várias vezes. Algumas coisas que são iteráveis, mas não atendem a essas condições, são implementadas IEnumerable<T>porque nenhuma outra interface padrão se encaixa na conta, mas os métodos que esperam coleções finitas que podem ser iteradas várias vezes são propensos a travar se houver coisas iteráveis ​​que não respeitem essas condições. .
11238

5
Seu quickSortexemplo poderia ser muito mais simples se retornasse a Stream; economizaria duas .stream()ligações e uma .collect(Collectors.toList())ligação. Se você, em seguida, substituir Collections.singleton(pivot).stream()com Stream.of(pivot)o código torna-se quase legível ...
Holger

22

Streams são construídos em torno de Spliterators, que são objetos mutáveis ​​e com estado. Eles não têm uma ação de "redefinição" e, de fato, exigir suporte a essa ação de retrocesso "tiraria muito poder". Como Random.ints()deveria lidar com essa solicitação?

Por outro lado, para Streams que têm uma origem recuperável, é fácil construir um equivalente Streampara ser usado novamente. Basta colocar as etapas feitas para transformar o Streammétodo em um reutilizável. Lembre-se de que repetir essas etapas não é uma operação cara, pois todas essas etapas são operações preguiçosas; o trabalho real começa com a operação do terminal e, dependendo da operação real do terminal, um código completamente diferente pode ser executado.

Cabe a você, o criador de tal método, especificar o que chamar o método duas vezes implica: ele reproduz exatamente a mesma sequência, como fazem os fluxos criados para uma matriz ou coleção não modificada ou produz um fluxo com um semânticas semelhantes, mas elementos diferentes, como um fluxo de entradas aleatórias ou um fluxo de linhas de entrada do console etc.


A propósito, para evitar confusão, uma operação de terminal consome o Streamque é distinto de fechar o Streamque a chamada close()no fluxo faz (o que é necessário para fluxos com recursos associados, como, por exemplo, produzidos por Files.lines()).


Parece que muita confusão decorre da comparação equivocada de IEnumerablecom Stream. Um IEnumerablerepresenta a capacidade de fornecer um real IEnumerator, então é como um Iterableem Java. Por outro lado, a Streamé um tipo de iterador e comparável a um, IEnumeratorpor isso é errado afirmar que esse tipo de dados pode ser usado várias vezes no .NET, o suporte para IEnumerator.Reseté opcional. Os exemplos discutidos aqui usam o fato de que um IEnumerablepode ser usado para buscar novos se IEnumerator funciona com os de Java Collectiontambém; Você pode conseguir um novo Stream. Se os desenvolvedores Java decidissem adicionar as Streamoperações Iterablediretamente, com operações intermediárias retornando outraIterable, era realmente comparável e poderia funcionar da mesma maneira.

No entanto, os desenvolvedores decidiram contra e a decisão é discutida nesta questão . O ponto mais importante é a confusão sobre as operações ansiosas de cobrança e as preguiçosas operações de fluxo. Ao olhar para a API .NET, eu (sim, pessoalmente) considero justificada. Embora pareça razoável olhar IEnumerablesozinho, uma coleção específica terá muitos métodos para manipular a coleção diretamente e muitos métodos retornando um atraso IEnumerable, enquanto a natureza específica de um método nem sempre é intuitivamente reconhecível. O pior exemplo que encontrei (dentro de alguns minutos em que olhei) é List.Reverse()cujo nome corresponde exatamente ao nome do herdado (esse é o terminal correto para métodos de extensão?), Enumerable.Reverse()Apesar de ter um comportamento totalmente contraditório.


Obviamente, essas são duas decisões distintas. O primeiro a Streamdiferenciar um tipo de Iterable/ Collectione o segundo a tornar Streamum tipo de iterador único em vez de outro tipo de iterável. Mas essas decisões foram tomadas em conjunto e pode ser que a separação dessas duas decisões nunca tenha sido considerada. Não foi criado para ser comparável ao do .NET em mente.

A decisão real do design da API foi adicionar um tipo aprimorado de iterador, o Spliterator. Spliterators podem ser fornecidos pelos antigos Iterables (que é a maneira como eles foram adaptados) ou implementações inteiramente novas. Em seguida, Streamfoi adicionado como um front-end de alto nível ao nível bastante baixo Spliterators. É isso aí. Você pode discutir se um design diferente seria melhor, mas isso não é produtivo, não muda, dada a forma como eles são projetados agora.

Há outro aspecto de implementação que você deve considerar. Streams não são estruturas de dados imutáveis. Cada operação intermediária pode retornar uma nova Streaminstância que encapsula a antiga, mas também pode manipular sua própria instância e retornar a si mesma (isso não impede a execução de ambos pela mesma operação). Exemplos comumente conhecidos são operações como parallelou unorderedque não adicionam outra etapa, mas manipulam todo o pipeline). Ter uma estrutura de dados tão mutável e tentar reutilizar (ou pior ainda, usá-la várias vezes ao mesmo tempo) não funciona bem…


Para ser completo, eis o exemplo do quicksort traduzido para a StreamAPI Java . Isso mostra que realmente não "tira muito poder".

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

Pode ser usado como

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(", ")));

Você pode escrever ainda mais compacto como

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
Bem, consome ou não, tentar consumi-lo novamente gera uma exceção de que o fluxo já estava fechado , não consumido. Quanto ao problema de redefinir um fluxo de números inteiros aleatórios, como você disse, cabe ao escritor da biblioteca definir o contrato exato de uma operação de redefinição.
Vitaliy

2
Não, a mensagem é "o fluxo já foi operado ou fechado" e não estávamos falando sobre uma operação de "redefinição", mas chamando duas ou mais operações de terminal ona, Streamenquanto a redefinição da fonte Spliteratorseria implícita. E tenho certeza de que, se isso era possível, havia uma pergunta no SO como "Por que chamar count()duas vezes em um Streamdá resultados diferentes a cada vez", etc ...
Holger

1
É absolutamente válido que count () dê resultados diferentes. count () é uma consulta em um fluxo e, se o fluxo for mutável (ou, para ser mais exato, o fluxo representa o resultado de uma consulta em uma coleção mutável), é esperado. Dê uma olhada na API do C #. Eles lidam com todos esses problemas com elegância.
Vitaliy

4
O que você chama de "absolutamente válido" é um comportamento contra-intuitivo. Afinal, é a principal motivação para perguntar sobre o uso de um fluxo várias vezes para processar o resultado, que deve ser o mesmo, de maneiras diferentes. StreamAté agora, todas as perguntas sobre SO sobre a natureza não reutilizável de s decorrem de uma tentativa de resolver um problema chamando as operações do terminal várias vezes (obviamente, caso contrário você não percebe), o que levou a uma solução silenciosamente interrompida se a StreamAPI permitir. com resultados diferentes em cada avaliação. Aqui está um bom exemplo .
21915 Holger

3
Na verdade, seu exemplo demonstra perfeitamente o que acontece se um programador não entender as implicações da aplicação de várias operações de terminal. Pense no que acontece quando cada uma dessas operações será aplicada a um conjunto totalmente diferente de elementos. Funciona apenas se a fonte do fluxo retornou os mesmos elementos em cada consulta, mas esta é exatamente a suposição errada sobre a qual estávamos falando.
Holger

8

Eu acho que existem muito poucas diferenças entre os dois quando você olha de perto o suficiente.

No que diz respeito, um IEnumerableparece ser uma construção reutilizável:

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

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

No entanto, o compilador está realmente trabalhando um pouco para nos ajudar; gera o seguinte código:

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

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

Cada vez que você realmente itera sobre o enumerável, o compilador cria um enumerador. O enumerador não é reutilizável; mais chamadas para MoveNextretornarão apenas false, e não há como redefini-lo para o início. Se você deseja repetir os números novamente, será necessário criar outra instância do enumerador.


Para ilustrar melhor que o IEnumerable possui (pode ter) o mesmo 'recurso' que um Java Stream, considere um enumerável cuja origem dos números não seja uma coleção estática. Por exemplo, podemos criar um objeto enumerável que gera uma sequência de 5 números aleatórios:

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

Agora, temos um código muito semelhante ao enumerável baseado em matriz anterior, mas com uma segunda iteração sobre numbers:

IEnumerable<int> numbers = new RandomNumberStream();

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

Na segunda vez que iteramos numbers, obteremos uma sequência diferente de números, que não é reutilizável no mesmo sentido. Ou, poderíamos ter escrito o RandomNumberStreamcomando para lançar uma exceção se você tentar iterá-la várias vezes, tornando o enumerável realmente inutilizável (como um Java Stream).

Além disso, o que sua classificação rápida baseada em enumerável significa quando aplicada a um RandomNumberStream?


Conclusão

Portanto, a maior diferença é que o .NET permite reutilizar um IEnumerable, criando implicitamente um novo IEnumeratorem segundo plano sempre que for necessário acessar elementos na sequência.

Esse comportamento implícito geralmente é útil (e "poderoso", como você declara), porque podemos repetidamente repetir uma coleção.

Mas, às vezes, esse comportamento implícito pode realmente causar problemas. Se sua fonte de dados não é estática ou é de alto custo de acesso (como um banco de dados ou site), muitas suposições IEnumerableprecisam ser descartadas; reutilizar não é tão simples


2

É possível ignorar algumas das proteções "executar uma vez" na API Stream; por exemplo, podemos evitar java.lang.IllegalStateExceptionexceções (com a mensagem "o fluxo já foi operado ou fechado") referenciando e reutilizando o Spliterator(e não o Streamdiretamente).

Por exemplo, esse código será executado sem gerar uma exceção:

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

No entanto, a produção será limitada a

prefix-hello
prefix-world

em vez de repetir a saída duas vezes. Isso ocorre porque o ArraySpliteratorusado como a Streamfonte é estável e armazena sua posição atual. Quando repetimos isso Stream, começamos novamente no final.

Temos várias opções para resolver esse desafio:

  1. Poderíamos fazer uso de um Streammétodo de criação sem estado , como Stream#generate(). Teríamos que gerenciar o estado externamente em nosso próprio código e redefinir entre Stream"replays":

    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. Outra solução (um pouco melhor, mas não perfeita) para isso é escrever nossa própria ArraySpliterator(ou Streamfonte similar ), que inclui alguma capacidade de redefinir o contador atual. Se o usássemos para gerar o Stream, poderíamos reproduzi-los com êxito.

    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. A melhor solução para esse problema (na minha opinião) é fazer uma nova cópia de qualquer estado Spliteratorusado no Streampipeline quando novos operadores forem chamados no Stream. Isso é mais complexo e envolvido na implementação, mas se você não se importa em usar bibliotecas de terceiros, o cyclops-react possui uma Streamimplementação que faz exatamente isso. (Divulgação: Eu sou o desenvolvedor principal deste projeto.)

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

Isso imprimirá

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

como esperado.

Ao utilizar nosso site, você reconhece que leu e compreendeu nossa Política de Cookies e nossa Política de Privacidade.
Licensed under cc by-sa 3.0 with attribution required.