Polución de Interfaces en Java 8


Una pregunta interesante en relación con Java 8 es por qué razón el grupo de expertos no decidió implementar un tipo función en vez del concepto de interfaces funcionales que utiliza actualmente el nuevo JDK 8. Por ejemplo, en un lenguaje como C# existe un conjunto predefinido de tipos de funciones que varían según el número de argumentos que una función acepta (llamado arity en inglés).

Así en C# tenemos los tipos Func que sirven para representar funciones con un tipo de retorno y Action para representar funciones de tipo de retorno vacío. Ambos tipos están representados por delegates que usan un número variable de tipos genéricos T1, T2, T3, …, T16, según el número de argumentos que la función acepta.

En el JDK 8, sin embargo, lo que tenemos es un concepto de interfaces funcionales que tienen diferentes nombres y diferentes métodos. Sus métodos abstractos representan a alguna de las firmas conocidas de funciones (nularia, unaria, binaria, ternaria, etc). Podríamos preguntarnos por qué el diseño del JDK 8 se aleja de soluciones ampliamente probadas con éxito en otros lenguajes similares, como C#. En este artículo vamos a explorar los diferentes problemas de diseño que enfrentó el grupo de expertos y las razones por las que el JDK 8 fue implementado de esta manera.

El Problema del Borrado de Tipos (Type Erasure)

Uno de los principales problemas de los que adolece Java data de los días en que se agregaron los tipos genéricos al lenguaje (alrededor del 2005 con el JDK 5). En Java la información de los tipos genéricos es descartada por el compilador una vez que éste ha verificado la integridad de los tipos del programa. Las razones por las que los tipos genéricos se implementaron de esta forma en Java son diversas pero una razón de peso era la necesidad de mantener compatibilidad hacia atrás con versiones anteriores de Java. A este proceso que realiza el compilador de descartar la información de los tipos genéricos una vez que ya no la necesita se le conoce como borrado de tipos o en inglés type erasure. La principal implicación de este diseño es, básicamente, que en Java no existiría diferencia entre un tipo Function<T1> y un tipo Function<T1,T2>, o Function<T1,T2,T3> o Function<T1,T2,T3, ..., Tn>. Para efectos de como funciona Java todos ellos estarían representados por un solo tipo llamado Function.

Así que el grupo de expertos tuvo que luchar con este problema. Brian Goetz, líder del proyecto, escribió lo siguiente en la lista de distribución del proyecto lambda:

[…] Como ejemplo sencillo consideremos un tipo función. En la propuesta original del proyecto lambda presentada en devoxx se consideró el tipo función. Yo insistí en quitarlo y esto me hizo muy impopular. Pero mi objeción al uso de un tipo función no se debe a que no me guste el tipo función — me encanta el tipo función — pero este tipo no se lleva bien con un aspecto del sistema de tipos de Java: el borrado de tipos. Las funciones con tipos borrados son el peor de los dos mundos. Así que los quitamos de este diseño.

Pero no estoy diciendo que “Java nunca tendrá un tipo función” (aunque reconozco que Java podría nunca tener un tipo función). Creo que para poder tener un tipo función primero tenemos que lidiar con el problema del borrado de tipos. Puede que eso sea posible o no. Pero en un mundo de tipos estructurales materilazados el tipo función empieza a cobrar mucho más sentido […]

¿Cómo influye todo esto en el diseño final de Java 8?

Bueno, el grupo de expertos decidió proponer una solución poco ortodoxa al problema. En vez de definir un tipo función, en su lugar tenemos una infinidad de nuevas interfaces que representan diferentes tipos de funciones. Veamos algunos ejemplos.

Funciones sin Tipo de Retorno (void)

En el mundo de las funciones sin un tipo de retorno tenemos algo como esto:

Tipo de Función Expresión Lambda Interfaces Funcionales Conocidas
Nullaria
() -> doSomething()
Runnable
Unaria
foo -> System.out.println(foo)
Consumer
IntConsumer
LongConsumer
DoubleConsumer
Binaria
(console,text) -> console.print(text)
BiConsumer
ObjIntConsumer
ObjLongConsumer
ObjDoubleConsumer
n-aria
(sender,host,text) -> sender.send(host, text)
Define tu propio tipo

