wandres.dev
ERRORES · sin excepciones comprobadas

try como expresión, finally y qué es excepcional de verdad

En Kotlin la construcción de captura produce un valor, y esa diferencia aparentemente cosmética reorganiza el código alrededor de la asignación en lugar de alrededor de la mutación. La lección estudia la forma expresión y su inferencia de tipos, las garantías reales y las trampas del bloque final, el idioma de adquisición y liberación que lo sustituye, y el criterio para decidir qué merece el canal de excepciones y qué es simplemente un resultado previsible del dominio.

⏱ 17 min

La primera vez que uno descubre que en Kotlin la captura de excepciones devuelve un valor tiende a archivarlo como una comodidad menor, del mismo cajón que la interpolación de cadenas. Es un error de apreciación. Cuando la captura es una expresión, el resultado del intento y el resultado del respaldo tienen que ser dos ramas del mismo tipo, y esa exigencia empuja a escribir el respaldo en el mismo sitio donde está el intento, en lugar de declarar una variable mutable arriba y rellenarla desde ramas dispersas. La forma cambia y con ella cambia lo que el compilador puede comprobar. Pero la lección más importante de este nivel no es sintáctica sino de criterio: casi todo el mal uso de las excepciones en producción no proviene de escribir mal la captura, sino de usar el canal de excepciones para comunicar cosas que no son excepcionales en absoluto, y de esperar del bloque final garantías que ese bloque nunca prometió.

🎯 Al terminar esta lección sabrás
  • Escribir la captura como expresión, entendiendo cómo se infiere su tipo a partir de todas las ramas y qué papel juega Nothing en ellas.
  • Enumerar lo que el bloque final garantiza y lo que no, incluida la razón por la que retornar desde él es un defecto.
  • Sustituir la pareja de adquisición y liberación manual por el idioma de la biblioteca estándar en lugar de escribirla a mano.
  • Distinguir un fallo verdaderamente excepcional de un resultado previsible del dominio, y decidir el canal en consecuencia.

La captura produce un valor

En Java la construcción es una sentencia y por tanto no puede aparecer a la derecha de una asignación, lo que fuerza el patrón de declarar una variable no inicializada y asignarla dentro de las ramas. En Kotlin es una expresión: el valor es la última expresión del bloque que se haya ejecutado, sea el del intento o el de la captura correspondiente.

val puerto: Int = try {
    texto.toInt()
} catch (e: NumberFormatException) {
    8080
}

El tipo se infiere como el supertipo común de todas las ramas alcanzables, con una excepción importante: una rama cuyo cuerpo termina lanzando tiene tipo Nothing, y Nothing es subtipo de todo, así que no arrastra la inferencia hacia arriba. Esa es la razón por la que el ejemplo siguiente se tipa como Configuracion y no como algo más amplio, y es también la razón por la que una rama que relanza no obliga a nadie a declarar tipos comunes artificiales.

val config: Configuracion = try {
    parsear(fuente)
} catch (e: FormatoInvalido) {
    Configuracion.PorDefecto
} catch (e: IOException) {
    throw ArranqueFallido("no se pudo leer la configuracion", e)   // Nothing
}

Hay una carencia sintáctica que sorprende a quien viene de Java y que conviene conocer antes de tropezar con ella: Kotlin no tiene captura múltiple, es decir, no existe una rama que enumere varios tipos separados por una barra. Cuando dos fallos distintos deben tratarse igual, o se repiten las ramas, o se captura un supertipo común y se discrimina dentro con un when, que además vuelve a leerse como una expresión.

val valor = try {
    parsear(fuente)
} catch (e: Exception) {
    when (e) {
        is NumberFormatException, is DateTimeParseException -> PorDefecto
        else -> throw e          // lo que no reconozco, no lo retengo
    }
}

Nótese la última rama, que no es decorativa. Al capturar un supertipo para poder discriminar, uno se hace responsable de devolver a su camino todo lo que no pensaba tratar; olvidar esa rama convierte una comodidad sintáctica en la captura universal que este nivel entero desaconseja.

