¿Por qué los flujos de Java son únicos?


239

A diferencia de C # IEnumerable, donde una tubería de ejecución se puede ejecutar tantas veces como queramos, en Java una secuencia se puede 'iterar' solo una vez.

Cualquier llamada a una operación de terminal cierra la secuencia y la deja inutilizable. Esta 'característica' le quita mucho poder.

Me imagino que la razón de 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 Clasificación rápida en 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));
}

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 dejarlo inutilizable.


44
¿Podría dar un ejemplo concreto en el que cerrar la corriente "le quite el poder"?
Rogério

23
Si desea utilizar los datos de una secuencia más de una vez, tendrá que volcarlos en una colección. Esto es más o menos cómo tiene que funcionar: o tiene que rehacer el cálculo para generar la secuencia, o debe almacenar el resultado intermedio.
Louis Wasserman

55
Ok, pero rehacer el mismo cálculo en la misma secuencia suena mal. Se crea una secuencia a partir de una fuente determinada antes de realizar un cálculo, al igual que se crean iteradores para cada iteración. Todavía me gustaría ver un ejemplo concreto real; al final, apuesto a que hay una manera limpia de resolver cada problema con las transmisiones de uso único, suponiendo que exista una forma correspondiente con los enumerables de C #.
Rogério

2
Esta era confuso al principio para mí, porque me pareció que esta pregunta se relacionaría C # s IEnumerablede las corrientes dejava.io.*
SpaceTrucker

99
Tenga en cuenta que usar IEnumerable varias veces en C # es un patrón frágil, por lo que la premisa de la pregunta puede ser ligeramente defectuosa. Muchas implementaciones de IEnumerable lo permiten, pero algunas no. Las herramientas de análisis de código tienden a advertirle contra hacer tal cosa.
Sander

Respuestas:


368

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

En 2012, estábamos agregando lambdas al lenguaje, y queríamos un conjunto de operaciones orientadas a colecciones o "datos masivos", programadas usando lambdas, que facilitaran el paralelismo. La idea de encadenar perezosamente las operaciones juntas estaba bien establecida en este punto. Tampoco queríamos que las operaciones intermedias almacenaran resultados.

Los principales problemas que necesitábamos decidir eran cómo se veían los objetos en la cadena en la API y cómo se conectaban a las fuentes de datos. Las fuentes a menudo eran 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 estaban la biblioteca de guayaba de Google y la biblioteca de colecciones Scala. (Si alguien está sorprendido por la influencia de Guava, tenga en cuenta que Kevin Bourrillion , desarrollador principal de Guava, estaba en el grupo de expertos JSR-335 Lambda ). En las colecciones de Scala, encontramos que esta charla de Martin Odersky es de particular interés: Future- Pruebas de colecciones Scala: de mutable a persistente a paralela . (Stanford EE380, 1 de junio de 2011)

Nuestro diseño de prototipo en ese momento se basaba en Iterable. Las operaciones familiares filter, mapetc. fueron métodos de extensión (predeterminados) Iterable. Llamar a uno agregó una operación a la cadena y devolvió otro Iterable. Una operación de terminal como countllamaría iterator()la cadena a la fuente, y las operaciones se implementaron dentro del iterador de cada etapa.

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

Si la fuente es una colección, esto generalmente funciona bien. Las colecciones son Iterable, y cada llamada a iterator()produce una instancia Iterator distinta que es independiente de cualquier otra instancia activa, y cada una atraviesa la colección de forma independiente. Excelente.

Ahora, ¿qué pasa si la fuente es de una sola vez, como leer líneas de un archivo? Quizás el primer iterador debería obtener todos los valores, pero el segundo y los siguientes deberían estar vacíos. Quizás los valores deberían estar entrelazados entre los iteradores. O tal vez cada iterador debería obtener los mismos valores. Entonces, ¿qué pasa si tienes dos iteradores y uno se adelanta al otro? Alguien tendrá que almacenar los valores en el segundo iterador hasta que se lean. Peor aún, qué pasa si obtienes un Iterador y lees todos los valores, y solo entonces obtienes un segundo Iterador. ¿De dónde vienen los valores ahora? ¿Existe algún requisito para que todos estén protegidos en caso de que alguien quiera un segundo iterador?

