streams procesamiento parte libreria funcional examples ejemplos datos con collection java java-8 java-stream api-design

procesamiento - stream java ejemplos



¿Por qué los Java Streams son únicos? (5)

A diferencia de IEnumerable 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.

Cualquier llamada a una operación de terminal cierra la secuencia, dejándola 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.


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 IEnumerable C # está más estrechamente relacionado con Iterable de Java , que puede crear tantos Iterators como desee. IEnumerables crea IEnumerators . Iterable de Java crear Iterators

La historia de cada concepto es similar, ya que tanto IEnumerable como Iterable tienen una motivación básica para permitir el bucle de estilo ''para cada uno'' sobre 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 idiomas, si una clase implementa IEnumerable / Iterable , esa clase debe implementar al menos un método único (para C #, es GetEnumerator y para Java es iterator() ). En cada caso, la instancia devuelta desde ese ( IEnumerator / IEnumerator ) le permite acceder a los miembros actuales y posteriores de los datos. Esta característica se usa en la sintaxis 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 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 funcionalidad 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 concepto IEnumerable . 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 para convertir de Iterators e Iterables a 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 Iteradores

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 única 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 de Stream , 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 secuencia de Stream sí misma no es Iterable , es una función compuesta.

Un Stream también pretende ser perezoso, en el sentido de que solo funciona cuando le solicita un valor.

Tenga en cuenta estos supuestos y características importantes 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.
  • 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 ese 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 se pueden suministrar a su vez 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 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 concepto 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 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 al resolver problemas, lo cual es justo clasificar como ''potenciación'', no ''reducción'', ''eliminación'' o ''restricción''.

¿Por qué los Java Streams 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 IEnumerable con un Stream está mal orientado. El contexto que está utilizando para decir que IEnumerable se puede ejecutar tantas veces como desee, se compara mejor con los Iterables Java, que se pueden repetir tantas veces como desee. Un Stream Java representa un subconjunto del concepto IEnumerable , y no el subconjunto que suministra datos, y por lo tanto no se puede "volver a ejecutar".

Cualquier llamada a una operación de terminal cierra la secuencia, dejándola 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 que IEnumerable 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 simple 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 IEnumerable como una fuente de datos:

IEnumerable<int> 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 esta es una operación de clasificación, el orden de ese suministro es significativo. Si considera que la clase Iterable Java es la coincidencia adecuada para esto, específicamente la especialización List de Iterable , ya 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 utiliza la fuente de datos ( List ), y transmite conceptos en diferentes puntos, y que en C # esas dos ''personalidades'' se pueden expresar en solo IEnumerable . Además, aunque he usado 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


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

A primera vista, una IEnumerable parece 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 itera sobre el enumerable, el compilador crea un enumerador. El enumerador no es reutilizable; nuevas llamadas a MoveNext solo 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 RandomNumberStream para generar 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 IEnumerable mediante la creación implícita de una nueva IEnumerator en 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 IEnumerable se deben descartar muchas suposiciones sobre ; reutilizar no es tan sencillo


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

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 ArraySpliterator utilizado como Stream fuente tiene estado y almacena su posición actual. Cuando repetimos esto Stream , comenzamos de nuevo al final.

Tenemos varias opciones para resolver este desafío:

  1. Podríamos hacer uso de un Stream mé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 Stream fuente similar ) que incluya cierta capacidad para restablecer el contador actual. Si lo usáramos para generar el Stream , podríamos volver a 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 Spliterator utilizado en la Stream tuberí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 Stream implementació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.


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 Guava 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 de filter , map , etc. eran métodos de extensión (por defecto) 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 iterator() en la cadena a 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 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.

¿Qué sucede 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, si obtiene un Iterador y lee todos los valores, y solo entonces obtiene 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 llama a 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 iterador para obtener un segundo iterador o subsecuentes 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 objetos Registrant y los imprime dos veces. Excepto que en realidad solo los imprime una vez. Resulta que él pensó que los registrants eran una colección, cuando en realidad 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 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 Stream y mantuvo solo operaciones ansiosas y mutantes directamente en Colecciones. Brian Goetz ha explicado la justificación de eso.

¿Qué pasa con permitir múltiples recorridos 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 operación de entrada ahora se deletrea collect(toList()) .)

Si el origen es una colección, la primera llamada into() 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 operación List.filter() que devuelve una List :

La mayor preocupación aquí es que demasiadas 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 usar ImmutableList.copyOf(Iterables.filter(list, predicate)) , que "establece por adelantado" lo que está haciendo y cómo caro es.

Para tomar un ejemplo específico, ¿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 "no reutilizables", 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 en múltiples fuentes de flujo diferentes, separa claramente las operaciones perezosas de las ansiosas y proporciona un modelo de ejecución directo.

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 los recorridos múltiples se comporten de manera diferente con diferentes fuentes; y permite una estructura de ramificación de operaciones IEnumerable anidadas, lo que puede dar lugar a 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. Llamar a QuickSort toma un IEnumerable y devuelve un IEnumerable , por lo que no se realiza ninguna clasificación hasta que se atraviesa el IEnumerable final. Sin embargo, lo que parece hacer la llamada es construir una estructura de árbol de IEnumerables que 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 provocará que ciertas llamadas de aspecto inocuo, 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 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 recalcularse, 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 IEnumerable devuelto. 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 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 IEnumerable se 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.


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

Por otro lado, para los Streams que tienen un origen rastreable, es fácil construir un Stream equivalente para ser usado nuevamente. 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 del terminal real, 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 el Stream que es distinto de cerrar el Stream como lo hace call close() en el stream (que es necesario para los streams que tienen recursos asociados como, por ejemplo, producido por Files.lines() ) .

Parece que mucha confusión proviene de la comparación equivocada 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 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 obtener nuevos IEnumerator y eso también funciona con las Collection de Java; puedes obtener una nueva Stream . Si los desarrolladores de Java decidieron agregar las operaciones Stream directamente a Iterable , y las operaciones intermedias devolvieron 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 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 considerar IEnumerable solo, una Colección particular tendrá muchos métodos para manipular la Colección directamente y muchos métodos que devolverán un IEnumerable perezoso, 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?) Enumerable.Reverse() mientras tiene un comportamiento contradictorio.

Por supuesto, estas son dos decisiones distintas. El primero en hacer de Stream un tipo distinto de Iterable / Collection y el segundo en hacer de Stream un tipo de iterador único en lugar de otro tipo de iterable. 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 . Spliterator s pueden ser proporcionados por los viejos Iterable s (que es la forma en que se actualizaron) o implementaciones completamente nuevas. Luego, Stream se agregó como un front-end de alto nivel al Spliterator s de nivel bastante bajo. 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. Stream no son estructuras de datos inmutables. Cada operación intermedia puede devolver una nueva instancia de Stream encapsula a la anterior, pero también puede manipular su propia instancia en su lugar y devolverse a sí misma (eso no impide hacer incluso ambas cosas para la misma operación). Los ejemplos comúnmente conocidos son operaciones como parallel o unordered que 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 Stream API 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()); }