Funciones con Algún Tipo de Retorno T

En el mundo de las funciones con un tipo de retorno T tenemos algunas de las siguientes:

Tipo de Función Expresión Lambda Interfaces Funcionales Conocidas
Nullaria
() -> "Hello World"
Callable
Supplier
BooleanSupplier
IntSupplier
LongSupplier
DoubleSupplier
Unaria
n -> n + 1
n -> n >= 0
Function
IntFunction
LongFunction
DoubleFunction
IntToLongFunction
IntToDoubleFunction
LongToIntFunction
LongToDoubleFunction
DoubleToIntFunction
DoubleToLongFunction
UnaryOperator
IntUnaryOperator
LongUnaryOperator
DoubleUnaryOperator
Predicate
IntPredicate
LongPredicate
DoublePredicate
Binaria
(a,b) -> a > b ? 1 : 0
(x,y) -> x + y
(x,y) -> x % y == 0
Comparator
BiFunction
ToIntBiFunction
ToLongBiFunction
ToDoubleBiFunction
BinaryOperator
IntBinaryOperator
LongBinaryOperator
DoubleBinaryOperator
BiPredicate
n-aria
(x,y,z) -> 2 * x + Math.sqrt(y) - z
Define tu propio tipo

Alguien pudiera decir que una ventaja de esta solución es que nos permite definir nuestros propios tipos, definir nuestras interfaces que acepten tantos argumentos como queramos, y entonces las podemos usar para crear expresiones lambda y referencias de métodos según se nos antoje. En otras palabras, podemos contaminar el mundo con incluso más interfaces funcionales. Otra ventaja es que podríamos usar interfaces funcionales ya existentes en Java para implementar expresiones lambda (p.ej. Comparable, Runnable y Callable) e incluso interfaces que nosotros mismos hubiéramos definido en el pasado, siempre y cuando sean interfaces funcionales (es decir, interfaces que solo tiene un método abstracto).

Sin embargo, la desventaja más evidente es esta explosión de interfaces con diferentes nombres y con diferentes métodos. Y ahora debemos memorizar o al menos recordar o reconocer que existen a fin de poder usarlas en nuestro código. Como todas tienen diferentes nombres y como todos nosotros podemos todos los días definir nuevas, la tarea se vuelve un poco más compleja en comparación con aquellos lenguajes en donde solo existe un tipo función. Es interesante resaltar que en el lenguaje de programación Scala este problem se resolvió definiendo interfaces con diferentes nombres como Function0Function1Function2, …, FunctionN. Es una manera de evitar el problema del borrado de tipos y definir un tipo función para el lenguaje. Asumo que esta solución no fue considerada como válida de cara a explotar la ventaja que sí tienen las interfaces funcionales de poder usar expresiones lambda incluso con tipos definidos en librerías anteriores a la liberación de Java 8.

El Problema de la Ausencia de Tipos Valor (Value Types)

Claramente el borrado de tipos es una de las fuerzas mas influyentes en el diseño de Java 8, pero no es suficiente para explicar por qué, aparte de esta explosión de interfaces, tenemos además otras con nombres similares y cuyas firmas de métodos se diferencian por el uso de tipos primitivos (p.ej. Function, IntFunction, LongFunction, DoubleFunction, etc).

La razón detrás de estas interfaces adicionales radica en el hecho de que en Java no tenemos un concepto de tipos valor (en inglés Value Types) como sí sucede en otros lenguajes como C#. Esto significa que los parámetros de tipos que usamos en las clases, interfaces y métodos genéricos de Java solo pueden ser sustituidos por tipos de referencia (reference types) y no por tipos primitivos.

En otras palabras, esto no es posible en Java

List<int> numbers = asList(1,2,3,4,5);

Pero esto otro sí lo es:

List<Integer> numbers = asList(1,2,3,4,5);