Conviene fijar una advertencia sobre el orden. Las ramas se prueban de arriba abajo y la primera compatible gana, de modo que capturar un supertipo antes que un subtipo deja a este último inalcanzable. Y capturar el supertipo de todas las excepciones, ya sea directamente o mediante el tipo raíz de la plataforma, atrapa también cosas que no son errores del programa sino señales del entorno, como veremos con la cancelación en la última lección de este nivel.

💡
La forma expresión empuja al respaldo correcto

Cuando la captura devuelve un valor, escribir un respaldo obliga a producir algo del mismo tipo justo ahí, y eso hace visible de inmediato si el respaldo es honesto. Si al escribirlo descubres que no tienes ningún valor razonable que devolver, acabas de recibir una señal de diseño: ese fallo no debía ser capturado en ese punto, sino propagado hacia una capa que sí sepa qué hacer con él.

El bloque final: lo que garantiza y lo que no

El bloque final se ejecuta tanto si el intento termina bien como si termina lanzando, y también cuando se abandona por un retorno. Su único cometido legítimo es deshacer lo que se adquirió: cerrar, liberar, restaurar, desbloquear. Todo lo demás que se le pide suele acabar mal.

La trampa clásica es escribir un retorno dentro de él. Un retorno en el bloque final descarta la excepción que estaba en vuelo y hace que la función devuelva con normalidad, de modo que un fallo real desaparece sin dejar rastro, sin registro y sin posibilidad de diagnóstico. Lo mismo ocurre, de forma más sutil, si el bloque final lanza su propia excepción: la nueva sustituye a la original y el motivo verdadero del problema se pierde.

// Defecto grave: el retorno se traga cualquier excepcion en vuelo
fun leer(): Int {
    try {
        return calcular()
    } finally {
        return -1        // el fallo desaparece sin dejar rastro
    }
}
flowchart TD
A[Bloque de intento] --> B{Lanza algo}
B -- No --> C[Valor del intento]
B -- Si --> D{Hay rama compatible}
D -- Si --> E[Valor de la rama de captura]
D -- No --> F[La excepcion sigue subiendo]
C --> G[Bloque final siempre se ejecuta]
E --> G
F --> G
G --> H{El bloque final retorna o lanza}
H -- Si --> I[Sustituye el resultado y oculta el fallo original]
H -- No --> J[Se conserva el resultado o la excepcion]

Hay además dos escenarios en los que ni siquiera se ejecuta, y conviene tenerlos presentes antes de apoyar en él ninguna garantía de corrección: la terminación abrupta del proceso y un error que impida al propio entorno seguir funcionando. El bloque final es una herramienta de limpieza ordenada, no un mecanismo transaccional.

En la práctica, casi todo el uso legítimo de la limpieza manual está cubierto por la biblioteca estándar. La función de uso cierra el recurso al terminar el bloque, propaga la excepción del cuerpo y, si el cierre también falla, adjunta ese segundo fallo como suprimido en lugar de dejar que sustituya al primero. Escribir la pareja a mano no solo es más largo: es más frágil.

File(ruta).bufferedReader().use { lector ->
    lector.readText()
}

Excepcional de verdad frente a resultado esperado

Aquí está el criterio que decide la calidad del manejo de errores de un proyecto entero, y no tiene nada que ver con la sintaxis. Una excepción es la herramienta adecuada cuando el fallo es infrecuente, cuando indica que una precondición del programa se ha roto y cuando el sitio que puede hacer algo al respecto está muy lejos del sitio donde se detecta. Un resultado del dominio es lo contrario: ocurre con normalidad, forma parte de la especificación de la operación y el invocador inmediato es quien debe decidir.

Un usuario que escribe mal su contraseña no es un suceso excepcional: es el funcionamiento normal de un formulario de acceso. Un identificador que no existe en la base de datos es una respuesta legítima de una búsqueda. Un texto que no representa un número es exactamente lo que se espera de una entrada libre. En los tres casos el uso de excepciones convierte un resultado ordinario en flujo de control invisible, caro y fácil de olvidar.

// Resultado esperado: el tipo lo dice y el compilador lo comprueba
fun buscarUsuario(id: Long): Usuario? = repositorio.encontrar(id)

// Fallo excepcional: la invariante del programa esta rota
fun conectar(url: String): Conexion =
    pool.abrir(url) ?: throw IllegalStateException("pool sin inicializar")