Claramente, permitir múltiples iteradores sobre una fuente de una sola vez plantea muchas preguntas. No teníamos buenas respuestas para ellos. Queríamos un comportamiento consistente y predecible para lo que sucede si llamas iterator()dos veces. Esto nos empujó a no permitir múltiples recorridos, haciendo que las tuberías fueran de una sola vez.

También observamos que otros se toparon con estos problemas. En el JDK, la mayoría de los Iterables son colecciones u objetos similares a colecciones, que permiten un recorrido múltiple. No se especifica en ninguna parte, pero parece haber una expectativa no escrita de que los Iterables permiten un recorrido múltiple. Una excepción notable es la interfaz NIO DirectoryStream . Su especificación incluye esta interesante advertencia:

Si bien DirectoryStream extiende Iterable, no es un Iterable de propósito general, ya que solo admite un único Iterador; Invocar el método de iterador para obtener un segundo iterador o sucesivos arroja IllegalStateException.

[negrita en original]

Esto parecía inusual y lo suficientemente desagradable como para que no quisiéramos crear un montón de nuevos Iterables que pudieran ser de una sola vez. Esto nos alejó del uso de Iterable.

Por esta época, apareció un artículo de Bruce Eckel que describía un problema que había tenido con Scala. 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 líneas de texto en Registrantobjetos y las imprime dos veces. Excepto que en realidad solo los imprime una vez. Resulta que él pensó que registrantsera una colección, cuando en realidad es un iterador. La segunda llamada a foreachencuentra un iterador vacío, desde el 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 intenta un recorrido múltiple. También destacó la importancia de distinguir entre estructuras perezosas tipo tubería de colecciones reales que almacenan datos. Esto, a su vez, condujo a la separación de las operaciones de tubería diferida a la nueva interfaz de Stream y mantuvo solo operaciones ansiosas y mutantes directamente en Colecciones. Brian Goetz ha explicado la justificación de eso.

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

Pero exploremos permitiendo múltiples recorridos desde tuberías basadas en colecciones. Digamos que hiciste esto:

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

(La intooperación ahora se deletrea collect(toList())).

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

Como mencioné anteriormente, habíamos estado hablando con los desarrolladores de Guava. Una de las cosas interesantes que tienen es un cementerio de ideas donde describen características que decidieron no implementar junto con los motivos. La idea de colecciones perezosas suena genial, pero esto es lo que tienen que decir al respecto. Considere una List.filter()operación que devuelve un List:

La mayor preocupación aquí es que muchas operaciones se convierten en costosas propuestas de tiempo lineal. Si desea filtrar una lista y recuperarla, y no solo una Colección o un Iterable, puede usarla ImmutableList.copyOf(Iterables.filter(list, predicate)), que "establece por adelantado" lo que está haciendo y lo costoso que es.

Para poner un ejemplo concreto, ¿cuál es el costo de get(0)o size()en una lista? Para clases de uso común como ArrayList, son O (1). Pero si llama a uno de estos en una lista filtrada perezosamente, tiene que ejecutar el filtro sobre la lista de respaldo, y de repente estas operaciones son O (n). Peor aún, tiene que recorrer la lista de respaldo en cada operación.

Esto nos pareció demasiada pereza. Una cosa es configurar algunas operaciones y diferir la ejecución real hasta que "vaya". Otra es configurar las cosas de tal manera que oculte una cantidad potencialmente grande de recálculo.