Este segundo ejemplo, sin embargo, incurre en el costo de convertir el tipo primitivo int en un objeto Integer antes de poder almacenarlo en la colección. De la misma manera, al recuperar el entero y usarlo en una operación aritmética, incurriríamos en el costo opuesto de extraer el valor entero encapsulado por el objeto. En inglés a esto se le suele llamar boxing y unboxing. Y este proceso se puede volver realmente caro en términos de desempeño cuando estamos lidiando con colecciones de datos primitivos.

Evidentemente el grupo de expertos necesitaba encontrar una manera de mejorar el desempeño de las nuevas APIs de colecciones en Java 8 para esos casos en donde se usan valores primitivos de datos. Así que para solucionar este problema el grupo de expertos decidió crear aún más interfaces para lidiar con cada uno de los posibles escenarios. En vista de que Java tiene alrededor de 8 diferentes tipos primitivos la explosión de interfaces hubiera sido una locura, y por eso los diseñadores decidieron solo lidiar con los tipos básicos int, long, y double, asumiendo que cualquier otro tipo puede ser fácilmente promovido a alguno de estos otros.

Una vez más recurro a una cita de de Brian Goetz en la lista de distribución del proyecto lambda para demostrar que los diseñadores encontraron grandes dificultades para lidiar con este problema:

[…] la filosofía tras el uso de streams especializados para tipos primitivos (p.ej. IntStream) está plagada de horribles compromisos. Por un lado todo esta horrible duplicación de código, polución de interfaces, etc. Por otro lado, cualquier clase de aritmética sobre tipos encajados (boxed types) apesta, y no tener un escenario para reducir valores de enteros sería terrible. Así que estamos contra la espada y la pared, y estamos tratando de no empeorar las cosas.

Truco #1 para no empeorar las cosas es: no vamos a soportar los ocho tipos primitivos. Solo vamos a soportar int, long, double; todos los demás se pueden simular con estos. Podría decirse que también nos podríamos deshacer de int, pero no creo que los desarrolladores de Java estén listos para eso. Sí, habrá peticiones para Character, y la respuesta es “mételo dentro de un entero” […]

Truco #2 es: estamos usando streams de primitivos para exponer cosas que se manejan mejor en el dominio de los primitivos (ordenamiento, reducción) pero no estamos intentando duplicar todo lo que existe en el dominio de tipos de rerencia. Por ejemplo, no existe IntStream.into(). (Si fuera el caso, la siguiente pregunta sería “Y dónde está el IntCollection? IntArrayList?, IntConcurrentSkipListMap?). La intención es que muchos streams puede comenzar con tipos de referencia y terminar como streams de primitivos, pero no a la inversa. Eso está bien y reduce el número de conversiones necesarias (p.ej. no hay sobrecarga de map para int-> T, no hay especialización de Function para int->T, etc).

Creo que pocos de nosotros pensaríamos que esta solución es deseable, pero creo que la mayoría estaría de acuerdo de que fue necesaria y no parece como que otra solución mejor pudiera haber sido desarrollada sin antes resolver el problema de la ausencia de tipos valor en Java. Al menos en estas citas encontramos la justificación para algo que muchos, de buenas a primeras, podrían considerar descabellado.

El Problema de los Checked Exceptions

Existe una tercera fuerza que pudo haber causado que esta polución de interfaces fuera inclusive peor. Se trata del hecho de que Java soporta dos tipos de excepciones llamados checked y unchecked.  El compilador exige que hagamos algo respecto a un método que puede lanzar una excepción de tipo checked, en esos casos debemos manejar la excepción (p.ej. con un try..catch) en el lugar o declarar que nuestro método también arroja la excepción (p.ej. con una cláusula throws en la firma del método). Ejemplos de excepciones checked son SQLException y IOException para mencionar un par de las más conocidas.

El asunto es que este comportamiento de Java crea un escenario interesante de cara a las interfaces funcionales provistas en Java 8. Todas estas interfaces funcionales tienen un métodos que no declara ninguna excepción. Eso quiere decir que estas interfaces funcionales no se pueden implementar con operaciones que están sujetas al uso de excepciones de tipo checked (a menos que manejemos la excepción directament el cuerpo del método, y en el caso de las expresiones lambda, en el cuerpo de la expresión misma). Por ejemplo, esto no es posible en Java 8:

