wandres.dev
GENÉRICOS Y VARIANZA · in, out y las estrellas

Parámetros de tipo y restricciones

Un parámetro de tipo es un parámetro como cualquier otro, salvo que habita en el sistema de tipos y se resuelve antes de que exista ningún objeto. Esta lección estudia dónde se declara en funciones, clases y extensiones, cuál es el ámbito de cada declaración y por qué el objeto acompañante no lo ve, qué significa el límite superior implícito y cómo cambia todo al escribir uno explícito, cómo se acumulan varias restricciones con la cláusula where, y de qué fuentes obtiene el compilador los argumentos de tipo cuando no los escribes a mano.

⏱ 20 min

La primera vez que se escribe un genérico casi siempre se escribe por imitación: alguien vio List<String> en una firma ajena y dedujo que las puntas de flecha eran una especie de adorno obligatorio para las colecciones. Esa lectura sobrevive durante años sin causar daño visible, hasta el día en que el compilador rechaza una línea con un mensaje sobre información insuficiente para inferir una variable de tipo y no hay nada en la intuición acumulada que sirva para responder. El desbloqueo consiste en aceptar una idea sencilla y de consecuencias enormes: un parámetro de tipo es literalmente un parámetro. Tiene un nombre, tiene un ámbito donde ese nombre significa algo, admite restricciones sobre los valores que puede recibir y se le pasa un argumento en cada llamada, aunque casi siempre lo pase el compilador por ti. Todo lo demás en este nivel, incluida la varianza, es consecuencia de tomarse esa frase en serio.

🎯 Al terminar esta lección sabrás
  • Declarar parámetros de tipo en funciones, clases y extensiones, y situar la declaración en el lugar correcto en cada caso.
  • Delimitar el ámbito de un parámetro de tipo y explicar por qué el companion object no puede verlo.
  • Aplicar restricciones de límite superior, distinguir el límite implícito del explícito y encadenar varias con la cláusula where.
  • Enumerar las fuentes de las que el compilador extrae los argumentos de tipo y reconocer cuándo hay que escribirlos.

El parámetro de tipo y su ámbito

Una función corriente abstrae sobre valores: en lugar de escribir el número tres dentro del cuerpo, se declara un parámetro y se recibe cualquier número. Un parámetro de tipo hace exactamente lo mismo un nivel más arriba: en lugar de fijar String en la firma, se declara un nombre y se recibe cualquier tipo. La única diferencia es que la sustitución no ocurre durante la ejecución sino durante la compilación, y por eso el argumento se escribe entre puntas de flecha en vez de entre paréntesis.

La posición de la declaración cambia según lo que se esté declarando, y esa asimetría desconcierta al principio. En una función, el bloque <T> va antes del nombre, porque el nombre ya pertenece a la firma que va a usar T. En una clase, va después, porque el nombre de la clase es lo que se está parametrizando. En una extensión conviven ambas reglas: la declaración precede al tipo receptor, que a su vez ya puede mencionar el parámetro.

fun <T> primeroOPorDefecto(lista: List<T>, alternativa: T): T =
    lista.firstOrNull() ?: alternativa

class Caja<T>(private var contenido: T) {
    fun leer(): T = contenido
    fun escribir(valor: T) { contenido = valor }
}

fun <T> Caja<T>.duplicar(): Pair<T, T> = leer() to leer()

El ámbito es la parte que se suele pasar por alto y la que produce los errores más opacos. Un parámetro declarado en la clase es visible en toda la clase: en las firmas de sus métodos, en los tipos de sus propiedades y en el cuerpo de unas y otras. Un parámetro declarado en una función solo es visible en esa función. La consecuencia incómoda aparece en el objeto acompañante, que pertenece a la clase pero no a ninguna instancia suya: como no hay instancia, no hay argumento de tipo, y el parámetro de la clase simplemente no existe allí dentro.

class Registro<T>(val elementos: List<T>) {
    companion object {
        // fun vacio(): Registro<T> = Registro(emptyList())  // error: T no existe aqui
        fun <T> vacio(): Registro<T> = Registro(emptyList()) // este T es OTRO parametro
    }
}

Que el segundo T sea un parámetro distinto que casualmente comparte nombre no es un tecnicismo: es la misma regla de sombreado que gobierna las variables locales, aplicada al mundo de los tipos.

