Covarianza y Contravarianza en Java

He descubierto que para comprender la covarianza y la contravarianza me ayuda primero examinar algunos ejemplos con arreglos de Java y luego comparar el comportamiento con clases genéricas de colecciones. Es importante destacar, sin embargo, que las mismas arreglas presentadas acá aplican a cualquier tipo genérico en Java, y no únicamente a las colecciones.

Arreglos Covariantes

Se dice que los arreglos son covariantes en Java, lo que básicamente significa que, dadas las reglas de subtipos en Java, un arreglo de tipo T[] puede contener elementos de tipo T o de cualquier subtipo de T. Por ejemplo:

Number[] numbers = new Number[3];
numbers[0] = new Integer(10);
numbers[1] = new Double(3.14);
numbers[2] = new Byte(0);

Pero no solo eso, la reglas de subtipos establece que un arreglo de tipo S[] es un subtipo de un arreglo de tipo T[] si S es un subtipo de T. Por lo tanto algo como esto también es válido:

Integer[] myInts = {1,2,3,4};
Number[] myNumber = myInts;

Es decir, de acuerdo con las reglas de subtipos en Java, un arreglo de Integer[] es un subtipo de un arreglo de Number[] porque Integer es un subtipo de Number.

Pero estas reglas de subtipos nos pueden llevar a preguntas interesantes, por ejemplo, ¿qué pasaría en este caso?

myNumber[0] = 3.14; //intento de contaminación de la pila

Esta última línea compila perfectamente, pero si ejecutamos este código obtendremos un ArrayStoreException porque estamos intentando poner un valor tipo Double en un arreglo de Integer. El hecho de que estemos accediendo al arreglo a través de una referencia de tipo Number es irrelevante aquí. Lo que realmente importa es que el arreglo es un arreglo de enteros.

Esto quiere decir que podemos engañar al compilador, pero no podemos engañar al sistema de tipos en tiempo de ejecución. Decimos entonces que los arreglos son tipos materializables (reifiable type). En otras palabras, durante la ejecución del programa, Java sabe cual es la verdadera naturaleza del arreglo independientemente del tipo de la referencia que usemos para acceder a él.

Podemos ver claramente que una cosa es el tipo intrínseco de un objeto y otro cosa diferente la referencia que usamos para acceder a él. Un mismo objeto podría ser accedido desde referencias de múltiples tipos compatibles, pero eso no altera el tipo intrínseco del objecto en sí mismo.

Problema con los Tipos Genéricos de Java

Ahora bien, el problema con los tipos genéricos de Java es que la información de los parámetros genéricos es descartada por el compilador tras completar su trabajo. Eso quiere decir que la información sobre el tipo de un parámetro genérico no está disponible en tiempo de ejecución. Este proceso que lleva a cabo el compilador de descartar la información de los tipos genéricos se conoce como borrado de tipos (type erasure).

Existen buenas razones para haber implementado los tipos genéricos de esta forma en Java, pero eso es una larga historia que podemos considerar en otro artículo aparte. De momento baste decir que esto tiene que ver con razones de compatibilidad con el código preexistente de versiones anteriores de Java.

El punto realmente importante aquí es que ya que no hay información de los tipos de los parámetros genéricos en tiempo de ejecución entonces no existe una manera de asegurar que no se está incurriendo en una contaminación de la pila (heap pollution). Es decir, es posible que una variable de un tipo paramétrico se refiera a un objeto que no es de ese tipo paramétrico.

Consideremos el siguiente código inseguro:

List<Integer> myInts = new ArrayList<Integer>();
myInts.add(1);
myInts.add(2);
List<Number> myNums = myInts; //error de compilación
myNums.add(3.14); //contaminación de la pila

Si el compilador de Java no evita que hagamos esto, el sistema de tipos en tiempo de ejecución tampoco podría detenernos porque no existe forma, en tiempo de ejecución, de saber que la lista arriba mostrada es una lista exclusivamente de números enteros. Esto significa que en tiempo de ejecución Java nos permitiría colocar cualquier tipo de objeto en esta lista, aunque de acuerdo con el código en tiempo de compilación, la lista solo debería aceptar objetos de tipo Integer. Es por esta razón que el compilador rechaza la línea número 4, porque es insegura, y si esto se permite romperá los supuestos del sistema de tipos. Claramente una lista de enteros no es lo mismo que una lista de números flotantes.

