wandres.dev
MACROS · metaprogramación

Freestanding y attached: los dos tipos y sus roles

Swift tiene dos familias de macro. Las libres se invocan con almohadilla y ocupan el lugar de una expresión o de una declaración; las adjuntas se escriben como atributo y se anclan a algo que ya existe. Los roles disponibles, qué puede hacer cada uno y por qué hay que declarar los nombres.

⏱ 18 min

La primera decisión al diseñar una macro no es qué código va a generar, sino dónde va a aparecer ese código. Swift responde a esa pregunta con dos familias sintácticamente distintas y conceptualmente complementarias. Una macro libre se invoca con una almohadilla delante y ocupa un hueco en el programa: se escribe donde iría una expresión o donde irían unas declaraciones, y lo que devuelve rellena exactamente ese hueco. Una macro adjunta se escribe como un atributo delante de algo que ya existe —un tipo, una función, una propiedad— y lo que devuelve se coloca alrededor de ese algo, en una posición que el rol elegido determina con precisión. La distinción no es cosmética: define qué recibe la implementación, qué puede producir y dónde acabará el resultado.

🎯 Al terminar esta lección sabrás
  • Separar las dos familias por su sintaxis de invocación y por lo que reciben como entrada.
  • Enumerar los roles libres y adjuntos, y asociar cada uno al protocolo que lo implementa.
  • Elegir el rol correcto a partir de dónde debe aparecer el código generado.
  • Explicar por qué hay que declarar los nombres introducidos y qué significa arbitrary.

Dos familias, dos preguntas

Una macro libre responde a la pregunta qué escribo aquí. Se invoca con #nombre y su expansión sustituye la invocación entera. Recibe únicamente los argumentos que le pases: no sabe nada del contexto donde ha caído, ni del tipo que la rodea, ni de la función donde está. Es autónoma, y de ahí el nombre.

Una macro adjunta responde a la pregunta qué le falta a esto. Se escribe @Nombre delante de una declaración y recibe dos cosas: el nodo del propio atributo, con sus argumentos, y la declaración completa a la que se ha adjuntado. Ese segundo parámetro lo cambia todo, porque le permite inspeccionar los miembros de un tipo, los parámetros de una función o el tipo de una propiedad, y generar en función de lo que encuentre.

// Libre: ocupa el hueco donde se escribe
let (valor, texto) = #stringify(a + b)

// Adjunta: se ancla a una declaracion existente y la observa
@Observable
final class Carrito {
    var articulos: [String] = []
}

Hay una consecuencia práctica inmediata. Si tu generación depende de qué hay dentro de un tipo, necesitas una adjunta; una libre nunca lo sabrá. Y si lo que quieres es un valor o unas declaraciones que no dependen de nada del entorno, una libre es más simple y más barata.

Las macros libres

Se declaran con @freestanding y un rol entre paréntesis. Hay dos estables y uno experimental.

@freestanding(expression) produce una expresión. La implementación conforma a ExpressionMacro y devuelve un ExprSyntax. Es el rol de #stringify, del #URL que valida una cadena en compilación, del #Predicate de SwiftData que convierte una clausura en un árbol consultable. Su marca distintiva es que el resultado tiene un tipo y puede colocarse en cualquier posición donde encaje ese tipo.

@freestanding(expression)
public macro URL(_ cadena: String) -> URL =
    #externalMacro(module: "MisMacrosMacros", type: "URLMacro")

// Si la cadena no es una URL valida, el error sale al compilar
let api = #URL("https://ejemplo.dev/v1")

@freestanding(declaration) produce una o varias declaraciones. Conforma a DeclarationMacro y devuelve un array de DeclSyntax. Sirve para fabricar familias enteras de tipos o funciones a partir de una lista, en el lugar donde escribes la invocación. Es el rol menos usado y el que más fácilmente se abusa, porque el código que introduce no está anclado a nada visible.

@freestanding(declaration, names: arbitrary)
public macro definirUnidades(_ nombres: String...) =
    #externalMacro(module: "MisMacrosMacros", type: "UnidadesMacro")

definirUnidades("Metro", "Segundo", "Kilogramo")
// genera tres struct con la misma forma

Existe además un rol codeItem, aún con nombre subrayado y no oficializado, que produce sentencias sueltas dentro de un cuerpo de función. Conviene conocerlo para no sorprenderse al verlo en bibliotecas, y no usarlo en producción.

Las macros adjuntas y sus anclajes

Aquí está la riqueza del sistema. @attached admite varios roles, y cada uno describe una posición distinta respecto a la declaración anotada.

Rol Protocolo Dónde aterriza lo generado
peer PeerMacro Al lado, en el mismo ámbito
member MemberMacro Dentro del cuerpo del tipo
memberAttribute MemberAttributeMacro Como atributo sobre cada miembro
accessor AccessorMacro Como accesores de la propiedad
extension ExtensionMacro Como extensión, con conformidades

peer es el más sencillo: recibe una declaración y produce declaraciones hermanas. Una macro que, dada una función asíncrona, genere su gemela con callback es el ejemplo canónico. member es el caballo de batalla: recibe un tipo entero y añade miembros dentro, que es lo que hacen @Observable y el @Model de SwiftData.

memberAttribute es el más sutil y el que más gente no entiende a la primera: no genera código, genera atributos, que se colocan sobre cada miembro del tipo anotado y que a su vez pueden ser macros. Es el mecanismo que permite que anotar una clase entera acabe transformando cada una de sus propiedades sin que escribas nada en ellas.

accessor convierte una propiedad almacenada en calculada aportándole get, set o los observadores. extension añade extensiones y, sobre todo, declara conformidades, que es la única vía por la que una macro puede hacer que un tipo conforme a un protocolo. Fue el rol que sustituyó al antiguo conformance, hoy retirado.

Hay finalmente dos roles recientes, body y preamble, que permiten a una macro generar o envolver el cuerpo de una función; llegaron detrás de una bandera experimental y son la única grieta en la regla de que las macros solo suman.

flowchart TB
q1[Donde debe aparecer el codigo generado]
q1 -->|En el hueco donde escribo| lib[Macro libre]
lib --> e1[Rol expression si es un valor]
lib --> e2[Rol declaration si son tipos o funciones]
q1 -->|Junto a algo que ya existe| adj[Macro adjunta]
adj --> r1[Rol member para anadir dentro del tipo]
adj --> r2[Rol peer para anadir al lado]
adj --> r3[Rol accessor para dar get y set]
adj --> r4[Rol extension para anadir conformidades]
adj --> r5[Rol memberAttribute para anotar cada miembro]
style lib fill:#89b4fa,color:#11111b
style adj fill:#cba6f7,color:#11111b
style r1 fill:#a6e3a1,color:#11111b

Declarar los nombres y combinar roles

Una macro que introduce nombres visibles debe anunciarlos en su declaración. No es burocracia: el compilador y las herramientas necesitan saber qué nombres van a existir sin tener que expandir la macro, porque expandir cuesta lanzar un proceso y porque el autocompletado no puede permitírselo en cada pulsación.

@attached(member, names: named(init), prefixed(_))
@attached(extension, conformances: Equatable, names: named(==))
public macro Modelo() =
    #externalMacro(module: "MisMacrosMacros", type: "ModeloMacro")

Las formas disponibles son named para un nombre concreto, prefixed y suffixed para nombres derivados del original con un adorno, overloaded para una sobrecarga del mismo nombre, y arbitrary para cuando de verdad no se puede saber de antemano. arbitrary funciona, pero apaga las optimizaciones de búsqueda y obliga al compilador a expandir para resolver nombres; úsalo solo cuando los nombres dependan de los datos, como al derivar un miembro por cada caso de un enum.

