tutoriales.com

Creando Macros Personalizadas en Swift: Metaprogramación en Tiempo de Compilación

Descubre cómo utilizar el sistema de macros introducido en Swift para realizar metaprogramación segura y en tiempo de compilación. Este tutorial te guiará paso a paso desde los conceptos básicos hasta la implementación de macros de expresión y de adición de miembros utilizando la librería Swift Syntax.

Avanzado12 min de lectura21 views
Reportar error

🚀 Introducción a las Macros en Swift

Las macros en Swift representan uno de los saltos evolutivos más importantes en el lenguaje, permitiéndonos realizar metaprogramación en tiempo de compilación de forma segura, tipada y sin sacrificar el rendimiento. A diferencia de las macros de preprocesador en lenguajes como C o C++, las macros de Swift son funciones puras que operan directamente sobre el Árbol de Sintaxis Abstracta (AST) de nuestro código gracias al framework SwiftSyntax.

En este tutorial exhaustivo, aprenderemos a diseñar, estructurar, implementar y probar nuestras propias macros personalizadas. Veremos cómo transformar código repetitivo en soluciones elegantes y mantenibles, eliminando el código boilerplate de nuestras aplicaciones.

📌 Nota: Para seguir este tutorial de manera óptima, necesitarás Xcode 15 o superior y un entorno compatible con Swift 5.9 o posterior.

🛠️ ¿Qué son las Macros y Cómo Funcionan?

Antes de escribir código, es fundamental comprender la arquitectura detrás del sistema de macros de Swift. Cuando el compilador encuentra una macro, detiene temporalmente la compilación tradicional para ejecutar el paquete de la macro en un proceso externo y aislado.

Archivo Fuente (.swift) Swift Parser Macro Plugin (Proceso SwiftSyntax) AST Expandido SILGen / LLVM IR Binario Final IPC / JSON

Existen dos categorías principales de macros en Swift que debemos dominar:

  1. Macros de Expansión Libre (Freestanding Macros): Aparecen independientes de cualquier declaración, precedidas por el símbolo de exclamación de cierre (#). Pueden producir valores o declaraciones completas.
  2. Macros de Adición (Attached Macros): Modifican o añaden funcionalidad a la declaración a la que están adjuntas, precedidas por el símbolo de arroba (@).

Comparativa de Tipos de Macros

Tipo de MacroSintaxisPropósito PrincipalEjemplo Común
------------
Expresión Libre#nombreMacro(...)Generar valores o código dinámico inline#stringify(a + b)
Declaración Libre#nombreMacro(...)Declarar múltiples tipos, funciones o variables#URL("https://...")
------------
Miembro Adjunto@NombreMacroAñadir propiedades o métodos a un tipo existente@Codable o similares
Miembro Conforme@NombreMacroAñadir conformidades a protocolos automáticamente@OptionSet

🏗️ Configurando un Paquete de Macros en Swift

El desarrollo de macros requiere una estructura de paquetes específica porque el código de la macro se ejecuta en la máquina anfitriona durante la compilación, mientras que el código de la aplicación se ejecuta en el dispositivo de destino.

Paso a Paso para la Creación del Paquete

Paso 1: Crear un paquete tipo macro en la terminal con swift package init --type macro.
Paso 2: Configurar el archivo Package.swift para incluir las dependencias de SwiftSyntax.
Paso 3: Separar el paquete en tres targets: el cliente, la implementación de la macro y el plugin del compilador.

Abramos nuestro archivo Package.swift para verificar que la configuración base sea correcta:

// swift-tools-version: 5.9

import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MiLibreriaMacros",
    platforms: [.iOS(.v17), .macOS(.v14)],
    products: [
        .library(
            name: "MiLibreriaMacros",
            targets: ["MiLibreriaMacros"]
        ),
        .executable(
            name: "MiLibreriaMacrosClient",
            targets: ["MiLibreriaMacrosClient"]
        ),
    ],
    dependencies: [
        .package(url: "https://github.com/apple/swift-syntax.git", from: "509.0.0")
    ],
    targets: [
        .target(
            name: "MiLibreriaMacros",
            dependencies: ["MiLibreriaMacrosPlugin"]
        ),
        .macro(
            name: "MiLibreriaMacrosPlugin",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax")
            ]
        ),
        .executableTarget(
            name: "MiLibreriaMacrosClient",
            dependencies: ["MiLibreriaMacros"]
        ),
        .testTarget(
            name: "MiLibreriaMacrosTests",
            dependencies: [
                .product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
                "MiLibreriaMacrosPlugin"
            ]
        )
    ]
)
⚠️ Advertencia: Las dependencias de SwiftSyntax deben coincidir exactamente con la versión del compilador de Swift que estás utilizando para evitar fallos extraños de enlace en tiempo de compilación.

✍️ Implementando nuestra Primera Macro de Expresión

Vamos a construir una macro clásica pero extremadamente útil: la macro #stringify. Esta macro toma cualquier expresión y devuelve una tupla que contiene tanto el valor de la expresión como su representación en texto como un string, ideal para depuración y pruebas unitarias.

1. Declaración de la Macro

En el target principal de la librería (MiLibreriaMacros), declaramos la interfaz pública de la macro:

@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "MiLibreriaMacrosPlugin", type: "StringifyMacro")

2. Implementación en el Plugin

Ahora, en el target del plugin (MiLibreriaMacrosPlugin), implementamos el protocolo ExpressionMacro:

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxBuilder

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingExprSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        guard let argument = node.argumentList.first?.expression else {
            fatalError("Se esperaba un argumento para la macro stringify")
        }

        return "(\(argument), \(argument.description))";
    }
}

