Escribir tu propio DSL: un constructor de HTML paso a paso
Construir un lenguaje embebido desde cero: elegir el tipo de resultado, escribir `buildBlock`, abrir el control de flujo con `buildOptional` y `buildArray`, anidar bloques mediante funciones que reciben clausuras, y decidir qué dejar fuera para que el DSL siga siendo legible.
Leer la mecánica de un result builder deja la impresión de que lo difícil es la transformación. No lo es: la transformación la hace el compilador y cabe en una tarde. Lo difícil de un DSL es el diseño, y las decisiones que importan se toman antes de escribir el primer método. Qué tipo representa un elemento del dominio, qué representa una colección de ellos, dónde vive el anidamiento, qué construcciones del lenguaje anfitrión tienen sentido dentro del bloque y cuáles conviene prohibir aunque sean fáciles de habilitar. Vamos a construir un generador de HTML entero, porque el dominio es familiar y porque exhibe los tres problemas que aparecen en cualquier DSL real: la heterogeneidad de los elementos, el anidamiento arbitrario y la tentación de permitir demasiado.
- Elegir el tipo de resultado de un builder a partir de la estructura del dominio.
- Escribir el builder completo con
buildBlock,buildExpression,buildOptional,buildEitherybuildArray. - Implementar el anidamiento mediante funciones que reciben un parámetro de clausura marcado.
- Justificar qué construcciones dejar fuera del DSL y por qué esa decisión es de diseño.
El dominio antes que el builder
Un documento HTML es un árbol de nodos donde cada nodo es un elemento con hijos o un fragmento de texto. Esa frase es ya el modelo, y en Swift se escribe con un enum recursivo indirecto:
indirect enum Nodo {
case texto(String)
case elemento(etiqueta: String, atributos: [String: String], hijos: [Nodo])
}
Ahora la pregunta decisiva: ¿qué devuelve un bloque del DSL? La tentación es responder Nodo, pero es incorrecta, porque un bloque puede aportar cero elementos —un if que no se cumple— o muchos. El tipo de resultado natural es una colección de nodos, y esa elección simplifica todos los métodos que vienen después, porque combinar se reduce a concatenar y el caso vacío se representa sin esfuerzo.
@resultBuilder
enum HTML {
static func buildBlock(_ partes: [Nodo]...) -> [Nodo] {
partes.flatMap { $0 }
}
static func buildExpression(_ nodo: Nodo) -> [Nodo] { [nodo] }
static func buildExpression(_ texto: String) -> [Nodo] { [.texto(texto)] }
static func buildExpression(_ nada: Void) -> [Nodo] { [] }
}
Las tres sobrecargas de buildExpression son la capa de adaptación: permiten escribir un nodo, una cadena suelta o una llamada que no devuelve nada, y que todo aterrice en el mismo tipo. La última evita el error más molesto para quien usa el DSL, el de colar una llamada de efecto lateral dentro del bloque.
Abrir el control de flujo
Con lo anterior el DSL ya funciona, pero solo admite listas literales de elementos. Los tres métodos restantes lo convierten en algo utilizable:
extension HTML {
static func buildOptional(_ parte: [Nodo]?) -> [Nodo] { parte ?? [] }
static func buildEither(first parte: [Nodo]) -> [Nodo] { parte }
static func buildEither(second parte: [Nodo]) -> [Nodo] { parte }
static func buildArray(_ partes: [[Nodo]]) -> [Nodo] { partes.flatMap { $0 } }
}
Sobre un resultado homogéneo, los cuatro son casi triviales, y esa trivialidad es una buena señal: significa que el tipo de resultado estaba bien elegido. Cuando escribir estos métodos resulta laborioso o exige casos especiales, casi siempre el problema está arriba, en un tipo de resultado que no cierra bien bajo la concatenación.
El anidamiento: funciones que reciben bloques
Falta la pieza que convierte una lista plana en un árbol, y no está en el builder sino en la API que lo rodea. Cada etiqueta HTML se modela como una función cuyo último parámetro es una clausura marcada con el atributo:
func etiqueta(
_ nombre: String,
_ atributos: [String: String] = [:],
@HTML hijos: () -> [Nodo] = { [] }
) -> Nodo {
.elemento(etiqueta: nombre, atributos: atributos, hijos: hijos())
}
func div(_ clase: String? = nil, @HTML hijos: () -> [Nodo]) -> Nodo {
etiqueta("div", clase.map { ["class": $0] } ?? [:], hijos: hijos)
}
func p(@HTML hijos: () -> [Nodo]) -> Nodo { etiqueta("p", hijos: hijos) }
func li(@HTML hijos: () -> [Nodo]) -> Nodo { etiqueta("li", hijos: hijos) }
func ul(@HTML hijos: () -> [Nodo]) -> Nodo { etiqueta("ul", hijos: hijos) }
El anidamiento se obtiene gratis: cada función devuelve un Nodo, y un Nodo es una expresión válida dentro de otro bloque, donde buildExpression lo recogerá. La recursión del tipo y la recursión de la sintaxis coinciden, que es exactamente lo que se busca en un DSL de árboles. El resultado ya se lee como el dominio y no como Swift:
let items = ["Ingresos", "Gastos", "Margen"]
let mostrarAviso = true
let pagina = div("informe") {
p { "Resumen del trimestre" }
if mostrarAviso {
p { "Cifras provisionales" }
}
ul {
for item in items {
li { item }
}
}
}
Solo queda el renderizado, que es una función recursiva ordinaria sobre el enum y no tiene nada que ver con el builder. Esa separación es deseable: el builder construye el árbol y nadie más; la interpretación del árbol es un problema aparte, y puede haber varias —serializar a texto, comparar dos árboles, calcular métricas— sobre la misma construcción.
flowchart TB dsl[Bloque del DSL escrito por el usuario] --> tr[Transformacion del compilador con el builder HTML] tr --> arbol[Valor de tipo array de Nodo] arbol --> r1[Renderizador a texto] arbol --> r2[Comparador de arboles] arbol --> r3[Validador de estructura] style dsl fill:#cba6f7,color:#11111b style arbol fill:#89b4fa,color:#11111b style r1 fill:#a6e3a1,color:#11111b
Qué dejar fuera
El tipo de resultado manda
Elige un tipo que cierre bajo la concatenación y que represente el caso vacío. Si los métodos del builder salen triviales, acertaste; si salen retorcidos, vuelve al tipo.
El anidamiento no es del builder
Los bloques anidados se consiguen con funciones que reciben clausuras marcadas. El builder solo sabe combinar hermanos; la jerarquía la pone tu API.
Prohibir es diseñar
Cada método que no implementas cierra una construcción. Un DSL que lo permite todo obliga a leer el bloque como código general en vez de como una descripción.
El mismo esqueleto sirve para dominios muy distintos con solo cambiar el tipo de resultado. Un constructor de consultas acumula predicados en un array y los combina con una conjunción en buildBlock; ahí buildOptional significa filtro condicional y buildArray significa filtro por cada elemento de una lista. Un constructor de validaciones acumula funciones que devuelven errores y los concatena, con la ventaja de que reporta todos los fallos en vez de detenerse en el primero. Un constructor de rutas acumula pares de patrón y manejador. En los tres casos, el trabajo de diseño está en el tipo del resultado y en la API de anidamiento; los métodos del builder son consecuencia.
La decisión más difícil suele ser cuánto permitir. Es tentador exponer todo lo posible, pero cada construcción habilitada acerca el bloque al código general y lo aleja de la descripción declarativa, y hay un coste real de legibilidad: cuando un bloque mezcla condicionales anidados, bucles y llamadas con efectos, deja de poder leerse de un vistazo como la forma del árbol. Una heurística que funciona: permite condicionales y bucles simples, y saca todo lo demás a funciones auxiliares que devuelvan un nodo ya construido.
Escribe primero, en un comentario, el bloque que te gustaría poder escribir. De ahí se deduce el tipo de resultado, qué expresiones debe aceptar buildExpression y qué funciones de anidamiento necesitas. Diseñar el builder antes que su sintaxis de uso es la vía rápida a un DSL que nadie quiere usar.
Todo DSL vive en una tensión entre dos extremos y conviene saber dónde te sitúas. Un lenguaje independiente —una plantilla, un archivo de configuración, un lenguaje de consulta con su propio parser— te da libertad sintáctica total: puedes inventar la notación que mejor describa el dominio, sin negociar con la gramática de nadie. El precio es todo lo demás: escribir un analizador, mantener una gramática, construir herramientas, renunciar al autocompletado y al comprobador de tipos del anfitrión, y descubrir en ejecución los errores que un compilador habría cazado. Un DSL empotrado como el que acabas de escribir hace el intercambio contrario: hereda gratis el sistema de tipos, el editor, el depurador, los genéricos y la capacidad de llamar a cualquier función del programa, a cambio de aceptar la sintaxis del anfitrión tal como es. Los result builders son notables porque desplazan ese equilibrio: amplían justo lo suficiente la sintaxis del anfitrión —la yuxtaposición de sentencias, el significado de un condicional dentro de un bloque— como para que la descripción de un árbol deje de parecer un literal de datos, sin abandonar en ningún momento el compilador. Y hay un detalle que suele pasar inadvertido: por muy declarativo que parezca tu bloque, sigue siendo código imperativo que se ejecuta cada vez. Las funciones se llaman, las clausuras se evalúan, los bucles iteran de verdad. Un bloque de HTML que consulta una base de datos en cada rama no es una descripción, es un programa disfrazado de descripción, con toda la latencia y todos los efectos de un programa. La disciplina que separa a un DSL bueno de uno peligroso no está en el builder ni en los tipos: está en mantener el bloque libre de efectos y barato de evaluar, porque quien lo lee lo leerá como si fuera datos, y quien lo llama puede evaluarlo muchas más veces de las que imaginas.
Modela primero el dominio y elige un tipo de resultado que cierre bajo la concatenación, normalmente una colección. Implementa buildBlock y las sobrecargas de buildExpression que normalicen la entrada, y abre el control de flujo con buildOptional, buildEither y buildArray. El anidamiento no lo aporta el builder sino funciones que reciben clausuras marcadas. Interpreta el árbol aparte, y prohíbe deliberadamente lo que aleje el bloque de una descripción.
- Completa el generador de HTML con un renderizador recursivo a texto y comprueba la salida del ejemplo de la lección.
- Añade atributos a las funciones de etiqueta y verifica que el árbol resultante los conserva.
- Elimina
buildArrayy observa qué parte del ejemplo deja de compilar; explica el mensaje. - Escribe un builder de validaciones que acumule errores y compáralo con una cadena de
guard, atendiendo a qué reporta cada uno. - Escribe un builder de consultas cuyo
buildBlockcombine predicados con una conjunción y decide, con argumentos, si permitir o no unforen su interior.