El análisis de flujo de datos: qué demuestra el compilador
Antes de conceder un smart cast, el compilador construye un grafo de control de la función y propaga sobre él un conjunto de afirmaciones acerca de cada valor. Esta lección describe ese mecanismo con precisión: qué es la información de flujo, cómo se estrecha en las ramas y cómo se une en los confluentes, por qué una llamada que devuelve Nothing poda aristas enteras, cómo se alcanza el punto fijo dentro de un bucle, y cuál es exactamente la frontera donde la demostración deja de sostenerse.
Hay una diferencia enorme entre creer y demostrar, y todo el comportamiento del compilador de Kotlin frente a los tipos estrechos se explica desde esa distinción. Cuando escribes una comprobación de nulidad y a continuación usas el valor sin operadores, no estás disfrutando de una concesión amable del lenguaje: estás cobrando el resultado de un análisis estático que ha recorrido la función entera, ha modelado sus caminos de ejecución posibles y ha concluido que en ese punto concreto no existe ningún camino por el que el valor pueda llegar siendo nulo. El análisis no adivina ni confía; deduce. Y como deduce, su alcance tiene un contorno exacto, definible y predecible. Quien conoce ese contorno deja de sorprenderse ante los errores de smart cast y empieza a leerlos como lo que realmente son, que es un informe sobre la estructura de su propio código.
- Describir el grafo de control de flujo sobre el que opera el análisis y qué representa cada nodo y cada arista.
- Explicar cómo se estrecha la información de flujo en las ramas y qué operación la combina en los puntos de confluencia.
- Justificar por qué una llamada que no retorna, una excepción o un
returntemprano amplían el alcance de una demostración. - Delimitar la frontera del análisis: qué hechos se propagan, cuáles se pierden y por qué esa pérdida es correcta.
El grafo de control es el terreno de la demostración
El frontend del compilador no razona sobre el texto que escribes sino sobre una representación intermedia en la que cada función se convierte en un grafo dirigido. Los nodos son fragmentos de código que se ejecutan sin bifurcaciones, y las aristas son las transferencias de control posibles entre ellos: una condición produce dos aristas salientes, un when produce tantas como ramas, un bucle produce una arista de retroceso hacia su cabecera, y una excepción produce aristas hacia los manejadores que puedan capturarla.
Sobre ese grafo el compilador transporta una estructura que podemos llamar información de flujo: una correspondencia entre valores estables y el conjunto de afirmaciones que se han demostrado sobre ellos hasta ese punto. Las afirmaciones son de dos clases, la pertenencia a un tipo y la distinción respecto de nulo, y ambas se acumulan.
fun describir(v: Any?): Int {
if (v == null) return 0
// aqui la informacion de flujo dice: v no es nulo
if (v !is CharSequence) return -1
// aqui dice ademas: v es CharSequence
return v.length
}
Lo importante de este ejemplo no es que funcione, sino por qué. En la tercera línea el tipo declarado de v sigue siendo Any? y no ha cambiado en absoluto; lo que ha cambiado es el conjunto de hechos asociados a ese valor en ese nodo del grafo. El tipo que el compilador usa para resolver length es el tipo intersección entre el declarado y todo lo demostrado, y ese tipo intersección existe únicamente en el análisis: no aparece en la firma, no se puede escribir a mano y desaparece en cuanto el control abandona la región donde se probó.
Estrechar en las ramas, unir en los confluentes
Cada arista que sale de una condición transporta una versión distinta de la información. La arista verdadera de una comparación con nulo añade la afirmación de no nulidad; la falsa añade la contraria. Lo mismo ocurre con una comprobación de tipo, con la particularidad de que la rama negativa también aporta un hecho útil, aunque menos frecuentemente aprovechable.
El momento delicado llega cuando dos o más aristas confluyen en un mismo nodo. Ahí el compilador no puede quedarse con la información de una rama, porque no sabe cuál se ejecutó; tiene que quedarse con lo que sea cierto viniendo de todas ellas. La operación es una intersección de conjuntos de hechos, y es la razón por la que un valor deja de estar estrechado justo después de cerrar un if.
flowchart TD A[Entrada: t declarado como String nulable] --> B[Condicion t distinto de nulo] B -->|arista verdadera| C[Hechos: t no es nulo] B -->|arista falsa| D[Hechos: t es nulo] C --> E[Confluencia tras el bloque] D --> E E --> F[Interseccion de hechos: no queda nada demostrado]
De esta operación se deduce inmediatamente el idioma más rentable de todo el lenguaje. Si una de las aristas que llegaría al confluente se elimina, la intersección se calcula sobre un solo camino y el hecho sobrevive. Eliminar una arista es exactamente lo que hace una salida temprana, y por eso la guarda inicial estrecha el resto de la función entera mientras que el bloque condicional solo estrecha su interior.
fun conBloque(t: String?) {
if (t != null) { println(t.length) }
// aqui t vuelve a ser String nulable: confluyeron dos caminos
}
fun conGuarda(t: String?) {
if (t == null) return
println(t.length) // el camino nulo no llega: el hecho sobrevive
}
El mismo argumento explica un fenómeno que suele aprenderse de memoria sin entenderlo. Una llamada cuyo tipo de retorno es Nothing no tiene arista saliente, porque por definición no devuelve el control; el compilador la trata como un final de camino igual que un return o un throw. Por eso error, TODO y cualquier función propia declarada con retorno Nothing funcionan como podadoras de aristas, y por eso el operador elvis combinado con una de ellas estrecha el resto del bloque. Los cortocircuitos de las conjunciones y disyunciones se explican del mismo modo, porque el lado derecho solo es alcanzable por una de las aristas y hereda su información.
Bucles, alias y el contorno exacto del análisis
Dentro de un bucle el grafo tiene un ciclo, y un ciclo impide calcular la información en una sola pasada: lo que vale en la cabecera depende de lo que vale al final del cuerpo, que a su vez depende de la cabecera. La solución clásica es iterar el cálculo hasta que deja de cambiar, es decir, hasta alcanzar un punto fijo. La consecuencia práctica es conservadora y conviene tenerla presente: un hecho demostrado antes del bucle sobrevive dentro solo si el cuerpo no puede invalidarlo, y una asignación en cualquier punto del cuerpo lo invalida para todas las vueltas, incluida la primera.
fun recorrer(entradas: List<String?>) {
var actual: String? = entradas.firstOrNull()
if (actual != null) {
for (e in entradas) {
// println(actual.length) // denegado: la asignacion de abajo cierra el ciclo
actual = e
}
}
}
El análisis también sigue la pista de los alias. Si copias un valor a un val local, los hechos demostrados sobre cualquiera de los dos se aplican a ambos mientras ambos sean estables, porque el compilador sabe que designan el mismo objeto. Esa propagación es la que hace que la copia local no sea un truco sino una construcción de primera clase dentro del razonamiento.
fun conAlias(v: Any?) {
val copia = v
if (copia is String) {
println(v.length) // v tambien queda estrechado: son el mismo valor
}
}
La reescritura del frontend amplió de forma notable el conjunto de hechos que sobreviven. Hoy la información atraviesa una variable booleana que guarda el resultado de una comprobación, cruza las disyunciones, entra en las lambdas de funciones en línea cuyo contrato garantiza la invocación en el sitio, sobrevive a bloques de captura de excepciones y tolera incrementos sobre variables numéricas. Buena parte de los rodeos que una generación de programadores aprendió a escribir por costumbre han dejado de hacer falta, y conviene revisarlos en lugar de arrastrarlos.
Lo que sí se propaga
Comprobaciones de nulidad y de tipo, negaciones, cortocircuitos, ramas de when, salidas por return, throw y Nothing, alias entre valores estables y booleanos que guardan el resultado de una comprobación.
Lo que corta la propagación
Toda confluencia con un camino que no comparte el hecho, toda asignación al valor analizado, todo acceso que pueda ejecutar código ajeno, y toda frontera de módulo donde el compilador no ve las escrituras posibles.
Lo que nunca se propaga
Relaciones aritméticas entre variables, invariantes de colecciones, igualdades estructurales entre objetos distintos y cualquier propiedad que no sea expresable como pertenencia a un tipo.
La última tarjeta merece un comentario, porque señala el límite de fondo. El análisis de flujo de Kotlin no es un demostrador de teoremas de propósito general: su red de afirmaciones está deliberadamente restringida a lo que el sistema de tipos puede expresar. Comprobar que un índice es menor que el tamaño de una lista no produce ningún hecho aprovechable, porque no existe un tipo que signifique índice válido de esta lista. Esa restricción no es una carencia sino una elección de ingeniería: mantiene el análisis decidible, rápido y, sobre todo, explicable a quien lo padece.
Leer una función como la lee el compilador
Conviene terminar con un ejercicio de lectura, porque el mecanismo se interioriza mejor recorriendo una función completa y anotando en cada línea qué se ha demostrado. El ejemplo siguiente reúne casi todos los elementos vistos: una guarda con elvis, un cortocircuito, una rama de when, una llamada que no retorna y una confluencia final.
fun resumir(entrada: Any?, respaldo: String?): String {
val v = entrada ?: respaldo ?: error("sin datos")
// v: Any, no nulo, porque las dos aristas nulas terminaron en Nothing
if (v is Int && v > 0) {
return "positivo " + v // v: Int en ambos lados de la conjuncion
}
return when (v) {
is CharSequence -> v.trim().toString() // v: CharSequence
is Collection<*> -> v.size.toString() // v: Collection
else -> v.toString() // v: Any, sin mas hechos
}
}
Merece la pena detenerse en la primera línea, porque concentra tres decisiones del análisis. El operador elvis introduce una bifurcación cuya arista nula conduce al segundo operando; el segundo elvis repite la maniobra; y la llamada final, cuyo tipo de retorno es Nothing, no tiene arista saliente. El resultado es que al confluir solo queda un camino vivo, y por eso el tipo de v es no nulable aunque ninguno de los tres operandos lo fuera. El compilador no ha aplicado ninguna regla especial del elvis: ha calculado la unión de los tipos de las aristas que efectivamente llegan.
La conjunción de la segunda comprobación ilustra el cortocircuito con un matiz que suele pasar inadvertido. El operando derecho solo se evalúa por la arista en la que el izquierdo fue cierto, de modo que hereda su información y puede comparar como entero sin conversión. Dentro del cuerpo del if el hecho persiste, porque ambas condiciones tuvieron que ser ciertas para llegar allí.
En el when final aparece la otra cara del mismo cálculo. Cada rama estrecha por separado, ninguna rama comparte hechos con las demás, y por eso la rama residual no dispone de nada más que el tipo demostrado en la primera línea. Si la función continuara después del when, la confluencia de las tres ramas dejaría de nuevo el tipo original, salvo por lo que las tres tuvieran en común.
El hábito que conviene adquirir es este: ante cualquier duda, escribe mentalmente al margen de cada línea el conjunto de hechos vigentes, y pregunta en el punto conflictivo qué arista llega allí sin ese hecho. La respuesta señala siempre o bien una confluencia evitable, o bien una escritura que el compilador no puede situar, y ambas cosas tienen remedio conocido.
Cuando el compilador deniega un estrechamiento, la pregunta productiva no es qué operador añadir, sino qué arista del grafo trae al punto de uso una versión del valor sin el hecho que necesitas. Casi siempre la respuesta es una confluencia evitable, y casi siempre el remedio es convertir el bloque condicional en una guarda con salida temprana o fijar el valor en un val local.
Conviene detenerse en lo que significa realmente que un lenguaje moderno dedique una parte sustancial de su frontend a demostrar hechos sobre valores individuales en puntos concretos del código. Durante décadas la comprobación de tipos fue una operación local: cada expresión tenía un tipo, ese tipo se derivaba de sus partes y el contexto apenas influía. Lo que introduce el análisis de flujo es una idea distinta y mucho más ambiciosa, que es que el tipo de una expresión depende del camino por el que se llegó hasta ella. Con eso, el compilador deja de ser un verificador de anotaciones y pasa a ser un lector del programa que sigue el mismo razonamiento que sigues tú cuando lees tu propia función: aquí ya comprobé que no era nulo, aquí ya descarté ese caso, aquí este valor solo puede ser de esta forma. La consecuencia profunda es que el compilador y el programador comparten por primera vez un modelo mental común, y por tanto pueden discrepar de manera informativa. Cuando el análisis concede lo que esperabas, confirma que tu razonamiento era correcto y te ahorra escribirlo. Cuando lo deniega, no está diciendo que no te entiende: está diciendo que ha encontrado un camino en tu grafo que tú no habías considerado, o una escritura que puede ocurrir en un intervalo que tú dabas por cerrado. Esa segunda situación es la valiosa, porque es literalmente un contraejemplo a una creencia tuya, entregado en el instante de escribir y sin coste en ejecución. Programar con esta idea interiorizada cambia el modo de estructurar el código: se prefieren las guardas tempranas a los bloques anidados no por estética sino porque podan aristas, se prefieren los valores inmutables no por dogma funcional sino porque sostienen hechos, y se prefieren las funciones cortas porque un grafo pequeño es un grafo del que se demuestra más. La ergonomía del lenguaje y el rigor del análisis resultan ser, sorprendentemente, la misma cosa vista desde dos lados.
- Coge una función tuya con varias condiciones anidadas y dibuja su grafo de control marcando en cada arista qué hechos transporta sobre un valor nulable concreto.
- Convierte un bloque condicional en una guarda con salida temprana y señala exactamente qué arista desapareció y qué intersección dejó de calcularse.
- Declara una función propia con retorno
Nothing, úsala tras un elvis y comprueba en el editor hasta dónde llega el estrechamiento resultante. - Escribe un bucle que asigne al valor analizado en su última línea y explica por qué la denegación afecta también a la primera vuelta.
- Busca en tu código una comprobación que no produzca ningún hecho aprovechable, como una comparación numérica, y razona qué tipo haría falta para que el análisis pudiera usarla.