Varianza, Covarianza y Contravarianza

En los sistemas de tipado estático, existe este concepto de varianza, que a veces puede entenderse muy fácil pero tampoco es tan intuitivo como parece.

Para ilustrar la varianza, vamos a definir una jerarquía de clases muy simple:

public class A {}
public class B extends A {}
public class C extends B {}

Y definimos un método o función que usa estos tipos:

public B f(B param) {
   //Qué puede devolver?
}

Primero que nada, viéndolo desde fuera, ¿A qué tipo de variables podemos asignar lo que devuelve esta función?

B algo;
A a = f(algo);
B b = f(algo);
C c = f(algo);

Las primeras dos líneas son correctas, la tercera da un error. Esto es porque la función devuelve un valor de tipo B, y B es también un A. Pero no podemos asignar a una subclase de B, al menos no sin hacer un cast, pero no vamos a hacer casts en esta ocasión. B no es un C, por lo tanto la tercera línea no compila.

Hasta aquí todo bien. Ahora, ¿Qué le podemos pasar como argumento a esta función? Tiene un solo parámetro de tipo B. En esta ocasión, es alrevés:

f(new A()); //ERROR
f(new B()); //OK
f(new C()); //OK

No podemos pasar un A como argumento porque la función espera un B y un A no es un B. Podemos obviamente pasar un B, pero también podemos pasar cualquier subtipo de B.

Y por último: ¿Qué puede devolver realmente la función? Está declarada con un tipo de retorno B, por lo tanto puede devolver B o cualquier subtipo de B. Esta implementación es válida:

B f(B param) {
  return new C();
}

A partir de Java 5, cuando sobreescriben un método de una interfaz o superclase, pueden definir un tipo de retorno más específico que el del método original. Es decir pueden usar un subtipo, no tiene que ser forzosamente el tipo original. Esto significa que los tipos de retorno son covariantes:

class Padre {
  public A metodo() {
    return new A();
  }
}
class Hijo extends Padre {
  @Override
  public C metodo() {
    return new C();
  }
}

Con los parámetros no se puede hacer algo similar, porque Java tiene sobrecarga de métodos, de modo que si en la clase Padre definimos public void otro(A a) y en la clase Hijo definimos public void otro(C c), son dos métodos completamente distintos (y el método Hijo.otro no puede invocar a super porque no hay tal, dado que no está sobreescribiendo el método del mismo nombre en la clase Padre).

Ahora, donde se pone interesante el asunto (y cuando digo interesante, quiero decir complicado y confuso), es cuando intervienen los parámetros de tipo, mejor conocidos como generics. Imaginemos un contenedor muy simple:

class Cont<Tipo> {
  public final Tipo t;
  public Cont(Tipo t) {
    this.t = t;
  }
}

En Java, los parámetros de tipo son invariantes por default. Entonces, ¿Qué pasa con esta función?

Cont<B> g(Cont<B> param) {
  //Qué puede devolver?
}

Cont<A> a = g(algo);
Cont<B> b = g(algo);
Cont<C> c = g(algo);

¿Las tres declaraciones son válidas? Siguiendo la misma lógica que para el ejemplo anterior, las primeras dos llamadas deberían ser válidas, ¿no? Y solamente la tercera debe fallar, ¿verdad?

Pues no.

Solamente la segunda llamada es válida. Las otras dos nos van a dar un error que nos ayuda en nada:

error: incompatible types
Cont<A> a = g(algo);
             ^
  required: Cont<A>
  found:    Cont<B>
error: incompatible types
Cont<C> c = g(algo);
             ^
  required: Cont<C>
  found:    Cont<B>

Pero, pero, pero... Cont<B> es un Cont<A>, ¿o no? Por lo tanto en donde puedo recibir un Cont<A>, puede recibir un Cont<B>, porque si un B es también un A, entonces un Cont<B> OBVIO es también un Cont<A>!

No.

