El modelo de errores de Swift: propagación explícita, no excepciones
Por qué throw, try y catch comparten la sintaxis de las excepciones y casi nada de su semántica. La firma como contrato, try como marca de punto de salida, las cuatro categorías de fallo del documento de diseño y el coste real del mecanismo en la convención de llamada.
Tres palabras heredadas —throw, try, catch— han sostenido durante una década un malentendido persistente: que Swift tiene excepciones. No las tiene. Debajo de esa sintaxis familiar no hay tablas de desenrollado, ni búsqueda dinámica de manejadores, ni un coste escondido en llamadas que nadie marcó. Hay algo mucho más modesto y mucho más fácil de razonar: una función que puede devolver dos cosas distintas, un registro reservado en la convención de llamada, y una obligación sintáctica de escribir en el punto exacto donde tu control de flujo puede abandonar el ámbito. Entender esa diferencia no es erudición: cambia lo que escribes, dónde lo capturas y qué pagas por ello.
- Distinguir la propagación explícita de Swift del modelo de excepciones dinámicas de C++ o Java.
- Leer
throwscomo parte del tipo de la función ytrycomo marca de punto de salida, no de manejo. - Clasificar cualquier fallo en las cuatro categorías del documento de diseño y elegir el mecanismo que le corresponde.
- Estimar el coste real de lanzar, propagar y capturar, y por qué el camino feliz no lo paga.
Dos mecanismos con la misma sintaxis
En el modelo clásico de excepciones, cualquier llamada puede lanzar. La firma no lo dice —las excepciones comprobadas de Java lo intentaron y la comunidad las abandonó por razones que merecen su propio ensayo—, así que el lector no sabe qué expresiones pueden interrumpir el flujo. Cuando algo se lanza, el sistema recorre la pila consultando tablas generadas por el compilador hasta encontrar un manejador compatible, destruyendo por el camino todo lo que había vivo. Es un salto no local con búsqueda en ejecución.
Swift hace justo lo contrario en los tres ejes. La capacidad de fallar está en el tipo: () throws -> Int es un tipo distinto de () -> Int. La propagación es manual y de un solo marco: si llamas a algo que lanza y no lo capturas, tu propia función debe declararse throws; no hay herencia silenciosa del efecto. Y cada punto donde el flujo puede escaparse lleva una marca visible.
enum ErrorDePago: Error {
case saldoInsuficiente(faltan: Decimal)
case tarjetaCaducada
}
func cobrar(_ importe: Decimal) throws -> Recibo {
guard saldo >= importe else {
throw ErrorDePago.saldoInsuficiente(faltan: importe - saldo)
}
return Recibo(importe: importe)
}
La segunda propiedad —la ausencia de herencia silenciosa del efecto— es la que más cambia la forma de escribir. Cada función decide explícitamente si absorbe el fallo o lo pasa hacia arriba, y esa decisión queda escrita en su firma para que el siguiente lector no tenga que reconstruirla:
func confirmarPedido() throws -> Recibo { // decide propagar
try cobrar(total)
}
func confirmarPedidoSeguro() -> Recibo? { // decide absorber
try? cobrar(total)
}
Ninguna de las dos es mejor en abstracto; lo relevante es que la elección no puede omitirse. En un lenguaje con excepciones dinámicas, la primera versión sería el comportamiento por defecto de cualquier función que no hiciera nada, y el hecho de que propague no aparecería en ninguna parte del fuente.
Hay además un detalle de sistema de tipos que casi nadie nombra y que ordena todo lo demás: existe una relación de subtipado entre funciones. Una función que no lanza puede pasarse donde se espera una que lanza, porque prometer menos fallos es siempre seguro; lo contrario no. Esa regla es la que permite que la biblioteca estándar acepte clausuras lanzadoras sin obligarte a escribir siempre una.
flowchart LR A[Funcion no lanzadora] --> B[Funcion rethrows] B --> C[Funcion throws] A -.subtipo de.-> C style A fill:#a6e3a1,color:#11111b style B fill:#f9e2af,color:#11111b style C fill:#f38ba8,color:#11111b
Lo que try marca en realidad
El error de lectura más común es creer que try significa intentar o manejar aquí. No significa ninguna de las dos cosas. try es una anotación para el lector humano que dice: esta expresión puede abandonar el ámbito actual en este punto. No captura, no protege, no cambia la semántica. Si la borras, el programa no compila; si la escribes de más, el compilador avisa de que sobra.
La marca cubre la expresión entera, no una llamada concreta, y por eso try va delante y no pegado a cada invocación:
let total = try sumar(cobrar(100), cobrar(50)) // una sola marca, dos puntos de salida
Las tres variantes cambian qué ocurre con el error, nunca dónde puede ocurrir. try lo propaga al llamante. try? lo convierte en nil, descartando toda la información del fallo. try! afirma que no ocurrirá y detiene el programa si ocurre. Esa tercera forma no es un atajo perezoso: es una aserción de invariante, y como toda aserción pertenece únicamente a los casos en que el fallo sería un bug tuyo, no una circunstancia del mundo.
do {
let recibo = try cobrar(120)
registrar(recibo)
} catch ErrorDePago.saldoInsuficiente(let faltan) {
avisar("Faltan \(faltan)")
} catch {
// `error` existe siempre, con tipo `any Error`
registrarFallo(error)
}
Fíjate en la última cláusula. Sin tipos declarados en la firma, el compilador no sabe qué familia de errores puede llegar, así que exige una cláusula general para que el do sea exhaustivo. Esa obligación —y el hecho de que el tipo concreto solo se recupere por coincidencia de patrones— es exactamente el hueco que vino a llenar typed throws.
Merece atención un detalle del bloque anterior que suele pasar inadvertido: la variable error no se declara en ninguna parte. La cláusula general la introduce implícitamente con tipo any Error, y ese existencial es todo lo que el sistema de tipos te da; para saber qué llegó de verdad hay que interrogarlo con una conversión condicional o coincidir contra patrones concretos, con lo que la exhaustividad deja de estar verificada y pasa a ser una convicción del programador. Mientras tanto, defer sigue ejecutándose en la salida por error igual que en la normal, porque la limpieza se emite estáticamente en ambos caminos y no depende de ninguna búsqueda en ejecución.
Descartar el error es correcto cuando la ausencia de valor es toda la información que necesitas: una caché que no encuentra la entrada, una preferencia opcional que no está escrita. Deja de serlo en cuanto el fallo podría tener varias causas y alguna de ellas exigiría una respuesta distinta; ahí try? no simplifica, oculta. La señal de alarma es escribirlo y a continuación no poder explicar qué significa el nil que acabas de producir.
Las cuatro categorías de fallo
El documento de diseño original separa cuatro tipos de fallo, y elegir mal entre ellos produce más daño que cualquier detalle de sintaxis.
Errores simples de dominio
El fallo es una posibilidad obvia y sin matices: convertir un texto a número, buscar una clave. No merecen un tipo de error; devuelven nil o un opcional.
Errores recuperables
El mundo no cooperó y quien llama puede hacer algo distinto: reintentar, avisar, cambiar de ruta. Este es el territorio propio de throws.
Fallos de lógica
Un bug. Índice fuera de rango, invariante rota, nil donde prometiste que no lo habría. Corresponden a precondition y a la parada del programa.
Errores universales
Pueden ocurrir casi en cualquier instrucción: memoria agotada, desbordamiento de pila. Swift decide deliberadamente no modelarlos.
La confusión entre la segunda y la tercera categoría es el fallo de diseño más caro de esta lección. Lanzar un error para señalar un bug convierte una detención inmediata y localizada en una corrupción que viaja tres capas hacia arriba antes de manifestarse. Detener el programa por un fallo que el usuario podría corregir es todavía peor. La pregunta que resuelve el caso dudoso no es cuán grave es el fallo, sino: ¿puede quien llama hacer algo sensato al recibirlo? Si la respuesta honesta es no, no es un error recuperable.
El coste que se paga y el que no
Nada de esto se explica del todo hasta que se mira la convención de llamada. Cuando una función throws termina, deja el resultado donde siempre y, además, deja el error —o cero— en un registro reservado por la ABI para ese fin. El llamante, tras cada llamada marcada con try, comprueba ese registro y salta. Eso es todo el mecanismo: una comparación y un salto condicional por punto de propagación.
Las consecuencias son directas y contraintuitivas para quien viene de C++. Una función que no lanza no paga absolutamente nada, ni en tamaño de código ni en tiempo, porque no hay tabla que registrar ni marco que preparar. El camino sin error dentro de una función lanzadora paga un salto predicho casi siempre correctamente. Y el camino con error no es dramáticamente más caro que un retorno normal, en contraste con las excepciones clásicas, donde lanzar puede costar órdenes de magnitud más que retornar.
Queda una factura real, y conviene nombrarla porque explica la lección siguiente. El tipo any Error es un existencial y su representación implica una caja en el montón. Lanzar, en el caso general, asigna memoria. En código de aplicación es irrelevante; en un bucle apretado, en un intérprete o en Swift embebido sin asignador, deja de serlo.
De este análisis salen dos consejos que suenan contradictorios y no lo son. El primero: no evites lanzar por miedo al coste, porque el miedo lo trajiste de otro lenguaje y aquí no aplica; una función que devuelve un opcional para no declararse lanzadora suele estar destruyendo información valiosa a cambio de nada. El segundo: no uses el fallo como control de flujo ordinario, porque un mecanismo barato no es un mecanismo gratuito y, sobre todo, porque cada try que escribes es una afirmación semántica de que ahí puede ocurrir algo excepcional. Cuando lo excepcional ocurre en cada iteración, la afirmación es falsa y el lector deja de poder confiar en la marca. El daño no es de rendimiento: es de comunicación, que es exactamente lo que el mecanismo vino a proteger.
Lo que de verdad tienes delante no es un mecanismo de errores: es la primera pieza de un sistema de efectos dentro del sistema de tipos de Swift. Un efecto es una capacidad que una función ejerce y que su firma debe confesar, porque cambia las obligaciones de quien la llama. Lanzar es uno; suspender es otro; aislarse en un actor es un tercero. Míralos juntos y la simetría es exacta y deliberada: throws se declara en la firma igual que async, se marca en el punto de uso con try igual que con await, no se hereda en silencio, y obliga a quien lo consume a declararlo también o a neutralizarlo explícitamente. Incluso comparten el mismo problema abierto —el polimorfismo de efectos— que en el caso de lanzar se resolvió con rethrows y luego con typed throws, y en el caso de suspender aún no se ha resuelto. Esta lectura reordena todo: try no es ruido sintáctico que el compilador podría inferir, es la única forma que tiene un lenguaje de mantener honesta la lectura local del código. Cuando el efecto es invisible en la firma y en el punto de uso, como en las excepciones clásicas, cada llamada del programa se vuelve un punto de salida potencial y ya no puedes razonar sobre ningún fragmento sin conocer el mundo entero. Swift elige lo contrario: pagar unas cuantas palabras clave en el fuente a cambio de que cualquier lector, mirando cinco líneas aisladas, sepa exactamente por dónde puede escaparse el control. Ese intercambio —verbosidad local a cambio de razonamiento local— es la tesis de diseño que recorre el lenguaje entero, y quien la entiende deja de discutir sobre si try sobra.
throws vive en el tipo de la función, no en su cuerpo. try marca dónde puede escaparse el control, no dónde se maneja. La propagación es de un marco cada vez y nunca implícita. El coste es una comparación y un salto por punto marcado, más una caja para el existencial del error. Y la decisión que más pesa no es sintáctica: es elegir entre error recuperable y fallo de lógica, porque cada uno tiene su mecanismo y confundirlos convierte un bug local en una corrupción distribuida.
- Escribe una función lanzadora y otra que no lo sea, inspecciona el SIL con la opción de emisión correspondiente y localiza la diferencia en el retorno.
- Toma tres fallos de un proyecto tuyo y clasifícalos en las cuatro categorías; justifica en cada caso qué podría hacer quien llama.
- Convierte un
try!de tu código entrycon captura y describe qué información aparece que antes se perdía. - Anida un
deferdentro de un bloque que lanza y comprueba en qué orden se ejecuta respecto a la propagación. - Explica por qué una función no lanzadora puede pasarse donde se espera una lanzadora y construye un ejemplo que lo aproveche.