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

Escribir un contrato: la promesa que el compilador no verifica

Guía completa de la API experimental de contratos: dónde debe colocarse el bloque, qué restricciones impone el compilador sobre su forma, cómo se combinan returns con implies para reinyectar hechos en el llamante, qué significan exactamente los cuatro modos de invocación de callsInPlace y qué habilita cada uno, y por qué el diseño acepta la afirmación sin comprobarla, con las consecuencias que eso tiene para la disciplina de quien lo escribe.

⏱ 21 min

Declarar un contrato es asumir un papel que hasta ese momento pertenecía en exclusiva al compilador: el de garante de una afirmación sobre el programa. La sintaxis es breve, apenas una línea al comienzo del cuerpo, y esa brevedad esconde un desplazamiento de responsabilidad considerable. El compilador leerá lo que afirmas, lo propagará al llamante, concederá estrechamientos, permitirá inicializaciones y ajustará su análisis en consecuencia, y no comprobará ni una sola vez que el cuerpo de tu función haga realmente lo que la promesa dice. La API sigue marcada como experimental, y una parte de esa marca no obedece a inestabilidad técnica sino a la naturaleza misma del pacto. Escribir contratos es una de las pocas ocasiones en Kotlin en que el lenguaje deja de protegerte y te pide, a cambio de una capacidad real, una disciplina que solo tú puedes ejercer.

🎯 Al terminar esta lección sabrás
  • Escribir un contrato válido respetando las restricciones sintácticas de posición, forma y visibilidad que impone el compilador.
  • Combinar returns y returnsNotNull con implies para reinyectar hechos de nulidad y de tipo en el llamante.
  • Elegir con criterio entre los cuatro modos de invocación de callsInPlace y saber qué habilita cada uno.
  • Razonar sobre las consecuencias de una promesa falsa y adoptar la disciplina de verificación que el compilador no ejerce.

La forma exacta de la declaración

El bloque de contrato tiene que ser la primera instrucción del cuerpo de la función, antes de cualquier otra sentencia. La función debe tener cuerpo de bloque, no de expresión, y debe ser de nivel superior o miembro final: el compilador rechaza los contratos en funciones abiertas, abstractas y sobrescritas, porque una promesa que una subclase puede reescribir no es una promesa. Como la API es experimental, hace falta activarla explícitamente en la declaración o en la configuración del módulo.

import kotlin.contracts.ExperimentalContracts
import kotlin.contracts.InvocationKind
import kotlin.contracts.contract

@OptIn(ExperimentalContracts::class)
fun esIdentificadorValido(t: String?): Boolean {
    contract { returns(true) implies (t != null) }
    return t != null && t.isNotEmpty() && t.all { it.isLetterOrDigit() }
}

Las condiciones que pueden aparecer a la derecha de implies están muy restringidas, y esa restricción es deliberada. Solo se admiten comparaciones con nulo, comprobaciones de tipo con is, referencias a parámetros booleanos y combinaciones de todo lo anterior con negación, conjunción y disyunción. No se admiten llamadas a funciones, accesos a propiedades ni comparaciones aritméticas, porque el análisis de flujo no sabría qué hacer con hechos que no puede representar como pertenencia a un tipo. Los parámetros mencionados deben serlo directamente, no a través de una expresión derivada.

@OptIn(ExperimentalContracts::class)
fun <T : Any> comprobarTipo(v: Any?): Boolean {
    contract { returns(true) implies (v is String) }   // valido: comprobacion de tipo
    return v is String
}

// contract { returns(true) implies (v.length > 0) }   // invalido: acceso y aritmetica

Las cláusulas de retorno y sus tres formas

La familia de cláusulas sobre el retorno tiene tres variantes y conviene distinguirlas con precisión, porque se eligen mal con frecuencia. La primera afirma que si la función devuelve un valor concreto, cierto de entre los tres literales admitidos, entonces la condición es cierta. La segunda, sin argumento, afirma que si la función devuelve normalmente, es decir, sin lanzar, entonces la condición es cierta. La tercera afirma lo mismo pero condicionado a que el retorno no sea nulo.