Al proponer no permitir flujos no lineales o de "no reutilización", Paul Sandoz describió las posibles consecuencias de permitirlos como resultado de "resultados inesperados o confusos". También mencionó que la ejecución paralela haría las cosas aún más complicadas. Finalmente, agregaría que una operación de canalización con efectos secundarios conduciría a errores difíciles y oscuros si la operación se ejecutara inesperadamente varias veces, o al menos un número diferente de veces de lo que esperaba el programador. (Pero los programadores de Java no escriben expresiones lambda con efectos secundarios, ¿verdad? ¿HACEN?)

Esa es la razón básica para el diseño de la API de Java 8 Streams que permite un recorrido de una sola vez y que requiere una tubería estrictamente lineal (sin ramificación). Proporciona un comportamiento consistente a través de múltiples fuentes de flujo diferentes, claramente separa las operaciones perezosas de las ansiosas, y proporciona un modelo de ejecución directo.


Con respecto a esto 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, parece que IEnumerablepermite que el recorrido múltiple se comporte de manera diferente con diferentes fuentes; y permite una estructura de ramificación de IEnumerableoperaciones anidadas , lo que puede resultar en una recalculación significativa. Si bien aprecio que los diferentes sistemas hacen diferentes compensaciones, estas son dos características que buscamos evitar en el diseño de la API de Java 8 Streams.

El ejemplo de clasificación rápida dado por el OP es interesante, desconcertante, y lamento decirlo, algo horrible. La llamada QuickSorttoma un IEnumerabley devuelve un IEnumerable, por lo que no se realiza ninguna clasificación hasta que IEnumerablese atraviesa el final . Sin embargo, lo que parece hacer la llamada es construir una estructura de árbol IEnumerablesque refleje la partición que haría Quicksort, sin hacerlo realmente. (Esto es un cálculo lento, después de todo). Si la fuente tiene N elementos, el árbol tendrá N elementos de ancho en su parte más ancha, y tendrá niveles de lg (N) de profundidad.

Me parece, y una vez más, no soy un experto en C # o .NET, que esto hará que ciertas llamadas de aspecto inocuo, como la selección de pivote a través ints.First(), sean más caras de lo que parecen. En el primer nivel, por supuesto, es O (1). Pero considere una partición profunda en el á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 perezosas, deben volverse a calcular, lo que requiere comparaciones de O (lg N). Por lo tanto, seleccionar el pivote sería una operación O (N lg N), que es tan costosa como una clase completa.

Pero en realidad no clasificamos hasta que atravesamos el retorno IEnumerable. En el algoritmo de clasificación rápida estándar, cada nivel de partición duplica el número de particiones. Cada partición es solo la mitad del tamaño, por lo que cada nivel permanece en la complejidad O (N). 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 perezosos, en la parte inferior del árbol hay N particiones. Calcular cada partición requiere un recorrido 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 correcto? Apenas puedo creer esto. Alguien por favor verifique esto por mí).

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


35
En primer lugar, ¡gracias por la excelente y no condescendiente respuesta! Esta es, con mucho, la explicación más precisa y precisa que obtuve. En lo que respecta al ejemplo de QuickSort, parece que tienes razón sobre ints. Primero hinchazón a medida que aumenta el nivel de recursión. Creo que esto se puede solucionar fácilmente calculando ansiosamente 'gt' y 'lt' (recopilando los resultados con ToArray). Dicho esto, ciertamente respalda su punto de que este estilo de programación puede generar un precio de rendimiento inesperado. (Continuar en el segundo comentario)
Vitaliy

18
Por otro lado, por mi experiencia con C # (más de 5 años) puedo decir que eliminar los cálculos 'redundantes' no es tan difícil una vez que se encuentra con un problema de rendimiento (o se prohibió, si alguien hizo lo impensable y presentó un lado afecta allí). Simplemente me pareció que se hizo demasiado compromiso para garantizar la pureza de la API, a expensas de las posibilidades de C #. Definitivamente me has ayudado a ajustar mi punto de vista.
Vitaliy

