Domingo Gallardo

Result builders en Swift (1)

Rescato un post antiguo, que publiqué hace tres años, en julio de 2022. He añadido otros dos, la segunda parte de este y la explicación de cómo se pueden definir arrays con distintos tipos de datos en Swift.

He añadido un addendum al final, generado por GPT o3, en el que se comentan los cambios introducidos en Swift en los últimos tres años que afectan a lo comentando en el artículo.

Desde que Apple presentó SwiftUI en la WWDC19 he querido entender las funcionalidades de Swift sobre las que se construye esta tecnología. Leí algún que otro post que entraba en el tema y me quedé con la idea de que en Swift 5.1 habían introducido algo llamado function builders que era la funcionalidad que permitía construir las vistas de SwiftUI de forma declarativa, pero no seguí estudiando más el tema.

Una cosa extraña de los function builders era que se trataba de una funcionalidad no documentada de Swift, que no había pasado por el proceso habitual de evolución del lenguaje en el que las propuestas de nuevas características se terminan aprobando o no tras una discusión abierta con la comunidad.

No tardó mucho en aparecer una propuesta y un pitch en los foros de la comunidad. Las discusiones se alargaron, se consideraron distintas alternativas, cambió de nombre a result builders y al final, casi dos años después, terminó siendo aceptada en octubre de 2020 y publicada en el lenguaje en la versión 5.4 lanzada en abril de 2021.

Más de un año después me he puesto realmente a estudiar los result builders y a intentar entender cómo funcionan. Después de pasar unos días leyendo documentación, creando algunas notas en Obsidian y haciendo pruebas con código Swift ha llegado el momento de intentar poner en todo en orden y hacer un post sobre el tema.

Objetivo de los result builders

Vamos a empezar explicando cuál es el objetivo de los result builders y después explicaremos cómo funcionan.

Un ejemplo con SwiftUI

Si vemos un ejemplo sencillo de código SwiftUI comprobaremos que podemos identificarlo como código Swift, pero que hay algo que no encaja del todo. Por ejemplo, el siguiente código construye una vista en la que se apilan verticalmente una imagen y un texto.

import SwiftUI

struct ContentView: View {
    var body: some View {
        VStack {
            Image(systemName: "globe")
                .imageScale(.large)
                .foregroundColor(.accentColor)
            Text("Hello, world!")
        }
    }
}

El resultado es el siguiente:

hello-world-swiftui

En el código se define un struct denominado ContentView que cumple el protocolo View. Este protocolo obliga a definir una propiedad body, que también debe cumplir el tipo View, construyéndose así, de forma recursiva, un árbol de vistas que SwiftUI se encarga de renderizar.

La propiedad body es una propiedad calculada, de tipo some View, que devuelve un VStack. Dejemos para otro post el uso de some y vamos a centrarnos en la construcción del VStack:

VStack {
    Image(systemName: "globe")
        .imageScale(.large)
        .foregroundColor(.accentColor)
    Text("Hello, world!")
}

Las llaves después de VStack definen una trailing clausura que se le pasa al incializador. Es equivalente a:

VStack(content: {
    Image(systemName: "globe")
        .imageScale(.large)
        .foregroundColor(.accentColor)
    Text("Hello, world!")
})

Si nos fijamos en el código de la clausura, veremos que hay algo raro. Hay dos sentencias que construyen una instancia de Image y otra instancia de Text. Son precisamente la imagen y el texto que se apilan y que se muestran en la vista resultante. Pero no se hace nada con esas instancias. ¿Cómo se pasan al Vstack? ¿Dónde está el return de la clausura?. ¿Qué magia es esta?

La explicación está en que SwiftUI define un result builder que realiza una transformación en tiempo de compilación del código anterior (que no es código Swift correcto) en un código similar al siguiente:

VStack {
    let v0 = Image(systemName: "globe")
                 .imageScale(.large)
                 .foregroundColor(.accentColor)
    let v1 = Text("Hello, world!")
    return ViewBuilder.buildBlock(v0, v1)
}

Este código sí que es código correcto de Swift. Las instancias creadas de Image y de Text se guardan en dos variables auxiliares y se llama a una función estática (ViewBuilder.buildBlock) que recibe estas dos vistas y las combina en una estructura, una pareja, que también es del tipo View y que se devuelve.

