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

El compilador como aliado: valores ignorados y contexto

Kotlin 2.3 amplió en dos direcciones lo que el compilador deduce por su cuenta: la detección de valores de retorno no usados, que convierte en aviso una familia entera de errores silenciosos alrededor de las operaciones inmutables, y una resolución sensible al contexto más completa, que permite omitir el calificador cuando el tipo esperado ya lo determina. Esta lección explica ambos mecanismos, los conecta con el análisis de flujo estudiado en el nivel y cierra con el hábito de escribir código que la deducción pueda seguir.

⏱ 20 min

Los cuatro capítulos anteriores describieron un compilador que deduce hechos y otro que acepta promesas. Queda una tercera relación, menos estudiada y probablemente la más rentable a diario: la del compilador que, sin que nadie se lo pida, señala construcciones cuya forma delata una intención incumplida. Kotlin 2.3 dio dos pasos grandes en esa dirección. El primero atiende a un error tan antiguo como las API inmutables, que consiste en llamar a una operación que devuelve un resultado nuevo y descartarlo creyendo que modificó algo. El segundo va en sentido opuesto y quita ruido en lugar de añadir avisos: permite omitir el calificador de un miembro cuando el tipo esperado en ese punto ya lo determina sin ambigüedad. Uno vigila y el otro simplifica, pero ambos proceden de la misma capacidad, que es la de saber en cada punto del programa qué se espera exactamente allí.

🎯 Al terminar esta lección sabrás
  • Explicar qué detecta el análisis de valores de retorno no usados y qué familia de errores elimina.
  • Reconocer las construcciones legítimas que descartan un valor y expresar la intención de forma que no genere ruido.
  • Usar la resolución sensible al contexto para omitir calificadores redundantes sin perder claridad.
  • Adoptar los hábitos de escritura que amplían lo que el análisis de flujo puede demostrar sobre tu código.

Valores de retorno que nadie recoge

Existe una asimetría desagradable en cualquier librería que combine operaciones con efecto y operaciones puras: unas modifican el receptor y devuelven nada, otras dejan el receptor intacto y devuelven un resultado nuevo. Ambas se llaman igual en el código, y descartar el resultado de las segundas produce un programa que compila, no lanza nada y simplemente no hace lo que aparenta.

fun limpiar(nombre: String): String {
    nombre.trim()          // el resultado se pierde: la cadena es inmutable
    return nombre
}

fun ordenar(datos: List<Int>): List<Int> {
    datos.sorted()         // devuelve una lista nueva que nadie recoge
    return datos
}

Estos fallos son especialmente crueles porque el código se lee bien y las pruebas superficiales pasan. La detección de valores de retorno no usados introducida en Kotlin 2.3 los convierte en un aviso del compilador: cuando una expresión de llamada devuelve un valor que no se asigna, no se retorna y no se usa como condición ni como argumento, el análisis lo señala. La regla no se aplica indiscriminadamente, porque hay funciones cuyo valor de retorno es legítimamente prescindible; la característica se activa de forma explícita en la configuración del compilador, y conviene hacerlo módulo a módulo para poder ir limpiando los avisos sin bloquear la construcción.

Hay dos maneras de convivir con el aviso cuando el descarte es deliberado, y ambas comunican algo al lector. La primera es asignar a una variable de descarte, que deja constancia de que el valor se conocía y se ignoró. La segunda es reconocer que la llamada sobra y eliminarla, que suele ser el desenlace más frecuente en cuanto uno mira el caso con atención.

fun limpiar(nombre: String): String = nombre.trim()   // lo que se queria escribir

fun registrar(cola: Cola<Tarea>) {
    val descartado = cola.poll()   // descarte explicito y con nombre
}
flowchart TD
A[Llamada cuyo resultado no se usa] --> B{La funcion tiene efecto sobre el receptor}
B -->|Si| C[La llamada es correcta: el efecto es el objetivo]
B -->|No| D[Sospecha: el resultado era el objetivo]
D --> E{El descarte es deliberado}
E -->|Si| F[Asigna a una variable de descarte con nombre]
E -->|No| G[Recoge el valor o elimina la llamada]

Conviene notar la relación de esta comprobación con el resto del nivel. Igual que el análisis de flujo demuestra hechos sobre valores y los contratos declaran hechos sobre funciones, esta detección razona sobre el destino de un valor dentro del grafo de la función. Es el mismo aparato aplicado a otra pregunta: en lugar de qué se sabe sobre este valor, adónde va este valor.

