Desmitificando Swift Macros
Contexto
Swift Macros es una innovadora API que se dio a conocer durante la WWDC2023. Aunque en un principio parece complicada de utilizar y que su utilidad podría percibirse como algo muy especializado, este artículo se propone desmitificarla y destacar su gran potencial. A medida que se explore más a fondo las capacidades de Swift Macros, se descubrirá, cómo esta API puede ser aprovechada de manera efectiva en una gran variedad de contextos, transformando la aparente complejidad en una herramienta poderosa y versátil.
A lo largo de este post, exploraremos ejemplos prácticos y casos de uso que ilustrarán la aplicabilidad y el impacto positivo que Swift Macros puede tener en el desarrollo de software. De este modo, se busca proporcionar a los desarrolladores una comprensión más clara y accesible de esta API, alentando la exploración y exprimir las capacidades en sus proyectos.
Macros
Una macro son secuencias de instrucciones que se registran para que se ejecuten de forma automática. En lenguajes de programación, como C o ensamblador, las macros son fragmentos de código que se expanden en tiempo de compilación.
Swift Macros
Durante la WWDC2023, Apple presenta la creación nativa de macros, con soporte desde Swift 5.9.
En esta presentación mostraron cómo funcionan y pueden crearse, además de cómo ha evolucionado Swift ofreciendo macros ya integradas en el lenguaje como son @Observable, o la esperada @SwiftData que llega con el fin de mejorar la experiencia de uso con CoreData.
Cómo funciona
Swift Macros está compuesto por dos elementos clave: Definición e Implementación. Estas dos partes separadas deben ubicarse en módulos diferentes. La razón detrás de esto es que cuando tu código activa un macro, el compilador de Swift identifica esta llamada y la dirige a un complemento de compilador especializado, donde reside toda la lógica de la macro.
El compilador de Swift inyecta esta expansión en tu código, compilando tanto el código existente como la expansión de las macros. Cuando ejecutas la aplicación, funciona como si hubieras escrito el código de la expansión manualmente.
Cómo está formada una macro
Comprender la sintaxis de las macros en Swift es esencial; conocer qué categorías y roles que la forman es la clave para escoger la más adecuado para tus necesidades específicas.
Cada rol dicta dónde se generará el código y cómo el compilador tratará dicho código generado.
Hay siete roles distintos, agrupados en dos categorías. Antes de entrar en las categorías, es necesario tener en cuenta que al configurar una macro, se aplican varias pautas: especificar la categoría, identificar el rol y, si es aplicable, configurar los modificadores específicos de cada rol.
Tipos de Swift Macros
freestanding
Se identifica con el símbolo #, su definición siempre debe comenzar por minúsculas. La expansión del código de esta macro se ubica en el lugar donde se ha definido.
Roles
#expression
Esta macro tienen la capacidad de generar bloques de código que pueden ser asignados a variables o propiedades como normalmente se haría como variable operada.
var parsedFileModels: [Model]? = #loadFile("file", "json", expectedType: [Model].self)
/// Expands to
var parsedFileModels: [Model]? = {
guard let path = Bundle.main.path(forResource: "file", ofType: "json") else { return nil }
let url = URL(fileURLWithPath: path)
guard let data = try? Data(contentsOf: url) else { return nil }
let model = try? JSONDecoder().decode([Model].self, from: data)
return model
}()
#declaration
Esta macro genera una expresión, similar a la macro #expression, pero difiriendo en que no puede devolver un tipo, por ende no es asignable.
class FooCell {
#cellIdentifier(FooCell.self)
}
/// Expands to
class FooCell {
static let cellIdentifier: String = "FooCell"
}
attached
Se identifica con el símbolo @, su definición siempre debe comenzar por mayúsculas. Estas macros necesitan una entidad asociada para la generación de código: clases, estructuras, extensiones, protocolos…
@member
Esta macro permite (entre otras opciones) generar constructores de la entidad a la que se asocia. Probablemente esta podría ser el rol que más código nos podría ahorrar de todo el catálogo.
@SwiftJsonMember
public class ExampleModel: Identifiable {
var name: String
var middleName: String?
var lastName: String
var age: Int
var isAlive: Bool
}
/// Expands to
@SwiftJsonMember
public class ExampleModel: Identifiable {
// …
required public init(json: Json) throws {
let jsonObject = try json.object()
self.name = try jsonObject.member("name").value()
self.middleName = try jsonObject.memberIfPresent("middleName")?.value()
self.lastName = try jsonObject.member("lastName").value()
self.age = try jsonObject.member("age").value()
self.isAlive = try jsonObject.member("isAlive").value()
}
}
@extension
Esta macro añade código al mismo nivel que la entidad original, permitiendo agregar funcionalidades necesarias a una clase o estructura sin escribir toda su implementación.
@SwiftJsonMember
public class ExampleModel: Identifiable {
var name: String
var middleName: String?
var lastName: String
var age: Int
var isAlive: Bool
}
/// Expands to
@SwiftJsonMember
public class ExampleModel: Identifiable {
// …
}
extension ExampleModel: JsonInitializable {}
@memberAttribute
Esta macro está diseñada para agregar atributos a declaraciones en el tipo o extensión donde se implementan.
@Discartable
extension Foo {
func bar() -> String {
result = "bar"
return result
}
func baz() -> String {
result = "baz"
return result
}
}
/// Expands to
@Discartable
extension Foo {
@discardableResult func bar() -> String {
result = "bar"
return result
}
@discardableResult func baz() -> String {
result = "baz"
return result
}
}
@peer
Esta macro comparte similitudes con la macro @extension en apariencia, ya que coloca el código generado al mismo nivel que la entidad original. No obstante, existe una diferencia fundamental: esta macro genera una entidad completamente nueva.
@Mockable
protocol FooProtocol {
func doSomething() -> Int
}
/// Expands to
@Mockable
protocol FooProtocol {
func doSomething() -> Int
}
struct FooStub: FooProtocol {
var doSomethingSpy: Bool = false
var doSomethingValue: Int = 1
func doSomething() -> Int {
doSomethingSpy.toggle()
return doSomethingValue
}
}
@accessor
Esta macro crea getters/setters para una propiedad específica, transformando lo que originalmente era una propiedad almacenada en una propiedad calculada.
@ZeroItemsAccessible
struct Cache<T: Hashable> {
private var content: [T: Any]
var intValues: [T]
}
/// Expands to
@ZeroItemsAccessible
struct Cache<T: Hashable> {
private var content: [T: Any]
var intValues : [T] {
get {
Array(content.filter { .zero == ($0.value as? Int) }.keys)
}
set {
newValue.forEach { value in content[value] = Int.zero }
}
}
}
Modificadores de macro
Los modificadores de macro permiten una mayor personalización través de especificadores.
Names
- named: Permite declarar uno o más nombres específicos.
- prefixed y suffixed: Permite declarar nombres que incluyen un prefijo o sufijo particular.
- overloaded: Permite declarar nombres especificado como su nombre base.
- arbitrary: Para declaraciones que no encajan en las otras categorías.
Conformances
Aunque inicialmente conformances era un rol, actualmente se utiliza como especificación de @extension, conformances es esencial cuando esa extensión implementa un protocolo, garantizando que sea reconocida como tal por el compilador.
Limitaciones y recomendaciones
Las macros en Swift tienen una serie de limitaciones debidas a su sintaxis y a que se ejecutan en lo que se conoce como Sandbox.
- Una macro sólo deben utilizar la información que pueda recibir del compilador.
- Una macro NO tienen acceso a archivos en disco o red.
- Una macro NO debería generar nunca información acerca de la fecha y hora, o números aleatorios.
- Una macro NO debería guardar información en variables globales que puedan utilizar otras macros en sus expansiones.
- Una macro está diseñada para ser probada mediante el uso de test unitarios, es una buena forma de comenzar con TDD ;).
En caso de NO seguir alguna de estas recomendaciones, las macros podrían tener comportamientos inconsistentes y generar problemas en la aplicación donde estén inyectadas.
Cómo crear una macro
Para crear una macro habría que crear un nuevo proyecto del tipo Package, y seleccionar la plantilla macro.
El proyecto por defecto contendrá 5 ficheros principales
- Package.swift – Contiene toda la información de paquete, dependencias, targets…
- <Nombre del paquete>.swift – Este fichero permite la definición de la macro, por defecto contendrá la definición de la macro #stringify.
- main.swift – Este fichero es el único ejecutable del paquete, es totalmente opcional, permite realizar pruebas locales con las macros creadas en el paquete.
- <Nombre del paquete>Macro.swift – Este fichero permite la implementación de la definición de la macro, es opcional y un mismo paquete puede tener diferentes ficheros con la implementación de diferentes macros.
- <Nombre del paquete>Tests.swift – Este fichero permite implementar tests sobre la expansión del código generado por la macro, es opcional y un mismo paquete puede contentes diferentes ficheros de tests.
La estructura <Nombre del paquete>Plugin que implementa el protocolo CompilerPlugin, permite la comunicación entre los diferentes ficheros antes mencionados.
Conclusión
Swift Macros poseen un inmenso potencial que puede revolucionar significativamente nuestros flujos de trabajo en el desarrollo de software. Al explorar esta nueva dimensión de Swift, no solo nos encontramos con la ventaja evidente de un proceso de generación de código más eficiente y rápido, sino que también se abren oportunidades para potenciar la modularidad y reutilización de nuestro código existente.
Además, la introducción de Swift Macros no solo facilita el desarrollo de nuevas funcionalidades, sino que también fomenta la creación de bibliotecas y componentes altamente modulares. Esto contribuye a la construcción de sistemas más escalables y mantenibles, donde los módulos generados mediante Swift Macros pueden ser fácilmente integrados en diferentes partes del proyecto. La reutilización eficiente del código no solo ahorra tiempo, sino que también mejora la consistencia y confiabilidad del software en general.
En resumen, Swift Macros no solo representan una herramienta para la eficiencia y velocidad en el desarrollo, sino que también son un catalizador para la creatividad y la innovación. Al adoptar y comprender plenamente el potencial de Swift Macros, los desarrolladores pueden potenciar sus habilidades y elevar la calidad de sus proyectos a nuevos niveles.
Fuentes
https://developer.apple.com/videos/play/wwdc2023/10167
https://developer.apple.com/videos/play/wwdc2023/10166
https://docs.swift.org/swift-book/documentation/the-swift-programming-language/macros