Para empezar, Cont<Tipo> es una clase que contiene algo, de un tipo desconocido al momento de declarar esa clase; ese Tipo no se sabe qué es, puede ser cualquier cosa. Pero, contrario a lo que se piensa inicialmente, un Cont<B> no es un Cont<A>. Tampoco es un Cont<C>.

Recuerden lo que dije al principio: En Java, por default, los parámetros de tipo son invariantes. Esto quiere decir que no aplica nada de lo que ya conocen de herencia cuando se trata de generics.

Para que un Cont<B> sea un Cont<A>, el tipo de parámetro de Cont debe ser covariante. El lugar más obvio para definir la varianza de un tipo de parámetro, es en donde se declara; sin embargo, en Java no es así; la varianza se declara en donde se usa el tipo, no donde fue declarado. Eso significa que se define en donde se invoca g. WAT??? Les dije que se iba a poner interesante:

Cont<? extends A> a = g(algo);
Cont<? extends B> b = g(algo);
Cont<? extends C> c = g(algo);

Ahora sí, solamente la tercera línea les da error. La segunda línea se pudo haber quedado simplemente como Cont<B>, pero entonces no se interpreta como covariante. A poco no son fáciles e intuitivos los generics y esto de la varianza?

Las primeras dos líneas funcionan porque definen un Cont que es covariante en A y un Cont que es covariante en B. El tercero, pues aunque sea covariante en C, no compila porque la función devuelve un Cont que es covariante en B, y pues B no es C. Ahora sí es similar al primer ejemplo donde no usamos generics.

Hay una manera de hacer que la tercera línea compile:

Cont<? super C> c = g(algo);

Eso significa que tenemos una variable tipo Cont contravariante en C. Eso significa que se le puede asignar un Cont de C o cualquier supertipo de C, y el tipo de retorno de g es Cont<B> y B es un supertipo de C, por tanto es válida la asignación.

Ahora vamos a ver qué pasa con los parámetros:

g(new Cont<>(new A()));
g(new Cont<>(new B()));
g(new Cont<>(new C()));

Afortunadamente podemos usar el operador diamante para no tener que teclear el argumento de tipo. <sarcasmo>UFFFFFF gracias Java 7</sarcasmo>.

Solamente la segunda línea es válida, las otras dos dan error. Esto es porque el parámetro de g es un Cont invariante en B. Lo intuitivo hubiera sido que la tercera línea también funcionara, no? Así como podemos pasarle un C a la función f(B), pues deberíamos poder pasarle un Cont<C> a g(Cont<B>)... para lograr esto, debemos hacer covariante el parámetro de g:

Cont<B> g(Cont<? extends B> param) {
  //no se fijen en esto
}
g(new Cont<>(new A())); //ERROR
g(new Cont<>(new B())); //OK
g(new Cont<>(new C())); //OK

Ahora sí, la primera llamada no compila porque estamos pasando un Cont<A> y la función espera un Cont<? extends B>, o dicho de manera más entendible, un Cont<B o cualquier subtipo de B>.

Sin embargo, esto no es lo más común cuando se trata de parámetros. Si bien es de lo más normal y esperado que f(B) pueda invocarse pasándole un C, porque a fin de cuentas un C es también un B, la verdad es que los generics se usan comúnmente para clases contenedoras, por ejemplo una lista. Por eso le puse Cont a la clase de ejemplo. Lo lógico sería poder usar un contenedor de C en donde se espera un contenedor de A, y que a un contenedor de A se le puedan meter elementos de tipo A o cualquier subtipo de A.

Si cambian el parámetro para que sea contravariante, entonces la tercera llamada será la que tenga un error:

Cont<B> g(Cont<? super B> param) {
  //no se fijen en esto
}
g(new Cont<>(new A())); //OK
g(new Cont<>(new B())); //OK
g(new Cont<>(new C())); //ERROR

Hay que tener cuidado aquí. Mi clase Cont es un contenedor inmutable: se crea con un elemento que ya no se puede reemplazar. Por lo tanto, Cont puede ser covariante, ya que si se crea un Cont<C>, se puede usar en donde se espera un Cont<A> sin ningún problema. Si hacemos el contenedor mutable, entonces tendrá que ser invariante, ya que de otra forma si se pasa un Cont<C> a código que espera un Cont<A> y se mete un A al contenedor, puede ocurrir un error (porque A no es C).