Basándose en este principio los diseñadores del sistema de tipos de Java se aseguraron que el compilador no pueda ser engañado. Si el compilador no puede ser engañado (como lo hicimos en el caso de los arreglos) entonces podemos esperar que en tiempo de ejecución los tipos serán consistentes con lo esperado y no habrá contaminaciones indeseadas de la pila.

Decimos entonces que los tipos genéricos son no materializables (non-reifiable), ya que no tienen existencia material en tiempo de ejecución y por ende no podemos determinar su verdadera naturaleza, ni los podemos validar.

Evidentemente esta propiedad de los tipos genéricos tendría un impacto negativo en el polimorfismo. Consideremos el siguiente ejemplo:

static long sum(Number[] numbers) {
   long summation = 0;
   for(Number number : numbers) {
      summation += number.longValue();
   }
   return summation;
}

Este método podríamos usarlo de alguna de las siguientes formas:

Integer[] myInts = {1,2,3,4,5};
Long[] myLongs = {1L, 2L, 3L, 4L, 5L};
Double[] myDoubles = {1.0, 2.0, 3.0, 4.0, 5.0};

System.out.println(sum(myInts));
System.out.println(sum(myLongs));
System.out.println(sum(myDoubles));

Sin embargo, sin intentamos implementar la misma funcionalidad, usando colecciones genéricas de Java, no tendremos éxito al hacerlo de la siguiente manera:

static long sum(List<Number> numbers) {
   long summation = 0;
   for(Number number : numbers) {
      summation += number.longValue();
   }
   return summation;
}

Aunque el código arriba mostrado compila, obtendremos errores de tiempo de compilación al intentar utilizarlo de la siguiente manera:

List<Integer> myInts = asList(1,2,3,4,5);
List<Long> myLongs = asList(1L, 2L, 3L, 4L, 5L);
List<Double> myDoubles = asList(1.0, 2.0, 3.0, 4.0, 5.0);

System.out.println(sum(myInts)); //error de compilación
System.out.println(sum(myLongs)); //error de compilación
System.out.println(sum(myDoubles)); //error de compilación

Básicamente el problema es que ahora no podemos considerar que una lista de Integer es un subtipo de una lista de Number, como ya habíamos visto anteriormente. Tal cosa se consideraría inseguro para el sistema de tipos y el compilador lo rechaza inmediatamente.

Claramente esto está afectando el poder del polimorfismo y necesitamos corregirlo. La solución consiste en utilizar dos poderosas características de los tipos genéricos de Java conocidas como covarianza y contravarianza.

Covarianza

Para este caso, en vez de usar un tipo T como el argumento para un parámetro genérico mas bien usamos un comodín (wildcard) declarado como ? extends T, donde T es un tipo base conocido. Utilizando esta técnica de covarianza podemos leer datos de un tipo paramétrico de una estructura de datos, pero no podemos escribir datos paramétricos de vuelta en la estructura porque sería inseguro, como veremos pronto.

Los siguientes ejemplos ilustran este concepto:

List<? extends Number> myNums = new ArrayList<Integer>();
List<? extends Number> myNums = new ArrayList<Float>();
List<? extends Number> myNums = new ArrayList<Double>();

Y podemos leer datos de nuestra estructura genérica myNums haciendo algo como:

Number n = myNums.get(0);

Esto es posible debido a que podemos estar seguros de que, cualquiera que sea la verdadera naturaleza de los objetos que la estructura genérica contiene, todo elemento genérico de la estructura puede ser, de forma segura, considerado un Number.

No obstante, no se nos permite hacer algo como lo siguiente:

myNums.add(45L); //error de compilación

Esto no se permite porque el compilador no pude determinar cual es el verdadero tipo del argumento genérico. Dada la declaración podría ser cualquier cosa que extiende Number. En otras palabras la verdadera naturaleza de la estructura podría ser Integer, Float o Double, etc., pero el compilador no puede estar seguro y por lo tanto rechaza a priori cualquier intento de modificar un valor genérico en la estructura. Así pues en una estructura covariante podemos leer, pero no escribir.

Contravarianza

Como se podría esperar la contravarianza es el concepto diametralmente opuesto; mediante este podemos escribir datos en una estructura genérica, pero no podemos leerlos de vuelta de forma segura. Para este caso usamos un comodín de la forma ? super T, donde T es nuestro tipo base.

Por ejemplo:

List<Object> myObjs = new List<Object();
myObjs.add("Luke");
myObjs.add("Obi-wan");
List<? super Number> myNums = myObjs;
myNums.add(10);
myNums.add(3.14);

