¿Por qué Java Streams se usa una sola vez?

A diferencia de IEnumerable C #, donde una tubería de ejecución se puede ejecutar tantas veces como queramos, en Java una stream puede ‘iterarse’ solo una vez.

Cualquier llamada a una operación de terminal cierra la transmisión, dejándola inutilizable. Esta ‘característica’ quita mucha potencia.

Me imagino que la razón para esto no es técnica. ¿Cuáles fueron las consideraciones de diseño detrás de esta extraña restricción?

Editar: para demostrar de lo que estoy hablando, considere la siguiente implementación de Quick-Sort en C #:

 IEnumerable QuickSort(IEnumerable ints) { if (!ints.Any()) { return Enumerable.Empty(); } int pivot = ints.First(); IEnumerable lt = ints.Where(i => i < pivot); IEnumerable gt = ints.Where(i => i > pivot); return QuickSort(lt).Concat(new int[] { pivot }).Concat(QuickSort(gt)); } 

¡Ahora para estar seguro, no estoy abogando por que esta sea una buena implementación de tipo rápido! Sin embargo, es un gran ejemplo del poder expresivo de la expresión lambda combinada con la operación de flujo.

¡Y no se puede hacer en Java! Ni siquiera puedo preguntarle a un flujo si está vacío sin hacerlo inutilizable.

Tengo algunos recuerdos del diseño inicial de la API de Streams que podrían arrojar algo de luz sobre el fundamento del diseño.

Ya en 2012, estábamos agregando lambdas al lenguaje, y queríamos un conjunto de operaciones orientadas a colecciones o “datos masivos”, progtwigdas con lambdas, que facilitaran el paralelismo. La idea de encadenar las operaciones de forma perezosa estaba bien establecida en este punto. Tampoco queríamos que las operaciones intermedias almacenaran los resultados.

Los principales problemas que teníamos que decidir eran qué aspecto tenían los objetos de la cadena en la API y cómo se conectaban a las fonts de datos. Las fonts eran a menudo colecciones, pero también queríamos admitir datos provenientes de un archivo o la red, o datos generados sobre la marcha, por ejemplo, de un generador de números aleatorios.

Hubo muchas influencias del trabajo existente en el diseño. Entre los más influyentes se encuentran la biblioteca de guayaba de Google y la biblioteca de colecciones Scala. (Si alguien está sorprendido por la influencia de la guayaba, tenga en cuenta que Kevin Bourrillion , desarrollador principal de guayaba, estaba en el grupo de expertos JSR-335 Lambda .) En las colecciones de Scala, encontramos esta charla de Martin Odersky de especial interés: Future- Pruebas de Scala Collections: de Mutable a Persistent a Parallel . (Stanford EE380, 2011 1 de junio)

Nuestro prototipo de diseño en ese momento estaba basado en Iterable . El filter operaciones familiares, el map , etc., fueron métodos de extensión (predeterminados) en Iterable . Llamar a uno agregó una operación a la cadena y devolvió otro Iterable . Una operación de terminal como count llamaría al iterator() por la cadena hasta la fuente, y las operaciones se implementaron dentro del iterador de cada etapa.

Dado que estos son Iterables, puede llamar al método iterator() más de una vez. ¿Qué debería pasar entonces?

Si la fuente es una colección, esto en su mayoría funciona bien. Las colecciones son Iterables, y cada llamada a iterator() produce una instancia distinta de Iterator que es independiente de cualquier otra instancia activa, y cada una atraviesa la colección de forma independiente. Estupendo.

Ahora, ¿qué pasa si la fuente es de una sola toma, como leer líneas de un archivo? Quizás el primer iterador debería obtener todos los valores, pero el segundo iterador debería estar vacío. Quizás los valores se deben intercalar entre los Iteradores. O tal vez cada iterador debería obtener todos los mismos valores. Entonces, ¿qué pasa si tienes dos iteradores y uno se pone más adelantado que el otro? Alguien tendrá que almacenar los valores en el segundo iterador hasta que se lean. Peor aún, qué pasa si obtiene un iterador y lee todos los valores, y solo luego obtiene un segundo iterador. ¿De dónde vienen los valores? ¿Existe un requisito para que todos ellos sean amortiguados por si acaso alguien quiere un segundo iterador?