Por otro lado, se pueden manejar contenedores contravariantes cuando solamente se les pasan elementos como parámetros. Supongamos que queremos un método que pueda meter un elemento en una lista de A, B o C:

void llena(List<A> lista) {
  lista.add(new C());
}
llena(new ArrayList<A>());
llena(new ArrayList<B>());
llena(new ArrayList<C>());

Solamente la primera llamada es válida, porque el parámetro de la función es invariante. Lo intuitivo para que funcionaran las otras dos llamadas, pues sería hacer el parámetro covariante:

void llena(List<? super A> lista) {
  lista.add(new C());
}

Pero al hacer esto ahora el error está en el código dentro del método, y el mensaje es bastante confuso. Lo que significa el error es que tenemos una lista covariante, por lo que cualquier método que tenga como valor de retorno el parámetro de tipo, se puede invocar sin problemas, pero no se pueden invocar los métodos que esperan argumentos de ese tipo.

Lo que realmente hay que hacer, es definir el parámetro como contravariante:

void llena(List<? extends C> lista) {
  lista.add(new C());
}
llena(new ArrayList<A>());
llena(new ArrayList<B>());
llena(new ArrayList<C>());

La definición del parámetro se puede interpretar como "Una lista de C o cualquier supertipo de C". Y ahora las tres invocaciones son válidas: el método puede meter un C en una lista de A, sin ningún problema.

Un ejemplo un poco más completo:

ArrayList<B> listB = new java.util.ArrayList<>();
llena(listB);
java.util.List<? extends A> listA = listB;

Tenemos una lista invariante en B, que podemos llenar con nuestro método, porque el método espera listas de C o un supertipo de C. Y luego podemos asignar esa lista a otra variable que es una lista de A o un subtipo de A. Si sacamos el primer elemento de listA, obtenemos un C, que es un subtipo de A. La seguridad de tipos no se ha roto en ningún momento.

Bueno pues espero que esto aclare un poco estos conceptos de varianza, covarianza y sobre todo, contravarianza.

Ya otro día les muestro cómo se hace todo esto en Ceylon. Hay diferencias importantes, como poder definir la varianza en donde se declaran los parámetros de tipo, pero también se puede definir en donde se usan.

Comentarios

Opciones de visualización de comentarios

Seleccione la forma que prefiera para mostrar los comentarios y haga clic en «Guardar las opciones» para activar los cambios.

Contravariance/Covariance

JEP draft

Por cierto, hay una propuesta para mejorar esta característica en Java: JEP draft: Improved variance for generic classes and interfaces.

Imagen de ezamudio

Muy bueno

Muy bueno ese diagrama.

Imagen de Cid

Muy buen post

Algún día intentando explicar los conceptos que aqui pones realice una diapositiva en la empresa que trabajaba pero igual y la regue no se si el concepto de lectura y escritura aplican y en lugar de "Clase" debí usar "Tipo":

Más bien creo que debí explicar que como el tipo es desconocido con el comodin podria generar errores:

class A {}
class B extends A {}
class C extends A {}

....

List<? extends A> lista = new ArrayList<B>();  // OK una Lista covariante de A puede recibir una lista invariante de A, B, o C
lista.add(new A());  // Falla porque rompemos el concepto de invarianza del objeto ArrayList<B> porque solo acepta B.

....

Imagen de ezamudio

Clase o Tipo

Uso el término Tipo en vez de Clase porque también abarca interfaces.

Por cierto tu diagrama dice Covarianza en ambas partes; el texto de arriba a la derecha debería decir Contravarianza.

