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

Cómo Declarar Módulos de Node.js

Creo que una de las cosas que me causó un poco de confusión cuando comencé a trabajar con Node.js fue entender exactamente cómo se pueden exponer diferentes tipos objetos utilizando módulos. Al principio pareciera intuitivamente sencillo, pero más tarde se da uno cuenta de que puede exponer funciones, constructores, objetos, o simplemente nuevas propiedades dentro del módulo y para cada caso existen diferentes formas de hacerlo. Veamos unos cuantos ejemplos.

Sobre los Módulos, Importar y Exportar

Empecemos por lo más obvio y sencillo y lo que probablemente todo el mundo entiende desde el primer día de trabajo con Node.js. En node un archivo de código se considera un módulo. Las variables, propiedades, funciones o constructores que se declaren en él son privadas al módulo y otros módulos no pueden verlas ni usarlas a no ser que el programador explícitamente las exponga al público; es decir todo lo que declaramos dentro de un módulo está encapsulado y oculto al mundo exterior de forma predeterminada a no ser que el programador exponga algo explícitamente. Para este propósito cada módulo tiene acceso a un objeto especial llamado module, y este objeto module, a su vez, tiene una propiedad llamada exports. Todo lo que el programador coloque en el objeto exports se vuelve público para ese módulo. Por ejemplo, el módulo foo.js expone una propiedad llamada bar que contiene una cadena de caracteres 'Hello World'. Cualquier otra declaración dentro del módulo sería privada y solo accesible al módulo mismo. Por ejemplo, la variable pi declarada abajo es inaccessible para cualquier otro módulo diferente de foo.js, mientras que la propiedad bar es públicamente accessible para otros módulos que importen el módulo foo.js.

//foo.js
var pi = 3.14; 
module.exports.bar = 'Hello World';

Así las cosas, un segundo módulo baz.js podría “importar” el módulo foo.js y tener acceso a la propiedad bar. En node, conseguimos tal efecto por medio de usar la función require. Por ejemplo de la siguiente manera:

//baz.js
var foo = require('./foo');
console.log(foo.bar); //imprime Hello World

Técnica 1 – Extender exports con Funcionalidad Adicional

Así las cosas, una técnica para exponer la funcionalidad de un módulo consiste en agregar nuevas funciones y propiedades a module.exports. Cuando este es el caso, node.js provee un acceso directo al objeto exports y no es necesario acceder a él a través de la forma un poco más larga module.exports. Por ejemplo:

//foo.js
exports.serviceOne = function(){ };
exports.serviceTwo = function(){ };
exports.serviceThree = function(){ };

Y como se podrán imaginar los usuarios de este módulo, al importarlo, obtendrán una referencia al mismo objeto exports y entonces podrán consumir toda esta misma funcionalidad.

//bar.js
var foo = require('./foo');
foo.serviceOne();
foo.serviceTwo();
foo.serviceThree();

Técnica 2 – Sustituir el Objeto exports Predeterminado por Otro Objeto

Probablemente ustedes podrán intuir, llegados a este punto, que dado que module.exports es el objeto que se expone públicamente para un módulo, entonces perfectamente podríamos sustituir el objeto que nos da node.js de forma predeterminada por nuestro propio objeto. Por ejemplo:

//foo.js
var service = {
   serviceOne: function(){ },
   serviceTwo: function(){ },
   serviceThree = function(){ }
};

module.exports = service;

El código en este último ejemplo se comporta exactamente igual que el código del ejemplo anterior. Es solo que esta vez hemos definido nuestro propio objeto a exportar.

Técnica 3 – Sustituir el Objeto exports Predeterminado por un Constructor

En todos los ejemplos hasta el momento, siempre exponemos una instancia de un objeto, pero nada nos impide sustituir al objeto module.exports por otros tipos de objetos como funciones y constructores. En el ejemplo siguiente exponemos un constructor, que el usuario del módulo puede utilizar más tarde para crear una instancia de este tipo de objeto.