Writer out = new StringWriter();
Consumer<String> printer = s -> out.write(s); //error de compilación

No se puede hacer esto porque la operación write declara que puede arrojar una excepción de tipo checked, un IOException en este caso, pero la firma del método en la interfaz Consumer que está siendo implementado por la expresión lambda no declara que podría arrojar ningún tipo de excepción.

Como se podrán imaginar, la solución a este problema habría sido crear aún más interfaces funcionales, algunas declarando excepciones y otras no (o implementar alguna otra solución a nivel del lenguaje que permitiera soportar transparencia de excepciones). Y una vez más, para tratar de “no empeorar” las cosas el grupo de expertos decidió no hacer nada en este caso.

Brian Goetz escribió en la lista de distribución del proyecto lambda:

Sí, tendrás que implementar tus propios SAM types excepcionales. Pero entonces la conversión lambda funcionará con ellos.

El grupo de expertos discutió sobre agregar soporte adicional al lenguaje y librerías para solucionar este problema, pero al final sentimos que no se justificaba el costo/beneficio.

Soluciones de librería causaría una explosión de 2x en los tipos SAM (excepcional vs no excepcional), lo cual no interactúa bien con las explosiones de combinaciones existentes para especializaciones de primitivos.

Las soluciones a nivel de lenguaje perdieron valor de cara a su complejidad/valor. Aunque hay algunas soluciones alternativas que vamos a continuar explorando – aunque claramente no para la versión 8 y probablemente tampoco para la 9.

De momento, tienen las herramientas que necesitan. Comprendo que ustedes preferirían que hiciéramos es milla extra por ustedes (y, además su solicitud sería una mampara para decir “por qué no se rinden de una vez con las excepciones tipo checked”), pero creo que el estado actual les permite hacer su trabajo.

Así las cosas, somos nosotros los desarrolladores los que debemos crear nuestras propias explosiones de interfaces para lidiar con este problema caso por caso:

interface IOConsumer<T> {
   void accept(T t) throws IOException;
}

static<T> Consumer<T> exceptionWrappingBlock(IOConsumer<T> b) {
   return e -> {
	try { b.accept(e); }
	catch (Exception ex) { throw new RuntimeException(ex); }
   };
}

A fin de lograr algo como:

Writer out = new StringWriter();
Consumer<String> printer = exceptionWrappingBlock(s -> out.write(s));

Probablemente en el futuro (quizás en el JDK 9) cuando tengamos Soporte para Tipos Valor en Java, y cuando se haya corregido el problema del borrado de tipos, entonces quizás nos podremos deshacernos también (o al menos no necesitar ya más) de toda esta polución de interfaces.

En resumen, podemos ver que el grupo de expertos tuvo que tomar difíciles decisiones de diseño para lidiar con diferentes tipos de problemas en Java a fin de poder implementar muchas de las nuevas características en Java 8 hoy. Aspectos como la carencia de tipos valor, el borrado de tipos y las excepciones tipo checked influenciaron poderosamente el diseño y si Java no hubiera tenido ninguna de estos problemas, la solución final habría sido, probablemente, muy diferente, quizás más parecida a la de otros lenguajes como C#. Pero el grupo de expertos tenía que pintar la raya en en algún lugar. Para bien o para mal, este es el diseño actual, y el tiempo se encargará de demostrar si estas decisiones de diseño fueron buenas o malas o si algo más podría haberse hecho al respecto.

Lectura Adicional

Responder

Introduce tus datos o haz clic en un icono para iniciar sesión:

Logo de WordPress.com

Estás comentando usando tu cuenta de WordPress.com. Cerrar sesión /  Cambiar )

Google+ photo

Estás comentando usando tu cuenta de Google+. Cerrar sesión /  Cambiar )

Imagen de Twitter

Estás comentando usando tu cuenta de Twitter. Cerrar sesión /  Cambiar )

Foto de Facebook

Estás comentando usando tu cuenta de Facebook. Cerrar sesión /  Cambiar )

Conectando a %s

This site uses Akismet to reduce spam. Learn how your comment data is processed.

A %d blogueros les gusta esto: