wandres.dev
CONTRATOS Y SMART CASTS · lo que el compilador deduce

Los contratos de la stdlib: cómo la librería habla con el compilador

Una función ordinaria es una caja negra para el análisis de flujo: el compilador ve su firma y nada más. Un contrato es el canal por el que esa función devuelve al llamante hechos que el análisis puede usar. Esta lección diseca el mecanismo con el código real de la biblioteca estándar, desde isNullOrEmpty hasta require, checkNotNull y las funciones de ámbito, y enseña a leer los contratos de las fuentes para predecir dónde habrá estrechamiento antes de escribir una sola línea.

⏱ 20 min

Hay un momento en el aprendizaje de Kotlin en el que uno repara en una anomalía y no sabe explicarla. Escribir la comparación con nulo a mano concede el estrechamiento, como es de esperar; pero envolver exactamente la misma comparación en una función propia lo destruye, mientras que llamar a una función de la biblioteca estándar que hace lo mismo lo conserva intacto. Durante un tiempo parece magia reservada a la librería, un privilegio del que el código de usuario no participa. No lo es. Lo que hay es un mecanismo declarado, público y disponible para cualquiera, que permite a una función devolver al llamante no solo un valor sino también un hecho comprobable por el análisis de flujo. Entender cómo funciona ese canal es entender por qué la biblioteca estándar de Kotlin se lee como si fuera parte del lenguaje.

🎯 Al terminar esta lección sabrás
  • Explicar por qué una llamada a función corta la propagación de hechos y qué restituye exactamente un contrato.
  • Leer y traducir a lenguaje natural los contratos reales de isNullOrEmpty, require, checkNotNull y las funciones de ámbito.
  • Distinguir las dos familias de cláusulas, la que informa sobre el valor de retorno y la que informa sobre la invocación de una lambda.
  • Localizar los contratos en las fuentes de la biblioteca y usarlos para predecir dónde habrá estrechamiento.

Por qué una llamada corta la propagación

El análisis de flujo trabaja dentro de una función y trata cada llamada como una caja negra descrita únicamente por su firma. Si escribes una función auxiliar que comprueba la nulidad y devuelve un booleano, el compilador ve que devuelve Boolean y nada más; no relaciona ese booleano con el argumento, porque en general no puede.

fun estaVacio(t: String?): Boolean = t == null || t.isEmpty()

fun etiqueta(t: String?): String {
    if (estaVacio(t)) return "vacio"
    // return t.trim()   // denegado: el booleano no dice nada sobre t
    return t!!.trim()
}

Un contrato es la declaración explícita de esa relación perdida. Se escribe como primera instrucción del cuerpo, mediante una llamada a una función de la biblioteca que no genera código y cuyo único efecto es informar al compilador. La cláusula más común afirma que, cuando la función devuelve cierto valor, cierta condición sobre los argumentos es verdadera.

@OptIn(ExperimentalContracts::class)
fun estaVacio(t: String?): Boolean {
    contract { returns(false) implies (t != null) }
    return t == null || t.isEmpty()
}

fun etiqueta(t: String?): String {
    if (estaVacio(t)) return "vacio"
    return t.trim()   // ahora si: el contrato reinyecto el hecho
}

Léase con cuidado la dirección de la afirmación. El contrato no dice que devolver falso implique haber comprobado algo, sino que si devuelve falso entonces el argumento no es nulo. Es una implicación en un solo sentido: del valor de retorno hacia el hecho, nunca al revés. Y el compilador la aplica únicamente en la rama del if que corresponde a ese valor de retorno.

Anatomía de los contratos reales

La biblioteca estándar declara el suyo en las funciones que estarían condenadas a inutilizar el análisis. El de la comprobación de vacío es prácticamente idéntico al del ejemplo anterior, con la salvedad de que se aplica a un receptor de extensión y de que la implicación se expresa sobre ese receptor.