77
@Vitaliy Gracias por el intercambio justo de ideas. Aprendí un poco sobre C # y .NET investigando y escribiendo esta respuesta.
Stuart Marks

10
Pequeño comentario: ReSharper es una extensión de Visual Studio que ayuda con C #. Con el código QuickSort anterior, ReSharper agrega una advertencia para cada usoints : "Posible enumeración múltiple de IEnumerable". Usar el mismo IEenumerablemás de una vez es sospechoso y debe evitarse. También señalaría esta pregunta (que he respondido), que muestra algunas de las advertencias con el enfoque .Net (además del bajo rendimiento): Lista <T> y I Diferencia numerable
Kobi

44
@Kobi Muy interesante que haya tal advertencia en ReSharper. Gracias por el puntero a su respuesta. No conozco C # /. NET, así que tendré que revisarlo cuidadosamente, pero parece mostrar problemas similares a los problemas de diseño que mencioné anteriormente.
Stuart Marks

122

Antecedentes

Si bien la pregunta parece simple, la respuesta real requiere algunos antecedentes para tener sentido. Si desea pasar a la conclusión, desplácese hacia abajo ...

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

Usando conceptos básicos, el concepto de C # IEnumerableestá más estrechamente relacionado con el de JavaIterable , que puede crear tantos iteradores como desee. IEnumerablescrear IEnumerators. IterableCrear JavaIterators

La historia de cada concepto es similar, en la que tanto IEnumerabley Iterabletener una motivación básica para permitir 'for-each' estilo de bucle a través de los miembros de las colecciones de datos. Eso es una simplificación excesiva, ya que ambos permiten 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 independientemente.

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

Elija su punto de comparación: funcionalidad mejorada

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

Java 8 también ha agregado funcionalidades para permitir un grado de programación funcional usando 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 programación funcional. En cualquier caso, hay muchos paralelos.

Entonces, este es el segundo punto. Las mejoras realizadas en C # se implementaron como una mejora del IEnumerableconcepto. Sin embargo, en Java, las mejoras realizadas se implementaron creando nuevos conceptos básicos de Lambdas y Streams, y luego también creando una forma relativamente trivial de convertir desde Iteratorsy Iterableshacia Streams, y viceversa.

Entonces, comparar IEnumerable con el concepto Stream de Java está incompleto. Debe compararlo con las API de Streams y Colecciones combinadas en Java.

En Java, los flujos no son lo mismo que Iterables o Iterator

Las transmisiones no están diseñadas para resolver problemas de la misma manera que los iteradores:

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

Con un Iterator, obtiene un valor de datos, lo procesa y luego obtiene otro valor de datos.

Con Streams, encadena una secuencia de funciones juntas, 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 Streaminstancia. La API de Streams le permite vincular una secuencia de Streaminstancias de una manera que encadena una secuencia de expresiones de transformación.

Para completar el Streamconcepto, necesita una fuente de datos para alimentar el flujo y una función de terminal que consume el flujo.

De hecho, la forma en que introduce valores en la secuencia puede ser de un Iterable, pero la Streamsecuencia en sí no es un Iterable, es una función compuesta.

A Streamtambién está destinado a ser vago, en el sentido de que solo funciona cuando solicita un valor.

Tenga en cuenta estos supuestos y características importantes de Streams:

  • A Streamen Java es un motor de transformación, transforma un elemento de datos en un estado, en otro estado.
  • los flujos no tienen un concepto del orden o la posición de los datos, simplemente transforman lo que se les pida.
  • las secuencias se pueden suministrar con datos de muchas fuentes, incluidas otras secuencias, iteradores, iterables, colecciones,
  • no puede "restablecer" una secuencia, eso sería como "reprogramar 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 secuencia en cualquier momento (a menos que la secuencia sea paralela, en cuyo punto, hay 1 elemento por subproceso). 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 recopilador de la secuencia que puede necesitar agregar y reducir múltiples valores.
  • Las secuencias pueden ser independientes (infinito), limitadas solo por la fuente de datos o colector (que también puede ser infinito).
  • Las secuencias son 'encadenables', el resultado de filtrar una secuencia es otra secuencia. Los valores ingresados ​​y transformados por una secuencia pueden a su vez ser suministrados a otra secuencia 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 que los conecte a la siguiente.