@OptIn(ExperimentalContracts::class)
fun exigirActivo(s: Sesion?) {
    contract { returns() implies (s != null) }    // si vuelvo, es que no era nulo
    if (s == null || !s.activa) error("sesion no utilizable")
}

@OptIn(ExperimentalContracts::class)
fun normalizar(t: String?): String? {
    contract { returnsNotNull() implies (t != null) }
    return t?.trim()?.takeIf { it.isNotEmpty() }
}

fun uso(s: Sesion?, t: String?) {
    exigirActivo(s)
    println(s.token)                       // estrechado tras la validacion

    val n = normalizar(t)
    if (n != null) println(t.length)       // estrechado por la tercera forma
}

Un contrato puede declarar varias cláusulas separadas por saltos de línea, y el compilador las aplicará todas. La combinación más útil es afirmar simultáneamente sobre las dos ramas de un booleano, que permite que tanto el if como su else estrechen.

@OptIn(ExperimentalContracts::class)
fun estaAusente(t: String?): Boolean {
    contract {
        returns(true) implies (t == null)
        returns(false) implies (t != null)
    }
    return t == null
}

Invocación en el sitio y sus cuatro modos

La segunda familia describe cómo la función usa una lambda que recibe. No habla de valores sino de ejecución, y su efecto sobre el llamante es distinto: en lugar de estrechar tipos, permite que el análisis trate el cuerpo de la lambda como si estuviera escrito en el punto de llamada. Eso habilita tres cosas concretas: inicializar un val declarado fuera, conservar dentro del bloque los hechos demostrados fuera, y evitar que el compilador considere la lambda como una posible fuente de escrituras diferidas.

@OptIn(ExperimentalContracts::class)
inline fun <T> medir(etiqueta: String, bloque: () -> T): T {
    contract { callsInPlace(bloque, InvocationKind.EXACTLY_ONCE) }
    val inicio = System.nanoTime()
    try {
        return bloque()
    } finally {
        println(etiqueta + " " + (System.nanoTime() - inicio))
    }
}

fun cargar(): Datos {
    val d: Datos                       // sin inicializar
    medir("carga") { d = leerDatos() } // el contrato garantiza una unica asignacion
    return d
}
flowchart TD
A[callsInPlace con un modo] --> B[EXACTLY_ONCE]
A --> C[AT_MOST_ONCE]
A --> D[AT_LEAST_ONCE]
A --> E[UNKNOWN]
B --> F[Permite inicializar un val externo y propagar hechos]
C --> G[No permite inicializar val: puede no ejecutarse nunca]
D --> H[No permite inicializar val: podria asignar dos veces]
E --> I[Solo evita la captura diferida: sin garantias de conteo]

Los cuatro modos deben elegirse por lo que la función hace de verdad, no por lo que resultaría cómodo. Declarar exactamente una vez en una función que a veces no invoca la lambda permite al llamante inicializar un valor que quedará sin asignar, y ese valor será leído después como si estuviera inicializado. Declarar como máximo una vez en una función que invoca en un bucle permite al llamante razonar sobre una única asignación cuando habrá muchas. El modo desconocido es el más honesto cuando la lambda se almacena o se pasa a otro sitio, pero entonces conviene preguntarse si el contrato aporta algo.

Hay un segundo efecto de esta familia que se aprovecha poco y que resulta muy útil en funciones de utilidad propias: los hechos demostrados antes de la llamada sobreviven dentro del bloque cuando el modo garantiza la ejecución en el sitio. Sin contrato, el compilador debe suponer que la lambda podría ejecutarse más tarde, en otro momento y quizá en otro hilo, y por tanto invalida todo lo que dependa de valores que puedan haber cambiado.

