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
, map
etc. 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 count
llamarí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 Registrant
objetos y las imprime dos veces. Excepto que en realidad solo los imprime una vez. Resulta que él pensó que registrants
era una colección, cuando en realidad es un iterador. La segunda llamada a foreach
encuentra 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 into
operació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 IEnumerable
permite que el recorrido múltiple se comporte de manera diferente con diferentes fuentes; y permite una estructura de ramificación de IEnumerable
operaciones 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 QuickSort
toma un IEnumerable
y devuelve un IEnumerable
, por lo que no se realiza ninguna clasificación hasta que IEnumerable
se atraviesa el final . Sin embargo, lo que parece hacer la llamada es construir una estructura de árbol 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 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 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.