Comparación de C #

Cuando considera que un Java Stream es solo una parte de un sistema de suministro, transmisión y recolección, y que los Streams e Iteradores a menudo se usan junto con Colecciones, entonces no es de extrañar que sea difícil relacionarse con los mismos conceptos que son Casi todos integrados en un solo IEnumerableconcepto 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 ningún problema de diseño, solo un problema en la coincidencia de conceptos entre los idiomas.
  • Las transmisiones resuelven problemas de una manera diferente
  • Las transmisiones agregan funcionalidad a Java (agregan una forma diferente de hacer las cosas, no quitan la funcionalidad)

Agregar Streams le brinda más opciones para resolver problemas, lo que es justo clasificar como 'potenciación', no 'reducción', 'eliminación' o 'restricción'.

¿Por qué los flujos de Java son únicos?

Esta pregunta es errónea, 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 diferente secuencia.

A diferencia de IEnumerable de C #, donde una tubería de ejecución se puede ejecutar tantas veces como queramos, en Java una secuencia se puede 'iterar' solo una vez.

Comparar un IEnumerablea un Streamestá equivocado. El contexto que está utilizando para decir que IEnumerablese puede ejecutar tantas veces como desee, se compara mejor con Java Iterables, que se puede repetir tantas veces como desee. Un Java Streamrepresenta un subconjunto del IEnumerableconcepto, y no el subconjunto que proporciona datos y, por lo tanto, no se puede "volver a ejecutar".

Cualquier llamada a una operación de terminal cierra la secuencia y la deja inutilizable. Esta 'característica' le quita mucho poder.

La primera afirmación es cierta, en cierto sentido. La declaración 'quita el poder' no lo es. Todavía está comparando Streams it IEnumerables. La operación del terminal en el flujo es como una cláusula de 'interrupción' en un bucle for. Siempre puede tener otra secuencia, si lo desea, y si puede volver a suministrar los datos que necesita. Nuevamente, si considera IEnumerableque es más como un Iterable, para esta declaración, Java lo hace bien.

Me imagino que la razón de 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 sencilla razón de que un Stream es un subconjunto de lo que cree que es. El subconjunto de flujo no controla el suministro de datos, por lo que debe restablecer el suministro, no el flujo. En ese contexto, no es tan extraño.

Ejemplo de QuickSort

Su ejemplo de clasificación rápida tiene la firma:

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

Está tratando la entrada IEnumerablecomo una fuente de datos:

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

Además, el valor de retorno IEnumerabletambién lo es, que es un suministro de datos, y dado que esta es una operación de Clasificación, el orden de ese suministro es significativo. Si considera que la Iterableclase Java es la coincidencia adecuada para esto, específicamente la Listespecialización de Iterable, dado que List es un suministro de datos que tiene un orden o iteración garantizados, entonces el código Java equivalente a su código sería:

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

Tenga en cuenta que hay un error (que he reproducido), ya que el tipo no maneja los valores duplicados con gracia, es un tipo de "valor único".

También tenga en cuenta cómo el código Java usa la fuente de datos ( List), y transmite conceptos en diferentes puntos, y que en C # esas dos 'personalidades' se pueden expresar de manera justa IEnumerable. Además, aunque lo he usado Listcomo tipo base, podría haber usado el más general Collection, y con una pequeña conversión de iterador a Stream, podría haber usado el aún más general.Iterable