El fragmento anterior muestra también lo segundo: una misma macro puede tener varios roles. Se apilan los atributos @attached sobre la misma declaración macro, y el mismo tipo de implementación conforma entonces a varios protocolos. Así funciona @Observable: un rol member que añade el registrador y el almacenamiento, un memberAttribute que marca cada propiedad, y un extension que declara la conformidad. Tres roles, un solo atributo para quien la usa.

🎯

La entrada decide la familia

Si necesitas ver la declaración anotada, tiene que ser adjunta. Una macro libre solo ve sus argumentos, nunca su entorno.

🧩

El rol es una posición

Cada rol nombra un lugar exacto del programa. Elegir rol es responder dónde debe aterrizar el resultado, no qué debe contener.

🏷️

Los nombres se anuncian

Declarar los nombres permite al compilador y al editor trabajar sin expandir. arbitrary es la salida de emergencia, no la opción cómoda.

Por qué el catálogo de roles es cerrado y por qué eso es lo correcto

La reacción natural de quien viene de Lisp o de Rust ante esta lista es sospechar que es arbitraria. En Lisp una macro es una función de código a código sin más restricción, y en Rust un proc_macro de atributo recibe el elemento entero y devuelve lo que quiera, incluida una versión modificada. Swift eligió lo contrario: un catálogo fijo de posiciones, cada una con su protocolo, su firma y su contrato. La razón no es timidez, es la conservación de una propiedad que Swift considera innegociable: que el significado del código escrito no cambie. Si una macro adjunta pudiera devolver una versión reescrita de la declaración anotada, entonces leer func transferir(desde: Cuenta, hacia: Cuenta) con un atributo encima dejaría de decirte nada sobre qué hace esa función, y ninguna auditoría de código volvería a ser fiable sin expandir cada macro del archivo. Al fijar los roles, Swift consigue algo notable: puedes saber, solo mirando la declaración de la macro y sin ejecutarla, qué clase de efecto puede tener sobre tu programa. Un rol accessor no puede añadir conformidades. Un rol member no puede tocar el cuerpo de tus funciones. Un rol peer no puede entrar en el tipo. Esa legibilidad estática es la misma apuesta que hay detrás de some y any, de la concurrencia verificada y del sistema de acceso: preferir un mecanismo menos expresivo pero cuyas consecuencias se puedan leer. El precio se paga cuando tu idea no encaja en ningún rol y descubres que no hay escapatoria, y la respuesta correcta entonces casi nunca es forzar la macro, sino aceptar que el lenguaje te está diciendo que ese problema no es de generación de código. Los roles body y preamble son la excepción que confirma la regla, y no es casualidad que fueran los más discutidos ni que llegaran los últimos y con reservas: son justamente los que permiten alterar lo que ya estaba escrito.

📝
Lo esencial de esta lección

Las macros libres se invocan con almohadilla y rellenan el hueco donde se escriben, con rol expression o declaration. Las adjuntas se escriben como atributo, reciben la declaración anotada y colocan el resultado según su rol: peer, member, memberAttribute, accessor o extension. Una macro puede tener varios roles a la vez, y debe declarar los nombres que introduce.

⚔️ Elige el rol correcto
  1. Para cada idea —validar una URL, añadir isFoo por cada caso de un enum, registrar toda llamada a una función, hacer que un tipo conforme a Codable— decide familia y rol, y justifícalo.
  2. Escribe la declaración macro completa, con roles y lista de nombres, para una macro que genere un inicializador y una conformidad a Equatable.
  3. Explica por qué memberAttribute es imprescindible para que @Observable funcione sin anotar cada propiedad.
  4. Razona qué le falta a una macro libre para poder generar miembros dentro de un tipo, y por qué esa carencia es deliberada.
  5. Busca una macro de una biblioteca que uses, lee su declaración y deduce, sin mirar la implementación, todo lo que puede hacerle a tu código.