wandres.dev
RESULT BUILDERS · DSLs en el lenguaje

Qué es un result builder: de un bloque de sentencias a un valor

Un result builder convierte una lista de sentencias sueltas en un único valor construido. Qué hace exactamente el atributo `@resultBuilder`, qué código reescribe el compilador en el punto de la llamada, y por qué esto es una transformación sintáctica y no una función mágica.

⏱ 16 min

Hay una asimetría vieja en casi todos los lenguajes: describir una estructura anidada obliga a escribirla con la sintaxis de los datos —comas, corchetes, paréntesis que se cierran seis niveles más abajo— mientras que describir un proceso se hace con la sintaxis cómoda de las sentencias, una por línea, sin puntuación entre ellas. Cuando lo que construyes es un árbol grande, un formulario, una consulta o una pantalla, esa diferencia se paga en cada línea. Los result builders son la respuesta de Swift a esa asimetría, y su idea cabe en una frase: permitir que un bloque de código que parece imperativo produzca un valor, dejando que el compilador reescriba las sentencias como llamadas a un constructor que tú defines. Lo importante no es la comodidad visual, sino dónde ocurre la transformación: en la sintaxis, antes de que exista ningún valor, y bajo tu control.

🎯 Al terminar esta lección sabrás
  • Reconocer el problema de construcción anidada que los result builders vienen a resolver.
  • Declarar un builder mínimo con @resultBuilder y aplicarlo a un parámetro de tipo clausura.
  • Describir la transformación literal: sentencias convertidas en resultados parciales y una llamada final a buildBlock.
  • Distinguir un result builder de una clausura corriente, de una macro y de un simple array de elementos.

El problema: describir un árbol con sentencias

Supón que quieres componer un documento de texto a partir de fragmentos. Sin ninguna ayuda del lenguaje, el árbol se escribe con la sintaxis de los datos, y cada nivel añade puntuación que no habla del dominio:

let doc = Documento(hijos: [
    Titulo("Informe"),
    Parrafo("Resumen del trimestre"),
    Lista(hijos: [
        Item("Ingresos"),
        Item("Gastos"),
    ]),
])

Las comas finales, los corchetes y el anidamiento no aportan información: son el andamiaje de un literal de array. Y en cuanto quieres incluir un elemento solo si se cumple una condición, el literal se rompe, porque dentro de un array no cabe una sentencia if. Hay que salir a una variable temporal, acumular con append y perder de vista la forma del árbol. Ese es exactamente el punto de fricción, y la propuesta SE-0289 lo formuló al revés: en lugar de meter control de flujo dentro de un literal de datos, dejemos que un bloque de control de flujo produzca datos.

La transformación: qué escribe el compilador

Un result builder es un tipo —struct, enum o class— marcado con el atributo @resultBuilder que expone al menos un método estático llamado buildBlock. Nada más.

@resultBuilder
enum ConstructorDeTexto {
    static func buildBlock(_ partes: String...) -> String {
        partes.joined(separator: "\n")
    }
}

func documento(@ConstructorDeTexto contenido: () -> String) -> String {
    contenido()
}

El atributo sobre el parámetro contenido es la parte decisiva: marca esa clausura como transformada por el builder. A partir de ahí, quien la escriba puede omitir las comas y el return:

let d = documento {
    "Informe del trimestre"
    "Ingresos al alza"
    "Gastos estables"
}

Conviene ver qué existe realmente después de la reescritura, porque no hay nada mágico. El compilador recorre las sentencias del bloque, guarda cada expresión en un resultado parcial con nombre interno, y termina llamando a buildBlock con todos ellos en orden:

// Lo que existe tras la transformacion
let d = documento {
    let v0 = "Informe del trimestre"
    let v1 = "Ingresos al alza"
    let v2 = "Gastos estables"
    return ConstructorDeTexto.buildBlock(v0, v1, v2)
}

Tres consecuencias se siguen de inmediato. Primero: la transformación es puramente sintáctica, ocurre en tiempo de compilación y produce código Swift ordinario que podrías haber escrito a mano. Segundo: el tipo del bloque no es el tipo de sus sentencias, sino el que devuelva buildBlock; el builder es libre de mapear tres String a un String, a un array o a un tipo compuesto totalmente distinto. Tercero: solo se transforman las sentencias que el builder sabe traducir; si escribes una sentencia para la que no has definido el método correspondiente, el error no es de tipos sino de capacidad, y lo veremos en la lección siguiente.

flowchart TB
fuente[Bloque con tres sentencias sin comas] --> comp[El compilador aplica la transformacion]
comp --> p0[Resultado parcial v0]
comp --> p1[Resultado parcial v1]
comp --> p2[Resultado parcial v2]
p0 --> bb[Llamada a buildBlock con v0 v1 v2]
p1 --> bb
p2 --> bb
bb --> valor[Un unico valor devuelto por el bloque]
style fuente fill:#cba6f7,color:#11111b
style comp fill:#89b4fa,color:#11111b
style valor fill:#a6e3a1,color:#11111b

Lo que es y lo que no es

🧱

No es una clausura normal

Una clausura corriente devuelve la última expresión o lo que diga su return. Un bloque de builder no devuelve ninguna de sus sentencias: devuelve lo que el builder fabrique con todas ellas.

✂️

No es una macro

Una macro recibe el árbol sintáctico y genera declaraciones nuevas. Un builder no genera nada: reescribe un bloque de sentencias como llamadas a métodos que tú ya escribiste, con reglas fijas.

📐

No es azúcar para un array

