Varianza en el uso y proyecciones estrella
Cuando una clase produce y consume a la vez, la anotación en la declaración es imposible y la flexibilidad hay que pedirla en cada firma. Esta lección estudia la proyección de tipo como varianza en el punto de uso, el precio exacto que el compilador cobra por concederla, el caso testigo de los arreglos, y la proyección estrella con su traducción precisa a límites de lectura y escritura, incluida la diferencia decisiva entre no saber el argumento y admitir cualquiera.
La lección anterior terminó reconociendo un límite: hay tipos que producen y consumen, y a esos no hay anotación que los salve. MutableList es el ejemplo evidente, pero el fenómeno es general y afecta a casi todo lo que tenga estado mutable, desde un arreglo hasta una caché. Sin embargo, la rigidez que eso impone es exagerada respecto del problema real, porque una función concreta casi nunca usa el tipo entero: usa tres métodos, y muchas veces los tres van en la misma dirección. Un procedimiento que recorre un arreglo para copiarlo solo lee del origen, aunque el tipo del origen sea perfectamente capaz de escribir. La proyección de tipo es el mecanismo que permite decir exactamente eso, en la firma y no en la declaración: de este parámetro solo voy a leer, así que trátalo como si fuera covariante y prohíbeme lo demás. Es la varianza de Java, la de los comodines, conservada en Kotlin para el terreno preciso donde la otra no llega.
- Escribir una proyección de tipo en una firma y justificar por qué la declaración no podía resolver ese caso.
- Enumerar las operaciones que el compilador deshabilita sobre un valor proyectado y explicar la regla que las selecciona.
- Traducir una proyección estrella a sus límites efectivos de lectura y de escritura según la varianza declarada del parámetro.
- Distinguir una proyección estrella de un argumento de tipo máximamente general y justificar la diferencia con un ejemplo.
La proyección de tipo, o la varianza pedida en la firma
Un tipo proyectado es una vista restringida de un tipo invariante. Se escribe poniendo out o in delante del argumento de tipo en el lugar donde se usa, no donde se declara, y produce un tipo distinto del original: Array<out Any> no es Array<Any>, es un Array del que solo se puede leer. La función que recibe esa vista gana la posibilidad de aceptar arreglos de cualquier subtipo y pierde la posibilidad de escribir en ellos.
fun copiar(origen: Array<out Any>, destino: Array<Any>) {
for (i in origen.indices) {
destino[i] = origen[i]
}
// origen[0] = "algo" // prohibido: la proyeccion out cierra la escritura
}
val enteros: Array<Int> = arrayOf(1, 2, 3)
val destino: Array<Any> = Array(3) { 0 }
copiar(enteros, destino) // valido: Array<Int> encaja en Array<out Any>
La regla que selecciona qué se deshabilita es la misma de la lección anterior, aplicada a un tipo concreto en lugar de a una declaración. En una proyección out, quedan disponibles los miembros donde el parámetro aparece en posición de salida y desaparecen aquellos donde aparece en posición de entrada. En una proyección in, ocurre lo simétrico: se puede escribir y no se puede leer con el tipo específico, porque lo que se lee solo puede tratarse como Any?.
fun rellenar(destino: Array<in String>, valor: String) {
for (i in destino.indices) {
destino[i] = valor // valido: escribir es lo que la proyeccion in permite
}
// val s: String = destino[0] // prohibido: lo leido solo es Any?
}
rellenar(Array<Any>(3) { "" }, "hola")
rellenar(Array<CharSequence>(3) { "" }, "hola")
Merece la pena notar que la proyección no es una conversión ni un envoltorio: no hay objeto nuevo, no hay coste en ejecución y no se copia nada. Es una restricción que existe únicamente en la mente del compilador durante la comprobación de tipos, exactamente igual que la promesa de un parámetro anotado. El valor que llega a la función es el mismo objeto de siempre, con todos sus métodos intactos; lo que ha cambiado es el permiso para nombrarlos.
Ante un parámetro genérico de un tipo invariante, la pregunta correcta es qué hace el cuerpo con él. Si solo lo recorre, out y la firma acepta muchos más llamadores. Si solo lo llena, in. Si hace ambas cosas, no hay proyección posible y el parámetro debe quedarse invariante, que es la señal de que la función está haciendo dos trabajos. Proyectar por reflejo, sin mirar el cuerpo, produce firmas que después hay que revertir cuando alguien añade una línea.
La proyección estrella y su traducción
Hay un caso más extremo: cuando no se sabe nada en absoluto sobre el argumento de tipo y aun así se quiere operar con seguridad sobre el valor. Escribir Array<*> no significa que el arreglo contenga cualquier cosa, significa que contiene elementos de algún tipo concreto que el código que lo recibe desconoce. La diferencia entre esas dos lecturas es la única idea difícil de la lección, y de ella salen todas las consecuencias.
La traducción es mecánica y conviene memorizarla. Para un parámetro declarado covariante con un límite superior, la estrella equivale a una proyección de salida hasta ese límite. Para un parámetro declarado contravariante, equivale a una proyección de entrada desde Nothing, que en la práctica significa que no hay nada que se pueda escribir. Y para un parámetro invariante, la estrella se comporta de las dos maneras a la vez según la dirección: al leer se comporta como el límite superior, al escribir como Nothing.
flowchart TD
A[Proyeccion estrella sobre un parametro] --> B{Como fue declarado}
B -- out T con limite L --> C[Equivale a proyeccion out hasta L]
B -- in T --> D[Equivale a proyeccion in desde Nothing]
B -- Invariante --> E[Al leer se comporta como L]
B -- Invariante --> F[Al escribir se comporta como Nothing]
D --> G[Nada escribible: Nothing no tiene valores]
F --> GEl resultado práctico es que sobre un valor con proyección estrella se puede leer obteniendo el límite superior, que en el caso habitual es Any?, y no se puede escribir absolutamente nada. Ni siquiera nulo, porque el tipo del parámetro de escritura pasa a ser Nothing, y Nothing no tiene ningún valor, tampoco el nulo.
fun describir(lista: MutableList<*>) {
println("tamano: ${lista.size}")
val primero: Any? = lista.firstOrNull() // leer devuelve el limite superior
// lista.add("x") // imposible: el parametro es Nothing
// lista.add(null) // tambien imposible, por lo mismo
lista.clear() // valido: no menciona el parametro
}
Estrella no es lo mismo que el tipo más general
Este es el punto donde casi todo el mundo tropieza, y el ejemplo que lo resuelve es de dos líneas. Una MutableList<Any?> es una lista cuyo tipo de elemento es Any?, y por tanto acepta que se le añada cualquier cosa. Una MutableList<*> es una lista cuyo tipo de elemento es desconocido, y precisamente por serlo no acepta que se le añada nada, porque cualquier valor que se intentase añadir podría violar el tipo real que el llamador conoce y el receptor no.
val cualquiera: MutableList<Any?> = mutableListOf()
cualquiera.add("texto")
cualquiera.add(42)
cualquiera.add(null) // todo valido: el tipo del elemento es Any?
val desconocida: MutableList<*> = mutableListOf<String>()
// desconocida.add("texto") // invalido: el tipo real podria no admitirlo
val leido: Any? = desconocida.firstOrNull()
La segunda lista es en realidad una lista de cadenas, y el sistema de tipos protege ese hecho aunque la firma lo haya olvidado. Añadir un entero a través de la vista con estrella produciría exactamente el contraejemplo del gato de la segunda lección. La estrella es, en este sentido, una cuantificación existencial: afirma que existe un tipo que hace válido este valor, sin decir cuál, y de una existencial no se puede extraer el testigo.
Hay un corolario que conviene tener presente porque decide firmas reales. La proyección estrella es la única forma de escribir el tipo de un genérico cuyo argumento no se conoce ni se puede introducir como parámetro de la función, y aparece en cuanto se guarda una colección heterogénea de contenedores. Escribir el mismo campo con un argumento general compilaría pero renunciaría a la protección, porque a partir de ese momento nada impediría mezclar elementos de tipos incompatibles dentro del mismo contenedor.
class Registro {
private val cajas = mutableListOf<MutableList<*>>()
fun anotar(caja: MutableList<*>) { cajas.add(caja) }
fun total(): Int = cajas.sumOf { it.size }
// fun contaminar() { cajas.first().add(0) } // imposible, y menos mal
}
Cuando hay varios parámetros de tipo, la estrella se aplica a cada uno por separado y pueden mezclarse con argumentos concretos, lo que permite conservar la información que sí interesa y renunciar solo a la que no.
fun claves(mapa: Map<*, String>): Set<Any?> = mapa.keys // solo se ignora la clave
fun cualquierMapa(mapa: Map<*, *>): Int = mapa.size // se ignoran ambos
fun aplicar(f: (Nothing) -> String): String = "no invocable" // el limite in llevado al extremo
Proyección explícita
Sabes algo sobre el argumento y solo renuncias a una dirección. Array<out Any> sigue permitiendo leer con tipo Any, que suele ser suficiente para recorrer, imprimir o volcar.
Proyección estrella
No sabes nada y renuncias a las dos direcciones salvo la lectura hasta el límite superior. Es la firma correcta para funciones de inspección, de registro o de comprobación de tamaño.
El error clásico
Usar Any? donde correspondía la estrella. Compila, acepta escrituras y traslada al llamador la obligación de convertir. La estrella deja el error en tu firma, Any? lo reparte por todo el proyecto.
Detrás de la asimetría que hace tropezar a todo el mundo hay una distinción lógica que merece nombrarse, porque una vez nombrada deja de ser contraintuitiva para siempre. Un tipo genérico ordinario expresa una cuantificación universal: fun <T> f(x: List<T>) afirma que la función sirve para todo T, y el cuerpo lo demuestra funcionando sin saber cuál. Una proyección estrella expresa lo contrario, una cuantificación existencial: List<*> afirma que existe algún T para el cual este valor es una List<T> legítima, pero no dice cuál es, y esa omisión no es un descuido sino el contenido mismo de la afirmación. La regla de eliminación de una existencial es justamente que no se puede recuperar el testigo, y todo lo que la proyección prohíbe se deduce de ahí sin necesidad de recordar ninguna tabla. No se puede escribir en una MutableList<*> porque escribir exigiría conocer el testigo para comprobar que el valor le pertenece. Sí se puede leer, pero solo hasta el límite superior, porque eso es lo único que la existencial garantiza sobre cualquier testigo posible. Y sí se puede llamar a size o a clear, porque esos miembros no mencionan el parámetro y por tanto son verdaderos para todos los testigos a la vez. La distinción explica además por qué la varianza en el punto de uso siempre será necesaria pese a existir la del punto de declaración: la declaración expresa una propiedad universal del tipo, cierta en todos sus usos, mientras que la proyección expresa un conocimiento local del llamador, cierto en esta llamada y falso en la siguiente. Son dos afirmaciones lógicamente distintas y ninguna puede sustituir a la otra. Java, al tener solo la segunda, obliga a repetir en cada firma algo que era una propiedad del tipo; un lenguaje que solo tuviera la primera dejaría sin expresar la mitad interesante del terreno, la de los contenedores mutables que se usan en una sola dirección. Kotlin tiene ambas y la elección entre ellas admite una regla corta: si la restricción es cierta para todo el mundo, va en la declaración; si es cierta para tu función, va en tu firma. Confundirlas produce, en un sentido, tipos que nadie puede usar con flexibilidad, y en el otro, firmas que prometen menos de lo que la clase podía dar.
La proyección de tipo pide la varianza en la firma en lugar de en la declaración, y sirve para los tipos invariantes que una función concreta usa en una sola dirección. Una proyección out habilita la lectura y cierra la escritura; una proyección in hace lo contrario. La estrella es el caso en que el argumento se desconoce por completo: permite leer hasta el límite superior y no permite escribir nada, ni siquiera nulo, porque el tipo del parámetro de escritura pasa a ser Nothing. No es intercambiable con un argumento máximamente general.
- Escribe la función de copia entre arreglos con la proyección
outen el origen e intenta escribir en él. Anota el mensaje exacto y explica qué miembro se ha deshabilitado. - Escribe la función de relleno con la proyección
inen el destino e intenta leer un elemento con el tipo específico. Comprueba qué tipo te ofrece el compilador en su lugar. - Declara una
MutableList<*>inicializada con una lista de cadenas e intenta añadirle un elemento, después un nulo. Explica en una frase por qué el segundo intento también falla. - Compara
MutableList<Any?>yMutableList<*>en un mismo archivo, listando tres operaciones que la primera permita y la segunda no. Decide cuál usarías en la firma de una función de registro. - Escribe la traducción de la proyección estrella para un parámetro declarado
out T : CharSequencey comprueba empíricamente qué tipo devuelve la lectura.