wandres.dev
FUNCIONES Y CLOSURES · captura y escape

@autoclosure y closures como control de flujo

El atributo `@autoclosure` envuelve automáticamente una expresión en un closure sin que el llamante escriba llaves, y con ello introduce en Swift algo que el lenguaje no tiene de serie: la evaluación perezosa de argumentos. Esta lección explica el mecanismo con precisión, muestra por qué el operador `??` y los operadores lógicos no podrían cortocircuitar sin él, y disecciona el caso ejemplar de `assert` y `precondition`, donde la condición y el mensaje desaparecen por completo en compilaciones de lanzamiento. Después construye control de flujo propio con la misma técnica y termina con el argumento serio en contra: un atributo que hace invisible en el punto de llamada que algo puede no ejecutarse, o ejecutarse varias veces.

⏱ 18 min

Swift evalúa los argumentos antes de entrar en la función. Siempre, sin excepción, con una única salida: convertir el argumento en un closure para que la función decida si lo ejecuta y cuándo. @autoclosure automatiza esa conversión de manera que el llamante no vea las llaves, y el resultado es una construcción con dos caras muy distintas. La cara luminosa es que permite escribir funciones que se comportan como estructuras del lenguaje —cortocircuitos, valores por defecto que no se calculan si no hacen falta, aserciones que se evaporan en producción— y esa cara es tan valiosa que operadores que todo el mundo da por primitivos están de hecho definidos con ella en la biblioteca estándar. La cara oscura es que hace desaparecer del punto de llamada la información de que una expresión puede no ejecutarse nunca, o ejecutarse tres veces, y esa desaparición convierte un efecto secundario inocente en un error que nadie ve al leer.

🎯 Al terminar esta lección sabrás
  • Explicar qué transformación aplica @autoclosure y en qué punto del código ocurre.
  • Reconstruir por qué el cortocircuito de ?? y de los operadores lógicos exige evaluación diferida.
  • Analizar el caso de assert y precondition y su comportamiento distinto según la configuración de compilación.
  • Aplicar el criterio de diseño que distingue un uso legítimo de un abuso peligroso.

La transformación, y dónde ocurre

Un parámetro marcado con @autoclosure debe tener tipo función sin parámetros. En el punto de llamada, el compilador toma la expresión escrita y la envuelve en un closure automáticamente, de modo que el texto se lee como si el argumento fuera un valor y la semántica es la de un cálculo aplazado.

func registrar(_ mensaje: @autoclosure () -> String, si condicion: Bool) {
  guard condicion else { return }
  print(mensaje())
}

registrar(construirInformeCostoso(), si: nivelDeDetalle == .completo)

La llamada no tiene llaves y sin embargo construirInformeCostoso no se ejecuta salvo que la condición sea cierta: la función adquiere así una capacidad que en la mayoría de lenguajes está reservada a las macros o a las palabras clave. Conviene fijar dos hechos que evitan la mitad de las confusiones. El primero es que el atributo describe el parámetro, no el argumento: quien llama no sabe, leyendo la llamada, que se está produciendo la conversión. El segundo es que la expresión envuelta puede ejecutarse cero, una o varias veces, según lo que haga el cuerpo, y que nada en el punto de llamada lo indica.

func repetir(_ accion: @autoclosure () -> Void, veces n: Int) {
  for _ in 0..<n { accion() }
}

var contador = 0
repetir(contador += 1, veces: 3)   // contador vale 3

Esa línea es sintácticamente indistinguible de una llamada normal y sin embargo incrementa tres veces. Es el argumento central en contra del uso indiscriminado del atributo, y volveremos a él al final.

El cortocircuito no es magia del compilador

La intuición extendida es que los operadores lógicos cortocircuitan porque el compilador los trata de forma especial. En Swift no es así: son funciones normales de la biblioteca estándar, y lo que las hace cortocircuitar es exactamente @autoclosure.

// Aproximacion a la declaracion de la biblioteca estandar
static func && (
  lhs: Bool,
  rhs: @autoclosure () throws -> Bool
) rethrows -> Bool {
  lhs ? try rhs() : false
}