Claramente, permitir múltiples iteradores sobre una única fuente genera muchas preguntas. No teníamos buenas respuestas para ellos. Queríamos un comportamiento consistente y predecible de lo que sucedería si llamaras a iterator() dos veces. Esto nos empujó a rechazar varios cruces, haciendo que los oleoductos tengan un solo disparo.

También observamos que otros chocaban con estos problemas. En el JDK, la mayoría de los Iterables son colecciones u objetos similares a colecciones, que permiten múltiples recorridos. No está especificado en ninguna parte, pero parecía haber una expectativa no escrita de que los Iterables permitieran el cruce múltiple. Una excepción notable es la interfaz NIO DirectoryStream . Su especificación incluye esta interesante advertencia:

Mientras DirectoryStream se extiende Iterable, no es un Iterable de uso general, ya que solo admite un iterador único; invocando el método del iterador para obtener un segundo iterador o un iterador subsiguiente lanza IllegalStateException.

[negrita en el original]

Esto parecía lo suficientemente desagradable y desagradable que no queríamos crear un montón de nuevos Iterables que pudieran ser únicos. Esto nos empujó a no usar Iterable.

Alrededor de este tiempo, apareció un artículo de Bruce Eckel que describía una serie de problemas que había tenido con Scala. Él había escrito este código:

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

Es bastante sencillo. Analiza las líneas de texto en los objetos del Registrant y los imprime dos veces. Excepto que en realidad solo los imprime una vez. Resulta que pensó que los registrants eran una colección, cuando de hecho es un iterador. La segunda llamada a foreach encuentra un iterador vacío, del cual se han agotado todos los valores, por lo que no imprime nada.

Este tipo de experiencia nos convenció de que era muy importante tener resultados claramente predecibles si se intentaba atravesar varias veces. También destacó la importancia de distinguir entre estructuras similares a tuberías perezosa de colecciones reales que almacenan datos. Esto, a su vez, condujo a la separación de las operaciones de la tubería perezosa en la nueva interfaz de Stream y manteniendo solo operaciones impacientes y mutantes directamente en las colecciones. Brian Goetz ha explicado la razón de ser de eso.

¿Qué hay de permitir el cruce múltiple para las tuberías basadas en la recolección, pero no permitirlo para las tuberías no basadas en la recolección? Es inconsistente, pero es sensato. Si está leyendo valores de la red, por supuesto no puede atravesarlos nuevamente. Si desea atravesarlos varias veces, debe insertarlos en una colección explícitamente.

Pero exploremos permitiendo el cruce múltiple desde las tuberías basadas en colecciones. Digamos que hiciste esto:

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