99
Si está pensando en 'iterar' una secuencia, lo está haciendo mal. Una secuencia representa el estado de los datos en un punto particular en el tiempo en una cadena de transformaciones. Los datos ingresan al sistema en una fuente de flujo, luego fluyen de un flujo a otro, cambiando de estado a medida que avanza, hasta que se recopilan, reducen o descargan al final. A Streames un concepto de punto en el tiempo, no una 'operación de bucle' ... (cont.)
rolfl

77
Con un flujo, tiene datos que ingresan al flujo como X y salen del flujo como Y. Hay una función que realiza el flujo que realiza esa transformación. f(x)El flujo encapsula la función, no encapsula los datos que fluyen a través de él
rolfl

44
IEnumerableTambién puede proporcionar valores aleatorios, ser independiente y activarse antes de que existan los datos.
Arturo Torres Sánchez

66
@Vitaliy: muchos métodos que reciben un IEnumerable<T>esperan que represente una colección finita que se puede repetir varias veces. Algunas cosas que son iterables pero que no cumplen esas condiciones se implementan IEnumerable<T>porque ninguna otra interfaz estándar cumple con los requisitos , pero los métodos que esperan colecciones finitas que se pueden repetir varias veces son propensos a fallar si se les dan cosas iterables que no cumplen con esas condiciones .
supercat

55
Su quickSortejemplo podría ser mucho más simple si devuelve un Stream; ahorraría dos .stream()llamadas y una .collect(Collectors.toList())llamada. Si luego lo reemplaza Collections.singleton(pivot).stream()con Stream.of(pivot)el código se vuelve casi legible ...
Holger

22

Streams se construyen alrededor de Spliterators que son objetos mutables con estado. No tienen una acción de "reinicio" y, de hecho, la necesidad de apoyar dicha acción de rebobinado "les quitaría mucho poder". ¿Cómo se Random.ints()supone que manejaría tal solicitud?

Por otro lado, para Streams que tienen un origen rastreable, es fácil construir un equivalente Streampara ser usado nuevamente. Simplemente ponga los pasos realizados para construir el Streamen 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, se puede ejecutar un código completamente diferente.

Depende de usted, el escritor de dicho método, especificar qué implica llamar dos veces al 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 elementos diferentes como un flujo de entradas aleatorias o un flujo de líneas de entrada de consola, etc.


Por cierto, para evitar confusiones, una operación de terminal consume la Streamque es distinta de cierre de la Streamcomo llamar close()en la corriente no (que se requiere para los flujos que tiene asociado recursos como, por ejemplo producido por Files.lines()).


Parece que mucha confusión proviene de la comparación equivocada de IEnumerablecon Stream. Un IEnumerablerepresenta la capacidad de proporcionar un real IEnumerator, por lo que es como un Iterableen Java. Por el contrario, a Streames un tipo de iterador y comparable a un, IEnumeratorpor lo que es incorrecto afirmar que este tipo de datos se puede usar varias veces en .NET, el soporte para IEnumerator.Resetes opcional. Los ejemplos discutidos aquí usan más bien el hecho de que un IEnumerablepuede usarse para obtener nuevos mensajes de correo IEnumeratorelectrónico y eso también funciona con los mensajes de Java Collection; Puedes conseguir uno nuevo Stream. Si los desarrolladores de Java decidieron agregar las Streamoperaciones Iterabledirectamente, las operaciones intermedias devuelven otraIterable, fue 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 ansiosas operaciones de Colección y las operaciones de Stream diferidas. Al mirar la API .NET, (sí, personalmente) me parece justificada. Si bien parece razonable analizarlo IEnumerablesolo, una Colección particular tendrá muchos métodos para manipular la Colección directamente y muchos métodos que devolverán un vago IEnumerable, mientras que la naturaleza particular de un método no siempre es intuitivamente reconocible. El peor ejemplo que encontré (en los pocos minutos que lo vi) 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?) Y Enumerable.Reverse()tiene un comportamiento totalmente contradictorio.


