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:

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: