wandres.dev
DSLS · lambdas con receptor

@DslMarker: cerrar el ámbito

Un DSL anidado sin protección deja al usuario alcanzar desde dentro el vocabulario de todos los niveles superiores, y el compilador no dice nada. Esta lección estudia el problema del receptor exterior accesible, la anatomía de la meta-anotación que lo prohíbe y cómo se declara una anotación de marca propia, la regla exacta de resolución que introduce y su alcance por tipo de marcador, la vía de escape explícita mediante etiquetas y cuándo es legítimo usarla, y el argumento por el que un DSL anidado sin marcador produce estructuras silenciosamente incorrectas.

⏱ 18 min

Hay un error de diseño que solo aparece cuando un DSL empieza a tener éxito. Mientras los bloques son planos, todo funciona; en cuanto se anidan tres niveles, alguien escribe dentro del más interno una palabra que pertenece al más externo, el compilador la acepta sin pestañear porque es perfectamente válida, y el resultado es una estructura que no corresponde a lo que el texto parece decir. No hay excepción, no hay aviso, no hay traza: hay un árbol mal formado que se descubrirá mucho más tarde y en otro sitio. El problema no es de disciplina del usuario ni de calidad de la documentación, sino una consecuencia directa e inevitable de cómo se apilan los receptores implícitos, y por eso Kotlin no lo resolvió con una recomendación sino con una herramienta del lenguaje. Entender esa herramienta es entender que un DSL no se define solo por lo que permite escribir, sino sobre todo por lo que impide.

🎯 Al terminar esta lección sabrás
  • Reproducir el problema del receptor exterior accesible y explicar por qué el compilador lo considera código correcto.
  • Declarar una anotación de marca propia con @DslMarker y aplicarla a los tipos de un DSL anidado.
  • Enunciar la regla de resolución exacta que introduce el marcador y determinar su alcance cuando hay varios marcadores distintos.
  • Usar la cualificación explícita como vía de escape deliberada y justificar cuándo es legítima.

El problema: los niveles superiores siguen al alcance

Según establecimos en la primera lección de este nivel, los receptores implícitos se apilan y el más cercano gana cuando hay conflicto de nombres. La parte que aún no habíamos mirado es la otra: cuando no hay conflicto, los receptores exteriores siguen siendo perfectamente accesibles desde dentro, sin cualificar y sin ninguna marca visible en el código.

class HtmlBuilder {
    fun head(bloque: HeadBuilder.() -> Unit) { /* ... */ }
    fun body(bloque: BodyBuilder.() -> Unit) { /* ... */ }
}

class HeadBuilder {
    fun titulo(texto: String) { /* ... */ }
}

fun html(bloque: HtmlBuilder.() -> Unit) = HtmlBuilder().apply(bloque)

val pagina = html {
    head {
        titulo("Informe")
        head { }        // compila: el receptor exterior sigue vigente
        body { }        // compila: y produce un cuerpo dentro de la cabecera
    }
}

Las dos últimas líneas del bloque interno son código legal. Dentro de head hay dos receptores implícitos, un HeadBuilder y un HtmlBuilder, y como HeadBuilder no tiene ningún miembro llamado head ni body, la resolución asciende un nivel y encuentra los del padre. El usuario creyó estar escribiendo dentro de la cabecera; el compilador entendió que llamaba a un método del documento; ambos tenían razón según sus propias reglas.

Lo que hace este fallo especialmente incómodo es que crece con el tamaño del DSL. Cuanto más rico es el vocabulario de los niveles superiores, más palabras filtra hacia abajo, y cuanto más se anida, más niveles se acumulan. Un DSL de configuración con cinco niveles pone a disposición del usuario, en su punto más profundo, la unión de cinco vocabularios distintos, presentados por el autocompletado como si todos fueran igual de pertinentes.

Conviene además notar que el daño no siempre es una estructura absurda y fácil de detectar. El caso realmente peligroso es aquel en que el nombre filtrado produce algo plausible: una entrada que aparece en el sitio equivocado del árbol, una propiedad que se asigna en el objeto de otro nivel, un elemento que se registra dos veces porque el usuario creyó estar declarándolo dentro cuando lo estaba declarando fuera. Ninguna de esas situaciones lanza excepción; todas producen un resultado que se puede serializar, guardar y desplegar.

val pagina = html {
    head {
        titulo("Informe")
        // El usuario cree describir la cabecera y esta anadiendo al documento
        body { }
    }
}
// El arbol resultante tiene un body hermano de head, no dentro de el.
flowchart TD
A[Bloque interno] --> B{El nombre existe en el receptor mas cercano}
B -- Si --> C[Se resuelve contra el nivel actual]
B -- No --> D{Hay un receptor exterior vigente}
D -- Si sin marcador --> E[Se resuelve contra el nivel superior en silencio]
D -- Si con marcador --> F[Error de compilacion por ambito restringido]
D -- No --> G[Error de nombre no resuelto]

La anotación que cierra la puerta

Kotlin resuelve el problema con @DslMarker, que no es una anotación que se aplique a los builders sino una meta-anotación que se aplica a otra anotación. El patrón consiste en declarar una anotación propia para el DSL, marcarla, y luego usarla sobre todas las clases que participan en él.

@DslMarker
@Target(AnnotationTarget.CLASS, AnnotationTarget.TYPE)
annotation class HtmlDsl

@HtmlDsl class HtmlBuilder { /* ... */ }
@HtmlDsl class HeadBuilder { /* ... */ }
@HtmlDsl class BodyBuilder { /* ... */ }

La regla que esa marca introduce es breve y conviene aprenderla literalmente: cuando en un ámbito hay dos o más receptores implícitos cuyos tipos llevan la misma anotación de marca, solo el más cercano es accesible de forma implícita. Los demás dejan de participar en la resolución de nombres, y cualquier intento de usarlos sin cualificar produce un error de compilación con un mensaje que habla explícitamente de ámbito de DSL.

val pagina = html {
    head {
        titulo("Informe")
        head { }        // error: solo el receptor mas cercano es accesible
        body { }        // error: mismo motivo
    }
    body { }            // valido: aqui el receptor mas cercano es HtmlBuilder
}

Hay tres detalles que separan a quien copia el patrón de quien lo entiende. El primero es que la restricción se agrupa por anotación, no por DSL en abstracto: dos marcadores distintos no se estorban, y por eso un DSL puede anidarse dentro de otro sin que ninguno pierda su vocabulario. El segundo es que la marca se hereda, de modo que anotar una interfaz o una clase base cubre a todos sus descendientes y evita el olvido más frecuente. El tercero es que la anotación puede aplicarse también al tipo en la firma, lo cual resulta imprescindible cuando el receptor es un tipo ajeno que no se puede anotar en su declaración.

@DslMarker annotation class ConfigDsl

// El tipo receptor no es nuestro: se marca en el punto de uso
fun servidor(bloque: (@ConfigDsl ServidorAjeno).() -> Unit) { /* ... */ }
💡
Una anotación de marca por DSL, y siempre sobre una base común

Declara la anotación en el mismo módulo que el DSL y aplícala a una interfaz o clase base de la que hereden todos los builders del árbol. Así, añadir un nivel nuevo dentro de un año no exige recordar la regla: el nivel nuevo la hereda por construcción. Y no reutilices el marcador de otro DSL para el tuyo, porque compartir la anotación significa compartir la restricción, y dos DSLs marcados igual dejan de poder anidarse el uno dentro del otro.

La vía de escape y su precio

La restricción afecta al acceso implícito, no al acceso en general. El receptor exterior sigue existiendo y sigue siendo alcanzable si se lo nombra con una etiqueta explícita, que es el nombre de la función que lo introdujo o la etiqueta que se le haya puesto a mano.

val pagina = html {
    val documento = this
    head {
        titulo("Informe")
        this@html.body { }       // valido y visible: se sale al nivel superior
        documento.body { }       // equivalente, capturando antes la referencia
    }
}