// forma equivalente a la de la biblioteca estandar
@OptIn(ExperimentalContracts::class)
fun CharSequence?.isNullOrEmpty(): Boolean {
    contract { returns(false) implies (this@isNullOrEmpty != null) }
    return this == null || this.length == 0
}

Las funciones de validación usan una variante donde la implicación no está condicionada a un valor de retorno concreto sino al mero hecho de haber retornado. Como require lanza una excepción cuando la condición falla, el simple hecho de que el control siga adelante ya demuestra que la condición era cierta, y eso es exactamente lo que declara.

// esquema de las validaciones de la biblioteca
contract { returns() implies (valor) }              // require, check
contract { returns() implies (valor != null) }      // requireNotNull, checkNotNull
contract { returnsNotNull() implies (t != null) }   // forma sobre el retorno

La segunda familia de cláusulas es distinta en naturaleza y mucho menos conocida. No habla del valor de retorno sino de cuántas veces y cuándo se invoca una lambda recibida como parámetro. Todas las funciones de ámbito la declaran, y de ahí procede una comodidad que casi nadie atribuye a su verdadera causa: la posibilidad de asignar un val dentro de un bloque run o apply y que el compilador acepte que quedó inicializado exactamente una vez.

// esquema de las funciones de ambito
@OptIn(ExperimentalContracts::class)
inline fun <T, R> T.let(bloque: (T) -> R): R {
    contract { callsInPlace(bloque, InvocationKind.EXACTLY_ONCE) }
    return bloque(this)
}

fun configurar(): Config {
    val destino: String
    run { destino = leerDestino() }   // valido gracias al contrato
    return Config(destino)
}
flowchart TD
A[Llamada a una funcion] --> B{Declara contrato}
B -->|No| C[Caja negra: solo cuenta la firma]
B -->|Si| D{Que clase de clausula}
D -->|returns implies| E[Reinyecta un hecho sobre los argumentos]
D -->|callsInPlace| F[Informa de cuando y cuantas veces corre la lambda]
E --> G[El analisis de flujo continua en el llamante]
F --> G

Leer las fuentes y predecir el comportamiento

La forma más rentable de dominar esta materia es abrir las fuentes de la biblioteca desde el editor y mirar las primeras líneas de las funciones que usas a diario. La navegación a la declaración muestra el cuerpo real, y el bloque de contrato es siempre lo primero. Ese hábito responde de un vistazo preguntas que de otro modo exigen prueba y error: si una comprobación conservará el estrechamiento, si una lambda puede inicializar un val externo, si una función de validación sirve como guarda.

El inventario merece conocerse por familias antes que por nombres. Las comprobaciones de vacío y de blanco declaran la implicación negativa sobre el receptor. Las validaciones que lanzan declaran la implicación sobre el retorno normal. Las funciones de ámbito declaran la invocación en el sitio, unas con exactamente una vez y otras con como máximo una vez cuando el receptor es nulable. Las funciones de recursos y de sincronización declaran también invocación en el sitio, lo que explica que dentro de ellas se pueda inicializar y retornar como si fueran sintaxis del lenguaje.

Ese conocimiento se traduce en predicciones inmediatas sobre el propio código. La primera es que una guarda escrita con una comprobación de la biblioteca estrecha igual que la comparación manual, de modo que no hay motivo para preferir la forma explícita por desconfianza. La segunda es que la elección de función de ámbito deja de ser cuestión de gusto en cuanto hay una inicialización de por medio, porque solo los modos que garantizan una ejecución sirven para ello.

fun etiquetar(t: String?): String {
    if (t.isNullOrBlank()) return "vacio"
    return t.trim()                 // estrechado por el contrato, sin comparar a mano
}

fun abrir(ruta: String): Contenido {
    val leido: String
    java.io.File(ruta).bufferedReader().use { leido = it.readText() }
    return Contenido(leido)         // valido: use declara invocacion en el sitio
}

