Sin excepciones comprobadas: la decisión de diseño
Java obligaba a declarar o capturar ciertos fallos y Kotlin retiró esa obligación por completo. Esta lección reconstruye la evidencia que llevó a la decisión, distingue con honestidad lo que se gana de lo que se pierde, explica por qué la recuperabilidad nunca fue una propiedad de la función que falla sino de quien la llama, y sitúa la anotación de propagación exactamente donde tiene sentido: en la frontera con Java y con las plataformas nativas.
Kotlin no eliminó las excepciones comprobadas por pereza ni por seguir una moda: las eliminó después de que el único lenguaje de masas que llegó a implementarlas tuviera dos décadas para demostrar que funcionaban, y el veredicto de esas dos décadas fue que el mecanismo producía más ruido que seguridad. Conviene enunciarlo con precisión, porque la frase de manual suena a simplificación y esconde una tesis bastante más interesante sobre dónde debe vivir la información de que algo puede fallar. La tesis es esta: el compilador puede verificar que una función declara un fallo, pero no puede verificar que quien la llama esté en condiciones de hacer algo útil con él, y una comprobación que solo alcanza la mitad del problema acaba satisfecha con gestos vacíos que además silencian el fallo de verdad. Esta lección examina la evidencia, mide sin adornos lo que se pierde al retirar el aviso del compilador, y explica qué sigue haciendo @Throws en un lenguaje que decidió no comprobar nada.
- Explicar qué garantía concreta prometían las excepciones comprobadas de Java y en qué punto exacto esa garantía se degradó en la práctica.
- Enumerar sin maquillaje lo que Kotlin gana y lo que renuncia al tratar toda excepción como no comprobada.
- Usar
@Throwspara que el código Kotlin siga siendo consumible desde Java y desde Swift sin sorpresas en la frontera. - Reconocer que la desaparición del aviso del compilador traslada la carga al diseño de tipos, a la revisión y a la documentación.
La garantía que Java quiso dar y por qué se rompió
La idea original era razonable y sigue siéndolo: si una operación puede fallar de forma previsible, ese fallo debería formar parte de su firma igual que forma parte el tipo de retorno, y el compilador debería negarse a compilar a quien lo ignore. Java implementó esa idea partiendo el árbol de excepciones en dos ramas, obligando a declarar o capturar las de una rama y dejando libre la otra. Ningún otro lenguaje de adopción masiva repitió el experimento, y esa unanimidad posterior no es casual.
Conviene recordar la mecánica exacta antes de criticarla, porque la crítica solo es justa si se dirige al mecanismo real. La rama libre está formada por los fallos que descienden del tipo de excepción en tiempo de ejecución y por los errores irrecuperables de la máquina; todo lo demás pertenece a la rama comprobada y obliga. La frontera, por tanto, no la traza ninguna propiedad del fallo: la traza la posición de la clase en una jerarquía de herencia decidida de antemano.
El primer problema es de categoría. Que un fallo sea recuperable no es una propiedad de la función que lo produce, sino de la que la llama. Una función de lectura de fichero no puede saber si su invocador tiene un plan alternativo, si está en un reintento, si está dentro de una tarea de fondo que puede abortarse o si está en un arranque donde cualquier fallo debe tumbar el proceso. Java pidió a quien escribe la función una decisión que solo puede tomar quien la usa, y el resultado previsible fue que la obligación cayera sobre gente sin contexto para cumplirla.
// La reacción más frecuente del programador obligado no es manejar el fallo:
try {
return leerFichero(ruta);
} catch (IOException e) {
throw new RuntimeException(e); // el aviso se convierte en ceremonia
}
El segundo problema es de composición. La cláusula de declaración forma parte del tipo del método, así que se propaga hacia arriba de forma viral y obliga a modificar firmas de capas que nada tienen que ver con el fallo. Peor aún, las interfaces funcionales de la biblioteca no declaran nada, de modo que en cuanto la lambda que se pasa a una operación de flujo necesita hacer entrada y salida, el sistema entero deja de encajar y hay que envolver, adaptar o rendirse.
// La interfaz funcional no declara nada, asi que esto no compila
Stream<String> textos = rutas.stream().map(ruta -> Files.readString(ruta));
El tercer problema es de evolución. Como la cláusula pertenece a la firma, añadir un fallo declarado nuevo a una función publicada rompe la compilación de todos sus consumidores, lo que convierte cualquier cambio interno en un cambio incompatible. El resultado perverso es que quien mantiene una biblioteca aprende a no declarar fallos nuevos jamás, y prefiere envolverlos en algo no comprobado precisamente para no molestar. El mecanismo termina desincentivando la sinceridad que pretendía imponer.
Cuando un mecanismo de seguridad genera un idioma estándar para desactivarlo, el mecanismo ha fracasado como mecanismo de seguridad. El bloque vacío y el reenvoltorio en una excepción no comprobada no son abusos de programadores descuidados: son la respuesta racional de alguien a quien se le exige tomar una decisión con información que no tiene. Kotlin no discute que sea bueno saber qué puede fallar; discute que el compilador sea el sitio donde imponerlo.
Lo que se gana y lo que se pierde
En Kotlin toda excepción es no comprobada. El compilador no obliga a capturar nada, y además ignora deliberadamente las cláusulas de declaración de los métodos Java que llama: puedes invocar un método que declara un fallo de entrada y salida sin escribir un solo try. El fallo sigue existiendo y sigue viajando por la pila exactamente igual; lo único que ha desaparecido es la verificación estática.
import java.io.File
// El método Java subyacente declara IOException. Aquí no hace falta nada.
fun cargar(ruta: String): String = File(ruta).readText()
flowchart TD
A[Metodo Java que declara IOException] --> B{Quien lo invoca}
B -- Codigo Java --> C[Obligado a capturar o a declarar]
B -- Codigo Kotlin --> D[Sin obligacion: la clausula se ignora]
C --> E[El fallo ocurre igual en tiempo de ejecucion]
D --> ELa ganancia es real y va mucho más allá de escribir menos. Las lambdas vuelven a componer sin fricción, porque ningún tipo función necesita declarar fallos; las abstracciones genéricas dejan de partirse en dos variantes; las corrutinas pueden propagar errores a través de fronteras de suspensión sin inventar firmas paralelas; y desaparece por completo el bloque escrito solo para callar al compilador, que era el sitio donde más errores se perdían de verdad.
La pérdida también es real y merece nombrarse sin defensa. Al leer una llamada ya no hay ninguna marca sintáctica que avise de que puede fallar, y la única fuente de verdad es la documentación, el código fuente o la experiencia. Cuando una refactorización introduce un fallo nuevo en una función profunda, nada avisa a los treinta sitios que la llaman; el aviso, si llega, llega en producción.
Conviene observar cómo responde a esa pérdida la propia biblioteca estándar, porque su respuesta anticipa todo el nivel. Para las operaciones que fallan de forma previsible ofrece pares de funciones: una que lanza y otra que devuelve nulo. La segunda no necesita ninguna comprobación especial del compilador, porque la posibilidad de fallo ya está en el tipo de retorno y el sistema de nulabilidad la verifica con las reglas ordinarias.
val a: Int = texto.toInt() // lanza si el formato es invalido
val b: Int? = texto.toIntOrNull() // el fallo esta en el tipo, comprobado
// El compilador obliga a tratar el caso, sin reglas especiales
val puerto = texto.toIntOrNull() ?: 8080
Lo que se gana
Composición limpia con lambdas, genéricos y corrutinas; firmas estables ante cambios internos; y la desaparición del bloque vacío escrito por obligación.
Lo que se pierde
El recordatorio automático en el punto de llamada y la detección estática de fallos nuevos introducidos por una refactorización lejana.
Lo que no cambia
El fallo sigue ahí. La diferencia es quién lo hace visible: antes el compilador con ruido, ahora el diseño del tipo de retorno y la revisión.
Ese par de funciones enseña dónde se ha mudado la comprobación. La versión que lanza sigue existiendo para el caso en que un formato inválido signifique de verdad que el programa está roto; la que devuelve un tipo nulable existe para el caso, mucho más frecuente, en que el texto venga de un usuario y un formato inválido sea el funcionamiento normal. Kotlin no eligió por ti: te dio las dos y trasladó la decisión al sitio donde se puede tomar con información, que es el punto de llamada.
El corolario práctico es incómodo pero clarificador: si retiras el aviso del compilador y no pones nada en su lugar, has empeorado la situación. Las tres lecciones siguientes tratan precisamente de con qué se sustituye, y la respuesta corta es que los fallos que de verdad importan al dominio no deberían viajar como excepciones sino como valores del tipo de retorno, donde el compilador vuelve a poder verificarlos sin ninguna regla especial.
@Throws y la frontera hacia otros lenguajes
Kotlin genera bytecode sin cláusula de propagación, y eso tiene una consecuencia visible para quien consume la biblioteca desde Java. Si un método Kotlin lanza un fallo de entrada y salida pero no lo declara, el compilador de Java rechazará un bloque que intente capturarlo, con el argumento de que esa excepción nunca se lanza en el bloque correspondiente. El consumidor queda atrapado entre un fallo que ocurre y un lenguaje que le niega el permiso para tratarlo.
La situación es más incómoda de lo que parece porque el fallo sí ocurre: simplemente el compilador de Java, que solo mira la firma declarada, ha decidido que no puede ocurrir. El consumidor acaba capturando el supertipo de todas las excepciones para poder tratar algo, con lo que la ausencia de una anotación en tu biblioteca ha provocado exactamente la captura demasiado ancha que el resto de este nivel intenta evitar.
Para eso existe @Throws. No cambia absolutamente nada del lado Kotlin: no obliga a nadie, no genera comprobaciones y no altera el flujo. Lo único que hace es emitir la cláusula en el bytecode para que el otro lado del puente la vea.
import java.io.File
import java.io.IOException
@Throws(IOException::class)
fun leerConfiguracion(ruta: String): String = File(ruta).readText()
Hay tres situaciones en las que la anotación deja de ser cortesía y pasa a ser obligatoria. La primera es implementar una interfaz Java cuyo método declara un fallo, porque la firma sobrescrita debe encajar. La segunda es cualquier biblioteca pensada para consumo desde Java, donde omitirla convierte el manejo correcto en un rodeo. La tercera es Kotlin nativo con destino a Swift y Objective-C, donde la anotación es lo que traduce el lanzamiento a la convención de error del anfitrión; sin ella, el fallo cruza la frontera como una terminación abrupta del proceso.
class Recurso : AutoCloseable {
// Sin la anotacion, la firma generada no declara nada y Java se queja
@Throws(Exception::class)
override fun close() { liberar() }
}
Es un error frecuente suponer que anotar una función obliga a sus invocadores Kotlin a capturar algo, o que activa alguna comprobación. No hace ni una cosa ni la otra: su efecto es exclusivamente sobre la firma generada para el otro lado del puente. Un invocador Kotlin de una función anotada compila exactamente igual que si la anotación no existiera.
Hay una consecuencia menos evidente y que afecta a las bibliotecas multiplataforma. Como la anotación describe tipos de la plataforma anfitriona, solo puede escribirse donde esos tipos existen, de modo que una declaración común no puede llevarla y hay que colocarla en la implementación específica de cada objetivo. Esa restricción empuja de forma natural hacia el diseño que este nivel defiende: el código común expresa sus fallos como valores, y las excepciones quedan confinadas en las implementaciones que tocan cada plataforma.
Para el consumo desde Kotlin, el sustituto de la anotación no es sintáctico sino documental. La etiqueta de documentación que describe los lanzamientos posibles no genera ninguna comprobación, pero sí aparece en la ayuda del editor y en la documentación publicada, que es el único canal que le queda a quien lee la firma. Escribirla en las funciones públicas que lanzan de forma deliberada no es una formalidad: es lo que queda del contrato después de retirar la verificación.
Vale la pena mirar esta decisión desde arriba, porque leída como una simple relajación resulta incomprensible en un lenguaje que dedicó su rasgo más famoso a que el compilador verifique la ausencia de nulo. Kotlin no cree menos que Java en la verificación estática de los fallos; cree que Java eligió mal el canal. Una excepción comprobada intenta añadir información al sistema de tipos por un mecanismo lateral que no participa de él: no se puede parametrizar, no aparece en el tipo de una lambda, no se compone bajo genéricos, no sobrevive a una abstracción y, sobre todo, no puede expresar la idea de que un valor sea válido o inválido porque vive en un canal aparte del valor. El resultado fue una verificación que solo funciona mientras la arquitectura es un árbol de llamadas directas y que se desmorona en cuanto aparece cualquier capa de indirección. Kotlin retira ese canal lateral y, a cambio, ofrece lo que Java no tenía cuando lo diseñó: tipos suma sellados con exhaustividad comprobada, nulabilidad en el tipo, genéricos que transportan cualquier información y funciones que devuelven valores compuestos con la misma facilidad con la que devuelven enteros. Cuando un fallo se modela como un caso de un tipo sellado y se consume en un when, el compilador vuelve a exigir que lo trates, pero ahora lo exige de forma composicional, sin propagación viral, sin idiomas para desactivarlo y sin pedir a la función que falla que adivine si su invocador puede recuperarse. La verdadera afirmación del lenguaje no es que los errores no merezcan comprobación, sino que un error importante merece ser un valor, y que lo que queda en el canal de excepciones es justamente lo que ninguna comprobación podía ayudar a resolver.
- Busca en un proyecto Java un bloque que capture un fallo y lo reenvuelva sin añadir información. Argumenta qué comprobó el compilador allí realmente.
- Escribe una función Kotlin que lea un fichero y consúmela desde una clase Java. Observa el error al intentar capturar, añade
@Throwsy compara. - Toma una función tuya que lance una excepción y lista quiénes la llaman. Estima cuántos sabrían del fallo sin abrir el código fuente.
- Explica por qué una interfaz funcional genérica no puede declarar fallos comprobados, y qué le ocurre a una biblioteca de flujos cuando lo intenta.
- Elige un fallo de tu dominio y decide si merece el canal de excepciones o el tipo de retorno. Justifica la respuesta pensando en quién llama, no en quién falla.