Resolución sensible al contexto

El segundo avance corrige un exceso de ceremonia que el propio sistema de tipos hacía innecesario. Cuando el compilador conoce el tipo esperado en una posición concreta, el nombre del miembro basta para identificarlo sin repetir el nombre del tipo. Esto ya funcionaba dentro de un when sobre un valor de tipo enumerado, y Kotlin 2.3 lo amplió a más posiciones: argumentos de llamada, valores de retorno, asignaciones, comparaciones y anotaciones, tanto para entradas de enumerados como para miembros de objetos anidados en clases selladas.

enum class Estado { PENDIENTE, ACTIVO, CERRADO }

fun transicion(desde: Estado): Estado = when (desde) {
    PENDIENTE -> ACTIVO      // sin calificar: el tipo esperado ya lo determina
    ACTIVO -> CERRADO
    CERRADO -> CERRADO
}

fun aplicar(e: Estado) { /* ... */ }

fun demo() {
    aplicar(ACTIVO)                  // argumento: el parametro fija el tipo
    val actual: Estado = PENDIENTE   // asignacion con tipo declarado
    if (actual == PENDIENTE) return  // comparacion contra un valor tipado
}

La resolución sigue un orden estricto y sin sorpresas: lo que esté en el ámbito local por declaración o importación gana siempre, y solo si el nombre no se resuelve así se consulta el ámbito derivado del tipo esperado. Eso significa que la característica no puede cambiar el significado de código que ya compilaba, lo cual era condición indispensable para introducirla.

El caso de las jerarquías selladas es donde más se nota el ahorro, porque los nombres de los subtipos suelen ser largos y el calificador los duplica en cada rama de un when que ya declara sobre qué valor discrimina.

sealed interface Resultado {
    data class Exito(val valor: String) : Resultado
    data class Fallo(val causa: String) : Resultado
}

fun describir(r: Resultado): String = when (r) {
    is Exito -> r.valor           // sin repetir el nombre de la interfaz
    is Fallo -> "error " + r.causa
}

El juicio sobre cuándo usarla es de legibilidad. Dentro de un when sobre un valor claramente tipado, el calificador repetido es puro ruido y omitirlo mejora la lectura. En un argumento de una llamada cuyo parámetro no se ve, un nombre suelto como el de una entrada de enumerado puede resultar críptico para quien lee sin editor. La regla práctica es omitir cuando el tipo esperado está visible a pocas líneas y conservar el calificador cuando no lo está.

🔮

Vigilancia

La detección de valores no usados convierte en aviso una familia entera de errores silenciosos alrededor de las operaciones inmutables. Actívala por módulo y limpia los avisos como deuda.

🧭

Economía

La resolución sensible al contexto elimina calificadores que el tipo esperado ya determina, sin cambiar jamás el significado de código existente.

📐

Continuidad

Ambas capacidades proceden del mismo aparato de análisis que concede los smart casts. Cuanto más nítido es el flujo de tu código, más aportan.

Escribir para que el análisis te siga

Todo lo estudiado en el nivel converge en un conjunto pequeño de hábitos que amplían de forma sistemática lo que el compilador puede demostrar. Prefiere la guarda con salida temprana al bloque anidado, porque poda aristas del grafo y conserva los hechos. Prefiere el valor inmutable al mutable, porque solo lo estable sostiene una demostración. Copia a un val local cualquier propiedad que vayas a comprobar y usar, porque cierra la ventana entre ambas lecturas. Mantén las funciones cortas, porque un grafo pequeño es un grafo del que se deduce más. Y declara un contrato cuando extraigas una comprobación a una función propia, para no pagar la abstracción con una pérdida de información en todos los llamantes.

// version que el analisis apenas puede seguir
fun procesar(p: Pedido?): String {
    if (p != null) {
        if (p.lineas.isNotEmpty()) {
            return p.referencia ?: "sin referencia"
        }
    }
    return "vacio"
}

// version que el analisis atraviesa de arriba abajo
fun procesarClaro(p: Pedido?): String {
    val pedido = p ?: return "vacio"
    if (pedido.lineas.isEmpty()) return "vacio"
    return pedido.referencia ?: "sin referencia"
}