Aunque no lo hemos visto en el ejemplo, también sería posible construir los elementos constituyentes de forma recursiva usando el mismo DSL. Por ejemplo, uno de los elementos que se pasan al VStack podría ser a su vez otro VStack formado por la combinación de otros elementos básicos.

Creación de DSLs

Mediante el result builder anterior podemos entonces transformar el código limpio y claro del principio (que no funciona en Swift) en un código compilable. El result builder añade todo lo necesario (variables temporales, llamada a la función de construcción, etc.) para que el código resultante sea correcto para el compilador. Y lo hace de forma totalmente transparente. El desarrollador no ve nada del segundo código, sólo ve el primero, el código limpio y claro.

El código que transforma el result builder es lo que se denomina un DSL (Domain Specific Language). En este caso, el DSL nos permite construir vistas de SwiftUI, describiendo y combinando sus elementos constituyentes.

Los result builders no solo se han utilizado para construir SwiftUI, sino que la comunidad ha creado una gran cantidad de DSLs para definir todo tipo de elementos, como HTML, CSS, grafos, funciones REST o tests. Incluso en la reciente WWDC22 se ha presentado un DSL para construir expresiones regulares en Swift, SwiftRegex.

Resumiendo, al igual que las macros en lenguajes de programación como LISP, o los define de C, los result builders permiten especificar unas transformaciones que se aplicarán al código fuente en tiempo de compilación. Veremos a continuación cómo se ha incluido esa funcionalidad en el lenguaje Swift.

Primer ejemplo

En primer lugar, para definir un result builder debemos especificar una función buildBlock que construya un resultado a partir de unos elementos. En el caso del ejemplo anterior se debe construir una composición de dos vistas a partir de las vistas individuales (la instancia de Image y de Text).

¿Cómo podemos definir esta función? La forma más sencilla es definir una función estática, a la que se pueda llamar sin necesidad de crear una instancia. Esta función se debe llamar buildBlock y debe tomar como parámetros los componentes individuales y devolver un nuevo componente resultado de su composición. Podemos definirla en una estructura, una clase o un enumerado anotado con el atributo @resultBuilder.

Un ejemplo muy sencillo que trabaja con cadenas es el siguiente:

@resultBuilder
struct StringConcatenator {
    static func buildBlock(_ component1: String, _ component2: String) -> String {
        return component1 + ", " + component2
    }
}

La función buildBlock toma dos cadenas y devuelve su concatenación, separándolas por una coma. La definimos como una función static de la estructura StringConcatenator. El atributo @resultBuilder indica que este tipo es un result builder y que vamos a poder especificar un DSL con él.

¿Cómo podemos ahora indicar que queremos usar este result builder? A los ingenieros de Swift se les ocurrió una idea genial. Al definir el tipo StringConcatenator como un result builder el compilador crea el atributo @StringConcatenator que podremos usar donde nos interese aplicarlo.

Por ejemplo, podemos escribir el siguiente código:

@StringConcatenator
func holaMundo() -> String {
    "Hola"
    "mundo"
}

print(holaMundo())

La función holaMundo() no sería correcta en Swift porque no tiene ningún return con la cadena a devolver. Además, sus dos sentencias no hacen nada, solo definir las cadenas "Hola" y "mundo". Pero si ejecutamos el código anterior veremos que el compilador no da ningún error y que el código se ejecuta correctamente e imprime el típico mensaje:

Hola, mundo

¿Qué está pasando? Al utilizar el atributo @StringConcatenator en la función holaMundo() estamos declarando que se trata de una función cuyo cuerpo lo estamos definiendo con un DSL que va a procesar el result builder StringConcatenator.

Al igual que en el ejemplo anterior de SwiftUI, cada sentencia del cuerpo de la función especifica un componente que el compilador debe procesar. En este caso son cadenas. Y al final se debe llamar a buildBlock para combinar estos componentes y devolver la cadena resultante. En concreto, el código resultante de la transformación es el siguiente:

func holaMundo() -> String {
    let v0 = "Hola"
    let v1 = "mundo"
    return StringConcatenator.buildBlock(v0, v1)
}

Este código transformado es el que se ejecuta finalmente en el programa y el que devuelve la cadena "Hola, mundo".

Número variable de argumentos