La tercera predicción es negativa y conviene tenerla presente. Una función que no declara contrato no informa de nada, por evidente que resulte su cuerpo al leerlo: el compilador no mira dentro. Por eso una comprobación propia, por bien escrita que esté, deja al llamante en la misma situación que una llamada a una librería ajena, y por eso conviene revisar las funciones auxiliares de validación que se acumulan en cualquier proyecto maduro.

🔮

Implicación sobre el retorno

returns(valor) implies (condicion) y returnsNotNull() implies (condicion). Sirven para que una comprobación envuelta siga informando al análisis del llamante.

🧭

Implicación por retorno normal

returns() implies (condicion). Es la forma de las validaciones que lanzan: si el control continúa, la condición se cumple.

🔁

Invocación en el sitio

callsInPlace(lambda, modo). Permite inicializar valores externos, habilita el estrechamiento dentro del bloque y evita capturas innecesarias.

💡
Si tu función envuelve una comprobación, es candidata a contrato

La señal es fácil de reconocer: escribes una función que devuelve un booleano a partir de una comprobación sobre sus argumentos, y en el llamante te ves obligado a repetir la comprobación o a añadir una aserción. Esa duplicación es el síntoma exacto de un contrato que falta. Antes de escribirlo, comprueba si la biblioteca ya ofrece la función que necesitas, porque en el terreno de nulos, cadenas y colecciones casi siempre la ofrece.

Un contrato es la reparación de una fuga de información que el sistema de tipos no sabía taponar

Vale la pena mirar este mecanismo desde una altura mayor, porque explica una tensión central del diseño de lenguajes. Cuando extraes una comprobación a una función, ganas en nombre, en reutilización y en intención; pero pierdes algo que casi nunca se contabiliza, que es la información que el compilador tenía sobre el punto de llamada. La abstracción, que es la herramienta fundamental de la programación, es también un mecanismo de pérdida de información: al ocultar el cuerpo tras una firma, oculta con él todo lo que el cuerpo demostraba. En la mayoría de los lenguajes esa pérdida es total e irreparable, y el resultado es un fenómeno conocido por todos, que es que envolver una comprobación en una función bien nombrada empeora lo que el análisis estático puede hacer con el código. Se paga legibilidad con seguridad, y esa transacción es absurda. Kotlin resuelve la tensión ampliando lo que una firma puede transportar: además del tipo del retorno, puede transportar afirmaciones sobre la relación entre el retorno y los argumentos, y afirmaciones sobre cómo se usan las lambdas recibidas. Con eso, una función deja de ser una caja negra descrita por sus tipos y pasa a ser una caja parcialmente transparente descrita por sus tipos y sus promesas. Lo notable es la elección de qué promesas hacer expresables: no un lenguaje de especificación completo con precondiciones y postcondiciones arbitrarias, que sería costoso de verificar e imposible de comprobar automáticamente, sino un subconjunto minúsculo y deliberado que resulta ser exactamente el que necesita el análisis de flujo para no perder el hilo. Esa moderación es lo que hace que el mecanismo funcione en un compilador que debe terminar en segundos. Y explica por qué la biblioteca estándar de Kotlin da la impresión de estar hecha del mismo material que el lenguaje: no es que tenga privilegios, es que declara con precisión lo que hace, y el compilador la toma en serio. Cualquier librería puede hacer lo mismo, y las buenas lo hacen.

⚔️ Audita la biblioteca
  1. Navega desde tu editor a la declaración de isNullOrBlank, require y checkNotNull y traduce sus contratos a una frase en castellano cada uno.
  2. Escribe una función envolvente sin contrato, observa la denegación en el llamante, añade el contrato y confirma que desaparece.
  3. Comprueba con un experimento cuál de las funciones de ámbito permite inicializar un val declarado fuera y explica la diferencia por el modo de invocación declarado.
  4. Busca en tu propio código tres comprobaciones envueltas que hoy obligan a repetir la condición y anota cuál sería el contrato de cada una.
  5. Razona por qué la implicación va del valor de retorno hacia el hecho y nunca en sentido contrario, y qué se rompería si fuera bidireccional.