rethrows: lanzar solo si tu clausura lanza
El polimorfismo de efectos en su forma más antigua: funciones de orden superior que heredan la capacidad de fallar de sus parámetros. La regla que impone el compilador, el subtipado que hace posible map y filter, las trampas de la clausura almacenada y la migración hacia el error genérico tipado.
Escribe una función que recibe una clausura y la llama. ¿Debe declararse lanzadora? Si dices que sí, condenas a todo el mundo a escribir try aunque su clausura sea inofensiva. Si dices que no, prohíbes las clausuras que fallan y la función queda inservible para la mitad de sus usos. El dilema no tiene salida dentro del sistema de tipos ingenuo, y la biblioteca estándar lo sufriría en cada uno de sus algoritmos: recorrer, transformar, filtrar, reducir, ordenar. La respuesta de Swift fue una palabra clave que expresa una condicional sobre efectos —lanzo si y solo si tú lanzas— y que durante nueve años fue el único polimorfismo de efectos del lenguaje.
- Formular el problema del polimorfismo de efectos y por qué ni lanzar ni no lanzar lo resuelven.
- Aplicar la regla que el compilador impone dentro de una función que relanza, incluidas sus excepciones.
- Diseñar APIs de orden superior que no impongan a sus llamantes un efecto que no ejercen.
- Traducir el mecanismo a su forma generalizada con error tipado y decidir cuál usar en cada caso.
El problema que resuelve
Mira el caso más pequeño posible y verás las dos malas opciones enfrentadas.
// Opción A: no lanza. Rechaza las clausuras que fallan.
func aplicar<T>(_ f: () -> T) -> T { f() }
// Opción B: lanza siempre. Obliga a `try` incluso con clausuras inofensivas.
func aplicar<T>(_ f: () throws -> T) throws -> T { try f() }
La opción B es la que más duele en la práctica, porque el efecto es contagioso: quien llame a esa función debe declararse lanzador o capturar, y quien le llame a él también, hasta contaminar una rama entera del programa por culpa de una posibilidad que en ese punto concreto no existe. Con la biblioteca estándar de por medio, la consecuencia sería que map sobre un array de enteros con una transformación trivial exigiría try.
La palabra clave expresa la dependencia con exactitud: el efecto de la función es función del efecto de sus parámetros.
func aplicar<T>(_ f: () throws -> T) rethrows -> T { try f() }
let a = aplicar { 42 } // sin `try`: la clausura no lanza
let b = try aplicar { try cobrar(10) } // con `try`: la clausura sí lanza
flowchart TB P[Clausura recibida] --> Q[Puede lanzar] Q -->|no| A[La funcion se comporta como no lanzadora] Q -->|si| B[La funcion se comporta como lanzadora] A --> R1[El llamante escribe la llamada sin try] B --> R2[El llamante debe escribir try o propagar] style A fill:#a6e3a1,color:#11111b style B fill:#f38ba8,color:#11111b style P fill:#89b4fa,color:#11111b
Y encaja en el subtipado que vimos en la primera lección: una función que relanza es un subtipo de la lanzadora y un supertipo de la que no lanza, así que puede usarse en cualquiera de los dos papeles. Ese es el motivo formal de que funcione sin duplicar sobrecargas.
La regla que el compilador impone
La palabra no es una promesa de buena fe: está verificada, y la regla es más estricta de lo que casi todo el mundo supone. Dentro de una función que relanza, el único error que puede salir es uno originado en una llamada a un parámetro lanzador. No puedes lanzar por tu cuenta.
func recorrer<T>(_ xs: [T], _ f: (T) throws -> Void) rethrows {
for x in xs {
// throw ErrorPropio.vacio ← error de compilación: no procede del parámetro
try f(x)
}
}
Hay una excepción exacta y muy útil: sí puedes lanzar desde dentro de una cláusula catch que capture un error procedente de una llamada a ese parámetro. Eso permite traducir o enriquecer el fallo ajeno sin inventarte uno propio.
func recorrerConIndice<T>(_ xs: [T], _ f: (T) throws -> Void) rethrows {
for (i, x) in xs.enumerated() {
do { try f(x) }
catch { throw ErrorConContexto(indice: i, causa: error) }
}
}
La otra mitad de la regla, la que suele descubrirse a golpes, es que el efecto se cuenta solo si el parámetro se llama dentro de la función. Si guardas la clausura para invocarla después, la relación condicional se rompe: en el momento de la llamada real ya no hay ningún try en el punto de uso que informe al llamante.
Un inicializador que relanza y guarda la clausura en una propiedad para llamarla más tarde no puede sostener la promesa. El compilador lo detecta en los casos directos, pero el patrón se cuela con facilidad a través de otra capa. Si la clausura sobrevive a la llamada, el efecto ya no es condicional: la función debe declararse lanzadora, o la clausura debe ser no lanzadora, o el fallo debe reificarse como dato.
Merece la pena notar que la marca también funciona en protocolos: un requisito declarado así puede satisfacerse con una implementación que lance, que relance o que no lance, y cada conformidad conserva su propio grado de efecto para sus usuarios.
Hay un tercer matiz, más sutil, que explica por qué la regla es tan restrictiva. El compilador debe poder demostrar la implicación en ambas direcciones: que si el parámetro no lanza, la función no lanza —de ahí la prohibición de lanzar por tu cuenta—, y que el llamante puede ver el punto donde ocurre —de ahí la exigencia de que la llamada esté dentro del cuerpo. Cualquier grieta en cualquiera de las dos convierte una garantía verificada en una promesa verbal, y el lenguaje prefiere rechazar el código antes que aceptar una condicional que no puede comprobar.
Llamar al parámetro
Permitido y esperado. Es la fuente legítima de todo error que salga de la función.
Lanzar un error propio
Prohibido en el cuerpo. Rompería la promesa cuando la clausura recibida no lanza nada.
Traducir dentro de un catch
Permitido, y es la vía correcta para añadir contexto al fallo ajeno sin inventarse uno.
Guardar la clausura
Incompatible con la condicional. Si el fallo ocurre después, no hay marca en el punto de uso.
Diseñar APIs de orden superior
De todo lo anterior sale un puñado de reglas de diseño que se aplican mecánicamente.
Si recibes una clausura y la llamas, relanza. No es una optimización ni un refinamiento tardío: es la elección por defecto correcta. Declararla lanzadora es imponer a todos tus llamantes un coste que solo algunos deberían pagar, y convertirla después en condicional es un cambio compatible que nadie hará.
No absorbas el fallo ajeno. Un try? sobre la llamada a la clausura del usuario convierte su error en silencio. Ese error no es tuyo y no te corresponde descartarlo; propágalo o tradúcelo con contexto.
Cuidado con la propagación desde una tarea hija. Si tu función lanza una tarea concurrente que invoca la clausura, el fallo ocurre fuera del marco donde se prometió, y la condicional deja de ser verificable. Ahí hace falta concurrencia estructurada, con la espera dentro del ámbito de la función.
Marca la clausura, no el resultado. Si tu función construye un valor a partir de lo que devuelve la clausura, el efecto sigue siendo del parámetro y la condicional se conserva. En cambio, si transformas el fallo en un dato para devolverlo envuelto, has salido del mecanismo por completo: ahí ya no relanzas nada, devuelves un valor con dos desenlaces, y la firma debe decirlo.
Recuerda que solo cubre un efecto. La marca habla de lanzar y de nada más. No existe un equivalente para suspender: una función de orden superior que quiera ser síncrona con clausuras síncronas y asíncrona con clausuras asíncronas sigue exigiendo dos sobrecargas o un uso de macros. Esa asimetría, hoy sin resolver, es el mejor recordatorio de que el sistema de efectos de Swift está a medio construir.
De la palabra clave al parámetro genérico
Con errores tipados, la condicional deja de necesitar una palabra especial: se expresa con un parámetro genérico ordinario.
func aplicar<T, E: Error>(_ f: () throws(E) -> T) throws(E) -> T {
try f()
}
Léelo con los extremos del retículo en la mano. Si la clausura no lanza, el compilador infiere Never y la función entera se comporta como no lanzadora. Si lanza un tipo concreto, ese mismo tipo sale por arriba sin ensancharse a existencial, que es justo lo que la palabra clave clásica no podía hacer: con ella, el error del usuario llegaba al llamante convertido en any Error, perdiendo por el camino toda la información de tipo que el propio llamante había puesto. La versión genérica es estrictamente más informativa.
¿Sustituye a la palabra clásica? En una API nueva y genérica, sí, y con ventaja. En el resto, no hay prisa: la forma antigua es más corta, no exige un parámetro genérico más, sigue siendo la que usa casi toda la biblioteca estándar por compatibilidad de fuente, y para un llamante que no tipa sus errores el resultado es idéntico. El criterio práctico: si el llamante se beneficiaría de recibir su propio tipo de error de vuelta, generaliza; si solo quieres no contagiar el efecto, la palabra basta.
Una advertencia sobre la migración, porque es la clase de cambio que parece inocuo y no lo es. Pasar una función publicada de la palabra clásica a la forma genérica altera su firma y, con ella, la inferencia en los puntos de llamada: el llamante que antes recibía un existencial pasa a recibir un tipo concreto, y cualquier código suyo que dependiera de esa amplitud —una variable declarada con el tipo del error, una colección de fallos heterogéneos— deja de compilar. En una biblioteca eso es un cambio de compatibilidad de fuente en toda regla y merece anunciarse como tal, aunque el comportamiento en ejecución sea idéntico.
Detrás de esta palabra clave hay una pregunta que ningún lenguaje con efectos ha resuelto del todo: cómo escribir una función cuyo comportamiento respecto a un efecto dependa del de sus argumentos. Es el mismo problema estructural que la varianza en los genéricos, y admite el mismo análisis. Una función de orden superior es un constructor de tipos, y su efecto es covariante respecto al efecto de sus parámetros: si la clausura falla, el todo falla; si no, no. rethrows es esa covarianza escrita a mano, para un único efecto, con una regla ad hoc verificada por el compilador y sin ninguna posibilidad de extensión. Por eso la historia es exactamente la que ya viste con typed throws: en cuanto el efecto se convierte en un parámetro genérico con Never en un extremo, la palabra clave se vuelve derivable en lugar de primitiva, y el compilador deja de necesitar conocimiento especial. Lo que hace esta lección más valiosa que su tema es que el problema no está resuelto para los demás efectos. No hay reasync, y por eso la biblioteca estándar duplica algoritmos en versión síncrona y asíncrona; el aislamiento de actores necesitó su propia maquinaria de parámetros de aislamiento; el envío entre dominios necesitó otra. Cada efecto nuevo que Swift añade reabre la misma pregunta y la contesta con un mecanismo distinto, y esa acumulación es la deuda de diseño más visible del lenguaje moderno. Cuando escribas una función de orden superior, no estás eligiendo entre dos palabras clave: estás decidiendo si tu abstracción es transparente a los efectos de quien la usa o si le impone los suyos. Las abstracciones que imponen efectos no se componen, y una biblioteca hecha de abstracciones que no se componen envejece muy deprisa.
- Escribe una función de orden superior en las tres variantes —no lanza, lanza, relanza— y compara el código que debe escribir el llamante en cada una.
- Intenta lanzar un error propio dentro de una función que relanza y explica el diagnóstico exacto del compilador.
- Usa la excepción de la cláusula de captura para envolver el fallo de la clausura con el índice del elemento.
- Construye un caso con la clausura almacenada en una propiedad y razona por qué la condicional deja de ser cierta.
- Reescribe tu función con error tipado genérico y comprueba con
Nevery con un tipo concreto que el llamante recibe lo que esperabas.