En el ejemplo anterior la función buildBlock está definida únicamente sobre dos argumentos. No funcionaría si quisiéramos construir una cadena con más de dos componentes. Podemos mejorarla usando la capacidad de Swift de definir funciones con un número variable de argumentos:

@resultBuilder
struct StringConcatenator {
    static func buildBlock(_ components: String...) -> String {
        return components.joined(separator: ", ")
    }
}

Ahora la función buildBlock recibe un número variable de cadenas guardadas en el array components. Y la función de orden superior joined recorre el array de cadenas y las une todas con una coma y un espacio.

Con este buildBlock podemos componer el número de cadenas que queramos en el DSL. Por ejemplo, podemos definir un saludo a partir de cuatro cadenas:

@StringConcatenator
func saludo(nombre: String) -> String {
    "Hola"
    "me"
    "llamo"
    nombre
}

Además, en este ejemplo, hemos añadido un parámetro nombre a la función. Este parámetro permite especificar el nombre que está saludando.

El result builder @StringConcatenator transforma el código anterior en:

func saludo(nombre: String) -> String {
    let v0 = "Hola"
    let v1 = "me"
    let v2 = "llamo"
    let v3 = nombre
    return StringConcatenator.buildBlock(v0, v1, v2, v3)
}

Si llamamos a la función original

print(saludo(nombre: "Frodo"))

se imprimirá lo siguiente:

Hola, me, llamo, Frodo

DSL en variables calculadas

Según la documentación oficial de Swift, podemos usar el atributo del result builder en los siguientes lugares:

El primer caso lo hemos visto en el apartado anterior. Vamos a ver un ejemplo del segundo caso.

Por ejemplo, podemos definir la siguiente estructura:

struct Persona {
    let nombre: String

    @StringConcatenator
    var saludo: String {
        "Hola"
        "me"
        "llamo"
        nombre
    }
}

let frodo = Persona(nombre: "Frodo")
print(frodo.saludo)

Ahora el DSL se utiliza para definir el getter de la variable calculada saludo. El result builder transforma ese getter de la misma forma que en los ejemplos anteriores, creando un getter que devuelve una cadena a partir de las cadenas que aparecen en las distintas sentencias del código original.

La instrucción let crea una instancia de Persona inicializando su nombre. Y la siguiente sentencia llama a la variable calculada, que devuelve la cadena con el saludo, y la imprime:

Hola, me, llamo, Frodo

DSL en parámetros

En la especificación de cómo usar el atributo del result builder se menciona en último lugar la posibilidad de usarlo en un parámetro de tipo clausura. Veamos un ejemplo:

func imprimeSaludo(@StringConcatenator _ contenido: () -> String) {
    let resultado = contenido()
    print(resultado)
}

Estamos definiendo una función que va a recibir una clausura sin argumentos que va a devolver una cadena. En el cuerpo de la función se ejecuta la clausura y se imprime el resultado. La anotación @StringConcatenator establece que podremos pasar como argumento clausuras DSL y que esas clausuras serán transformadas por el result builder.

De esta forma, podemos llamar a la función anterior usando una clausura en la que definimos las cadenas que van a aparecer en el saludo. Y además podemos hacerlo sin usar el atributo @StringConcatenator (ya se ha definido en el parámetro de la función):

imprimeSaludo {
    "Hola"
    "mundo"
}

El código anterior imprime:

Hola, mundo

Veamos con más detalle cómo funciona el ejemplo. La función imprimeSaludo recibe como parámetro la clausura contenido. Se trata de una clausura sin parámetros que devuelve una cadena. Y está precedido del atributo @StringConcatenator. Esto hace que cualquier argumento que se pase (una clausura que devuelve una cadena) sea transformado por el result builder.

En la llamada a la función vemos que se utiliza la característica de Swift de la clausura al final, mediante la que se pueden omitir los paréntesis cuando el último argumento es una clausura.

El código final generado por el compilador es el siguiente:

imprimeSaludo({
    let v0 = "Hola"
    let v1 = "mundo"
    return StringConcatenator.buildBlock(v0, v1)
})

Evidentemente, este código es mucho menos claro y directo que el código anterior:

imprimeSaludo {
    "Hola"
    "mundo"
}

DSLs avanzados