Por supuesto, estas son dos decisiones distintas. El primero para hacer Streamun tipo distinto de Iterable/ Collectiony el segundo para hacer Streamun tipo de iterador de tiempo en lugar de otro tipo de iterador. Pero estas decisiones se tomaron juntas y podría darse el caso de que nunca se consideró separar estas dos decisiones. No fue creado teniendo en cuenta que es comparable a .NET.

La decisión real del diseño de la API fue agregar un tipo mejorado de iterador, el Spliterator. SpliteratorLos s pueden ser proporcionados por los viejos Iterables (que es la forma en que se actualizaron) o implementaciones completamente nuevas. Luego, Streamse agregó como un front-end de alto nivel a los niveles bastante bajos Spliterator. 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 considerar. StreamLos s no son estructuras de datos inmutables. Cada operación intermedia puede devolver una nueva Streaminstancia encapsulando la anterior, pero también puede manipular su propia instancia en su lugar y devolverse a sí misma (eso no impide hacer ni siquiera las dos cosas para la misma operación). Ejemplos comúnmente conocidos son operaciones como parallelo unorderedque no agregan otro paso sino que manipulan toda la tubería). Tener una estructura de datos tan mutable e intentos de reutilización (o peor aún, usarlos varias veces al mismo tiempo) no funciona bien ...


Para completar, aquí está su ejemplo de clasificación rápida traducido a la StreamAPI de Java . Muestra que en realidad no "quita mucho 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);
}

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

Puedes escribirlo aún más 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
Bueno, consume o no, intentar consumirlo nuevamente arroja una excepción de que el flujo ya estaba cerrado , no consumido. En cuanto al problema con el restablecimiento de una secuencia de enteros aleatorios, como usted dijo, depende del escritor de la biblioteca definir el contrato exacto de una operación de restablecimiento.
Vitaliy

2
No, el mensaje es "el flujo ya ha sido operado o cerrado" y no estábamos hablando de una operación de "reinicio", sino que llamamos a dos o más operaciones de terminal ona, Streammientras que el reinicio de las fuentes Spliteratorestaría implícito. Y estoy bastante seguro de que si eso fuera posible, hubo preguntas en SO como "¿Por qué llamar count()dos veces a un Streamda resultados diferentes cada vez", etc ...
Holger

1
Es absolutamente válido para count () dar resultados diferentes. count () es una consulta en una secuencia, y si la secuencia es mutable (o para ser más exactos, la secuencia representa el resultado de una consulta en una colección mutable), entonces se espera. Echa un vistazo a la API de C #. Se ocupan de todos estos problemas con gracia.
Vitaliy

44
Lo que llamas "absolutamente válido" es un comportamiento contrario a la intuición. Después de todo, es la principal motivación para preguntar sobre el uso de una secuencia varias veces para procesar el resultado, que se espera sea el mismo, de diferentes maneras. Cada pregunta sobre SO sobre la naturaleza no reutilizable de Streams hasta ahora se deriva de un intento de resolver un problema llamando a operaciones de terminal varias veces (obviamente, de lo contrario no se da cuenta) lo que condujo a una solución silenciosa si la StreamAPI lo permitía con diferentes resultados en cada evaluación. Aquí hay un buen ejemplo .
Holger

3
En realidad, su ejemplo demuestra perfectamente lo que sucede si un programador no comprende las implicaciones de aplicar múltiples operaciones de terminal. Solo piense en lo que sucede cuando cada una de estas operaciones se aplicará a un conjunto de elementos completamente diferente. Solo funciona si la fuente de la secuencia devolvió los mismos elementos en cada consulta, pero esta es exactamente la suposición incorrecta de la que estábamos hablando.
Holger

8

Creo que hay muy pocas diferencias entre los dos cuando miras lo suficientemente de cerca.

