El problema de la varianza
Una lista de perros no es una lista de animales, y entender por qué es el paso que separa usar genéricos de diseñarlos. Esta lección plantea la pregunta de la varianza con precisión, la responde con el contraejemplo del gato insertado, muestra el fallo histórico de los arreglos de Java como prueba de qué ocurre cuando se decide mal, y ordena los tres regímenes posibles con el criterio de productor y consumidor, incluida la doble varianza de los tipos función.
Hay una intuición que todo el mundo trae puesta antes de estudiar genéricos y que es rigurosamente falsa: si un perro es un animal, una caja de perros debería ser una caja de animales. La intuición es tan fuerte que el primer error del compilador que la contradice parece una limitación caprichosa del lenguaje, algo que Kotlin todavía no ha resuelto y que en una versión futura arreglarán. No es eso. Esa negativa es la respuesta correcta a una pregunta muy antigua sobre cómo se propaga la relación de subtipos a través de un constructor de tipos, y la prueba de que es correcta está grabada en un fallo de diseño de Java que lleva treinta años produciendo excepciones en tiempo de ejecución. Esta lección no enseña sintaxis: enseña a formular la pregunta con la precisión suficiente para que la respuesta deje de parecer arbitraria.
- Formular con precisión la pregunta de la varianza como una relación entre tipos construidos y no entre valores.
- Construir el contraejemplo que demuestra por qué la covarianza sobre un contenedor mutable rompe el sistema de tipos.
- Distinguir covarianza, contravarianza e invarianza y asociar cada una a un papel de productor o de consumidor.
- Explicar la doble varianza de los tipos función y por qué el parámetro y el retorno van en sentidos opuestos.
La pregunta, formulada con exactitud
Partimos de una relación de subtipos conocida y no discutida: Perro es subtipo de Animal, lo que significa que en cualquier lugar donde el programa espere un Animal se puede entregar un Perro sin que nada se rompa. La pregunta de la varianza toma esa relación y la traslada un nivel arriba: dado un constructor de tipos C, qué relación existe entre C<Perro> y C<Animal>. Solo hay tres respuestas posibles y las tres tienen nombre.
Si C<Perro> es subtipo de C<Animal>, la relación original se ha conservado y decimos que C es covariante. Si la relación se ha invertido, de modo que C<Animal> resulta ser subtipo de C<Perro>, decimos que C es contravariante. Y si no hay ninguna relación en ninguna dirección, de forma que dos tipos construidos a partir del mismo C con argumentos distintos son sencillamente ajenos, decimos que C es invariante. Kotlin toma la última opción como comportamiento por omisión, y lo hace por una razón que se ve mejor intentando lo contrario.
open class Animal
class Perro : Animal()
class Gato : Animal()
val perros: MutableList<Perro> = mutableListOf(Perro())
// val animales: MutableList<Animal> = perros // el compilador lo rechaza
El contraejemplo cabe en dos líneas más. Supongamos por un momento que esa asignación se permitiera. La variable animales tendría el tipo estático MutableList<Animal>, y sobre ese tipo la operación de añadir un Gato es perfectamente legítima: la firma la admite y ningún compilador tendría motivo para quejarse. Pero la lista subyacente es la misma que perros, cuyo tipo estático promete que todos sus elementos son perros. En el instante en que alguien lea el primer elemento a través de perros obtendrá un gato con la etiqueta de perro puesta. El sistema de tipos habría mentido.
// Si la asignacion anterior se permitiese:
// animales.add(Gato())
// val p: Perro = perros[1] // un Gato con tipo estatico Perro
Conviene aislar bien la causa. Leer de una MutableList<Perro> como si fuera una MutableList<Animal> es completamente seguro: todo perro es un animal y ninguna lectura puede fallar. Lo que rompe el sistema es la operación de escritura, porque la vista general admite valores que la vista específica no puede aceptar. La covarianza no es peligrosa por sí misma, lo es en presencia de operaciones que consumen el parámetro de tipo. Esa observación es exactamente lo que la siguiente lección convertirá en una regla del compilador.
La prueba histórica: los arreglos de Java
Java tomó en 1995 la decisión contraria para los arreglos, y el resultado es el mejor argumento disponible a favor de la invarianza. En Java, un arreglo de String es un subtipo de un arreglo de Object, sin condiciones ni restricciones, lo que permite escribir código que compila limpiamente y falla al ejecutarse.
String[] cadenas = new String[] { "a", "b" };
Object[] objetos = cadenas; // permitido: los arreglos son covariantes
objetos[0] = Integer.valueOf(42); // compila, y lanza ArrayStoreException
El lenguaje se vio obligado a añadir una comprobación de tipos en cada escritura sobre un arreglo para detectar ese caso, lo que significa que la covarianza de los arreglos se paga con una verificación en tiempo de ejecución en todas las escrituras del programa, incluidas las de los millones de arreglos que nunca fueron asignados a un supertipo. La decisión se tomó porque en aquella versión no existían genéricos y sin covarianza no había forma de escribir un método que ordenara cualquier arreglo. Fue un intercambio consciente entre expresividad y solidez, y cuando llegaron los genéricos, Java eligió lo contrario para ellos.
Kotlin hereda los arreglos de la plataforma pero no su varianza: en Kotlin, Array<String> no es subtipo de Array<Any>, y el error aparece en compilación en lugar de en producción. Ese es el patrón que se repetirá en todo el nivel: mover la detección del fallo hacia atrás en el tiempo, del cliente al compilador.
Productor y consumidor: el criterio que decide
La regla operativa que ordena todo lo anterior es sorprendentemente simple una vez formulada. Para cada tipo, hay que preguntarse qué hace con su parámetro de tipo. Si solo lo produce, es decir, si T únicamente aparece en posiciones de salida como los tipos de retorno, entonces la covarianza es segura. Si solo lo consume, es decir, si T únicamente aparece en posiciones de entrada como los tipos de los parámetros, entonces lo seguro es la contravarianza. Si hace ambas cosas, no queda más remedio que la invarianza.
La contravarianza cuesta más de aceptar porque su intuición está invertida. Pensemos en un comparador. Un objeto que sabe comparar dos animales cualesquiera sabe necesariamente comparar dos perros, porque los perros son animales; en cambio, un objeto que solo sabe comparar perros no sirve para comparar animales en general. Por tanto el comparador de animales debe poder usarse donde se espera un comparador de perros, y no al revés: la relación se ha invertido respecto de la de los tipos comparados. Cuanto más general es lo que el consumidor acepta, más sitios puede ocupar.
fun interface Comparador<T> {
fun comparar(a: T, b: T): Int
}
val porEdad = Comparador<Animal> { a, b -> 0 }
// Lo que queremos poder hacer y la invarianza impide:
// val paraPerros: Comparador<Perro> = porEdad
Léase la última línea como una petición razonable, porque lo es: quien pida un comparador de perros va a pasarle perros, y un comparador de animales sabe recibirlos. La operación es segura y aun así el compilador la rechaza mientras nadie declare la varianza, exactamente igual que rechazaba la asignación de listas al principio de la lección. La diferencia es que aquí la negativa protege una seguridad que no está en peligro, y ese exceso de celo es lo que las anotaciones de la lección siguiente vienen a corregir.
flowchart TB base[Perro es subtipo de Animal] base --> co[Productor: Fuente de Perro es subtipo de Fuente de Animal] base --> contra[Consumidor: Sumidero de Animal es subtipo de Sumidero de Perro] base --> inv[Productor y consumidor a la vez: sin relacion] co --> nota1[Se conserva el sentido] contra --> nota2[Se invierte el sentido] inv --> nota3[Invariante por seguridad]
Los tipos función son el caso donde ambos regímenes conviven en la misma declaración, y por eso son el mejor ejercicio del nivel. Un tipo función consume sus parámetros y produce su resultado, de modo que es contravariante en los primeros y covariante en el segundo. La firma real de la biblioteca estándar lo dice sin ambigüedad, y la consecuencia es que (Animal) -> Perro puede usarse en cualquier sitio donde se espere (Perro) -> Animal.
// En la biblioteca estandar: interface Function1<in P1, out R>
val deAnimalAPerro: (Animal) -> Perro = { Perro() }
val dePerroAAnimal: (Perro) -> Animal = deAnimalAPerro // valido
// La direccion contraria no lo es:
// val alReves: (Animal) -> Perro = dePerroAAnimal
Merece la pena leer la asignación válida despacio. Quien la recibe promete llamarla con un Perro y usar el resultado como Animal. La función real acepta cualquier Animal, así que un perro le vale; y devuelve un Perro, que es un Animal perfectamente utilizable. Ambas promesas se cumplen con holgura, y la holgura sobrante en cada extremo va precisamente en sentidos opuestos.
La biblioteca estándar de Kotlin aplicó este análisis antes de escribir una sola interfaz, y el resultado está a la vista de cualquiera que compare las dos familias de colecciones. La interfaz de solo lectura no tiene ningún método que reciba un elemento, y por eso pudo declararse covariante; la mutable añade justamente esos métodos, y por eso no pudo. La separación que a primera vista parece una duplicación incómoda es en realidad la única forma de que la mitad más usada del tipo escape de la invarianza.
val perritos: List<Perro> = listOf(Perro())
val bichos: List<Animal> = perritos // valido: List solo produce
val mutables: MutableList<Perro> = mutableListOf(Perro())
// val otros: MutableList<Animal> = mutables // invalido: MutableList tambien consume
Productor: solo sale
List, Iterable, Sequence, Deferred. El parámetro aparece en los retornos y nunca en los parámetros. Admiten covarianza porque leer de más nunca puede corromper nada.
Consumidor: solo entra
Comparable, Comparator, SendChannel, cualquier callback de un solo sentido. Admiten contravarianza porque aceptar de más siempre es aceptar lo suficiente.
Ambos: invariante
MutableList, Array, MutableMap, cualquier propiedad var. No hay elección posible: cualquier relación de subtipos permitiría el contraejemplo del gato.
Lo que hay debajo de esta lección es un teorema, no un criterio estético, y verlo así cambia la forma de discutir sobre APIs. La regla de subtipos de Liskov exige que un valor de un subtipo pueda sustituir a uno del supertipo sin que ningún observador detecte la diferencia, y esa exigencia, aplicada a los tipos construidos, no deja margen de opinión: la varianza de un constructor de tipos queda determinada por las posiciones en las que su parámetro aparece dentro de la declaración, exactamente igual que el signo de un producto queda determinado por los signos de sus factores. La analogía es literal y vale la pena tomarla en serio. Las posiciones de salida cuentan como positivas y las de entrada como negativas; un parámetro que aparece dentro del argumento de otro tipo hereda el producto de los signos, y de ahí sale el hecho, aparentemente misterioso, de que en (( T ) -> Unit) -> Unit el parámetro vuelva a ser covariante porque dos negativos se multiplican. Nadie decidió eso: se deduce. Lo que sí es una decisión de diseño es qué operaciones se ponen en la interfaz, y por eso la varianza no se elige al final, cuando ya está escrita la clase, sino al principio, cuando se decide si el tipo va a ser un contenedor de lectura o un contenedor de escritura. La separación entre List y MutableList en Kotlin no es una duplicación incómoda heredada de Java, es la consecuencia práctica de esta aritmética: partiendo la interfaz en dos, la mitad que solo produce puede ser covariante y volverse utilizable en mil sitios donde la mitad completa jamás lo sería. Java, que no partió la interfaz, tuvo que pagar el mismo precio en el punto de uso con sus comodines, escribiéndolos una y otra vez en cada firma. Y los arreglos, que se decidieron antes de que nadie hiciera este cálculo, siguen pagando una comprobación en tiempo de ejecución en cada escritura como recordatorio permanente de lo que cuesta equivocarse aquí. La conclusión práctica para quien diseña una API genérica es incómoda pero clara: si tu tipo produce y consume a la vez, ninguna anotación va a salvarte, y la pregunta correcta no es cómo hacerlo covariante sino si esas dos responsabilidades debían estar en el mismo tipo.
La varianza responde a una sola pregunta: dada la relación entre Perro y Animal, qué relación hay entre C<Perro> y C<Animal>. Si el tipo solo entrega valores del parámetro, la relación se conserva y hay covarianza. Si solo los recibe, se invierte y hay contravarianza. Si hace ambas cosas, no hay relación posible sin abrir la puerta al contraejemplo del gato, y de ahí que Kotlin sea invariante por omisión. Los tipos función combinan los dos regímenes porque combinan los dos papeles.
- Escribe el contraejemplo del gato en Java o en un lenguaje con arreglos covariantes y provoca la excepción en tiempo de ejecución. Después escríbelo en Kotlin y anota en qué momento aparece el error.
- Para cada uno de estos tipos, decide si su parámetro se produce, se consume o ambas cosas:
Iterator,Comparator,MutableMap,Sequence,Channel. - Explica con tus palabras por qué un comparador de animales sirve para ordenar perros pero uno de perros no sirve para ordenar animales, sin usar las palabras covariante ni contravariante.
- Asigna una lambda de tipo
(Animal) -> Perroa una variable de tipo(Perro) -> Animaly comprueba que compila. Después invierte la asignación y lee el mensaje de error identificando cuál de los dos extremos falla. - Toma una interfaz genérica de tu propio código y clasifícala. Si sale invariante, propón cómo partirla en dos para que una de las mitades pueda dejar de serlo.