En los ejemplos anteriores hemos visto cómo se puede usar un DSL para construir un componente a partir de componentes elementales. Pero sólo hemos visto una pequeña parte de todo lo que permiten hacer los result builders.

Si vemos un ejemplo avanzado de SwiftUI veremos que el result builder definido en SwiftUI (la estructura ViewBuilder) permite un DSL mucho más avanzado, en el que podemos usar bucles (ForEach) y condicionales (if).

Ejemplo del artículo de Hacking with Swift List Items Inside if Statements:

struct TestView: View {
    ...
    var body: some View {
        List {
            Button("Add a fresh potato") {
                self.basket.vegetables.append(Vegetable(name: "Potato", freshness: 1))
            }.foregroundColor(.blue)                        

            Section(header: Text(sectionHeadings[0])) {
                ForEach(self.basket.vegetables) { vegetable in
                    if vegetable.freshness == 0 {
                        Text(vegetable.name)
                    }
                }
            }

            Section(header: Text(sectionHeadings[1])) {
                ForEach(self.basket.vegetables) { vegetable in
                    if vegetable.freshness == 1 {
                        Text(vegetable.name)
                    }
                }
            }
        }
    }
}

En próximos posts seguiremos explorando el funcionamiento de los result builders y cómo utilizarlos para construir este tipo de DSL tan potente.

Referencias

Addendum (abril 2025) — ¿Qué ha pasado en Swift en estos tres años?

TL;DR
Las ideas básicas del post siguen siendo correctas, pero Swift ha eliminado varias limitaciones de los result builders y ha incorporado nuevas –y poderosas– macros que conviene conocer. Este apéndice resume los cambios relevantes (Swift 5.7 → 5.10) manteniendo el tono divulgativo del artículo original.

1. Fin del “límite de 10” gracias a parameter packs

En 2021 los result builders gestaban internamente una tupla de hasta diez genéricos, de ahí la restricción que comentaba el post.
Desde Swift 5.9 el compilador entiende variadic generics (propuesta SE‑0390) y la librería estándar ha reescrito ViewBuilder así:

@resultBuilder
public enum ViewBuilder {
    public static func buildBlock<each Content>(
        _ components: repeat each Content
    ) -> TupleView<(repeat each Content)> where repeat each Content: View
}

Parameter packs (<each T> / repeat each T) delegan la aridad al compilador, por lo que el DSL de SwiftUI (y cualquier builder que adopte ese patrón) acepta ahora tantos elementos como quieras, sin sobrecargas manuales.

Cómo adaptarlo a tus builders
Sustituye tu viejo

static func buildBlock(_ parts: String...) -> String

por la variante moderna:

static func buildBlock<each S>(_ parts: repeat each S) -> String
    where repeat each S == String

2. Entra en escena la nueva familia de macros

Swift 5.9 introdujo macros de compilador (SE‑0389, SE‑0397).
Aunque en el post comparábamos los result builders con las macros de LISP/C, las macros nativas de Swift juegan en otra liga:

Característica Result Builder Macro
Se aplica dentro de un cuerpo ({ … }) ✔︎ Opcional
Genera código expresivo (vistas, HTML…) ✔︎ ✔︎
Puede crear o alterar declaraciones completas ✔︎
Tiene acceso al AST completo ✗ (solo su cuerpo) ✔︎
Se invoca con atributo @MiBuilder @attachedMacro, #macro

Cuándo elegir qué

3. SwiftRegex ya forma parte del lenguaje

Lo que en WWDC22 se presentó como “SwiftRegex” quedó integrado en la sintaxis estándar a partir de Swift 5.7.
Hoy puedes escribir:

let fecha  = "27/04/2025"
let patron = Regex(#"\d{2}/\d{2}/\d{4}"#)

if fecha ~= patron {
    // …
}

El builder subyacente emplea componentes de expresiones regulares, no un result builder clásico, pero tu explicación sobre DSLs declarativos sigue plenamente vigente.

4. Concurrencia estricta y builders asíncronos

Desde Swift 5.10 el modo Strict Concurrency está activo por defecto.
Si tu builder genera código async:

@MyBuilder
func vista() async -> some View {
   // … 
}

marca las sobrecargas buildBlock con async/throws pertinentes o el compilador mostrará advertencias.

5. Otras minucias de sintaxis

Para profundizar

#programación