Result builders en Swift (2)
Segundo post antiguo recuperado, de julio de 2022.
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.
En el post anterior de la serie sobre result builders vimos cómo éstos permiten utilizar un DSL para definir una clausura o un bloque de código que construye un componente a partir de componentes elementales.
Vimos el ejemplo sencillo de un constructor de cadenas:
@resultBuilder
struct StringConcatenator {
static func buildBlock(_ components: String...) -> String {
return components.joined(separator: ", ")
}
}
El código anterior crea la anotación @StringConcatenator
que podemos usar
para aplicar el result builder. Por ejemplo, podemos aplicarlo a la
definición de una función:
@StringConcatenator
func holaMundo() -> String {
"Hola"
"mundo"
}
print(holaMundo())
// Imprime: Hola, mundo
La función anterior construye una cadena uniendo las cadenas elementales que definimos en su cuerpo. Recordemos que el result builder transforma en tiempo de compilación este cuerpo, convirtiéndolo en algo como:
func holaMundo() -> String {
let v0 = "Hola"
let v1 = "mundo"
return StringConcatenator.buildBlock(v0, v1)
}
Por último, terminamos explicando que si anotábamos con el atributo un parámetro de una función, el result builder se aplicaba a la clausura que se pasaba como parámetro. Algo interesante porque permite usar el result builder sin que aparezca la anotación:
func imprimeSaludo(@StringConcatenator _ contenido: () -> String) {
print(contenido())
}
// Llamamos a la función con una clausura que usa el DSL.
// No es necesario añadir la anotación @StringConcatenator.
imprimeSaludo {
"Hola"
"mundo"
}
// Imprime: Hola, mundo
En este segundo post vamos a ver otros lugares en los que se puede usar el atributo del result builder y otras transformaciones que se pueden realizar.
Result builders en inicializadores
En SwiftUI se utiliza el result builder ViewBuilder para construir vistas. Un ejemplo es el siguiente:
let vista =
HStack {
ForEach(
1...5,
id: \.self
){
Text("Ítem \($0)")
}
}
La vista construida es una pila horizontal con cinco subvistas de tipo
Text
:
Vemos que el HStack
recibe una clausura con código DSL que
especifica las subvistas. El ViewBuilder
transformará ese DSL en
código Swift.
¿Por qué no tenemos que usar el atributo @ViewBuilder
?. La
explicación está en que ese atributo se ha usado en un parámetro de
una función. En concreto en un parámetro del inicializador de
HStack
.
Vamos a hacer algo similar con el StringConcatenator
.
Ejemplo de result builder en un inicializador
Supongamos la siguiente estructura Persona
:
struct Persona {
let contenido: () -> String
var saludo: String {
contenido()
}
init(@StringConcatenator contenido: @escaping () -> String) {
self.contenido = contenido
}
}
Estamos definiendo una estructura con una propiedad almacenada
contenido
que contiene una clausura sin parámetros que devuelve una
cadena. Y una variable calculada saludo
que devuelve la cadena
resultante de ejecutar esa clausura.
Definimos también el inicializador de Persona
con el parámetro que
inicializa la propiedad contenido
. Para construir una instancia de
Persona
debemos pasar como argumento la clausura que va a generar el
saludo. Y añadimos a ese parámetro el atributo @StringConcatenator
para indicar el argumento que pasemos debe ser transformado por el
result builder. El atributo @escaping
no es importante; tiene que
ver con la forma de gestionar el ámbito de la clausura y el compilador
da un error si no lo ponemos.
Ahora ya podemos crear una instancia de Persona
pasando una clausura
que usa el DSL:
let frodo = Persona {
"Hola"
"me"
"llamo"
"Frodo"
}
Una vez construida la instancia, se habrá guardado en su propiedad
contenido
la clausura que devuelve el saludo. Llamamos a la clausura
accediendo a la propiedad saludo
:
print(frodo.saludo)
Se imprime:
Hola, me, llamo, Frodo
Simplificando el inicializador
A los ingenieros que diseñaron los result builders se les ocurrió un azucar sintáctico que permite hacer más sencilla todavía la construcción anterior.
Dado que las estructuras en Swift generan automáticamente un inicializador memberwise, se podría usar el atributo del result builder directamente en la propiedad. No tenemos que definir el inicializador porque Swift lo crea automáticamente:
struct PersonaSimple {
@StringConcatenator let contenido: () -> String
var saludo: String {
contenido()
}
}
No hace falta especificar nada más. Swift genera automáticamente el inicializador de la estructura correctamente y podemos usarlo de la misma forma que antes:
let frodo2 = PersonaSimple {
"Hola"
"me"
"llamo"
"Frodo"
}
print(frodo2.saludo)
// Imprime: Hola, me, llamo, Frodo
Esta forma de definir un result builder es una de las más usadas. Se utiliza en la gran mayoría de DSLs construidos en Swift, incluido SwiftUI.
Result builders en protocolos
Otra forma de aplicar un result builder sin usar explícitamente la anotación correspondiente es mediante un protocolo. Si marcamos con la anotación un método o una propiedad de un protocolo se aplicará el result builder en el código que adopta el protocolo.
Vamos a seguir con el ejemplo del saludo construido con el
@StringConcatenator
. Podemos definir un protocolo con una
propiedad con el saludo:
protocol Educado {
@StringConcatenator var saludo: String {get}
}
Al definir de esta forma la propiedad, cualquier tipo que adopte el
protocolo Educado
deberá definir una propiedad saludo
en la que se
podrá usar el result builder. Por ejemplo, definimos la estructura
PersonaEducada
de la siguiente forma:
struct PersonaEducada: Educado {
var nombre: String
var saludo: String {
"Hola"
"me"
"llamo"
nombre
}
}
Estamos definiendo el saludo
con las cadenas que se muestran en las
distintas sentencias ("Hola"
, "me"
, "llamo"
) y la propiedad
nombre
. El result builder @StringConcatenator
transformará este
código de la forma que hemos visto anteriormente.
Al ser saludo
una variable calculada, la única variable almacenada
que hay que especificar al crear la estructura es el nombre
de la
persona. Lo hacemos de la forma siguiente, llamando al inicializador
memberwise creado automáticamente:
let gandalf = PersonaEducada(nombre: "Gandalf")
Y, una vez creada la instancia de una PersonaEducada
podemos pedir
su saludo:
print(gandalf.saludo)
Como siempre, se imprimirá:
Hola, me, llamo, Gandalf
Transformaciones más elaboradas
Hasta ahora hemos visto cómo el result builder construye un
componente complejo a partir de componentes elementales usando la
función estática buildBlock
.
El perfil de esta función es el siguiente:
static func buildBlock(_ components: Component...) -> Component
En el caso de los ejemplos anteriores el tipo componente es un
String
y la función buildBlock
recibe un número variable de
cadenas y construye la cadena resultante.
Sin embargo, es posible que en ciertos DSLs tengamos que hacer algún
tipo de transformación en los componentes iniciales. O aplicar una
última transformación al valor resultante. Para tener este control más
fino podemos especificar dos funciones adicionales en el result
builder, las funciones buildExpression
y buildFinalResult
.
El perfil de ambas funciones es el siguiente:
static func buildExpression(_ expression: Expression) -> Component
static func buildFinalResult(_ component: Component) -> FinalResult
La función
buildExpression(_ expression: Expression) -> Component
se utiliza para transformar los resultados de las sentencias del DSL, del tipo Expression en el tipo resultante Component que se va a usar en elbuildBlock
. Permite que el tipo de las expresiones que aparecen en el DSL sea distinto del tipo resultante.La función
buildFinalResult(_ component: Component) -> FinalResult
se usa para construir el resultado final que va a devolver el result builder. Permite distinguir el tipo componente del tipo resultado de forma que, por ejemplo, el result builder podría realizar transformaciones internas en un tipo que no queremos exponer a los clientes y al final realizar una transformación al tipo resultante.
Estas funciones son opcionales. Si no las especificamos, el result builder solo trabaja con el tipo Component tal y como hemos visto en los ejemplos anteriores.
Un ejemplo sencillo es el siguiente, en el que definimos un result builder que construye un array de números reales. Las expresiones que escribimos en el DSL son de números enteros.
@resultBuilder
struct ArrayBuilder {
static func buildExpression(_ expression: Int) -> [Int] {
return [expression]
}
static func buildBlock(_ components: [Int]...) -> [Int] {
return Array(components.joined())
}
static func buildFinalResult(_ component: [Int]) -> [Double] {
component.map {Double($0)}
}
}
La función
buildExpression
transforma el número entero original en un array con un único dato. En este caso el tipo Expression es unInt
y el tipo Component resultante es un[Int]
.La función
buildBlock
es la que une varios componentes (arrays de enteros de un elemento) en un resultado final, un array de enteros.Y la función
buildFinalBlock
transforma el componente resultante de la función anterior en el tipo FinalResult, un[Double]
.
Podemos ver un resultado del funcionamiento en el siguiente ejemplo:
@ArrayBuilder
func buildArray() -> [Double] {
100
100+100
(100+100)*2
}
print(buildArray())
En el DSL que define el cuerpo de la función se escriben tres sentencias que devuelven enteros. Estas tres sentencias son las expresiones que va a tomar el result builder para aplicar todas las transformaciones anteriores.
El resultado final es el siguiente array de números reales:
[100.0, 200.0, 400.0]
Referencias
- Propuesta de Result Builders en Swift Evolution
- Explicación detallada en Language Reference
- Fichero de código con los ejemplos del post
Addendum (abril 2025) — ¿Qué ha pasado en Swift en estos tres años?
Contexto rápido
Desde que publicaste este segundo artículo (2022) Swift ha seguido evolucionando.
Este apéndice resume los cambios que impactan en los temas que tratabas: inicializadores, protocolos y funciones avanzadas (buildExpression
,buildFinalResult
).
1. Inicializadores + result builder → ahora también en clases
Swift 5.8 amplió la capacidad de marcar initializers designados de clase con atributos de builder.
Ejemplo adaptado a tu Persona
:
class Persona {
private let contenido: () -> String
var saludo: String { contenido() }
init(@StringConcatenator contenido: @escaping () -> String) { // ✅ válido en 5.8+
self.contenido = contenido
}
}
2. Memberwise + atributos: se generan automáticamente
A partir de Swift 5.9, cuando anotas una propiedad stored con un builder (p. ej. @StringConcatenator let contenido: () -> String
) el compilador ya no exige que marques el parámetro del member‑wise initializer con el mismo atributo; lo hace solo.
struct PersonaSimple {
@StringConcatenator let contenido: () -> String // ← suficiente
}
3. Protocolos con builders: ahora admiten async
/throws
Con la adopción de Strict Concurrency (Swift 5.10), los requirements de protocolo pueden declararse:
protocol Educado {
@StringConcatenator var saludo: String { get async }
}
Quien implemente el protocolo podrá usar un builder y además devolver un valor asíncrono.
4. Nuevas funciones de fase intermedia
Swift 5.7 introdujo buildPartialBlock(first:)
y buildPartialBlock(accumulated:)
.
Si las implementas puedes omitir buildBlock
, y el compilador ensamblará el resultado incrementalmente (útil para performance en builders pesados).
static func buildPartialBlock<each T>(first value: repeat each T) -> (repeat each T) { value }
static func buildPartialBlock<each T>(accumulated: (repeat each T), next: (repeat each T)) -> (repeat each T) {
(repeat each accumulated, repeat each next)
}
Tip: Con parameter packs (
<each T>
) no necesitas sobrecargas de 1…10 elementos.
5. buildExpression
+ registros de errores
Si tu buildExpression
puede lanzar, ya puedes marcarla throws
(Swift 5.9).
El error se propaga al punto donde se usa el builder; no es necesario capturarlo dentro.
static func buildExpression(_ value: Int) throws -> [Int] { … }
6. Macros vs. Result Builders (recordatorio breve)
La nueva era de Swift Macros (SE‑0389/0397) no reemplaza a los builders pero sí cubre casos que antes forzábamos con ellos:
Qué quiero lograr | Builder | Macro |
---|---|---|
DSL declarativo (SwiftUI, HTML…) | ✔︎ | ✔︎ |
Generar nuevas declaraciones, envoltorios, Codable automático… |
✗ | ✔︎ |
Validación del AST completo en compile‑time | ✗ | ✔︎ |
Para seguir profundizando
- SE‑0390 – Variadic Generics (parameter packs)
- SE‑0389 / SE‑0397 – Swift Macros
- WWDC23 “Design Data‑Driven Apps with Result Builders”