El resultado puede ser un tipo distinto en cada posición y conservar la estructura estática del bloque. Un array borra esa información; un builder puede preservarla en el tipo.

Merece la pena insistir en el punto que más confusión causa al principio: dentro de un bloque de builder, una expresión suelta no es código muerto. En Swift ordinario, escribir "hola" en una línea de una función es una sentencia inútil que el compilador advierte. Dentro de un bloque transformado, esa misma línea es una contribución al resultado. Es la misma sintaxis con dos semánticas, seleccionadas por la presencia del atributo en la declaración del parámetro, muy lejos del punto donde escribes el bloque. Esa distancia entre la causa y el efecto es la fuente de la mayoría de los desconciertos del nivel, y también de sus errores más crípticos.

El atributo, además, puede aplicarse en tres sitios distintos, y conviene reconocerlos porque el efecto es el mismo pero la lectura cambia:

// 1. Sobre un parametro de tipo funcion: transforma el bloque de quien llama
func documento(@ConstructorDeTexto contenido: () -> String) -> String { contenido() }

// 2. Sobre una propiedad o una funcion: transforma su propio cuerpo
struct Informe {
    @ConstructorDeTexto var cuerpo: String {
        "Encabezado"
        "Contenido"
    }
}

// 3. Sobre un tipo: se propaga a los miembros con cuerpo que no lo contradigan
@ConstructorDeTexto
struct Plantilla { /* ... */ }

El caso que verás mil veces es el segundo, aunque no lo escribas tú: el body de una vista de SwiftUI es una propiedad calculada cuyo cuerpo se transforma con un builder que viene heredado del requisito del protocolo. Ese detalle —un atributo escrito en la declaración de View y no en tu código— explica por qué la sintaxis de SwiftUI parece parte del lenguaje sin serlo.

Queda una precisión sobre el orden de evaluación, porque induce a error con facilidad. Las sentencias del bloque se ejecutan de arriba abajo, como en cualquier función: primero se evalúa la primera expresión, luego la segunda, y solo al final se llama a buildBlock con todos los resultados ya calculados. Un builder no puede, por tanto, decidir no evaluar una de sus partes: cuando la recibe, ya está evaluada. Esa es la diferencia esencial con una macro, que sí ve la expresión sin evaluar y puede descartarla, y explica por qué un DSL de builders no sirve para construir mecanismos perezosos sin envolver explícitamente cada parte en una clausura.

💡
La regla de lectura

Cuando encuentres un bloque en el que las líneas no llevan comas ni return y aun así producen un valor, busca el atributo del builder en la declaración de la función o la propiedad que lo recibe. Ese atributo es el único sitio donde está escrito qué significan esas líneas.

La sintaxis como interfaz programable

Lo verdaderamente notable de SE-0289 no es que permita escribir vistas bonitas, sino la clase de poder que concede. En la enorme mayoría de los lenguajes, el significado de un bloque de sentencias es fijo, definido de una vez para siempre por la especificación: ejecutar en orden y devolver, si acaso, lo último. Swift decidió abrir esa regla y convertirla en un punto de extensión: qué significa yuxtaponer dos sentencias, qué significa un if sin else, qué significa un for dentro de un bloque, pasan a ser preguntas cuya respuesta escribe una biblioteca. Es una idea con una genealogía ilustre. Haskell la llama notación do, y ahí lo que se programa es la secuenciación mediante una mónada; Scala tiene sus comprensiones for; los lenguajes Lisp resuelven el problema entero con macros porque el código ya es un dato. Swift eligió un camino intermedio, deliberadamente estrecho: en vez de un mecanismo general de reescritura sintáctica, un conjunto cerrado de métodos con nombres fijos que el compilador buscará por convención, cada uno correspondiente a una construcción concreta del lenguaje. Esa estrechez es una decisión de diseño y no una limitación accidental. Un mecanismo general habría hecho ilegible cualquier código, porque ninguna línea significaría lo que parece. Un conjunto fijo de puntos de extensión mantiene la promesa de que un bloque de builder sigue siendo Swift reconocible: las sentencias siguen ejecutándose en orden, los tipos siguen comprobándose, la única libertad es cómo se combinan los resultados. El precio de esa contención lo pagarás en las lecciones siguientes, cuando descubras que el builder no puede hacer cosas perfectamente razonables —un while que acumule, un guard que corte— simplemente porque no existe un método con el nombre adecuado. La contrapartida es que el mecanismo cabe en la cabeza, y que el compilador puede diagnosticar. Casi.

📝
Lo esencial de esta lección

Un result builder es un tipo con @resultBuilder y un método estático buildBlock. Al marcar con él un parámetro de clausura, una propiedad o una función, el compilador reescribe las sentencias del cuerpo como resultados parciales y una llamada final a buildBlock. La transformación es sintáctica, ocurre en compilación y produce código ordinario. El tipo del bloque es el que devuelva el builder, no el de sus sentencias.

⚔️ Reconstruye la transformación
  1. Escribe un builder ConstructorDeLista que reciba String variádicos y devuelva un array de String, y una función que lo use en un parámetro de clausura.
  2. Escribe a mano la versión expandida de una llamada con cuatro líneas y comprueba que produce el mismo valor.
  3. Cambia el tipo de retorno de buildBlock a Int devolviendo el número de partes; observa cómo cambia el tipo del bloque sin tocar el bloque.
  4. Quita el atributo del parámetro y anota el error exacto que aparece en cada línea del bloque.
  5. Aplica el atributo a una propiedad calculada en vez de a un parámetro y explica en qué se parece eso al body de una vista de SwiftUI.