El operando derecho llega sin evaluar y solo se evalúa si el izquierdo lo exige. El mismo mecanismo sostiene el operador de fusión de nilos, cuya utilidad depende por completo de que el valor por defecto no se calcule cuando el opcional trae contenido.

func ?? <T>(
  opcional: T?,
  porDefecto: @autoclosure () throws -> T
) rethrows -> T {
  switch opcional {
  case let valor?: return valor
  case nil: return try porDefecto()
  }
}

let config = cache[clave] ?? leerDelDisco(clave)   // no toca el disco si hay cache

Que estos operadores sean funciones ordinarias y no sintaxis privilegiada es una propiedad notable del diseño de Swift, y @autoclosure es la pieza que la hace posible. Sin él, el lenguaje necesitaría casos especiales en el compilador para tres o cuatro operadores, y ningún programador podría escribir el quinto.

💡
La combinación con throws y rethrows es deliberada

Fíjate en que los dos ejemplos declaran el parámetro como () throws -> T y la función como rethrows. Es lo que permite que try algo() ?? otraCosa() funcione sin obligar a marcar como lanzadoras las llamadas que no lo son. Si escribes un operador propio con evaluación diferida, copia ese patrón: es la diferencia entre una abstracción transparente y una que contamina las firmas de sus usuarios.

El caso ejemplar: assert y precondition

La demostración más limpia del valor del atributo está en las funciones de comprobación de la biblioteca estándar, cuya firma aproximada es la siguiente.

func assert(
  _ condition: @autoclosure () -> Bool,
  _ message: @autoclosure () -> String = String(),
  file: StaticString = #fileID,
  line: UInt = #line
)

Los dos primeros parámetros son diferidos, y eso permite algo que ninguna otra forma consigue: en compilaciones optimizadas de lanzamiento, assert no evalúa ni la condición ni el mensaje. No es que descarte el resultado, es que la expresión no llega a ejecutarse. Una aserción que recorre una colección entera para comprobar una invariante, o que construye un mensaje concatenando la descripción de veinte objetos, cuesta exactamente cero en producción.

assert(
  inventario.allSatisfy { $0.cantidad >= 0 },
  "Inventario con cantidades negativas: \(inventario.filter { $0.cantidad < 0 })"
)

Escrita sin evaluación diferida, esta línea recorrería la colección dos veces y construiría la cadena completa en cada ejecución, incluso cuando la condición se cumple y el mensaje no se usa. La familia de comprobaciones aprovecha esa propiedad con matices que conviene distinguir.

Función Se evalúa en depuración Se evalúa en lanzamiento Efecto al fallar
assert No Termina el proceso
assertionFailure No Termina el proceso
precondition Termina el proceso
preconditionFailure Termina el proceso

La regla de uso que se deriva es precisa: assert documenta invariantes internas cuya violación indica un fallo de programación y cuya comprobación puede permitirse desaparecer; precondition valida contratos cuyo incumplimiento haría inseguro continuar, y por eso sobrevive a la optimización. Elegir mal significa o bien pagar en producción por comprobaciones prescindibles, o bien continuar ejecutando con una invariante rota porque la comprobación se desvaneció.

Construir control de flujo, y saber cuándo no

Con la técnica en la mano, escribir estructuras propias es directo. El patrón útil aparece siempre que un argumento sea caro de producir y su necesidad dependa de una decisión que la función toma.

extension Optional {
  func oLanzar(_ error: @autoclosure () -> Error) throws -> Wrapped {
    guard let valor = self else { throw error() }
    return valor
  }
}

let usuario = try sesion.usuario.oLanzar(ErrorDeSesion.sinUsuario(recopilarContexto()))

También es la forma correcta de construir un registro por niveles donde la interpolación de la cadena solo ocurra si el nivel está activo, que es una de las optimizaciones más rentables y menos aplicadas en aplicaciones con instrumentación densa.

