Недавно я столкнулся с задачей, которая требовала написания большого объема шаблонного кода. Вспомнив, что в Swift 5.9 появились макросы, созданные специально для генерации шаблонного кода, я решил попробовать их в действии. Ранее я работал с макросами в Objective-C и C++, поэтому ожидал увидеть нечто похожее. Однако, поискав информацию, я понял, что макросы в Swift — это совсем другое, не похожее на то, что я встречал в других языках.
В отличие от макросов в C++ или Objective-C, в Swift нужно писать гораздо больше кода, соблюдая при этом строгие правила оформления. Иначе можно столкнуться с загадочными ошибками компиляции, решение которых не всегда очевидно. Дополнительные трудности возникают из-за того, что многие статьи и видео просто повторяют официальную документацию, не объясняя понятным языком, как именно использовать макросы. Часто вместо этого начинаются сложные рассуждения о структуре AST (Abstract Syntax Tree) или приводятся примеры кода, которые демонстрируют результат работы макроса, но не показывают, как его создать и отладить.
Именно из-за таких трудностей я решил написать эту статью. Её цель — максимально просто, без углубления в теорию, объяснить, как можно уже сегодня начать использовать макросы в Swift. Если вам захочется изучить эту тему подробнее, вы всегда сможете обратиться к официальной документации или материалам с WWDC, где этот вопрос разобран более детально. А если вам понравится моя подача, пишите в комментариях — я постараюсь объяснить сложные моменты в отдельных статьях.
Что такое макросы?
Простыми словами, макросы — это языковая фича, которая позволяет автоматически генерировать дополнительный код до того, как программа будет скомпилирована. Те, кто программировал на Objective-C или C++, уже знакомы с этой концепцией. В этих языках макросы создавались с помощью директивы #define
, которая автоматически "разворачивала" указанный код перед компиляцией программы.
В Swift 5.9 эта функция также стала доступна, хотя и в немного иной форме, с определёнными ограничениями. В соответствии с философией Swift, разработчики постарались реализовать генерацию шаблонного кода с возможностью компиляторных проверок, насколько это возможно. Однако здесь есть свои особенности:
Как начать работу с макросами?
SwiftSyntax
Для начала работы с макросами нужно подключить библиотеку SwiftSyntax. Эта библиотека является основой для взаимодействия с исходным кодом программы и макросами.SPM (Swift Package Manager)
Макросы доступны только в рамках Swift Package Manager. Начиная с версии Swift 5.9, в файлеPackage.swift
появилась возможность добавлять новый тип таргета —CompilerPlugin
, который позволяет подключить к вашему модулю целевой таргет.macro
, где будет храниться реализация макросов.Разделение объявления и реализации макросов
Для каждого макроса нужно создавать отдельные файлы для объявления и реализации. Это напоминает подход в Objective-C с.h
и.m
файлами, где один файл описывает публичный интерфейс, а второй — внутреннюю реализацию.
Типы макросов
Существует два основных типа макросов:
Freestanding макросы
Это макросы, которые можно вызывать независимо в коде. Их можно рассматривать как функции, но с расширенными возможностями.Attached макросы
Эти макросы привязываются к конкретному объекту или функции, расширяя их функционал. Они чем-то напоминаютproperty wrappers
, но дают ещё больше возможностей.
Создание пакета
Чтобы добавить макросы в проект, нужно использовать пакет SPM (Swift Package Manager). У вас есть два варианта: либо добавить новый таргет в уже существующий пакет, либо создать новый пакет.
Если вы выбрали второй вариант, всё, что нужно сделать, — это выбрать File > New > Package в Xcode и затем выбрать тип пакета Swift Macro. Xcode автоматически сгенерирует для вас шаблон пакета.
Если у вас уже есть существующий пакет, некоторые шаги придётся выполнить вручную. Сначала откройте файл Package.swift и добавьте импорт:
import CompilerPluginSupport
Так как макросы поддерживаются начиная с версии Swift 5.9, рекомендуется явно указать версию инструментария в Package.swift
:
// swift-tools-version: 5.9
Затем добавьте зависимость от библиотеки SwiftSyntax
.
dependencies: [
.package(url: "https://github.com/apple/swift-syntax", from: "509.0.0")
]
Далее нужно добавить ваш макро-таргет в список таргетов пакета и создать соответствующую папку (в моем примере — MyProjectMacros
) в структуре проекта.
.macro(
name: "MyProjectMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
)
После этого, последний шаг - добавьте зависимость от макроса в нужный таргет. В моём коде это выглядит так:
.target(
name: "MyLibrary",
dependencies: ["MyProjectMacros"]
)
Итоговый результат
// swift-tools-version: 5.9
// The swift-tools-version declares the minimum version of Swift required to build this package.
import PackageDescription
import CompilerPluginSupport
let package = Package(
name: "MyLibrary",
platforms: [ .iOS(.v17), .macOS(.v13)],
products: [
.library(
name: "MyLibrary",
targets: ["MyLibrary"]),
], dependencies: [
.package(url: "https://github.com/apple/swift-syntax", from: "509.0.0")
],
targets: [
.macro(
name: "MyProjectMacros",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax")
]
),
.target(
name: "MyLibrary",
dependencies: ["MyProjectMacros"]
),
]
)
Объявление макроса
Как упоминалось ранее, в Swift макросы делятся на две части: объявление и реализация. Объявление макроса нужно делать не в .macro
таргете, а в обычном .target
— в моем примере это MyLibrary
.
Для объявления макроса нужно придерживаться определенной структуре:
@/* атрибут */(/* тип */, /* дополнительная информация */)
macro /* имя макроса */(/* входящие параметры */) -> /* выходные параметры */ = #externalMacro(module: /* модуль где хранится макрос */, type: /* тип реализации макроса*/)
Для объявления макроса важно придерживаться определённой структуры. Сначала нужно указать атрибут макроса и (если требуется) дополнительную информацию для компилятора, например, какие типы будет генерировать макрос. Затем на следующей строке пишется ключевое слово macro
, имя макроса, входные параметры, а если макрос поддерживает — и выходные параметры. После этого через #externalMacro
указывается модуль, в котором хранится реализация макроса, и его имя.
Пример может показаться сложным, но дальше будут конкретные примеры, которые помогут всё прояснить.
Как упоминалось ранее, существует два типа макросов: freestanding и attached. Начнём с freestanding. Этот тип макросов делится на два подтипа:
-
Expression — макрос, который выполняет какое-то выражение. Его можно представить как вызов функции. Это единственный тип макроса, который может возвращать результат.
@freestanding(declaration, names: named(MyClass)) public macro declarationMacro() = #externalMacro(module: "MyProjectMacros", type: "DeclarationMacro") #declarationMacro // Может например сгенерировать обьект типа MyClass /* class MyClass { func $s22DeclarationMacroClient03_F8D28BC059F4523B96C95750FD5F825D2Ll10FuncUniquefMf0_6uniquefMu_() { } } */
Declaration — как следует из названия, такие макросы генерируют независимый код для объявления объектов или функций.
@freestanding(expression)
public macro expressionMacro<Int>(_ value: Int) -> String = #externalMacro(module: "MyProjectMacros", type: "ExpressionMacro")
#expressionMacro(12)
// Может создать код котораый будет конвертировать значени в строку.
/*
"Your value: 12"
*/
Теперь перейдём к attached макросам. В этом случае существует 5 различных видов:
-
Peer — этот макрос создаёт дополнительные объявления или типы внутри области видимости, к которой он прикреплён. Например, может сгенерировать новый класс-хелпер внутри текущего класса.
Пример
@attached(peer) public macro peer() = #externalMacro(module: "MyProjectMacros", type: "MyPeerMacro") @peer macro generateUserProfileAndManager() { // Генерирует структуру для хранения данных struct UserProfile { var user: User var bio: String func displayProfile() -> String { return "\(user.name) is \(user.age) years old. Bio: \(bio)" } } // Генерирует менеджер class UserManager { private var users: [User] = [] func addUser(_ user: User) { users.append(user) } func getUser(byName name: String) -> User? { return users.first { $0.name == name } } func listUsers() -> [User] { return users } }
-
Member — расширяет функционал объекта или свойства, к которому прикреплён, но не может вводить новые типы или структуры за пределами этого объекта.
Пример
@attached(member) public macro member() = #externalMacro(module: "MyProjectMacros", type: "MyMemberMacro") @member struct MyStruct { // Макрос сгенерирует дополнительный код внутри структуры }
-
Member attribute — генерирует код, относящийся не к объекту целиком, а к конкретному свойству, к которому был применён макрос. Этот макрос работает для всего свойства, но не фокусируется на его отдельных частях.
Пример
@attached(memberAttribute) public macro memberAttribute() = #externalMacro(module: "MyProjectMacros", type: "MyMemberAttributeMacro") struct MyStruct { @memberAttribute var isValid: Bool // Макрос сгенерирует дополнительный функционал для этой проперти. // Напирмер логику валидации для проперти }
-
Accessor — позволяет генерировать логику для аксессоров свойства, таких как
get
,set
,willSet
иdidSet
. В отличие от member attribute, этот макрос применяется только к аксессорам, а не ко всему свойству.Пример
import SwiftCompilerPlugin import SwiftSyntaxMacros import SwiftSyntax @main struct MyProjectMacros: CompilerPlugin { var providingMacros: [Macro.Type] = [ // Туту будет список ваших макросов, сейчас он пуст. ] }
-
Extension — создаёт реализацию для соответствия объекту какому-то протоколу. Например, может автоматически подписать класс на протокол
Equatable
и сгенерировать необходимые методы.Пример
@attached(extension) public macro extensionMacro() = #externalMacro(module: "MyProjectMacros", type: "MyExtensionMacro") @extensionMacro struct MyStruct { // Макрос сгенерирует код и подпишет обьект на определенный протокол. }
Переходим ко второй части — реализации макроса.
Для начала откройте модуль, в котором хранятся ваши макросы (в моём случае это MyProjectMacros
), и создайте в нём основной файл. Вы можете назвать его как угодно, но не называйте его main
, так как Xcode может выдать ошибку. В этом файле нужно указать точку входа с помощью атрибута @main
, а также добавить необходимые импорты для корректной работы.
import SwiftCompilerPlugin
import SwiftSyntaxMacros
import SwiftSyntax
@main
struct MyProjectMacros: CompilerPlugin {
var providingMacros: [Macro.Type] = [
// Тут будет список ваших макросов, сейчас он пуст.
]
}
Далее следует определить макрос. Для этого создаём структуру с тем именем, которое вы указали при объявлении макроса на предыдущем шаге с использованием #externalMacro(module: "MyProjectMacros", type: "MyPeerMacro")
. В моём случае это MyPeerMacro
. Так как тип макроса — Peer, структура MyPeerMacro
должна реализовывать протокол PeerMacro
.
Полный код с примером инициализации всех макросов:
import Foundation
import SwiftCompilerPlugin
import SwiftSyntaxMacros
import SwiftSyntax
// Freestanding
public struct MyExpressionMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// Код вашего макроса...
}
}
public struct MyDeclarationMacro: DeclarationMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Код вашего макроса...
}
}
// Attached
public struct MyPeerMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Код вашего макроса...
}
}
public struct MyMemberMacro: MemberMacro {
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Код вашего макроса...
}
}
public struct MyMemberAttributeMacro: MemberAttributeMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingAttributesFor member: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AttributeSyntax] {
// Код вашего макроса...
}
}
public struct MyAccessorMacro: AccessorMacro {
public static func expansion(
of node: AttributeSyntax,
providingAccessorsOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [AccessorDeclSyntax] {
// Код вашего макроса...
}
}
public struct MyExtensionMacro: ExtensionMacro {
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
// Код вашего макроса...
}
}
@main
struct MyProjectMacros: CompilerPlugin {
// Тут явно регистрируем макросы.
var providingMacros: [Macro.Type] = [
MyPeerMacro.self,
MyMemberMacro.self,
MyMemberAttributeMacro.self,
MyAccessorMacro.self,
MyExtensionMacro.self,
]
}
Аналогично работают и другие типы макросов: у каждого есть одноимённый протокол, который нужно реализовать. Но как же это сделать?
SwiftSyntax добавил множество новых типов, с которыми большинство разработчиков могли не сталкиваться ранее. Он предоставляет доступ к синтаксическому дереву программы, что позволяет получать нужные данные. Однако для тех, кто впервые работает с макросами в Swift, это может показаться сложным. Чтобы не усложнять статью, я не буду подробно объяснять работу с синтаксическим деревом — для этого вы можете обратиться к официальной документации.
Аналогично работают и другие типы макросов: у каждого есть одноимённый протокол, который нужно реализовать. Но как же это сделать?
SwiftSyntax добавил множество новых типов, с которыми большинство разработчиков могли не сталкиваться ранее. Эта библиотека предоставляет доступ к синтаксическому дереву программы, что позволяет извлекать необходимые данные. Однако для тех, кто впервые работает с макросами в Swift, это может показаться сложным. Чтобы не перегружать статью, я не буду подробно объяснять, как работать с синтаксическим деревом. За более детальной информацией можно обратиться к официальной документации.
Поскольку вариантов использования макросов огромное количество, разбирать каждый из них нет смысла. Вместо этого давайте сосредоточимся на ключевых концепциях: из каких основных протоколов состоят макросы и как их отлаживать.
Основные моменты:
Ваша цель — на основе входных данных сгенерировать код и вернуть нужный результат, как это делается в обычной функции.
Declaration:
Основной элемент, с которым вам предстоит работать, — этоDeclGroupSyntax
. Он содержит всю информацию об объекте, к которому относится макрос. Этот элемент можно удобно преобразовать в тип объекта, с которым макрос должен работать. Например:
if let structDecl = declaration.as(StructDeclSyntax.self) {
// Ваш код работы с структурой
}
Здесь мы явно проверяем, что макрос добавляется к структуре. Если это не так, компилятор выдаст ошибку.
Node:
Ещё один важный аргумент —AttributeSyntax
, который представляет атрибуты, применённые к макросу, такие как свойства, методы или типы. Например, с его помощью можно получить информацию о таких атрибутах, как@objc
,@discardableResult
и других.
Пример команды po node
Printing description of node:
MacroExpansionExprSyntax
├─pound: pound
├─macroName: identifier("stringify")
├─leftParen: leftParen
├─arguments: LabeledExprListSyntax
│ ╰─[0]: LabeledExprSyntax
│ ╰─expression: InfixOperatorExprSyntax
│ ├─leftOperand: DeclReferenceExprSyntax
│ │ ╰─baseName: identifier("hello")
│ ├─operator: BinaryOperatorExprSyntax
│ │ ╰─operator: binaryOperator("+")
│ ╰─rightOperand: DeclReferenceExprSyntax
│ ╰─baseName: identifier("world")
├─rightParen: rightParen
╰─additionalTrailingClosures: MultipleTrailingClosureElementListSyntax
Сам код макроса можно написать как строковый литерал. Этот подход показан Apple на WWDC и является самым простым, но небезопасным вариантом написания кода для макросов. Лучше использовать этот способ только для простых случаев.
Полный пример макроса
public struct MyPeerMacro: PeerMacro {
public static func expansion(
of node: AttributeSyntax,
providingPeersOf declaration: some DeclSyntaxProtocol,
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Проверка на то что тип структура
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
return []
}
// Берем имя структуры
let structName = structDecl.name.text
// СоздаемTracker class для нашей структуры
let trackerDecl = """
class \(structName)Tracker {
private var instances: [\(structName)] = []
func track(_ instance: \(structName)) {
instances.append(instance)
print("Tracking instance: \\(instance)")
}
func listTrackedInstances() -> [\(structName)] {
return instances
}
}
"""
// Возвращаем наше выражение
return [DeclSyntax(stringLiteral: trackerDecl)]
}
}
Как дебажить макросы?
Поскольку макросы выполняются на этапе компиляции, а не во время выполнения программы (runtime), у нас нет возможности установить брейкпоинты для проверки их работы. Однако есть решение — тесты. Мы можем создать тесты для нашего макроса, и во время их выполнения брейкпоинты начнут работать. Давайте разберём, как это сделать. Я не буду описывать весь процесс тестирования макроса, так как моя цель — объяснить, как дебажить макросы.
Создание тестового таргета:
Сначала нужно создать тестовый таргет в вашем Package.swift
.
.testTarget(
name: "MyProjectTests",
dependencies: [
"MyProjectMacros",
.product(name: "SwiftSyntaxMacrosTestSupport", package: "swift-syntax"),
]
),
Добавление импортов:
В самом тесте необходимо добавить нужные импорты. Важно использовать условную компиляцию с #if canImport
, так как макросы поддерживаются только на той платформе, на которой вы разрабатываете (например, macOS на вашем Mac). Чтобы тесты сработали, укажите целевой таргет Mac, а не симулятор.
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
import SwiftSyntaxMacrosTestSupport
import XCTest
// Добавляем импорт макросов в тестовую среду
#if canImport(MyProjectMacros)
import MyProjectMacros
let testMacros: [String: Macro.Type] = [
"peer": MyPeerMacro.self,
]
#endif
Написание теста:
Далее напишите код для тестирования макроса. Убедитесь, что вы установили брейкпоинт внутри тела вашего макроса. Это позволит вам увидеть, какие значения приходят во входные параметры AttributeSyntax
и DeclSyntaxProtocol
.
final class MacrosTests: XCTestCase {
func testMacro() throws {
#if canImport(WWDCMacros)
assertMacroExpansion(
"""
@peer
struct Test {}
""",
expandedSource: """
ваш ожидаемый результат
""",
macros: testMacros
)
#else
throw XCTSkip("macros are only su pported when running tests for the host platform")
#endif
}
}
Заключение
На этом всё! В этой статье мы разобрали самый необходимый практический минимум, чтобы вы знали, как добавить макросы в свой проект, какие виды макросов существуют и как их правильно дебажить. Этого вполне достаточно, чтобы начать.
Если вы хотите углубиться в эту тему, можете обратиться к официальной документации Apple. Также не стесняйтесь писать в комментариях — я с радостью сделаю детальный разбор работы макросов под капотом.
Так же подписывайтесь на мой ТГ канал, там я стараюсь понятым языком писать о технологиях, в небольших постах.