(El into funcionamiento ahora se deletrea collect(toList()) .

Si el origen es una colección, la primera llamada into() creará una cadena de Iterators de vuelta al origen, ejecutará las operaciones del pipeline y enviará los resultados al destino. La segunda llamada a into() creará otra cadena de iteradores y ejecutará las operaciones de canalización nuevamente . Esto no es obviamente incorrecto, pero tiene el efecto de realizar todas las operaciones de filtro y mapa una segunda vez para cada elemento. Creo que muchos progtwigdores se habrían sorprendido con este comportamiento.

Como mencioné anteriormente, hemos estado hablando con los desarrolladores de Guava. Una de las mejores cosas que tienen es un Cementerio de ideas en el que describen las características que decidieron no implementar junto con las razones. La idea de las colecciones perezosas suena muy bien, pero esto es lo que tienen que decir al respecto. Considere una operación List.filter() que devuelve una List :

La mayor preocupación aquí es que demasiadas operaciones se vuelven costosas, proposiciones de tiempo lineal. Si desea filtrar una lista y obtener una lista, y no solo una Colección o una Iterable, puede usar ImmutableList.copyOf(Iterables.filter(list, predicate)) , que “indica por adelantado” qué está haciendo y cómo es caro.

Para tomar un ejemplo específico, ¿cuál es el costo de get(0) o size() en una lista? Para clases comúnmente usadas como ArrayList , son O (1). Pero si llama a uno de estos en una lista filtrada de forma diferida, tiene que ejecutar el filtro sobre la lista de respaldo, y de repente estas operaciones son O (n). Peor aún, tiene que atravesar la lista de respaldo en cada operación.

Esto nos pareció demasiado holgazán. Una cosa es configurar algunas operaciones y diferir la ejecución real hasta que “vaya”. Otra cosa es configurar las cosas de tal manera que oculte una cantidad potencialmente grande de recalculación.

Al proponer no permitir transmisiones no lineales o de “no reutilización”, Paul Sandoz describió las posibles consecuencias de permitir que generen “resultados inesperados o confusos”. También mencionó que la ejecución en paralelo haría las cosas aún más difíciles. Finalmente, agregaría que una operación de canalización con efectos secundarios llevaría a errores difíciles y oscuros si la operación se ejecutara inesperadamente varias veces, o al menos una cantidad de veces diferente a la esperada por el progtwigdor. (Pero los progtwigdores de Java no escriben expresiones lambda con efectos secundarios, ¿verdad? ¿HACEN?)

Esa es la razón fundamental para el diseño de la API de Java 8 Streams que permite el cruce de un solo tramo y que requiere una canalización estrictamente lineal (sin ramificación). Proporciona un comportamiento uniforme en múltiples fonts de flujo diferentes, separa claramente las operaciones de perezoso de las ansiosas y proporciona un modelo de ejecución sencillo.


Con respecto a IEnumerable , estoy lejos de ser un experto en C # y .NET, por lo que agradecería que me corrijan (suavemente) si saco conclusiones incorrectas. Sin embargo, IEnumerable que IEnumerable permite que el recorrido múltiple se comporte de manera diferente con diferentes fonts; y permite una estructura de IEnumerable operaciones IEnumerable anidadas, lo que puede dar como resultado un recálculo significativo. Si bien aprecio que diferentes sistemas realicen diferentes intercambios, estas son dos características que intentamos evitar en el diseño de Java 8 Streams API.

El ejemplo de quicksort dado por el OP es interesante, desconcertante, y siento decirlo, algo horrible. Llamar a QuickSort toma un IEnumerable y devuelve un IEnumerable , por lo que no se realiza ninguna clasificación hasta que se IEnumerable el IEnumerable final. Lo que la llamada parece hacer, sin embargo, es construir una estructura de árbol de IEnumerables que refleje la partición que haría la IEnumerables rápida, sin hacerlo realmente. (Esto es un cálculo perezoso, después de todo.) Si la fuente tiene N elementos, el árbol tendrá N elementos más ancho en su parte más ancha, y tendrá niveles de LG (N) profundos.

Me parece a mí, y una vez más, no soy un experto en C # o .NET, que esto provocará que ciertas llamadas de aspecto inofensivo, como la selección de pivote a través de ints.First() , sean más caras de lo que parecen . En el primer nivel, por supuesto, es O (1). Pero considere una partición en el fondo del árbol, en el borde derecho. Para calcular el primer elemento de esta partición, se debe atravesar toda la fuente, una operación O (N). Pero dado que las particiones anteriores son flojas, deben ser recalculadas, requiriendo comparaciones O (lg N). Entonces, la selección del pivote sería una operación O (N lg N), que es tan costosa como una clasificación completa.

Pero en realidad no IEnumerable hasta que atravesamos el IEnumerable devuelto. En el algoritmo estándar de quicksort, cada nivel de particionamiento dobla el número de particiones. Cada partición es solo la mitad del tamaño, por lo que cada nivel permanece en O (N) complejidad. El árbol de particiones es O (lg N) alto, por lo que el trabajo total es O (N lg N).

Con el árbol de IEnumerables, en la parte inferior del árbol, hay N particiones. Calcular cada partición requiere un cruce de N elementos, cada uno de los cuales requiere comparaciones lg (N) en el árbol. Para calcular todas las particiones en la parte inferior del árbol, entonces, se requieren comparaciones O (N ^ 2 lg N).

(¿Es esto correcto? Apenas puedo creer esto. Alguien por favor verifique esto por mí.)

En cualquier caso, es realmente genial que IEnumerable se pueda usar de esta manera para construir complicadas estructuras de computación. Pero si aumenta la complejidad computacional tanto como creo que lo hace, parecería que progtwigr de esta manera es algo que debería evitarse a menos que uno sea extremadamente cuidadoso.

Fondo

Si bien la pregunta parece simple, la respuesta real requiere algunos antecedentes para tener sentido. Si quieres saltar a la conclusión, desplázate hacia abajo …

Elija su punto de comparación: funcionalidad básica

Utilizando conceptos básicos, el concepto IEnumerable C # se relaciona más estrechamente con Iterable de Java , que puede crear tantos Iteradores como desee. IEnumerables crea IEnumerators . Iterable de Java crea Iterators

La historia de cada concepto es similar, en el sentido de que tanto IEnumerable como Iterable tienen una motivación básica para permitir que el estilo ‘for-each’ abarque a los miembros de las colecciones de datos. Eso es una simplificación excesiva ya que ambos permiten algo más que eso, y también llegaron a esa etapa a través de diferentes progresiones, pero es una característica común importante.

Comparemos esa característica: en ambos lenguajes, si una clase implementa IEnumerable / Iterable , entonces esa clase debe implementar al menos un único método (para C #, es GetEnumerator y para Java es iterator() ). En cada caso, la instancia devuelta por ese ( IEnumerator / IEnumerator ) le permite acceder a los miembros actuales y posteriores de los datos. Esta característica se usa en la syntax de cada idioma.

Elija su punto de comparación: funcionalidad mejorada

IEnumerable en C # se ha ampliado para permitir una serie de otras características del lenguaje ( principalmente relacionadas con Linq ). Las características añadidas incluyen selecciones, proyecciones, agregaciones, etc. Estas extensiones tienen una fuerte motivación por el uso en la teoría de conjuntos, similar a los conceptos de bases de datos relacionales y SQL.

Java 8 también ha agregado funcionalidad para permitir un grado de progtwigción funcional utilizando Streams y Lambdas. Tenga en cuenta que las secuencias de Java 8 no están motivadas principalmente por la teoría de conjuntos, sino por la progtwigción funcional. De todos modos, hay muchos paralelismos.

Entonces, este es el segundo punto. Las mejoras realizadas a C # se implementaron como una mejora del concepto de IEnumerable . En Java, sin embargo, las mejoras realizadas se implementaron mediante la creación de nuevos conceptos básicos de Lambdas y Streams, y luego también creando una forma relativamente trivial de convertir Iterators e Iterables a Streams, y viceversa.

Entonces, comparar IEnumerable con el concepto de Stream de Java es incompleto. Debe compararlo con las API combinadas de Streams and Collections en Java.

En Java, las transmisiones no son lo mismo que los Iterables o Iteradores

Los flujos no están diseñados para resolver problemas de la misma manera que los iteradores:

  • Los iteradores son una forma de describir la secuencia de datos.
  • Los flujos son una forma de describir una secuencia de transformaciones de datos.

Con un Iterator , obtienes un valor de datos, lo procesas y luego obtienes otro valor de datos.

Con Streams, encadena una secuencia de funciones, luego alimenta un valor de entrada a la secuencia y obtiene el valor de salida de la secuencia combinada. Tenga en cuenta que, en términos de Java, cada función se encapsula en una sola instancia de Stream . La API de Streams le permite vincular una secuencia de instancias de Stream de una manera que encadena una secuencia de expresiones de transformación.

Para completar el concepto Stream , necesita una fuente de datos para alimentar la transmisión y una función de terminal que consum la transmisión.

La forma en que introduce valores en la secuencia puede ser de un Iterable , pero la secuencia Stream no es Iterable , es una función compuesta.

Un Stream también está destinado a ser flojo, en el sentido de que solo funciona cuando solicita un valor.

Tenga en cuenta estas importantes suposiciones y características de Streams:

  • Un Stream en Java es un motor de transformación, transforma un elemento de datos en un estado, para estar en otro estado.
  • las transmisiones no tienen ningún concepto del orden o la posición de los datos, simplemente transforman lo que se les pide.
  • las secuencias se pueden suministrar con datos de muchas fonts, incluidas otras secuencias, Iteradores, Iterables, Colecciones,
  • no puede “restablecer” una secuencia, sería como “reprogtwigr la transformación”. Restablecer la fuente de datos es probablemente lo que desea.
  • lógicamente solo hay 1 elemento de datos ‘en vuelo’ en la transmisión en cualquier momento (a menos que la transmisión sea una transmisión paralela, en cuyo punto, hay 1 elemento por cadena). Esto es independiente de la fuente de datos que puede tener más de los elementos actuales “listos” para ser suministrados a la secuencia, o el colector de la secuencia que puede necesitar agregar y reducir valores múltiples.
  • Los flujos pueden ser ilimitados (infinitos), limitados solo por la fuente de datos, o el colector (que también puede ser infinito).
  • Los flujos son “encadenables”, el resultado de filtrar una secuencia, es otra secuencia. Los valores de entrada ay transformados por una secuencia pueden a su vez ser suministrados a otra stream que realiza una transformación diferente. Los datos, en su estado transformado, fluyen de una secuencia a la siguiente. No es necesario que intervenga y extraiga los datos de una secuencia y la conecte a la siguiente.

Comparación de C #

Cuando considera que una secuencia de Java es solo una parte de un sistema de suministro, transmisión y recostackción, y que los flujos e iteradores a menudo se usan junto con las colecciones, no es de extrañar que sea difícil relacionarse con los mismos conceptos que son casi todo incluido en un solo concepto de IEnumerable en C #.

Partes de IEnumerable (y conceptos relacionados cercanos) son evidentes en todos los conceptos de Java Iterator, Iterable, Lambda y Stream.

Hay pequeñas cosas que los conceptos de Java pueden hacer que son más difíciles en IEnumerable, y viceversa.


Conclusión

  • Aquí no hay un problema de diseño, solo un problema para unir conceptos entre los idiomas.
  • Las streams resuelven los problemas de una manera diferente
  • Los flujos agregan funcionalidad a Java (agregan una forma diferente de hacer las cosas, no quitan la funcionalidad)

Agregar secuencias le brinda más opciones cuando resuelve problemas, lo cual es justo clasificar como ‘mejorar el poder’, no ‘reducir’, ‘quitar’ o ‘restringir’.

¿Por qué Java Streams se usa una sola vez?

Esta pregunta está mal dirigida, porque las secuencias son secuencias de funciones, no datos. Dependiendo de la fuente de datos que alimenta la secuencia, puede restablecer la fuente de datos y alimentar la misma o una secuencia diferente.

A diferencia de IEnumerable de C #, donde una tubería de ejecución se puede ejecutar tantas veces como queramos, en Java una stream puede ‘iterarse’ solo una vez.

Comparar un IEnumerable con un Stream está mal orientado. El contexto que está utilizando para decir que IEnumerable se puede ejecutar tantas veces como lo desee, es mejor si se compara con los Iterables Java, que se pueden repetir tantas veces como lo desee. Un Stream Java representa un subconjunto del concepto IEnumerable , y no el subconjunto que proporciona datos, y por lo tanto no puede ser ‘repetido’.

Cualquier llamada a una operación de terminal cierra la transmisión, dejándola inutilizable. Esta ‘característica’ quita mucha potencia.

La primera afirmación es verdadera, en cierto sentido. La afirmación de “quita poder” no lo es. Aún está comparando Streams it IEnumerables. La operación del terminal en la secuencia es como una cláusula ‘break’ en un ciclo for. Siempre puede tener otra transmisión, si lo desea, y si puede reabastecer los datos que necesita. Nuevamente, si considera que IEnumerable es más como un Iterable , para esta afirmación, Java lo hace muy bien.

Me imagino que la razón para esto no es técnica. ¿Cuáles fueron las consideraciones de diseño detrás de esta extraña restricción?

La razón es técnica, y por la simple razón de que Stream es un subconjunto de lo que se piensa. El subconjunto de flujo no controla el suministro de datos, por lo que debe restablecer el suministro, no la secuencia. En ese contexto, no es tan extraño.

Ejemplo de QuickSort

Su ejemplo de quicksort tiene la firma:

 IEnumerable QuickSort(IEnumerable ints) 

Está tratando la entrada IEnumerable como fuente de datos:

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

Además, el valor de retorno también es IEnumerable , que es un suministro de datos, y dado que se trata de una operación de clasificación, el orden de ese suministro es significativo. Si considera que la clase Iterable Java es la Iterable adecuada para esto, específicamente la especialización de List de Iterable , dado que List es un suministro de datos que tiene una orden o iteración garantizada, entonces el código Java equivalente a su código sería:

 Stream quickSort(List 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 lt = ints.stream().filter(i -> i < pivot).collect(Collectors.toList()); List gt = ints.stream().filter(i -> i > pivot).collect(Collectors.toList()); return Stream.concat(Stream.concat(quickSort(lt), Stream.of(pivot)),quickSort(gt)); } 

Tenga en cuenta que hay un error (que he reproducido), ya que el género no maneja los valores duplicados correctamente, es un tipo de 'valor único'.

También tenga en cuenta cómo el código de Java usa el origen de datos ( List ) y los conceptos de transmisión en diferentes puntos, y que en C # esas dos 'personalidades' se pueden express en solo IEnumerable . Además, aunque utilizo List como el tipo base, podría haber usado la Collection más general, y con una pequeña conversión de iterador a Stream, podría haber usado el Iterable aún más general.

Stream se crean alrededor de Spliterator s, que son objetos mutables y con estado. No tienen una acción de “reinicio” y, de hecho, requerir que se respalde dicha acción de rebobinado “quitará mucho poder”. ¿Cómo se Random.ints() que Random.ints() manejaría tal solicitud?

Por otro lado, para Stream s que tiene un origen de retorno, es fácil construir un Stream equivalente para usar de nuevo. Simplemente ponga los pasos hechos para construir el Stream en un método reutilizable. Tenga en cuenta que repetir estos pasos no es una operación costosa ya que todos estos pasos son operaciones perezosas; el trabajo real comienza con la operación del terminal y, dependiendo de la operación real del terminal, pueden ejecutarse códigos completamente diferentes.

Depende de usted, el autor de dicho método, especificar qué implica dos veces el llamado del método: ¿reproduce exactamente la misma secuencia, como lo hacen las secuencias creadas para una matriz o colección no modificada, o produce una secuencia con un semántica similar pero con diferentes elementos como una secuencia de entradas aleatorias o una secuencia de líneas de entrada de consola, etc.


Por cierto, para evitar confusiones, una operación de terminal consume el Stream que es distinto de cerrar el Stream como lo hace la llamada close() en el flujo (que se requiere para las secuencias que tienen recursos asociados como, por ejemplo, producidos por Files.lines() ) .


Parece que mucha confusión proviene de una comparación IEnumerable de IEnumerable con Stream . Un IEnumerable representa la capacidad de proporcionar un IEnumerator real, por lo que es como un Iterable en Java. Por el contrario, un Stream es un tipo de iterador y comparable a un IEnumerator por lo que es incorrecto afirmar que este tipo de tipo de datos se puede usar varias veces en .NET, el soporte para IEnumerator.Reset es opcional. Los ejemplos discutidos aquí usan más bien el hecho de que un IEnumerable se puede usar para buscar nuevos IEnumerator y que también funciona con Collection de Java; puedes obtener un nuevo Stream . Si los desarrolladores de Java decidieron agregar las operaciones de Stream a Iterable directamente, con operaciones intermedias que devuelven otro Iterable , era realmente comparable y podría funcionar de la misma manera.

Sin embargo, los desarrolladores decidieron no hacerlo y la decisión se discute en esta pregunta . El punto más importante es la confusión sobre las operaciones de recolección ansiosas y las operaciones de Stream perezosas. Al mirar la API de .NET, yo (sí, personalmente) lo encuentro justificado. Si bien parece razonable mirar IEnumerable solo, una Colección particular tendrá muchos métodos que manipulan la Colección directamente y muchos métodos que devuelven un IEnumerable perezoso, mientras que la naturaleza particular de un método no siempre es intuitivamente reconocible. El peor ejemplo que encontré (dentro de los pocos minutos que lo miré) es List.Reverse() cuyo nombre coincide exactamente con el nombre del heredado (¿es este el término correcto para los métodos de extensión?) Enumerable.Reverse() al tener un comportamiento contradictorio


Por supuesto, estas son dos decisiones distintas. El primero en hacer que Stream un tipo distinto de Iterable / Collection y el segundo para hacer que Stream una especie de iterador de una vez en lugar de otro tipo de iterable. Pero estas decisiones se tomaron juntas y podría ser el caso que la separación de estas dos decisiones nunca fue considerada. No fue creado con ser comparable a .NET en mente.

La decisión real del diseño de la API fue agregar un tipo mejorado de iterador, el Spliterator . Spliterator s pueden ser proporcionados por los viejos Iterable s (que es la forma en que estos fueron adaptados) o implementaciones completamente nuevas. Luego, Stream se agregó como un front-end de alto nivel para el Spliterator s de bajo nivel. Eso es. Puede discutir si un diseño diferente sería mejor, pero eso no es productivo, no cambiará, dada la forma en que están diseñados ahora.

Hay otro aspecto de implementación que debes tener en cuenta. Stream s no son estructuras de datos inmutables. Cada operación intermedia puede devolver una nueva instancia de Stream encapsulando la anterior, pero también puede manipular su propia instancia en su lugar y regresar a sí misma (eso no impide hacer ambas cosas para la misma operación). Los ejemplos comúnmente conocidos son operaciones parallel o unordered que no agregan otro paso sino que manipulan toda la tubería). Tener una estructura de datos mutable e intentar volver a usar (o, lo que es peor, usarlo varias veces al mismo tiempo) no funciona bien …


Para completar, aquí está su ejemplo de quicksort traducido a Java Stream API. Muestra que realmente no “quita mucho poder”.

 static Stream quickSort(Supplier> ints) { final Optional optPivot = ints.get().findAny(); if(!optPivot.isPresent()) return Stream.empty(); final int pivot = optPivot.get(); Supplier> lt = ()->ints.get().filter(i -> i < pivot); Supplier> gt = ()->ints.get().filter(i -> i > pivot); return Stream.of(quickSort(lt), Stream.of(pivot), quickSort(gt)).flatMap(s->s); } 

It can be used like

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

You can write it even more compact as

 static Stream quickSort(Supplier> 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()); } 