No olvides registrar tu macro en el punto de entrada principal del plugin:

import SwiftCompilerPlugin
import SwiftSyntaxMacros

@main
struct MiLibreriaMacrosPlugin: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self
    ];
}
💡 Consejo: Utiliza interpolación de cadenas directamente con objetos que conformen a ExprSyntaxProtocol; SwiftSyntaxBuilder se encarga de convertir de manera segura la estructura sintáctica en código ejecutable.

📦 Creando una Macro de Adición de Miembros

Las macros de expresión son excelentes para cálculos rápidos, pero el verdadero poder de la metaprogramación se revela cuando creamos macros adjuntas que generan propiedades enteras. Creemos una macro llamada @AutoDefaultInit que añada automáticamente un inicializador sin argumentos a nuestras estructuras cuando sea posible.

1. Declaración de la Macro Adjunta

@attached(member, names: named(init))
public macro AutoDefaultInit() = #externalMacro(module: "MiLibreriaMacrosPlugin", type: "AutoDefaultInitMacro")

2. Implementación de la Macro

Para implementar esta macro, debemos cumplir con el protocolo MemberMacro, inspeccionando las propiedades de la estructura receptora:

import SwiftSyntax
import SwiftSyntaxMacros

public struct AutoDefaultInitMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        
        guard let structDecl = declaration.as(StructDeclSyntax.self) else {
            throw CustomError.message("@AutoDefaultInit solo puede aplicarse a estructuras.")
        }

        // Recolectamos todas las propiedades almacenadas
        let variables = structDecl.memberBlock.members
            .compactMap { $0.decl.as(VariableDeclSyntax.self) }
            
        var initializations: [String] = []

        for variable in variables {
            guard let binding = variable.bindings.first,
                  let identifier = binding.pattern.as(IdentifierPatternSyntax.self)?.identifier.text,
                  let typeAnnotation = binding.typeAnnotation else {
                continue
            }
            
            // Si la propiedad tiene un valor por defecto asignado
            if let initializer = binding.initializer {
                initializations.indentedAppend("\(identifier) = \(initializer.value)")
            } else {
                // Si no tiene valor, asumimos que se pasa o requiere un valor por defecto basado en su tipo
                initializations.indentedAppend("\(identifier) = .init()")
            }
        }

        let initCode = """
        public init() {
            \(initializations.joined(separator: "\n    "))
        }
        """

        return [DeclSyntax(stringLiteral: initCode)]
    }
}

enum CustomError: Error {
    case message(String)
}

extension Array where Element == String {
    mutating func indentedAppend(_ element: String) {
        self.append("    \(element)")
    }
}

🧪 Pruebas Unitarias para Macros

Una de las mayores ventajas del ecosistema de macros en Swift es lo fácil que resulta probarlas utilizando SwiftSyntaxMacrosTestSupport. Las pruebas comparan el texto expandido con una cadena esperada.

Creemos un archivo de pruebas en nuestro target de tests:

import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
import MiLibreriaMacrosPlugin

final class MiLibreriaMacrosTests: XCTestCase {

    func testStringifyMacro() {
        assertMacroExpansion(
            "#stringify(x + y)",
            expandedSource: "(x + y, \"x + y\")",
            macros: ["stringify": StringifyMacro.self]
        )
    }
    
    func testAutoDefaultInitMacro() {
        assertMacroExpansion(
            """
            @AutoDefaultInit
            struct Usuario {
                var id: String = ""
                var edad: Int = 0
            }
            """,
            expandedSource: """
            struct Usuario {
                var id: String = ""
                var edad: Int = 0
                
                public init() {
                    id = ""
                    edad = 0
                }
            }
            """,
            macros: ["AutoDefaultInit": AutoDefaultInitMacro.self]
        )
    }
}
🔥 Importante: Las pruebas de macros son deterministas y se ejecutan muy rápido porque no requieren compilar código nativo real, sino únicamente validar la transformación de texto y sintaxis.

🔍 Buenas Prácticas y Consejos Avanzados

Para asegurarte de que tus macros sean robustas, seguras y fáciles de mantener en proyectos grandes, sigue estas pautas:

  • Valida los contextos: Siempre comprueba que la macro se esté aplicando al tipo de elemento sintáctico correcto (estructuras, clases, enums o funciones).
  • Proporciona mensajes de error claros: Lanza errores descriptivos utilizando Diagnostic o MacroExpansionContext para guiar al desarrollador cuando use la macro incorrectamente.
  • Evita dependencias pesadas en el plugin: El target del plugin de la macro se ejecuta de forma aislada y no debe depender de frameworks específicos de plataformas como UIKit o AppKit.
Preguntas Frecuentes sobre el Desarrollo de Macros

¿Puedo depurar el código de una macro paso a paso? Sí. Puedes configurar un esquema de Xcode para adjuntar el depurador al proceso de compilación externo o utilizar puntos de interrupción (breakpoints) directamente en la implementación de tu macro.

¿Las macros afectan el tiempo de compilación? Introducen un coste menor debido al proceso externo de SwiftSyntax, pero el impacto suele ser insignificante comparado con la ganancia en productividad y seguridad en tiempo de compilación.


🎯 Conclusión

Las macros en Swift han revolucionado la forma en que los desarrolladores manejamos la metaprogramación. Al aprovechar SwiftSyntax y las herramientas nativas del compilador, ahora podemos escribir código limpio, libre de boilerplate y con la garantía absoluta de que nuestros patrones personalizados son seguros y están validados antes de llegar a producción. ¡Es el momento de automatizar tus propios flujos de desarrollo!

Tutoriales relacionados

Comentarios (0)

Aún no hay comentarios. ¡Sé el primero!