Las dos versiones hacen lo mismo y compilan igual, pero no son equivalentes desde el punto de vista de lo demostrable. En la primera, cada hecho vive dentro de un bloque y muere al cerrarlo; cualquier línea añadida al final de la función trabajará con el tipo original. En la segunda, cada guarda poda una arista y los hechos se acumulan hasta el final, de modo que el código que se añada mañana heredará todo lo probado hasta allí. La diferencia no se nota el día que se escribe, sino el día que se modifica.

Queda un hábito más, y es el de tratar la denegación como información sobre el diseño y no solo sobre la línea. Cuando el mismo valor obliga a la misma guarda en cinco métodos de una clase, el problema no está en las cinco guardas sino en que el estado de la clase permite una combinación que ningún método quiere manejar. Cuando una comprobación extraída obliga a repetir la condición en todos sus llamantes, lo que falta es un contrato. Y cuando una propiedad pública abierta retira el estrechamiento a todo el mundo, conviene preguntarse si esa apertura se decidió o simplemente se heredó de una plantilla.

📝
El orden de adopción importa

Si vas a incorporar todo esto a un proyecto existente, el orden que menos fricción produce es empezar por las guardas y las copias locales, que no cambian la API; seguir por los contratos en las funciones de validación propias, que eliminan aserciones en cascada; y dejar para el final la activación de los avisos nuevos, cuando el código ya está lo bastante nítido como para que la oleada resulte manejable.

💡
El aviso de hoy es la incidencia que no tendrás

Activar comprobaciones nuevas siempre produce una oleada inicial de avisos que tienta a desactivarlas. Merece la pena resistir esa tentación durante una tarde: en la experiencia de casi todos los equipos, una fracción pequeña pero no nula de los avisos de valor no usado resulta ser un error real y silencioso que llevaba tiempo en producción.

La dirección del lenguaje es clara: mover al compilador todo lo que hoy soporta la revisión humana

Si se contempla la trayectoria completa que traza este nivel, aparece una intención de diseño coherente que va mucho más allá de cada característica suelta. El análisis de flujo tomó una parte del razonamiento que antes hacía el programador de cabeza y la volvió mecánica. Los contratos tomaron la información que la abstracción destruía y la volvieron declarable. La detección de valores no usados tomó una clase de error que solo la revisión atenta detectaba y la volvió automática. La resolución sensible al contexto tomó una ceremonia que existía únicamente porque el compilador no quería deducir y la volvió innecesaria. Los cuatro movimientos son el mismo movimiento repetido: identificar algo que hoy depende de la disciplina, la memoria o la suerte de una persona, y trasladarlo a un lugar donde se comprueba siempre, gratis y sin cansancio. Esa es la apuesta central de Kotlin y explica decisiones que sin ella parecerían caprichosas, desde la nulidad en el tipo hasta la exhaustividad obligatoria del when. Vale la pena extraer de aquí una consecuencia práctica para el modo de trabajar. Cuando aparece un aviso o un error del compilador, la reacción instintiva es preguntarse cómo silenciarlo, y esa pregunta es casi siempre la equivocada. La pregunta útil es qué sabe el compilador que yo no sé, porque la respuesta suele ser un camino de ejecución que no habías considerado, una escritura que ocurre en un intervalo que dabas por cerrado o un valor que creías estar modificando y solo estabas calculando. También conviene invertir la relación de vez en cuando y preguntarse qué le falta al compilador para poder ayudarte: si una función es demasiado larga, si un valor debería ser inmutable, si una comprobación extraída necesita un contrato, si una propiedad pública debería dejar de ser abierta. Escribir código que el análisis pueda seguir no es escribir para complacer a una herramienta; es escribir de forma que las propiedades que crees que tu programa tiene sean propiedades que alguien, incansablemente y en cada compilación, esté comprobando por ti.

⚔️ Deja que te revise
  1. Activa la detección de valores de retorno no usados en un módulo tuyo, clasifica los avisos en errores reales y descartes legítimos, y anota la proporción.
  2. Busca en tu código tres llamadas a operaciones inmutables cuyo resultado se descarte y decide para cada una si sobra la llamada o falta la asignación.
  3. Reescribe un when sobre un enumerado omitiendo los calificadores y valora si la lectura mejora o empeora en cada posición.
  4. Toma la función anidada del último ejemplo, conviértela en la versión con guardas y señala qué aristas del grafo desaparecieron.
  5. Elige una comprobación extraída a función propia, dótala de contrato y comprueba cuántos operadores de aserción desaparecen en sus llamantes.