Esa vía de escape no es una fuga del sistema sino la mitad esencial del diseño. El objetivo de @DslMarker nunca fue impedir el acceso, que en muchos casos es legítimo, sino impedir que ocurra por accidente y sin dejar rastro. Al obligar a escribir la etiqueta, el lenguaje convierte un salto silencioso de nivel en una afirmación explícita que el revisor de código puede ver, cuestionar y aprobar. La diferencia entre las dos situaciones no está en lo que el programa hace, sino en si el texto dice lo que el programa hace.

🚧

Restringe, no prohíbe

Solo se bloquea el acceso implícito. La cualificación con this@etiqueta sigue funcionando y es la forma correcta de subir de nivel cuando hace falta de verdad.

🧬

Se hereda

Marcar una interfaz base cubre a todos los builders que la implementen. Es la única manera realista de que el DSL siga protegido cuando crece.

🧩

Agrupa por anotación

Marcadores distintos no interfieren entre sí. Dos DSLs independientes pueden anidarse conservando cada uno su propio vocabulario en su propio nivel.

Un DSL se define por su ámbito, y un ámbito sin cerrar no es un idioma sino una sugerencia

Conviene mirar esta anotación desde una altura mayor que la de un remedio contra un descuido, porque lo que corrige es una confusión entre dos cosas que se parecen mucho y no son la misma. Un conjunto de funciones que pueden llamarse en un punto del programa es una biblioteca; un conjunto de funciones que pueden llamarse en un punto del programa y solo ahí es un lenguaje. Toda la utilidad de un DSL descansa en que el lector pueda leer un bloque anidado y deducir su significado a partir de su posición sin reconstruir mentalmente la pila entera de contextos que lo rodean, y esa deducción solo es válida si la posición determina de verdad el vocabulario. En cuanto los niveles superiores filtran sus palabras hacia abajo, la posición deja de ser informativa, el bloque ya no significa lo que parece y el lector se ve obligado a hacer exactamente el trabajo que el DSL prometía ahorrarle: comprobar en qué clase vive cada nombre. Lo grave no es que se pueda escribir algo incorrecto, porque eso pasa en cualquier API, sino que lo incorrecto sea indistinguible de lo correcto a simple vista y además pase la compilación, que es la peor combinación posible de las que puede ofrecer un lenguaje estáticamente tipado. Esa es la razón por la que la solución llegó al lenguaje y no a una guía de estilo: un problema de resolución de nombres solo se puede resolver en la resolución de nombres. Y hay una lección de diseño más general escondida aquí, que reaparecerá en la última lección del nivel: la calidad de una API no se mide por lo que permite expresar sino por la exactitud con la que su superficie coincide con lo que tiene sentido en cada momento. Un ámbito bien cerrado no limita al usuario, lo orienta; le dice, mediante el autocompletado y el compilador, cuál es el conjunto de frases con sentido en el punto donde tiene el cursor. Sin marcador, ese conjunto es la unión de todos los niveles y el usuario está solo.

⚔️ Cierra tu ámbito y comprueba la frontera
  1. Toma el DSL anidado que escribiste en la lección anterior y provoca deliberadamente el error del receptor exterior. Ejecuta el resultado y describe la estructura que realmente se construyó.
  2. Declara una anotación propia con @DslMarker, aplícala a todos los builders y comprueba que la misma línea deja de compilar. Traduce el mensaje del compilador a la regla de resolución.
  3. Mueve la anotación desde cada clase concreta a una interfaz base común y verifica que la protección se mantiene. Explica qué ganas frente a anotar una por una.
  4. Escribe un caso en el que necesites de verdad el nivel superior desde dentro y resuélvelo con this@etiqueta. Argumenta ante un revisor imaginario por qué ese salto es legítimo.
  5. Anida dos DSLs con marcadores distintos y comprueba que ambos vocabularios conviven. Predice después qué ocurriría si compartieran la misma anotación de marca y confírmalo.