La cosa con las colecciones mutables es que la única manera de que sean seguras, es que sean invariantes. Si tienes una lista mutable de B, debe ser invariante, porque si bien le puedes meter subtipos de B, lo único que puedes estar seguro a la hora de sacar algo de ahí, es que va a ser un B. Por eso en Java decidieron implementar varianza en el sitio de uso o use-site variance: la defines donde la vas a usar. En código que inserta elementos a una lista, te importa el tipo más específico de la lista, para que por ejemplo no vayas a meter un BigDecimal en una lista de Integer, por lo tanto puedes usar contravarianza. En código que toma elementos de una lista, te importa el tipo más general de los elementos, por lo tanto puedes usar covarianza. Si la lista es mutable, pues el único punto en común es la invarianza.

Imagen de echan

Parece un accidente o corto

Parece un accidente o corto cirtuito cuando mezclas herencia (subtyping) mas generics ( polimorfismo paramétrico ). Creo que en Java es mas complejo de lo que realmente es gracias al type erasure. Al eliminar el parametro de tipo en la compilacion se pierde la relacion A-B-C y es por eso que hay que redefinirla al momento de usarla, como la amnesia temporal, hay que darle pistas al compilador para recordarle que la relacion es valida.

Como comenta @ezamudio, seria mas facil definir la polaridad al momento de declarar el contenedor generico pero eso complicaria el sistema de tipos y eso no va con Java ;), Lo mas sencillo es aventar la bronca a los programadores :).

Saben quien define mejor la varianza?

Imagen de Cid

El mal hábito del copy & paste

Es correcto deberia decir contravarianza, creo que hice las diapositivas muy de prisa y no había vuelto a dar un curso para notarlo, creo que como coloca @jpaul sobre una prouesta de mejorar el uso de varianza e inferencias de la misma, todavía es tema de conversación como para las siguientes versiones de Java.

Imagen de ezamudio

Siguiente

Ya publiqué la siguiente parte, donde vuelvo a hacer estos ejemplos pero en Ceylon, para mostrar cómo es más fácil manejar la varianza cuando se tiene una sintaxis bien pensada y un mejor diseño del sistema de tipos.

Wikipedia

 

Para todos aquellos interesados, Wikipedia tiene un muy excelente artículo respecto a este tema, con muy buenas referencias, artículo que además aborda algunas de las idiosincrasias de Java, algunas de las cuales el OP ya ha trabajado aquí.

¡Por si sirve de algo!

~~~

Imagen de Nopalin

Excelente artículo

Me inclino ante usted don ezamudio, es claro por que ahora es un java champion.

Ahora, a ver si no me salgo de contexto, algo que me gusta hacer es relacionar los conceptos con la vida diaria, en este caso, con programación real no teórica.
Tendrá algún ejemplo donde alguien lleve al extremo el polimorfismo de tipos? en casi 10 años que llevo programando en java, no he tenido la necesidad de algo por el estilo, por eso me surge la duda de si en realidad es aplicable a un proyecto real. La covarianza la aplique en un lenguajito de expresiones que hize para un cálculo de nómina, donde la Expresion (interface principal) retornaba un Objet al ser evaluada y ps cada implementación retornaba el tipo adecuado, ya sean fechas, numéricos, booleanos, etc, es lo más complejo que he llegado a hacer, de ahi en fuera sólo lo más común de la OOP, como herencia, polimorfismo, sobre escritura de métodos, sobre carga de métodos..

Saludos y agradesco comparta sus conocimientos.

Imagen de ezamudio

Polimorfismos

El polimorfismo de subtipos es justo lo que mencionas, Nopalin. Ese es un ejemplo de tipos de retorno covariantes. Por cierto, la cláusula throws de un método también es covariante.

Como menciono en el artículo, la varianza de los parámetros de tipo es poco usada en Java porque se debe definir en donde se usa un tipo, y la sintaxis no ayuda en nada. Ceylon simplifica el uso de varianza al hacer que se pueda definir en donde se declara un tipo, y opcionalmente en donde se usa (cuando fueron declarados invariantes). El simple hecho de que un Iterable sea definido como Iterable<out Element> hace que esa interfaz sea covariante y no es necesario estar definiendo esa varianza en todos lados; simplemente al recibir un Iterable ya sabes que es covariante en su Element.