Ahora el argumento en contra, que es serio y debe pesar más que el entusiasmo. @autoclosure rompe la correspondencia entre lo que se lee y lo que ocurre. Quien mira una llamada ve una expresión evaluada; en realidad ve una expresión que quizá no se evalúe, o que se evalúe varias veces, y la única manera de saberlo es ir a leer la firma. Cuando el argumento tiene efectos secundarios —una escritura, un incremento, una llamada a red— esa opacidad produce errores que ningún tipo detecta. De ahí un criterio de diseño con tres condiciones que conviene exigir juntas.

  • El argumento debe ser una expresión pura y cara, no una acción con efectos.
  • La función debe leerse como una construcción del lenguaje, no como una operación cualquiera.
  • El nombre debe sugerir la condicionalidad por sí mismo, como hacen assert o el operador de fusión.

Si alguna de las tres falla, el closure explícito con sus llaves visibles es la opción correcta, porque paga con cuatro caracteres una honestidad que el lector agradecerá.

🍊

Cero, una o varias veces

El punto de llamada no revela cuántas veces se evalúa la expresión. Por eso el argumento debe ser puro, y por eso el atributo no es un adorno reversible.

🧪

Assert es gratis, precondition no

Uno documenta invariantes y desaparece al optimizar; el otro valida contratos y sobrevive. Confundirlos es un fallo de seguridad, no de estilo.

flowchart LR
E[Expresion en el sitio de llamada] -->|autoclosure| C[Closure sin parametros]
C --> F[Cuerpo de la funcion decide]
F -->|no la invoca| Z[Coste cero]
F -->|la invoca una vez| U[Evaluacion normal]
F -->|la invoca en bucle| M[Evaluacion repetida]
style Z fill:#a6e3a1,color:#11111b
style M fill:#f38ba8,color:#11111b
Diferir la evaluación es tomar prestado un poder que el lenguaje reserva a sus palabras clave

Vale la pena reconocer qué se está haciendo realmente al escribir @autoclosure, porque es más de lo que la palabra sugiere. Toda la estrategia de evaluación de Swift es estricta y por valor: cuando lees una llamada, sabes que los argumentos ya se calcularon y sabes en qué orden. Esa garantía es tan básica que ni siquiera la formulamos, y sin embargo es la que permite leer código imperativo sin sostener un modelo mental del interior de cada función llamada. @autoclosure suspende esa garantía localmente y, al hacerlo, mete al llamante en el régimen de evaluación perezosa sin decírselo. La razón por la que la biblioteca estándar puede permitírselo con ?? y con los operadores lógicos no es que sea la biblioteca estándar, sino que en esos casos existe un acuerdo cultural anterior al lenguaje: todo programador espera que el operando derecho de una conjunción cortocircuite, porque lo espera desde antes de conocer Swift, y el atributo se limita a implementar una expectativa que ya estaba ahí. Cuando escribes una función propia con evaluación diferida, no tienes ese acuerdo de tu parte y tienes que fabricarlo con el nombre, la documentación y la disciplina de exigir argumentos puros. Ese es el criterio que separa el uso legítimo del abuso, y es también la razón por la que el atributo aparece dos veces en toda una base de código bien escrita y treinta en una mal escrita. La evaluación perezosa es un poder prestado, y como todo préstamo, lo importante no es lo que compras con él sino si puedes devolverlo cuando alguien lea el código dentro de un año.

⚔️ Mide y decide
  1. Busca en tu proyecto las aserciones cuyo mensaje interpola objetos costosos y comprueba, con una compilación de lanzamiento, que el trabajo desaparece de verdad.
  2. Revisa cada precondition de tu código y decide si el contrato justifica pagar la comprobación en producción. Convierte a assert los que no lo justifiquen.
  3. Implementa tu propio operador de fusión con @autoclosure y rethrows, y después quita el atributo para observar exactamente qué se rompe y cuánto trabajo extra aparece.
  4. Escribe una función de registro por niveles con mensaje diferido y mide el coste de una ejecución con el nivel desactivado, frente a la versión que interpola siempre.
  5. Localiza cualquier @autoclosure cuyo argumento típico tenga efectos secundarios. Reescríbelo como closure explícito y comprueba si el sitio de llamada se volvió más honesto o solo más largo.