En este caso, la verdadera naturaleza de la estructura de datos es una lista de Object, y gracias a la contravarianza se nos permite colocar un objeto de tipo Number en esta esctructura, básicamente porque todo Number es un subtipo de Object; así todos los números son también objetos y por lo tanto esta modificación es válida.

No obstante, no podemos leer un dato de forma segura de la estructura de datos genérica y asumir que dicho dato será un número.

Number myNum = myNums.get(0); //error de compilación

Como podemos ver, si el compilador permitiera esto, en tiempo de ejecución obtendríamos una exception de tipo ClassCastException puesto que el objeto 0 de la lista es, en realidad, una cadena de caracteres.

El Principio Get/Put

En resumen, usamos covarianza cuando sólo nos interesa leer datos de la estructura genérica y usamos contravarianza cuando solo nos interesa escribir datos en la estructura genérica. Y usamos invarianza cuando deseamos hacer ambas cosas.

El mejor ejemplo que he encontrado para ilustrar esto es la siguiente pieza de código que copia el contenido de una lista genérica a otra. En este caso sólo leemos de la estructura fuente, y sólo escribimos en al estructura destino.

public static void copy(List<? extends Number> source,
                        List<? super Number> destiny) {
   for(Number number : source) {
      destiny.add(number);
   }
}

Gracias al poder de la covarianza y la contravarianza podemos hacer que esto funcione:

List<Integer> myInts = asList(1,2,3,4);
List<Double> myDoubles = asList(3.14, 6.28);
List<Object> myObjs = new ArrayList<Object>();
copy(myInts, myObjs);
copy(myDoubles, myObjs);

Lectura Adicional

La mayoría de mis reflexiones sobre este tópico se derivan de mi lectura de un excelente libro:

Ensayo sobre la Importancia del Encapsulamiento

El encapsulamiento consiste en más que simplemente definir métodos de acceso y de mutación para una clase. Es un concepto mucho más amplio de programación (no exclusivamente vinculando con la orientación a objectos) que consiste en minimizar la interdependencia entre los módulos y típicamente se implementa mediante el ocultamiento de información. Para comprender el concepto de encapsulamiento es de primordial importancia darse cuenta de que tiene dos objetivos fundamentales: ocultar la complejidad y ocultar el origen de los cambios.

Sobre Ocultar la Complejidad

El encapsulamiento está inherentemente relacionado con los conceptos de modularidad y abstracción y es por eso que, en opinión de este escritor, es necesario primero comprender estos dos conceptos.

Consideremos, como ejemplo, el nivel de abstracción en el concepto de un automóvil. Un automóvil es complejo en su funcionamiento interno; posee varios subsistemas, como el sistema de transmisión, el sistema de frenos, el sistema de abastecimiento de combustible, etcétera.

No obstante hemos llegado a simplificar este concepto dentro de una simple abstracción,  e interactuamos con todos los automóviles en el mundo a través de lo que denominaremos su interfaz pública: por ejemplo, sabemos que todos los automóviles tienen un rueda de volante a través de la cual controlamos la dirección del movimiento del vehículo, tenemos varios pedales, uno de los cuales sirve para acelerar y controlar la velocidad y otro con el que controlamos el frenado, y tenemos una palanca de cambios con la que controlamos si deseamos ir hacia adelante o hacia a atrás. Todas estas características constituyen la interfaz pública de la abstracción de un automóvil. Una mañana podemos conducir un sedán y por la tarde conducir un automóvil deportivo como si fueran exactamente la misma cosa. Incluso si son de diferentes fabricantes.

demaged carSin embargo, pocos de nosotros conocemos los detalles del funcionamiento de todos los sistemas bajo el capó del vehículo. Esta simple analogía demuestra que los seres humanos lidiamos con la complejidad de los objetos del mundo real mediante definir abstracciones con interfaces públicas que usamos para interactuar con ellos y ocultar todos los detalles innecesarios bajo el capó de estas abstracciones. Y es importante enfatizar la palabra “innecesarios” en esta última oración, porque la belleza de las abstracción es no tener que comprender todos los detalles para ser capaz de interactuar con un objeto internamente complejo, sino que basta con entender un concepto abstracto general, una idea simple de cómo funciona y cómo utilizarlo para realizar una tarea.  Es por ese motivo que la mayoría de nosotros no sabemos, o no nos interesa saber a detalle, cómo un automóvil acelera o frena o cambia de velocidad, pero el desconocimiento de estos detalles no nos impide conducir uno.