I think there are very few differences between the two when you look closely enough.

At it’s face, an IEnumerable does appear to be a reusable construct:

 IEnumerable numbers = new int[] { 1, 2, 3, 4, 5 }; foreach (var n in numbers) { Console.WriteLine(n); } 

However, the compiler is actually doing a little bit of work to help us out; it generates the following code:

 IEnumerable numbers = new int[] { 1, 2, 3, 4, 5 }; IEnumerator enumerator = numbers.GetEnumerator(); while (enumerator.MoveNext()) { Console.WriteLine(enumerator.Current); } 

Each time you would actually iterate over the enumerable, the compiler creates an enumerator. The enumerator is not reusable; further calls to MoveNext will just return false, and there is no way to reset it to the beginning. If you want to iterate over the numbers again, you will need to create another enumerator instance.


To better illustrate that the IEnumerable has (can have) the same ‘feature’ as a Java Stream, consider a enumerable whose source of the numbers is not a static collection. For example, we can create an enumerable object which generates a sequence of 5 random numbers:

 class Generator : IEnumerator { 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 { Random _r = new Random(); public IEnumerator GetEnumerator() { return new Generator(_r); } public IEnumerator IEnumerable.GetEnumerator() { return this.GetEnumerator(); } } 

Now we have very similar code to the previous array-based enumerable, but with a second iteration over numbers :

 IEnumerable numbers = new RandomNumberStream(); foreach (var n in numbers) { Console.WriteLine(n); } foreach (var n in numbers) { Console.WriteLine(n); } 

The second time we iterate over numbers we will get a different sequence of numbers, which isn’t reusable in the same sense. Or, we could have written the RandomNumberStream to thrown an exception if you try to iterate over it multiple times, making the enumerable actually unusable (like a Java Stream).

Also, what does your enumerable-based quick sort mean when applied to a RandomNumberStream ?


Conclusión

So, the biggest difference is that .NET allows you to reuse an IEnumerable by implicitly creating a new IEnumerator in the background whenever it would need to access elements in the sequence.

This implicit behavior is often useful (and ‘powerful’ as you state), because we can repeatedly iterate over a collection.

But sometimes, this implicit behavior can actually cause problems. If your data source is not static, or is costly to access (like a database or web site), then a lot of assumptions about IEnumerable have to be discarded; reuse is not that straight-forward

It is possible to bypass some of the “run once” protections in the Stream API; for example we can avoid java.lang.IllegalStateException exceptions (with message “stream has already been operated upon or closed”) by referencing and reusing the Spliterator (rather than the Stream directly).

For example, this code will run without throwing an exception:

  Spliterator split = Stream.of("hello","world") .map(s->"prefix-"+s) .spliterator(); Stream replayable1 = StreamSupport.stream(split,false); Stream replayable2 = StreamSupport.stream(split,false); replayable1.forEach(System.out::println); replayable2.forEach(System.out::println); 

However the output will be limited to

 prefix-hello prefix-world 

rather than repeating the output twice. This is because the ArraySpliterator used as the Stream source is stateful and stores its current position. When we replay this Stream we start again at the end.

We have a number of options to solve this challenge:

  1. We could make use of a stateless Stream creation method such as Stream#generate() . We would have to manage state externally in our own code and reset between Stream “replays”:

     Spliterator split = Stream.generate(this::nextValue) .map(s->"prefix-"+s) .spliterator(); Stream replayable1 = StreamSupport.stream(split,false); Stream replayable2 = StreamSupport.stream(split,false); replayable1.forEach(System.out::println); this.resetCounter(); replayable2.forEach(System.out::println); 
  2. Another (slightly better but not perfect) solution to this is to write our own ArraySpliterator (or similar Stream source) that includes some capacity to reset the current counter. If we were to use it to generate the Stream we could potentially replay them successfully.

     MyArraySpliterator arraySplit = new MyArraySpliterator("hello","world"); Spliterator split = StreamSupport.stream(arraySplit,false) .map(s->"prefix-"+s) .spliterator(); Stream replayable1 = StreamSupport.stream(split,false); Stream replayable2 = StreamSupport.stream(split,false); replayable1.forEach(System.out::println); arraySplit.reset(); replayable2.forEach(System.out::println); 
  3. The best solution to this problem (in my opinion) is to make a new copy of any stateful Spliterator s used in the Stream pipeline when new operators are invoked on the Stream . This is more complex and involved to implement, but if you don’t mind using third party libraries, cyclops-react has a Stream implementation that does exactly this. (Disclosure: I am the lead developer for this project.)

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

Esto se imprimirá

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

como se esperaba.

    Intereting Posts