@OptIn(ExperimentalContracts::class)
inline fun <T> reintentar(veces: Int, bloque: () -> T): T {
    contract { callsInPlace(bloque, InvocationKind.AT_LEAST_ONCE) }
    var ultimo: Throwable? = null
    repeat(veces) {
        try { return bloque() } catch (e: Throwable) { ultimo = e }
    }
    throw ultimo ?: IllegalStateException("sin intentos")
}

Nótese que aquí el modo correcto es al menos una vez y no exactamente una vez, porque el bloque puede ejecutarse varias veces si falla. Declarar el modo más fuerte sería cómodo para el llamante y falso, y esa combinación es precisamente la que produce los fallos más difíciles de rastrear.

⚠️
El compilador acepta tu promesa sin leerla contra el cuerpo

No existe ninguna comprobación de coherencia entre lo que afirma el contrato y lo que hace la función. Puedes declarar que un retorno verdadero implica no nulidad y devolver verdadero para un argumento nulo; compilará, el llamante omitirá la comprobación y el fallo aparecerá lejos del punto donde está el error. Un contrato falso es indistinguible de una aserción falsa, con el agravante de que su efecto se propaga a todo el código que llame a la función.

Los contratos son el punto donde Kotlin te presta su autoridad y te pide algo a cambio

Hay que apreciar lo insólito de este mecanismo dentro de un lenguaje cuya identidad entera se construyó sobre la idea de no fiarse. Kotlin nació diciendo que la nulidad debía estar en el tipo porque la disciplina del programador no basta, que la mutabilidad debía ser explícita porque la costumbre no basta, que la exhaustividad debía comprobarse porque la revisión no basta. Y sin embargo, en los contratos, hace exactamente lo contrario: acepta una afirmación sin prueba, la propaga por todo el programa y construye sobre ella conclusiones que después no revisa. La contradicción es solo aparente, y entenderla enseña algo sobre los límites reales de la verificación estática. Comprobar que el cuerpo de una función cumple una postcondición arbitraria es, en el caso general, un problema indecidible; hacerlo para el subconjunto que Kotlin admite sería posible en muchos casos pero exigiría un motor de razonamiento cuyo coste en tiempo de compilación nadie está dispuesto a pagar en cada tecla pulsada. Ante esa disyuntiva, el diseño elige la única salida practicable, que es delimitar con extremo cuidado qué se puede prometer, dejar la promesa en manos de quien escribe la función y concentrar el riesgo en un lugar minúsculo y perfectamente localizable, que son las dos o tres líneas de un bloque de contrato que además está marcado como experimental para que nadie lo escriba por descuido. Visto así, el mecanismo no es una grieta en la filosofía del lenguaje sino su aplicación consecuente: si no se puede verificar, al menos que se declare, que se declare en un sitio visible, que se declare con un vocabulario tan restringido que sea difícil equivocarse y que quien lo declare sepa que está firmando. Esa es también la razón por la que un contrato debería ir siempre acompañado de una prueba que ejercite precisamente la condición prometida: no porque el compilador la exija, sino porque el compilador ha renunciado a exigirla y alguien tiene que ocupar ese hueco. Cuando escribes un contrato dejas de ser usuario del sistema de tipos y pasas a ser, por unas líneas, parte de él.

⚔️ Firma y verifica
  1. Escribe una función de validación de dominio propia con un contrato de la primera forma y confirma en el llamante que el estrechamiento aparece.
  2. Declara las dos cláusulas complementarias sobre un mismo booleano y comprueba que estrechan tanto la rama afirmativa como la negativa.
  3. Implementa una función de medición con callsInPlace en modo exactamente una vez y úsala para inicializar un val externo.
  4. Cambia deliberadamente ese modo a como máximo una vez y traduce el mensaje del compilador al razonamiento sobre asignaciones.
  5. Escribe un contrato falso, verifica que compila, provoca el fallo en el llamante y escribe después la prueba que lo habría detectado.