⚠️

Señal de excepción

Invariante rota, error de programación, indisponibilidad de infraestructura. Nadie cerca puede repararlo y el diagnóstico importa más que la recuperación.

📦

Señal de valor

Ausencia, validación fallida, entrada del usuario incorrecta, competencia por un recurso. Lo espera la especificación y el invocador decide.

⏱️

Señal de coste

Construir la traza de pila es caro. Si el fallo ocurre en un bucle caliente o por cada petición, el canal de excepciones es también una decisión de rendimiento.

Existe además una familia de excepciones que Kotlin lanza y que jamás deben capturarse para continuar, porque significan que el programa tiene un defecto. La biblioteca estándar ofrece tres funciones para producirlas y la elección entre ellas no es de estilo: comunica de quién es la culpa. La comprobación de argumentos culpa a quien llamó, la de estado culpa al objeto y a la secuencia de uso, y la de aserción culpa a una invariante interna que debería ser imposible de romper.

fun transferir(importe: Dinero, destino: Cuenta) {
    require(importe.esPositivo()) { "el importe debe ser positivo" }   // culpa del invocador
    check(estaAbierta) { "la sesion no esta abierta" }                 // culpa del estado
    val saldo = cache[destino.id] ?: error("cuenta sin cachear")       // invariante rota
}

El detalle relevante es que el mensaje va dentro de una lambda y por tanto solo se construye si la condición falla, de modo que interpolar información costosa no penaliza el camino correcto. Estas tres funciones están pensadas para fallar rápido y ruidosamente durante el desarrollo, no para dirigir el flujo en producción: capturarlas para continuar equivale a decidir que el programa siga ejecutándose sobre una suposición que acaba de demostrarse falsa.

La excepción es un canal de comunicación entre capas distantes, no un tipo de retorno con mala prensa

Merece la pena entender por qué la pregunta correcta no es si las excepciones son buenas o malas, sino qué canal está uno usando y quién escucha en el otro extremo. Una función tiene dos formas de comunicar hacia fuera: el valor de retorno, que habla exclusivamente con su invocador inmediato y aparece en la firma para que el compilador lo verifique, y la excepción, que atraviesa silenciosamente todos los marcos intermedios hasta encontrar a alguien dispuesto a escuchar. Esa diferencia no es de estilo: es una diferencia de destinatario. Cuando un fallo forma parte de la conversación con quien acaba de llamar, mandarlo por el canal que salta por encima de esa conversación es una equivocación de dirección, y todo lo que sigue después es consecuencia de ella: el invocador no está obligado a mirar, el tipo no comunica nada, la revisión no lo detecta y la lógica de recuperación acaba escrita en una capa que ya perdió el contexto para recuperarse. Cuando el fallo, en cambio, indica que alguna suposición del sistema se ha roto, el invocador inmediato es exactamente la persona equivocada a quien preguntar, y obligarle a decidir sería el error simétrico: ahí el canal que atraviesa capas es el correcto porque el único que puede responder es el que gobierna el proceso completo. La forma expresión y el bloque final son detalles de ergonomía dentro de ese esquema; lo que decide si un sistema es diagnosticable a las tres de la mañana es haber acertado, operación por operación, sobre quién es el destinatario del fallo. Las tres lecciones siguientes desarrollan las herramientas para el primer caso, pero ninguna de ellas sustituye a esta pregunta.

⚔️ Recoloca tus fallos
  1. Reescribe una asignación de tu código que use una variable mutable rellenada dentro de una captura, convirtiéndola en la forma expresión.
  2. Encuentra un bloque final con retorno o con una operación que pueda lanzar. Describe qué información se pierde exactamente en cada caso.
  3. Sustituye una pareja manual de apertura y cierre por la función de uso, y explica qué ocurre cuando fallan a la vez el cuerpo y el cierre.
  4. Lista cinco fallos de tu dominio y clasifícalos según su destinatario: el invocador inmediato o el gobernante del proceso. Justifica cada uno.
  5. Busca un sitio donde captures una excepción para devolver un valor por defecto y valora si la operación no debería haber devuelto un tipo nulable desde el principio.