De una forma similar el software a menudo se construye con decenas de otros componentes más pequeños y especializados. Estas piezas se agrupan para formar componentes cada vez más complejos y a menudo no tenemos necesidad de comprender el funcionamiento interno de todas las piezas para interactuar con ellas. Basta con comprender cuál es el estímulo o entrada que acepta un objeto y la respuesta esperada del mismo. Es decir, cada componente se convierte en una caja negra para su usuario.

En su libro Code Complete, Steve McConnell usa una analogía de un iceberg: solo una pequeña porción de un iceberg es visible en la superficie del océano, la mayor parte de su verdadero tamaño esta oculto bajo el agua. De manera similar, en nuestros diseños de software las partes visibles de nuestros clases y módulos son su interfaz pública, y ésta está expuesta al mundo exterior, el resto debería estar oculta a los ojos del usuario. En palabras del mismo McConell

“La interfaz de una clase debería revelar tan poco como sea posible sobre su funcionamiento interno”.

Claramente, si nos basamos en nuestra analogía del automóvil podemos ver que el encapsulamiento es algo bueno puesto que oculta detalles de complejidad innecesaria para los usuarios. Hace que los automóviles sean simples de utilizar y de entender.

Sobre Ocultar el Origen de los Cambios

Ahora bien,  continuando con la analogía, pensemos en aquellos días en la que los automóviles no tenían un sistema hidráulico de dirección. Un día los fabricantes de autos finalmente lo inventaron y decidieron que, a partir de ese día, los vehículos debería tenerlo. Aun así esto no cambio en nada la manera como los conductores debían interactuar con sus vehículos. A lo sumo los conductores llegaron a disfrutar una mejor experiencia de manejo. El punto es que un cambio como este fue posible porque la implementación interna del automóvil estaba encapsulada, es decir, oculta a los ojos de los usuarios. En otras palabras, se podían realizar cambios de forma segura sin afectar la interfaz pública de esta abstracción.

De forma similar, si alcanzamos niveles apropiados de encapsulamiento en nuestros diseños de software podemos fomentar de forma segura el cambio y la evolución de nuestas APIs sin afectar a los usuarios de las mismas. Al hacer esto minimizamos el impacto de los cambios y fomentamos la independencia de los módulos. Así las cosas, el encapsulamiento es un medio para conseguir implementar otros importante atributo de un buen diseño de software: el acoplamiento débil.

En su libro Effective Java, Joshua Bloch resalta el poder del ocultamiento de información y el acoplamiento débil cuando dice:

“El ocultamiento de la información es importante por muchas razones, la mayoría de las cuales se derivan del hecho que mediante ésta se logra el desacoplamiento de los módulos que comprometen el sistema, permitiendo que éstos puedan ser desarrollados, probados, optimizados, utilizados, comprendidos y modificados de manera independiente. Esto acelera el desarrollo de sistemas porque los módulos pueden ser desarrollados de forma paralela. Alivia el costo del mantenimiento porque los módulos pueden ser comprendidos más fácilmente y depurados sin el temor de dañar otros módulos […] facilita el afinamiento del desempeño [ya que] estos módulos pueden ser optimizados sin afectar la correctitud de otros módulos [e] incrementa la reutilización del software porque los módulos que no están fuertemente acoplados suelen resultar útiles en otros contextos además de aquellos para los cuales fueron desarrollados”

Así que una vez más podemos ver que el encapsulamiento es un atributo muy deseable que facilita la introducción de cambios y que fomentan la evolución del software. Siempre que respetemos las interfaz pública de nuestras abstracciones tenemos la libertad de cambiar lo que queramos de su funcionamiento encapsulado.

Cuando se Rompe la Interfaz Pública

Entonces ¿qué sucede cuando no alcanzamos los niveles apropiados de encapsulamiento en nuestros diseños?

Bueno, imaginemos que un día los fabricantes de autos deciden poner la tapilla del combustible debajo del automóvil en vez de en uno de sus costados. Digamos que compramos uno de estos nuevos automóviles modificados y cuando se nos termina el combustible vamos a la gasolinera más cercana y entonces nos damos cuenta de que no sabemos en donde está la tapilla del combustible. Tras buscar un rato finalmente descubrimos que está debajo del auto, pero la manguera del combustible no llega hasta ella y la boquilla de la manguera no se corresponde con la nueva interfaz de la tapilla. En ese momento el mundo entero se cae a pedazos, todo porque hemos desobedecido el contrato de la interfaz pública de nuestra abstracción. Un cambio como este costaría millones. Necesitaríamos cambiar todas las bombas de combustible del mundo, sin mencionar talleres mecánicos y repuestos.