A primera vista, IEnumerableparece ser una construcción reutilizable:

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

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

Sin embargo, el compilador realmente está haciendo un poco de trabajo para ayudarnos; genera el siguiente 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 usted realmente itera sobre el enumerable, el compilador crea un enumerador. El enumerador no es reutilizable; nuevas llamadas a MoveNextsimplemente devolverán false, y no hay forma de restablecerlo al principio. Si desea repetir los números nuevamente, deberá crear otra instancia de enumerador.


Para ilustrar mejor que IEnumerable tiene (puede tener) la misma 'característica' que Java Stream, considere un enumerable cuya fuente de números no sea una colección estática. Por ejemplo, podemos crear un objeto enumerable que genere una secuencia de 5 números aleatorios:

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

Ahora tenemos un código muy similar al enumerable basado en matriz anterior, pero con una segunda iteración sobre numbers:

IEnumerable<int> numbers = new RandomNumberStream();

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

La segunda vez que iteramos numbers, obtendremos una secuencia de números diferente, que no es reutilizable en el mismo sentido. O bien, podríamos haber escrito el RandomNumberStreampara lanzar una excepción si intenta iterar sobre él varias veces, haciendo que el enumerable sea realmente inutilizable (como un Java Stream).

Además, ¿qué significa su ordenación rápida basada en enumerable cuando se aplica a un RandomNumberStream?


Conclusión

Por lo tanto, la mayor diferencia es que .NET le permite reutilizar una IEnumerableal crear implícitamente una nueva IEnumeratoren segundo plano siempre que necesite acceder a elementos en la secuencia.

Este comportamiento implícito es a menudo útil (y 'poderoso' como usted dice), porque podemos iterar repetidamente sobre una colección.

Pero a veces, este comportamiento implícito puede causar problemas. Si su fuente de datos no es estática, o su acceso es costoso (como una base de datos o sitio web), entonces IEnumerablese deben descartar muchas suposiciones sobre ; reutilizar no es tan sencillo


2

Es posible omitir algunas de las protecciones de "ejecutar una vez" en la API de Stream; por ejemplo, podemos evitar java.lang.IllegalStateExceptionexcepciones (con el mensaje "el flujo ya se ha operado o cerrado") haciendo referencia y reutilizando el Spliterator(en lugar del Streamdirectamente).

Por ejemplo, este código se ejecutará sin lanzar una excepción:

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

Sin embargo, la salida se limitará a

prefix-hello
prefix-world

en lugar de repetir la salida dos veces. Esto se debe a que el ArraySpliteratorutilizado como Streamfuente tiene estado y almacena su posición actual. Cuando reproducimos esto Stream, comenzamos de nuevo al final.

Tenemos varias opciones para resolver este desafío:

  1. Podríamos hacer uso de un Streammétodo de creación sin estado como Stream#generate(). Tendríamos que gestionar el estado externamente en nuestro propio código y restablecer entre Stream"repeticiones":

    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. Otra solución (ligeramente mejor pero no perfecta) para esto es escribir nuestra propia ArraySpliterator(o Streamfuente similar ) que incluya cierta capacidad para restablecer el contador actual. Si lo usáramos para generar el Stream, podríamos reproducirlo con é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. La mejor solución a este problema (en mi opinión) es hacer una nueva copia de cualquier estado con estado Spliteratorutilizado en la Streamtubería cuando se invocan nuevos operadores en el Stream. Esto es más complejo e implica implementarlo, pero si no le importa usar bibliotecas de terceros, cyclops-react tiene una Streamimplementación que hace exactamente esto. (Divulgación: soy el desarrollador principal de este proyecto).

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

Esto imprimirá

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

como se esperaba.

Al usar nuestro sitio, usted reconoce que ha leído y comprende nuestra Política de Cookies y Política de Privacidad.
Licensed under cc by-sa 3.0 with attribution required.