Restricciones: el límite superior implícito y el explícito

Cuando se escribe <T> sin más, Kotlin no está diciendo que T pueda ser cualquier cosa arbitraria: está aplicando un límite superior implícito, y ese límite es Any?. La consecuencia práctica es inmediata y sorprende a casi todo el mundo la primera vez: T puede ser un tipo nulable, y por tanto dentro de la función no se puede llamar a ningún miembro sobre un valor de tipo T sin comprobación previa.

fun <T> describir(valor: T): String = valor.toString()   // valor puede ser nulo
fun <T : Any> describirNoNulo(valor: T): String = valor.toString()

describir(null)          // compila: T se infiere como Nothing?
// describirNoNulo(null) // no compila: el limite prohibe la nulabilidad

Escribir T : Any es, entonces, la forma canónica de exigir no nulabilidad, y no una redundancia. A partir de ahí, cualquier tipo puede servir de límite, y el ejercicio interesante es el límite recursivo, en el que el parámetro aparece dentro de su propia restricción. La firma <T : Comparable<T>> dice que T debe saber compararse consigo mismo y no con otra cosa, lo que impide comparar una fecha con un entero aunque ambos sean comparables por separado. Esta construcción tiene nombre propio en la literatura de sistemas de tipos, polimorfismo acotado por F, y es la única forma de expresar en una firma la idea de que un tipo se relaciona con su propia identidad.

fun <T : Comparable<T>> maximo(a: T, b: T): T = if (a > b) a else b

maximo(3, 9)                  // Int es Comparable<Int>
maximo("alfa", "beta")        // String es Comparable<String>
// maximo(3, "beta")          // no hay ningun T que satisfaga ambas cosas
💡
La restricción es un contrato de entrada, no un tipo de retorno

Una restricción declara lo que la función necesita saber sobre T para hacer su trabajo, no lo que devuelve. Si el cuerpo solo llama a toString, el límite correcto es Any y nada más; si además ordena, aparece Comparable. Restringir de más no protege a nadie: reduce el conjunto de tipos que pueden usar la función sin darle a la función ninguna capacidad que use. La firma mínima suficiente es también la más reutilizable.

Varias restricciones con where

Entre las puntas de flecha solo cabe un límite. En cuanto un parámetro deba cumplir dos condiciones a la vez, la sintaxis se traslada a una cláusula where situada después de la firma y antes del cuerpo. La regla estructural es la misma que en la herencia: de todos los límites, como mucho uno puede ser una clase, y el resto han de ser interfaces.

fun <T> volcarOrdenado(origen: Iterable<T>, destino: MutableList<String>)
    where T : CharSequence,
          T : Comparable<T> {
    origen.sorted().forEach { destino.add(it.toString()) }
}

class IndicePorClave<K, V>(private val extraer: (V) -> K)
    where K : Comparable<K>,
          V : Any

La cláusula sirve igualmente para clases e interfaces, y cuando hay varios parámetros de tipo permite restringir cada uno por separado sin que la firma se vuelva ilegible. Ese es su verdadero mérito: no añade poder expresivo respecto de escribir un límite en la posición corta, lo añade respecto de acumular límites, que en la posición corta es imposible.

flowchart TD
A[Necesito restringir un parametro de tipo] --> B{Cuantas condiciones}
B -- Una --> C[Escribela en las puntas de flecha]
B -- Varias --> D[Usa la clausula where]
D --> E{Alguna es una clase}
E -- Como mucho una --> F[Valido]
E -- Dos o mas --> G[Error: solo una clase, el resto interfaces]

De dónde salen los argumentos de tipo

Casi nunca se escriben, y por eso conviene saber exactamente de dónde vienen. El compilador dispone de tres fuentes y las consulta en conjunto, no en cascada. La primera son los tipos de los argumentos de la llamada: si a primeroOPorDefecto se le pasa una List<Int>, la ecuación se resuelve sola. La segunda es el tipo esperado en el punto donde se usa la expresión, que puede ser la anotación de una variable, el tipo de retorno de la función que la envuelve o el tipo del parámetro al que se pasa. La tercera es la escritura explícita, que sigue estando disponible y no es una derrota.

