Qué es una macro de Swift: código que genera código
Una macro recibe sintaxis y devuelve sintaxis durante la compilación. Qué la separa del preprocesador de C, qué garantizan la higiene y el tipado, y por qué Swift abrió por fin la síntesis que antes era privilegio exclusivo del compilador.
Hay una asimetría vieja en Swift que casi nadie nota hasta que le estorba: el compilador escribe código por ti todo el rato —el inicializador por miembros de un struct, los métodos de Codable, el operador de igualdad de un Equatable, la lista de allCases— y ninguna de esas síntesis estaba a tu alcance. Eran privilegios cocinados dentro del compilador, uno a uno, tras años de propuestas. Si tu necesidad se parecía a Codable pero no era Codable, no había nada que hacer salvo teclear a mano o generar archivos con una herramienta externa. Las macros, llegadas en Swift 5.9, terminan con esa asimetría: un programa tuyo, escrito en Swift ordinario, se ejecuta durante la compilación, recibe el árbol sintáctico de lo que has escrito y devuelve más árbol sintáctico que el compilador incorpora como si lo hubieras tecleado tú. No es una plantilla de texto ni un preprocesador: es un compilador extensible.
- Situar las macros como la apertura de la síntesis que el compilador hacía en exclusiva.
- Describir el ciclo real de una expansión: sintaxis de entrada, plugin externo, sintaxis de salida.
- Explicar qué significan en concreto las garantías de higiene, tipado y carácter aditivo.
- Distinguir una macro de Swift de un preprocesador de texto y de un generador de código externo.
La síntesis que antes era privilegio
Repasa lo que el compilador ya generaba antes de 2023 y verás un patrón incómodo. Codable produce dos métodos y un enum de claves. Un struct recibe un inicializador por miembros. Un enum sin valores asociados obtiene ==, hash y, si lo pides, allCases. Un actor gana un ejecutor. Cada una de esas capacidades resolvió un caso real, cada una exigió una propuesta de evolución del lenguaje y una implementación en C++ dentro del compilador, y ninguna era extensible por nadie de fuera de Apple.
La alternativa que floreció mientras tanto fue la generación de código externa: Sourcery, SwiftGen, gyb. Herramientas que leen tus fuentes, escriben archivos nuevos y los meten en el proyecto antes de compilar. Funcionan, pero el precio es alto y siempre el mismo: código generado que vive en el repositorio, un paso de build fuera del compilador que hay que orquestar, ausencia total de comprobación de tipos en la plantilla, y un desfase permanente entre lo que ves y lo que se compila.
Las macros atacan exactamente ese hueco. No son azúcar sintáctico ni un atajo de escritura: son el reconocimiento de que la generación de código es una necesidad estructural de cualquier lenguaje estáticamente tipado con un sistema de tipos expresivo, y de que esa necesidad merece un mecanismo de primera clase dentro del compilador en vez de un ecosistema de andamios alrededor.
El resultado se ve mejor en lo que Apple construyó encima en cuanto tuvo el mecanismo. @Observable sustituyó a un protocolo con un property wrapper por propiedad. @Model de SwiftData reemplazó al modelo de datos gráfico de Core Data. Swift Testing entero descansa sobre @Test y @Suite. Y #Predicate convierte una clausura ordinaria en un árbol que se puede traducir a una consulta de base de datos. Ninguna de esas cuatro cosas cabía en el lenguaje antes, y las cuatro llegaron en los dos años siguientes a las macros: es la señal más clara de que el hueco existía.
Qué hace una macro exactamente
Una macro tiene dos mitades, y confundirlas es el error inicial más común. La declaración vive en tu módulo, la escribes con la palabra clave macro y parece la firma de una función: tiene parámetros, tiene tipo de retorno y participa en la comprobación de tipos como cualquier otra declaración. La implementación vive en otro objetivo de compilación, es un tipo que conforma a un protocolo de SwiftSyntaxMacros y contiene el programa que hace el trabajo.
// Declaracion: lo que ve quien la usa
@freestanding(expression)
public macro stringify<T>(_ valor: T) -> (T, String) =
#externalMacro(module: "MisMacrosMacros", type: "StringifyMacro")
// Uso
let par = #stringify(2 + 3) // par == (5, "2 + 3")
El compilador, al encontrar el uso, no interpreta nada por sí mismo. Localiza el plugin indicado por #externalMacro, lo lanza como un proceso aparte, le envía por un canal el árbol sintáctico de la invocación serializado, y espera. Del otro lado, tu implementación recibe ese árbol, lo inspecciona, construye un árbol nuevo y lo devuelve. El compilador toma la respuesta, la parsea, la coloca en un búfer virtual asociado al punto de uso y sigue compilando con ella dentro.
// Implementacion, en el objetivo de macro
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
guard let arg = node.arguments.first?.expression else {
throw MacroError.faltaArgumento
}
return "(\(arg), \(literal: arg.description))"
}
}
Dos detalles de ese fragmento merecen atención porque contradicen la intuición. El primero: lo que se devuelve es un ExprSyntax, un nodo de árbol, no una cadena; la cadena literal del return funciona porque los nodos de SwiftSyntax se pueden construir por interpolación, pero lo que viaja de vuelta es sintaxis parseada. El segundo: la macro no sabe cuánto vale 2 + 3. Solo ve que hay una suma de dos literales. Una macro opera sobre la forma del programa, jamás sobre su significado ni sobre su ejecución.
Lo que acaba compilándose, por tanto, es código Swift perfectamente ordinario que podrías haber tecleado:
// Lo que escribes
let par = #stringify(2 + 3)
// Lo que el compilador compila realmente
let par = (2 + 3, "2 + 3")
Nada de la macro sobrevive en el binario. No hay una tabla de expansiones, ni una capa de indirección en ejecución, ni una dependencia de swift-syntax en tu aplicación. El plugin hizo su trabajo durante la build y desapareció, y el coste que dejó atrás es exclusivamente tiempo de compilación.
flowchart TB fuente[Fuente con la invocacion de la macro] --> parser[El compilador parsea y encuentra el uso] parser --> plugin[Lanza el plugin como proceso aparte] plugin --> impl[La implementacion recibe el arbol sintactico] impl --> nuevo[Devuelve sintaxis nueva] nuevo --> buffer[El compilador la inserta en un buffer virtual] buffer --> tipos[Comprobacion de tipos sobre el resultado] style fuente fill:#cba6f7,color:#11111b style plugin fill:#89b4fa,color:#11111b style tipos fill:#a6e3a1,color:#11111b
Las tres garantías
Lo que hace fiable a este mecanismo no es la expansión en sí, sino las tres promesas que la rodean.
Está tipada. La declaración macro tiene firma. Si llamas a #stringify con dos argumentos, el error aparece antes de que el plugin llegue a ejecutarse, y lo señala la invocación, no el resultado. Y el tipo de retorno declarado obliga: si la expansión produce algo que no encaja, el error es tuyo, no del usuario. La comprobación ocurre además dos veces, antes de expandir sobre los argumentos y después de expandir sobre el código generado, de modo que una macro no puede colar código mal formado en tu programa.
Es higiénica. Los identificadores que la macro introduce y no ha declarado en su lista de nombres se renombran a algo único e invisible. Una macro no puede, por accidente, pisar una variable tuya llamada temporal ni capturar una que estuviera en el ámbito de la llamada. Y a la inversa: los nombres que la macro usa se resuelven en el ámbito donde la macro fue definida, no donde se invoca. Cuando necesita un nombre local irrepetible, lo pide explícitamente:
let temporal = context.makeUniqueName("acumulador")
return "let \(temporal) = \(arg); return \(temporal) * 2"
Es la diferencia exacta con el preprocesador de C, donde una macro que declare una variable auxiliar rompe cualquier llamada que use ese mismo nombre, y donde la única defensa históricamente ha sido inventar identificadores suficientemente feos.
Es aditiva. Una macro añade; no borra ni reescribe lo que ya existe. Tu código fuente sigue significando lo que significaba y sigue estando ahí. Esta restricción parece una limitación y es en realidad la garantía que sostiene todo lo demás: leer un archivo con macros nunca puede engañarte sobre lo que hay escrito en él, solo ocultarte lo que se ha añadido, y eso siempre puedes desplegarlo.
Sintaxis, no texto
La entrada y la salida son árboles con estructura, no cadenas. Un paréntesis mal puesto es un error de la macro en el acto, no una sorpresa a mil líneas de distancia.
Higiene por defecto
Los nombres introducidos se uniquifican salvo que se declaren. Ninguna expansión puede colisionar en silencio con tus identificadores.
Solo suma
Ninguna macro elimina ni altera lo que escribiste. Lo que lees en el archivo sigue siendo cierto; lo generado se apila encima y se puede inspeccionar.
Por qué no es un preprocesador
La palabra macro arrastra la herencia de C, y conviene desactivarla de golpe. #define MAX(a,b) ((a)>(b)?(a):(b)) es una sustitución de tokens antes del análisis sintáctico: no conoce tipos, no conoce ámbitos, evalúa sus argumentos tantas veces como aparezcan —de ahí el clásico desastre con un incremento como argumento— y sus errores se reportan sobre texto que nadie escribió, sin ninguna manera de verlo. La paréntesis defensiva que todo el mundo aprende a poner es la confesión del problema: el preprocesador ni siquiera sabe qué es una expresión.
Una macro de Swift ocurre después del análisis sintáctico, sobre nodos con estructura, con la comprobación de tipos a ambos lados, con higiene de nombres y con un límite claro de lo que puede tocar. La lista de lo que tiene y el preprocesador no: firma tipada, ámbitos respetados, argumentos que son árboles y no texto, diagnósticos con posición real, y una expansión que se puede desplegar y leer en el editor.
/* C: sustitucion de tokens, sin tipos ni ambitos */
#define MAX(a,b) ((a) > (b) ? (a) : (b))
int y = MAX(x++, 3); /* x se incrementa dos veces */
En Swift, el argumento llega a la macro como un nodo de expresión, y si la implementación lo coloca dos veces en la salida el resultado será igualmente incorrecto; la diferencia es que ahí el error es de quien escribió la macro y se ve en su prueba de expansión, no una trampa oculta que estalla en la enésima llamada de un usuario que no sabía nada.
También conviene separarla del generador externo. Sourcery lee tus fuentes con un parser propio y escribe archivos que acaban en tu repositorio; la macro no produce ningún archivo, vive dentro de la compilación y su resultado no existe en disco. Esa es la diferencia práctica que más se nota: no hay artefactos que versionar, no hay desincronización posible entre fuente y generado, y no hay un paso de build que alguien olvide ejecutar.
Detrás de las macros hay una decisión de diseño que atraviesa la historia entera de Swift y que explica muchas otras. Objective-C resolvía la metaprogramación en tiempo de ejecución: un runtime dinámico que permitía preguntar por las propiedades de una clase, fabricar métodos al vuelo, intercambiar implementaciones. Era enormemente potente y pagaba tres facturas: coste en cada acceso, imposibilidad de eliminar código muerto porque cualquier cosa podía invocarse por nombre, y cero garantías estáticas —el error aparecía cuando el usuario tocaba el botón, no cuando compilabas. Swift nació con la ambición contraria: mover todo lo que se pueda al momento de la compilación, donde el error es barato y la información se puede tirar después. Los genéricos con especialización, el despacho estático, Codable con síntesis en compilación en vez de reflexión, los tipos opacos, la concurrencia verificada por el compilador: todos son el mismo movimiento repetido. Las macros son ese movimiento aplicado a la metaprogramación misma, y su virtud no es hacer más cosas que el runtime de Objective-C —hacen menos, y a propósito— sino hacerlas en un momento donde el compilador todavía puede verificarlas y donde no cuestan nada en ejecución. Hay un detalle exquisito en cómo se implementó: la macro no corre dentro del compilador sino en otro proceso, aislado, que se comunica por un canal. Eso significa que un fallo tuyo no tumba la compilación entera, que el plugin puede compilarse con las mismas reglas que cualquier programa Swift, y que la superficie de ataque queda acotada. Un lenguaje que decide extender su compilador con procesos separados en vez de con complementos cargados en memoria está diciendo algo sobre qué clase de sistema quiere ser: uno donde la extensibilidad no se compra con fragilidad.
Una macro de Swift es un programa que se ejecuta durante la compilación, recibe el árbol sintáctico de la invocación y devuelve sintaxis nueva que el compilador incorpora. Tiene dos mitades: una declaración tipada en tu módulo y una implementación en un objetivo aparte que se lanza como proceso. Es tipada, higiénica y aditiva, y ninguna de esas tres cosas es cierta de un preprocesador de texto.
- Enumera cinco síntesis que el compilador de Swift ya hacía antes de las macros y decide cuáles podrían hoy reescribirse como macro de biblioteca.
- Escribe en C una macro de preprocesador con doble evaluación de argumentos y explica por qué el equivalente en Swift no puede tener ese fallo.
- Toma un tipo que hoy generas con Sourcery y enumera qué desaparecería del repositorio si fuera una macro.
- Razona qué debería devolver
#stringifysi le pasas una llamada a función con efectos, y por qué la macro no puede saber su valor. - Argumenta por qué el carácter aditivo no es una carencia sino un requisito para poder leer código ajeno con confianza.