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:
-
Podríamos hacer uso de un
Stream
método de creación sin estado comoStream#generate()
. Tendríamos que gestionar el estado externamente en nuestro propio código y restablecer entreStream
"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);
-
Otra solución (ligeramente mejor pero no perfecta) para esto es escribir nuestra propia
ArraySpliterator
(oStream
fuente similar ) que incluya cierta capacidad para restablecer el contador actual. Si lo usáramos para generar elStream
, 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);
-
La mejor solución a este problema (en mi opinión) es hacer una nueva copia de cualquier estado con estado
Spliterator
utilizado en laStream
tubería cuando se invocan nuevos operadores en elStream
. Esto es más complejo e implica implementarlo, pero si no le importa usar bibliotecas de terceros, cyclops-react tiene unaStream
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());
}