val a = primeroOPorDefecto(listOf(1, 2, 3), 0)   // T = Int, por los argumentos
val b: List<String> = emptyList()                // T = String, por el tipo esperado
val c = emptyList<String>()                      // T = String, escrito a mano
// val d = emptyList()                           // error: ninguna fuente aporta nada

Cuando los argumentos no coinciden en tipo, la inferencia no falla: busca el supertipo común más específico y lo usa. Ese comportamiento es cómodo y a la vez es el origen de firmas más laxas de lo que uno cree haber escrito, porque un listOf(1, "a") produce silenciosamente una List<Any> y a partir de ahí ninguna operación aritmética vuelve a estar disponible.

val mezcla = listOf(1, "a")            // List<Any>
val numeros = listOf<Number>(1, 2.5)   // List<Number>, fijado a mano
val vacia = mutableListOf<String>()    // sin argumentos no hay nada que inferir
🔷

Escribe el argumento cuando informe

Fijar el tipo a mano deja de ser ruido en cuanto la inferencia produciría algo más general de lo que quieres. listOf<Number>(1, 2) documenta una decisión; listOf(1, 2) documenta una casualidad.

🧭

El error de inferencia señala una fuente que falta

Ante un mensaje sobre información insuficiente, la pregunta no es qué tipo poner sino cuál de las tres fuentes está vacía. Casi siempre basta con anotar la variable receptora en lugar de la llamada.

Un genérico es una cuantificación universal, y la restricción es lo que le quitas de universal

Conviene ver la firma fun <T> identidad(x: T): T por lo que realmente afirma en lógica: para todo tipo T, dado un valor de T, existe un valor de T. La palabra decisiva es todo, porque implica que el cuerpo de la función no puede saber nada sobre T más allá de lo que el límite le concede, y por tanto no puede hacer nada con ese valor salvo devolverlo, ignorarlo o guardarlo. Esa ignorancia forzosa no es una carencia, es una garantía, y tiene una consecuencia célebre que Wadler formuló como teoremas gratuitos: de una firma suficientemente polimórfica se deducen propiedades del comportamiento sin haber leído una sola línea del cuerpo. Si una función tiene el tipo (List<T>) -> List<T> para todo T, ninguna implementación posible puede inventar elementos ni inspeccionar los que recibe, de modo que su resultado será siempre una permutación, un filtrado o una repetición de la entrada, y eso se sabe antes de abrir el archivo. Cada restricción que añades es un trozo de esa garantía que devuelves a cambio de poder: al escribir T : Comparable<T> ganas la capacidad de ordenar y pierdes la certeza de que el orden de salida sea independiente del contenido. Al escribir T : Any ganas poder llamar a toString sin comprobar nada y pierdes la posibilidad de que alguien te pase un nulo legítimo. Programar bien con genéricos consiste, en buena medida, en pedir el mínimo que el algoritmo necesita, porque cada requisito no usado es una restricción que le impones a tu llamador a cambio de nada. Y hay una advertencia final que el resto del nivel desarrollará: esta lectura lógica describe el sistema de tipos, no la máquina virtual. La cuantificación universal se comprueba en compilación y se borra después, de modo que las garantías son reales para quien lee el código y para quien lo compila, pero no existen ya en el objeto que corre. Reconocer dónde termina la promesa del sistema de tipos y empieza la realidad del runtime es exactamente el tema de la última lección.

⚔️ Haz hablar a la inferencia
  1. Escribe fun <T> describir(valor: T) y llámala con null. Anota qué tipo infirió el compilador y después añade el límite T : Any para ver qué cambia en el mensaje de error.
  2. Declara una clase genérica con un companion object e intenta usar el parámetro de la clase dentro del acompañante. Corrige el error y explica en una frase por qué el nuevo parámetro no es el mismo.
  3. Implementa maximo con la restricción T : Comparable<T> y razona qué llamada dejaría de compilar si la restricción fuera solo T : Any.
  4. Escribe una función con dos restricciones sobre el mismo parámetro usando where, e intenta después poner dos clases como límites para comprobar qué dice el compilador.
  5. Provoca a propósito un error de información insuficiente y arréglalo de las tres maneras posibles: anotando la variable, escribiendo el argumento de tipo y pasando un argumento que lo determine.