Creando tu primer Swift Macro
Libranner Santos
27 septiembre, 202310min de lectura
Los Macros nos permiten generar código en tiempo de compilación. Son herramientas ideales cuando queremos evitar repetir el mismo código muchas veces. Al proceso de añadir código mediante un macro se le llama expansión.
En este artículo aprenderemos a crear un macro para generar automáticamente los constructores (init
) de nuestras estructuras (struct
).
Tipos de Macros
Existen dos tipos de macros: Freestanding (Independientes) y Attached (Adjuntas). Estos se subdividen según los roles.
Los roles definen las siguientes reglas de un macro:
- Dónde puede ser utilizada.
- Dónde se insertará la expansión.
- Qué tipo de código generará al expandirse.
Podemos tener una de las siguientes combinaciones según el tipo de macro y su rol:
Freestanding Macro
Para ejecutar un macro independiente, utilizamos #
seguido del nombre de la macro y los parámetros entre paréntesis. Por ejemplo:
func myFunction() {#myMacro("Hello")}
Dentro de myFunction()
estamos ejecutando #myMacro
. Si esta macro genera código, se reemplazará en tiempo de compilación. Sin embargo, este tipo de macros también pueden no generar código, sino realizar una acción, como lo hace la macro #warning
.
Attached Macro
Estos se diferencia de los freestanding en que son precedidos por @
y pueden modificar la declaración a la que este asociados. El código agregado por este tipo de macros puede ser algo como agregar un nuevo método o conformidad a un protocolo.
En este artículo, crearemos un macro que genere constructores automáticamente. Al final, lograremos con esto:
@GenerateInitstruct Student {var identification: Stringvar name: Stringvar age: Int}
Que se genere el contructor, y el código que se compilará será el siguiente:
struct Student {var identification: Stringvar name: Stringvar age: Intinit(identification: String, name: String, age: Int) {self.identification = identificationself.name = nameself.age = age}}
Pasos para crear un Macro
Estos son los pasos para crear un Macro:
- Crear el proyecto.
- Declarar el Macro.
- Aunque no es indispensable, pero es muy recomendable, hacer las pruebas unitarias.
- Implementar el Macro.
- ¡Usar el Macro! 😀.
Crear el proyecto
Los Macros son un tipo de Swift Package, para crear uno, debemos ir a File > New > Package:
Luego, especificamos el nombre del paquete y el directorio donde deseamos guardarlo:
Inicialmente, Xcode creará un proyecto de ejemplo:
En sources tenemos cuatro carpetas:
- Generate, contiene el código donde declaramos el macro.
- GenerateClient, esta contiene main.swift , que podemos utilizar para probar y expandir el macro.
- GenerateMacros, aquí es donde colocaremos la implementación del (de los) macro(s).
- GenerateTests, en este directorio escribiremos las pruebas unitarias.
El proyecto viene con un macro de ejemplo; eliminarás este código mientras creas el macro.
Declarar el Macro
Abre Generate.swift y borra todo el contenido, luego escribe:
// 1@attached(member, names: named(init))// 2public macro GenerateInit() = #externalMacro(module: "GenerateMacros", type: "GenerateInitMacro")
Con este código:
- Indicamos el tipo de macro,
@attached(member)
. Puesto que agregará una nueva declaración con este macro.named(init)
indica el nombre del elemento que el macro agregará, en este caso, un constructor (init). - Declaramos el macro
GenerateInit()
.externalMacro(module:type:)
especifica el módulo donde se encuentra y el nombre del macro. Toma en cuenta que estamos usando un macro para la declaración, "macro-ception" 😅.
Escribir test unitarios para un Macro
Es una buena práctica escribir test unitarios para los macros. Crearemos la prueba unitaria antes que la implementación:
Modifica la declaración de testMacros
para que quede así:
let testMacros: [String: Macro.Type] = ["GenerateInit": GenerateInitMacro.self,]
Luego, borra todo el código dentro de GenerateTests
y escribe:
func testMacro() throws {assertMacroExpansion("""@GenerateInitstruct Student {var identification: Stringvar name: Stringvar age: Int}""",expandedSource: """struct Student {var identification: Stringvar name: Stringvar age: Intinit(identification: String, name: String, age: Int) {self.identification = identificationself.name = nameself.age = age}}""",macros: testMacros)}
Utilizando assertMacroExpansion
pasamos como primer parámetro como se vería el código al que aplicaríamos el macro @GenerateInit
. Mientras que en expandedSource
pondríamos lo que debería ser el resultado de expandir el macro. Por último, pasamos en macros
el listado de macros que tengamos disponibles.
Tendrás un error de compilación puesto que no has declarado GenerateInitMacro
. Abre GenerateMacro.swift y reemplaza StringifyMacro
por GenerateInitMacro
en todos los lugares.
En main.swift borra todo el código excepto el import Generate
.
Volveremos a este archivo en breve; hasta ahora solo hemos hecho lo necesario para que el proyecto compile.
Ejecuta la prueba; en principio, debería fallar porque aún no hemos implementado el macro:
failed - Actual output (+) differed from expected output (-):struct Student {var identification: Stringvar name: Stringvar age: Int–– init(identification: String, name: String, age: Int) {– self.identification = identification– self.name = name– self.age = age– }}Actual expanded source:struct Student {var identification: Stringvar name: Stringvar age: Int}
Implementar un Macro
Abre GenerateMacro.swift y borra la declaración existente de GenerateInitMacro
, así como los comentarios asociados a esta.
Comencemos creando un enum
que contendrá los diferentes errores; al comienzo del archivo, agrega este código:
enum GenerateInitError: CustomStringConvertible, Error {case noStructvar description: String {switch self {case .noStruct: return "@GenerateInit can only be applied to structs"}}}
GenerateInitError
conforma a CustomStringConvertible
esto nos permite agregar en description
un mensaje explicativo, que luego veremos mostrado por Xcode. Además, conforma Error
indicando que este es un tipo que podemos usar para lanzar una excepción usando throw
.
GenerateInitError
contiene un case
, noStruct
. Utilizaremos este error ya que nuestra macro solo puede aplicarse a estructuras.
Continuemos con la implementación de GenerateInitMacro
. Para esto escribiremos:
public struct GenerateInitMacro: MemberMacro {public static func expansion(of node: AttributeSyntax,providingMembersOf declaration: some DeclGroupSyntax,in context: some MacroExpansionContext) throws -> [DeclSyntax] {// 1guard let structDecl = declaration.as(StructDeclSyntax.self) else {throw GenerateInitError.noStruct}// 2let (signature, body) = extractBodyAndSignature(for: structDecl)// 3let initDecl = try InitializerDeclSyntax(SyntaxNodeString(stringLiteral: signature)) {for line in body {ExprSyntax(stringLiteral: line)}}// 4return [DeclSyntax(initDecl)]}}
Esto es lo que hace este método:
- Utilizando
declaration.as(StructDeclSyntax.self)
validamos que la declaración donde aplicamos el macro es unstruct
. De lo contrario, lanzamos el errorGenerateInitError.noStruct
. - Utilizando
extractBodyAndSignature(for:)
guardamos ensignature
ybody
la firma de nuestro constructor y el contenido del constructor, respectivamente. En un momento veremos el contenidoextractBodyAndSignature(for:)
. - Creamos el constructor usando
InitializerDeclSyntax
, para lo cual pasamos la firma como unaSyntaxNodeString
y convertimos cada línea del contenido en unExprSyntax
mediante un buclefor-in
. - Por último, retornamos el resultado dentro de un arreglo, pero primero lo convertimos a
DeclSyntax
. Retornamos un arreglo, puesto que este tipo de macro puede producir más de una declaración como resultado, aunque en este caso sea solo una.
El código no compilará, porque no hemos creado extractBodyAndSignature(for:)
. Justo debajo del método que acabamos de crear, escribe lo siguiente:
private static func extractBodyAndSignature(for declaration: StructDeclSyntax) -> (params: String, body: [String]) {// 1var parameters: [String] = []var body: [String] = []// 2declaration.memberBlock.members.forEach { member inif let patternBinding = member.decl.as(VariableDeclSyntax.self)?.bindings.as(PatternBindingListSyntax.self)?.first?.as(PatternBindingSyntax.self),let identifier = patternBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier,let type = patternBinding.typeAnnotation?.as(TypeAnnotationSyntax.self)?.type {// 3let parameter = "\(identifier): \(type)"parameters.append(parameter)body.append("self.\(identifier) = \(identifier)")}}// 4let signature = "init(\(parameters.joined(separator: ", ")))"// 5return (params: signature, body: body)}
Este método hace lo siguiente:
- Declara dos arreglos que contendrá la lista de parámetros y las líneas del cuerpo de nuestro contructor, respectivamente.
- Utilizando
forEach
recorremos la lista de miembros del bloque y obtenemosidentifier
ytype
, que luego usaremos para crear la firma y el cuerpo. En la sección Explorando la sintaxis, tienes más información sobre el porqué de este código. - Mediante interpolación, aseguramos que el parámetro y el cuerpo contengan la información necesaria.
- Agregamos la palabra reservada
init
, nos aseguramos que cada miembro deparameters
forme parte de la firma, separados por coma, y agregamos los parentésis. - Retornamos el resultado usando una tupla.
Ahora validemos que la prueba unitaria pasa. Puedes presionar Command + U o ir a Product > Test en el menú.
Si todo está correcto, sigamos con algunas explicaciones útiles. Si no, puedes revisar el código anterior.
Explorando la sintáxis
Es probable que te haya llamado la atención el código necesario para obtener los datos que necesitamos, particularmente estas líneas:
if let patternBinding = member.decl.as(VariableDeclSyntax.self)?.bindings.as(PatternBindingListSyntax.self)?.first?.as(PatternBindingSyntax.self),let identifier = patternBinding.pattern.as(IdentifierPatternSyntax.self)?.identifier,let type = patternBinding.typeAnnotation?.as(TypeAnnotationSyntax.self)?.type
Para entender esto necesitamos conocimientos sobre el árbol sintáctico que crea como parte de la compilación (Abstract Syntax Tree). Si colocamos un breakpoint en la línea declaration.memberBlock.member...
e imprimimos el contenido de declaration
en la consola podremos ver lo siguiente:
Printing description of declaration:StructDeclSyntax├─attributes: AttributeListSyntax│ ╰─[0]: AttributeSyntax│ ├─atSign: atSign│ ╰─attributeName: IdentifierTypeSyntax│ ╰─name: identifier("GenerateInit")├─modifiers: DeclModifierListSyntax├─structKeyword: keyword(SwiftSyntax.Keyword.struct)├─name: identifier("Student")╰─memberBlock: MemberBlockSyntax├─leftBrace: leftBrace├─members: MemberBlockItemListSyntax│ ├─[0]: MemberBlockItemSyntax│ │ ╰─decl: VariableDeclSyntax│ │ ├─attributes: AttributeListSyntax│ │ ├─modifiers: DeclModifierListSyntax│ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)│ │ ╰─bindings: PatternBindingListSyntax│ │ ╰─[0]: PatternBindingSyntax│ │ ├─pattern: IdentifierPatternSyntax│ │ │ ╰─identifier: identifier("identification")│ │ ╰─typeAnnotation: TypeAnnotationSyntax│ │ ├─colon: colon│ │ ╰─type: IdentifierTypeSyntax│ │ ╰─name: identifier("String")│ ├─[1]: MemberBlockItemSyntax│ │ ╰─decl: VariableDeclSyntax│ │ ├─attributes: AttributeListSyntax│ │ ├─modifiers: DeclModifierListSyntax│ │ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)│ │ ╰─bindings: PatternBindingListSyntax│ │ ╰─[0]: PatternBindingSyntax│ │ ├─pattern: IdentifierPatternSyntax│ │ │ ╰─identifier: identifier("name")│ │ ╰─typeAnnotation: TypeAnnotationSyntax│ │ ├─colon: colon│ │ ╰─type: IdentifierTypeSyntax│ │ ╰─name: identifier("String")│ ╰─[2]: MemberBlockItemSyntax│ ╰─decl: VariableDeclSyntax│ ├─attributes: AttributeListSyntax│ ├─modifiers: DeclModifierListSyntax│ ├─bindingSpecifier: keyword(SwiftSyntax.Keyword.var)│ ╰─bindings: PatternBindingListSyntax│ ╰─[0]: PatternBindingSyntax│ ├─pattern: IdentifierPatternSyntax│ │ ╰─identifier: identifier("age")│ ╰─typeAnnotation: TypeAnnotationSyntax│ ├─colon: colon│ ╰─type: IdentifierTypeSyntax│ ╰─name: identifier("Int")╰─rightBrace: rightBrace
Este árbol nos muestra el contenido de nuestro struct
. Incluyendo símbolos como {
, :
, y anotaciones como @escaping
. Cada vez que creemos un macro podemos visualizar de esta manera para entender como obtener la información que necesitemos.
Expandir un Macro
Ahora que tenemos la implementación del macro, volvamos a main.swift y probemos el macro que creamos. Dentro del archivo, escribe:
@GenerateInitstruct Student {var identification: Stringvar name: Stringvar age: Int}
Estamos utilizando @GenerateInit
como una anotación antes de la declaración de Student
.
Podemos expandir los macros para visualizar el código que produce. Solo debes hacer clic sobre @GenerateInit
, luego en el menú Editor selecciona Expand Macro.
Verás el código producido por el macro sombreado y podrás ocultarlo presionando el botón en el extremo superior izquierdo:
Errores en un Macro
Hemos creado GenerateInitError
para listar los errores que el macro puede producir. En este caso, solo tenemos uno, y ocurre cuando intentamos usarlo con algo que no sea una struct
.
Si, en main.swift, cambiamos nuestra declaración para usar class
, veremos el siguiente error en Xcode:
Esto se debe a que los macros deben ser correctos en tiempo de compilación. Es decir, no podremos compilar hasta resolver este error.
Otras cosas a tomar en cuenta
- Los macros solo pueden agregar código, no eliminarlo ni modificar el código existente.
- Tanto el resultado como los parámetros de entrada se evalúan sintácticamente, y si hay algún error, lo veremos en Xcode al compilar.
- Cualquier error en el Macro será tratado como un error de compilación por Xcode.