Cuando no alcanzamos niveles apropiados de encapsulamiento terminamos rompiendo la interfaz pública de nuestras abstracciones y entonces hay que pagar un precio: el costo del cambio. Asimismo, esta última parte de nuestra analogía revela que el no definir abstracciones con niveles apropiados de encapsulamiento terminará causando dificultades cuando los cambios ocurran.

Es digno de mencionar que los cambios siempre ocurrirán, lo importante es poder minimizar el impacto de esos cambios. Cuando los cambios ocurren en la interfaz pública de una abstracción el cambio es sumamente caro, pues afecta a todos sus usuarios.

Entonces el objetivo del encapsulamiento es reducir la complejidad de las abstracciones por medio de proveer un mecanismo para ocultar detalles de implementación. También ayuda a minimizar la interdependencia y facilitar el cambio. Maximizamos el encapsulamiento por medio de minimizar la exposición de los detalles de implementación.

Sin embargo, el encapsulamiento no servirá de nada si no definimos abstracciones apropiadas. Puesto de manera sencilla, no hay forma de cambiar la interfaz pública de una abstracción sin afectar y probablemente interrumpir a los usuarios. Así, el diseño de abstracciones apropiadas es de primordial importancia para facilitar la evolución del software y el encapsulamiento es solo una de muchas herramientas que contribuyen a la creación de dichas abstracciones. No obstante, ningún nivel de encapsulamiento va a hacer que una mala abstracción funcione bien.

Encapsulamiento con Java

Una de esas cosas que siempre queremos tener encpsuladas es el estado de una clase. El estado de una clase solo debería ser accesible a través de su interfaz pública.

En un lenguaje orientado a objetos, como Java, conseguimos esto por medio de los modificadores de accesibilidad (public, protected, private, etc). Con estos niveles de accesibilidad controlamos el nivel de encapsulamiento. Entre menos restrictivo el nivel, más caro será el cambio cuando este ocurra y más acoplada está la clase con otras clases dependientes (clases de usuario, subclases, etc).

En los lenguajes que soportan la herencia existen dos interfaces públicas: la interfaz pública compartida con los usuarios de la clase, y la interfaz protegida compartida con sus subclases, es decir, las subclases son un tipo especial de usuario.  De ahí que cuando se introduce la herencia en el diseño de software entonces se vuelve de mayor relevancia que diseñemos niveles apropiados de encapsulamiento para las interfaces públicas y protegidas a fin de facilitar los cambios futuros y la evolución de las APIs.

¿Por qué Getters y Setters?

Muchas gente se pregunta por qué necesitamos métodos de acceso y de mutación en Java (popularmente conocidos como getters y setters). Muchos dicen, ¿por qué no podemos simplemente acceder a los datos directamente?

El propósito del encapsulamiento aquí no es ocultar los datos mismos, sino los detalles de implementación de éstos y la manera en cómo son manipulados para presentarlos al usuario. Así que el propósito de los getters y setters es ofrecer una interfaz pública a través de la cual podemos obtener acceso a la información encapsulada en un objeto. Más tarde podemos alterar la representación interna de los datos sin comprometer la interfaz pública de la clase. Por el contrario, si se exponen los datos directamente comprometemos el encapsulamiento y, por lo tanto, la capacidad de cambiar la forma de manipularlos en el futuro sin afectar a sus usuarios. Estaríamos creando una dependencia con los datos directamente y no con la interfaz pública de la clase. Es un coctél perfecto de problemas y nos lo tendremos que beber cuando el cambio finalmente no encuentre.

Hay varias razones de peso por las cuales queremos encapsular acceso a nuestros campos. El mejor compendio de razones que he encontrado fue formulado, una vez más, por Joshua Bloch en Effective Java.

  • Se pueden limitar los valores almacenados en un campo(p.ej. género debe ser F o M).
  • Se pueden tomar acciones cuando el campo es modificado (desencadenar un evento, validar, etc).
  • Se puede proveer seguridad de hilos por medio de sincronizar los métodos.
  • Se puede cambiar la representación interna de los datos (p.ej. campos calculados, tipos de datos diferentes).
  • Se puede hacer que un campo sea de solo lectura.

Sin embargo, una vez más, queremos apuntar a que el encapsulamiento es más que solo ocultar campos. En Java, como ejemplo, podemos ocultar clases completas, y en el futuro inclusive módulos completos.

Lectura Adicionales