Varianza en la declaración: out e in
Kotlin permite anotar la varianza una sola vez, en la declaración del parámetro de tipo, y a cambio comprueba que la clase se comporte como lo prometido. Esta lección estudia qué significan exactamente out e in en el punto de declaración, qué posiciones quedan prohibidas para cada uno y por qué las propiedades var y los miembros privados son casos aparte, qué papel juega la anotación de escape de la biblioteca estándar, y por qué Kotlin resolvió el problema aquí en lugar de dejarlo para el punto de uso como hizo Java.
La lección anterior terminó con un diagnóstico y sin tratamiento: sabemos que la varianza de un tipo genérico está determinada por las posiciones en que aparece su parámetro, pero seguimos escribiendo tipos invariantes porque no le hemos dicho nada al compilador. Las dos palabras que faltan son out e in, y su colocación revela una decisión de diseño que separa a Kotlin de Java con nitidez. En Java, quien sufre el problema es quien escribe la firma que consume el tipo, y por eso los comodines se repiten en cada parámetro de cada método de cada API. En Kotlin, quien lo resuelve es quien declara el tipo, una vez, y a partir de ahí todos sus usuarios heredan la solución sin escribir nada. El precio de esa comodidad es que el compilador deja de creerte y empieza a comprobarte: en el momento en que escribes out, una lista de construcciones perfectamente normales se vuelve ilegal dentro de tu clase, y entender exactamente cuáles y por qué es el trabajo de esta lección.
- Anotar un parámetro de tipo con
outo coninen el punto de declaración y justificar la elección desde las operaciones de la interfaz. - Enumerar las posiciones que cada anotación prohíbe y explicar por qué una propiedad
varnunca puede ser covariante. - Reconocer las tres excepciones a la comprobación: constructores, miembros privados y la anotación de escape.
- Contrastar la varianza en la declaración con la de Java en el punto de uso y evaluar qué gana y qué pierde cada una.
out: prometer que el tipo solo sale
Escribir out delante de un parámetro de tipo es una promesa con dos caras. Hacia fuera, declara que el tipo construido conserva la relación de subtipos, de modo que Fuente<Perro> pasa a ser subtipo de Fuente<Animal> y puede entregarse en cualquier sitio donde se espere el segundo. Hacia dentro, se convierte en una obligación que el compilador verifica línea a línea: el parámetro solo puede aparecer en posiciones de salida.
interface Fuente<out T> {
fun siguiente(): T
val ultimo: T
fun tresPrimeros(): List<T>
// fun aceptar(valor: T) // prohibido: T en posicion de entrada
// var actual: T // prohibido: un var tambien escribe
}
open class Animal
class Perro : Animal()
val perros: Fuente<Perro> = TODO()
val animales: Fuente<Animal> = perros // valido gracias a out
La prohibición sobre var es la que más desconcierta, y es también la más instructiva. Una propiedad de solo lectura tiene un único accesor que devuelve T, luego es pura salida; una propiedad mutable tiene además un setter que recibe un T, y ese parámetro oculto basta para romper la promesa. La regla no habla de sintaxis sino de la firma real que se genera, y por eso val es aceptable y var no lo es aunque a simple vista sean la misma línea con una letra distinta.
Hay una consecuencia elegante que aparece en cuanto se acepta la covarianza. Como Nothing es subtipo de absolutamente todos los tipos, una List<Nothing> resulta ser subtipo de cualquier List<T> imaginable, y por eso la biblioteca estándar puede tener una única instancia compartida para la lista vacía en lugar de crear una por cada tipo de elemento. No es un truco de implementación: es la varianza haciendo su trabajo en el sitio más extremo posible del retículo de tipos.
in: prometer que el tipo solo entra
La anotación simétrica invierte todo lo anterior. Un parámetro marcado con in invierte la relación de subtipos, de modo que un Sumidero<Animal> es subtipo de Sumidero<Perro> y sirve allí donde se pida el segundo. A cambio, el parámetro solo puede aparecer en posiciones de entrada, y por tanto no puede ser el tipo de retorno de ningún método público ni el tipo de ninguna propiedad.
interface Sumidero<in T> {
fun aceptar(valor: T)
fun aceptarTodos(valores: List<T>)
fun descartar(): Nothing = throw UnsupportedOperationException()
// fun ultimo(): T // prohibido: T en posicion de salida
}
val deAnimales: Sumidero<Animal> = TODO()
val dePerros: Sumidero<Perro> = deAnimales // valido gracias a in
El ejemplo canónico de la biblioteca estándar es Comparable, declarada como Comparable<in T> porque su único método recibe el otro operando y devuelve un entero. Gracias a esa anotación, una implementación capaz de comparar cualquier Number puede usarse donde se exija comparar Int, sin que nadie tenga que escribir un adaptador. La misma lógica gobierna Comparator, los canales de solo envío de las corrutinas y cualquier interfaz de retrollamada que reciba un evento y no devuelva nada.
// En la biblioteca estandar: interface Comparable<in T>
val comparadorGeneral: Comparator<Animal> = Comparator { _, _ -> 0 }
val paraPerros: Comparator<Perro> = comparadorGeneral // valido gracias a in
fun ordenar(perros: List<Perro>, criterio: Comparator<in Perro>) =
perros.sortedWith(criterio)
La última firma anticipa la lección siguiente: cuando el tipo ya viene anotado, la mayoría de los usos no necesitan escribir nada, pero un parámetro declarado con una restricción explícita permite documentar en la propia firma que se admite cualquier comparador más general. Es la misma idea vista desde el otro extremo del contrato.
Que un método pueda devolver Nothing en una interfaz contravariante no es una excepción a la regla sino una aplicación suya: Nothing es subtipo de todo, así que aparece con seguridad en cualquier posición de salida por restrictiva que sea.
Las tres excepciones y lo que revelan
La comprobación del compilador tiene exactamente tres huecos, y cada uno tiene una justificación distinta que conviene no confundir.
El primero son los parámetros del constructor. Un constructor no es un método al que se pueda llamar sobre un objeto ya existente a través de una referencia de supertipo, de modo que no hay ninguna forma de aprovecharlo para colar un valor del tipo equivocado. Por eso una clase covariante puede recibir su parámetro de tipo en el constructor sin problema alguno.
class Caja<out T>(val contenido: T) // valido: el parametro es del constructor
class Par<out A, out B>(val primero: A, val segundo: B)
val cajaDePerro: Caja<Perro> = Caja(Perro())
val cajaDeAnimal: Caja<Animal> = cajaDePerro
El segundo son los miembros privados. La varianza es una propiedad del contrato público, porque solo a través del contrato público puede alguien ver el objeto con un tipo estático distinto del real. Un método privado no forma parte de ese contrato, y por tanto puede usar el parámetro en cualquier posición sin poner nada en riesgo.
class Almacen<out T>(private val elementos: MutableList<@UnsafeVariance T>) {
fun leer(indice: Int): T = elementos[indice]
private fun anotar(valor: T) { elementos.add(valor) } // privado: permitido
}
El tercero es la anotación de escape que aparece en la línea anterior, y es la más delicada. Existe porque a veces una operación menciona el parámetro en una posición prohibida sin llegar a comprometer nada. El caso canónico está en la propia biblioteca estándar: List<out E> declara contains con un parámetro de tipo E, lo que sobre el papel es una posición de entrada, pero la implementación se limita a comparar por igualdad y nunca almacena el valor recibido. La anotación le dice al compilador que deje de comprobar ahí, y es responsabilidad de quien la escribe que la promesa siga siendo cierta.
Cada vez que se silencia la comprobación de varianza, la garantía deja de ser una propiedad verificada y pasa a ser un comentario. En la biblioteca estándar está justificada porque el cuerpo de esos métodos es conocido, está fijado y no puede sobrescribirse de forma que almacene el valor. En código propio, la aparición de esta anotación es casi siempre la señal de que el tipo no era realmente un productor puro y de que la varianza que se le quiso poner no le correspondía.
flowchart TD
A[Donde aparece el parametro de tipo] --> B{Posicion}
B -- Tipo de retorno o val --> S[Posicion de salida]
B -- Parametro de metodo o setter --> E[Posicion de entrada]
S --> OK1[Compatible con out]
E --> OK2[Compatible con in]
S --> KO2[Incompatible con in]
E --> KO1[Incompatible con out]
A --> X{Es constructor o miembro privado}
X -- Si --> LIBRE[Sin comprobacion de varianza]Por qué en la declaración y no en el uso
Java resolvió el mismo problema en el lugar opuesto. Como sus tipos genéricos son siempre invariantes, la flexibilidad se consigue escribiendo comodines en cada firma que quiera aceptar más de un argumento de tipo, con la fórmula que la comunidad memorizó como productor extiende y consumidor supera. El resultado funciona, pero desplaza el coste a quien menos información tiene.
// Java: la varianza se escribe una vez por cada uso
static void volcar(List<? extends Animal> origen, List<? super Perro> destino) {
for (Animal a : origen) { /* ... */ }
}
Kotlin observó que en la inmensa mayoría de los casos el autor del tipo ya sabe si su clase produce o consume, y que obligar a repetir esa información en cada firma de cada cliente es redundante y propenso al olvido. Al anotar List<out E> una sola vez, todas las funciones que reciban una List obtienen la flexibilidad gratis y ningún autor de API tiene que acordarse de nada. La contrapartida es que la varianza en la declaración solo es aplicable cuando el tipo es realmente puro en un sentido, y hay tipos que no lo son: MutableList produce y consume, y ninguna anotación va a arreglarlo. Para esos casos Kotlin conserva también el mecanismo del punto de uso, que es el asunto de la lección siguiente. Los dos mecanismos no compiten, se reparten el terreno: la declaración cubre los tipos que tienen una vocación clara, y el uso rescata a los que no la tienen.
Hay una forma equivocada de leer out que sobrevive mucho tiempo: pensar que es una concesión que se le pide al compilador para que deje pasar una asignación que antes rechazaba. Es exactamente lo contrario, y verlo al revés cambia la manera de diseñar. Escribir out no relaja ninguna comprobación: añade una, la de que tu clase no use el parámetro en posiciones de entrada, y solo como consecuencia de haber superado esa comprobación el compilador se permite conceder la relación de subtipos. La flexibilidad hacia fuera se paga con rigidez hacia dentro, y el pago es previo. Esto convierte a la varianza en la declaración en algo mucho más interesante que un mecanismo de conveniencia: es un dispositivo que fuerza a decidir, en el momento de escribir la interfaz, cuál es la dirección del flujo de datos que atraviesa el tipo. Un diseñador que quiera que su tipo sea covariante descubrirá en el acto qué métodos sobran, y esa lista de métodos incómodos suele coincidir con precisión inquietante con los que estaban rompiendo la cohesión de la clase por motivos que nada tenían que ver con los genéricos. La separación entre List y MutableList es el ejemplo mayor, pero el fenómeno se repite en escala pequeña cada vez que alguien intenta anotar una interfaz propia y no puede. Conviene además notar lo que esta decisión implica sobre dónde vive el conocimiento en un ecosistema de bibliotecas. En el modelo de Java, la información sobre la varianza está distribuida entre miles de firmas escritas por clientes que pueden equivocarse y que, si se equivocan, solo perjudican a sus propios usuarios. En el modelo de Kotlin, esa información está centralizada en la declaración, escrita por quien conoce la implementación, comprobada una vez y propagada sin coste. Es la misma clase de decisión que se toma al elegir entre validar en cada llamador o validar en el constructor, y tiene la misma moraleja: cuando una propiedad es cierta del tipo y no de la llamada, el sitio correcto para afirmarla es el tipo. Lo que Kotlin no puede hacer, y ningún lenguaje puede, es fabricar una vocación que la clase no tiene. Ahí es donde termina la varianza en la declaración y empieza la del punto de uso.
out concede covarianza a cambio de prohibir el parámetro en posiciones de entrada; in concede contravarianza a cambio de prohibirlo en posiciones de salida. Una propiedad var nunca es compatible con ninguna de las dos porque genera un accesor de escritura. Quedan exentos los parámetros del constructor y los miembros privados, porque no forman parte del contrato visible desde un supertipo. La anotación se escribe una vez en la declaración y beneficia a todos los usos, que es lo que la distingue de los comodines de Java.
- Declara
interface Fuente<out T>con un método que devuelvaT, comprueba la asignación deFuente<Perro>aFuente<Animal>y después añade un método que recibaTpara leer el error exacto. - Convierte una propiedad
valde un tipo covariante envary explica, mirando los accesores generados, por qué el compilador cambia de opinión. - Declara
interface Sumidero<in T>y verifica la dirección de la asignación. Añade después un método que devuelvaTy otro que devuelvaNothing, y razona por qué solo uno de los dos es aceptado. - Escribe una clase covariante que reciba el parámetro en el constructor y otra que lo reciba en un método privado. Comprueba que ambas compilan y formula en una frase la diferencia entre las dos exenciones.
- Busca en la biblioteca estándar de Kotlin la declaración de
Listy localiza el uso de la anotación de escape encontains. Argumenta si el mismo razonamiento valdría para un método que guardara el valor recibido.