Creando tu primer Swift Macro

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:

Tabla que muestra los diferentes tipos de macros.

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:

@GenerateInit
struct Student {
var identification: String
var name: String
var age: Int
}

Que se genere el contructor, y el código que se compilará será el siguiente:

struct Student {
var identification: String
var name: String
var age: Int
init(identification: String, name: String, age: Int) {
self.identification = identification
self.name = name
self.age = age
}
}

Pasos para crear un Macro

Estos son los pasos para crear un Macro:

  1. Crear el proyecto.
  2. Declarar el Macro.
  3. Aunque no es indispensable, pero es muy recomendable, hacer las pruebas unitarias.
  4. Implementar el Macro.
  5. ¡Usar el Macro! 😀.

Crear el proyecto

Los Macros son un tipo de Swift Package, para crear uno, debemos ir a File > New > Package:

Captura de pantalla: Crear nuevo paquete

Luego, especificamos el nombre del paquete y el directorio donde deseamos guardarlo:

Captura de pantalla: Nombrar paquete

Inicialmente, Xcode creará un proyecto de ejemplo:

Captura de pantalla: Estructura del proyecto Xcode

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))
// 2
public macro GenerateInit() = #externalMacro(module: "GenerateMacros", type: "GenerateInitMacro")

Con este código:

  1. 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).
  2. 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(
"""
@GenerateInit
struct Student {
var identification: String
var name: String
var age: Int
}
""",
expandedSource: """
struct Student {
var identification: String
var name: String
var age: Int
init(identification: String, name: String, age: Int) {
self.identification = identification
self.name = name
self.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: String
var name: String
var 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: String
var name: String
var 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 noStruct
var 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] {
// 1
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
throw GenerateInitError.noStruct
}
// 2
let (signature, body) = extractBodyAndSignature(for: structDecl)
// 3
let initDecl = try InitializerDeclSyntax(SyntaxNodeString(stringLiteral: signature)) {
for line in body {
ExprSyntax(stringLiteral: line)
}
}
// 4
return [DeclSyntax(initDecl)]
}
}

Esto es lo que hace este método:

  1. Utilizando declaration.as(StructDeclSyntax.self) validamos que la declaración donde aplicamos el macro es un struct. De lo contrario, lanzamos el error GenerateInitError.noStruct.
  2. Utilizando extractBodyAndSignature(for:) guardamos en signature y body la firma de nuestro constructor y el contenido del constructor, respectivamente. En un momento veremos el contenido extractBodyAndSignature(for:).
  3. Creamos el constructor usando InitializerDeclSyntax, para lo cual pasamos la firma como una SyntaxNodeString y convertimos cada línea del contenido en un ExprSyntax mediante un bucle for-in.
  4. 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]) {
// 1
var parameters: [String] = []
var body: [String] = []
// 2
declaration.memberBlock.members.forEach { member in
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 {
// 3
let parameter = "\(identifier): \(type)"
parameters.append(parameter)
body.append("self.\(identifier) = \(identifier)")
}
}
// 4
let signature = "init(\(parameters.joined(separator: ", ")))"
// 5
return (params: signature, body: body)
}

Este método hace lo siguiente:

  1. Declara dos arreglos que contendrá la lista de parámetros y las líneas del cuerpo de nuestro contructor, respectivamente.
  2. Utilizando forEach recorremos la lista de miembros del bloque y obtenemos identifier y type, 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.
  3. Mediante interpolación, aseguramos que el parámetro y el cuerpo contengan la información necesaria.
  4. Agregamos la palabra reservada init, nos aseguramos que cada miembro de parameters forme parte de la firma, separados por coma, y agregamos los parentésis.
  5. 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:

@GenerateInit
struct Student {
var identification: String
var name: String
var 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:

Macro expandido en Xcode.

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:

Error en el macro, mostrando el mensaje que elegimos.

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.

Comparte este artículo

Subscríbete a nuestro Newsletter

Mantente al día en el mundo de las aplicaciones móviles con nuestro blog especializado.

Artículos semanales

Todas las semanas artículos nuevos sobre el mundo de las aplicaciones móviles.

No spam

No te enviaremos spam, solo contenido de calidad. Puedes darte de baja cuando quieras.

Contenido de calidad

Nada de contenido generado de manera automática usando ChatGPT.

Recomendaciones

Tips indispensables sobre mejores prácticas y metodologías.

© 2024 AsyncLearn