//Foo.js
function Foo(name){
   this.name = name;
}

Foo.prototype.serviceOne = function(){ };
Foo.prototype.serviceTwo = function(){ };
Foo.prototype.serviceThree = function(){ };

module.exports = Foo;

Así las cosas, el usuario que importa este módulo recibe un constructor de Foo que puede usar para crear tantas instancias de este tipo como considere apropiado.

//bar.js
var Foo = require('./Foo');
var foo = new Foo('Obi-wan');
foo.serviceOne();
foo.serviceTwo();
foo.serviceThree();

Técnica 4 – Sustituir el Objeto exports Predeterminado por una Función

Es fácil imaginar ahora que, si podemos exponer un constructor, entonces también podemos usar una función común y corriente como el objeto que se expone a través de module.exports. El siguiente ejemplo expone una función que nos permit obtener acceso a un objeto privado del módulo, en este caso un objeto de servicio.

//foo.js
var serviceA = {};
serviceA.serviceOne = function(){ };
serviceA.serviceTwo = function(){ };
serviceA.serviceThree = function(){ };

var serviceB = {};
serviceB.serviceOne = function(){ };
serviceB.serviceTwo = function(){ };
serviceB.serviceThree = function(){ };

module.exports = function(name){
   switch(name){
      case 'A': return serviceA;
      case 'B': return serviceB;
      default: throw new Error('Unknown service name: ' + name);
   }
};

Y ahora el usuario del módulo, al importarlo, recibirá una referencia a nuestra función anónima, y al invocarla pues termina obteniendo acceso al objeto encapsulado de servicio. Por ejemplo:

//bar.js
var foo = require('./foo');
var obj = foo('A');
obj.serviceOne();
obj.serviceTwo();
obj.serviceThree();

Algunos programadores acostumbran invocar la función de inmediato en vez de almacenarla en una referencia e invocarla después. Por ejemplo:

//bar.js
var foo = require('./foo')('Obi-wan');
foo.serviceOne();
foo.serviceTwo();
foo.serviceThree();

Así que como podemos ver, todo lo que exponemos en module.exports es lo que se hace disponible al usuario que importa el módulo directamente y como vimos podemos exponer toda clase de objetos, funciones y constructores usando diferentes técnicas.

Sobre los Módulos y el Uso de Estado Global

Un aspecto significativo de los módulos en node.js es que todo lo que declaramos dentro del módulo es privado a dicho módulo. Esto marca una diferencia significativa de JavaScript en node en comparación con JavaScript en el browser en donde existe un objeto global (p.ej. window) que es el receptáculo final de todo lo que declaramos dentro de un archivo de JavaScript (a menos que lo coloquemos dentro de un alcance de función). En node existe también un objeto global pero nada se coloca en este objeto de manera automática como en el caso de JavaScript en el browser.

Sin embargo, es raro que sea necesario colocar algo en el objeto global en node.js dado que los módulos de Node.js se evalúan una sola vez y después esto se retorna el mismo objeto que module.exports exponga. Por eso es mejor utilizar un módulo para compartir estado global que colocar datos en el objeto global. Por ejemplo, el siguiente código expone un objeto con la configuración de una conexión a una base de datos de Mongo.

//config.js

dbConfig = {
  url:'mongodb://foo',
  user: 'anakin',
  password: '*******'
}

module.exports = dbConfig;

Ahora cada vez que necesitemos tener acceso a nuestra configuración de la base de datos podemos importar el módulo config.js. El código del módulo se evaluará sola la primera vez, así que podemos tener la garantía de que todos los demás módulos que usen nuestro módulo de configuración obtendrán una referencia al mismo objeto que se publico en module.exports la primera vez que este módulo sea requerido.

//foo.js
var dbConfig1 = require('./config');
var dbConfig2 = require('./config');
var assert = require('assert');
assert(dbConfig1==dbConfi2);