Макроси Swift: практичний посібник з метапрограмування та генерації коду

Практичний посібник з макросів Swift: від базових понять до створення власних макросів #URL, @CodableKeys та @AutoInit. Автономні та прикріплені макроси, SwiftSyntax, тестування з assertMacroExpansion та snapshot testing.

Swift Макроси 2026: Гайд із SwiftSyntax
On this page
  1. Вступ
  2. Що таке макроси Swift
  3. Типи макросів
  4. Створення першого макросу
  5. Практичні приклади
  6. Тестування макросів
  7. Вбудовані макроси Swift
  8. Найкращі практики та обмеження
  9. Висновок

Вступ

Якщо ви пишете на Swift, то напевно знаєте цей відчуття: ви копіюєте один і той самий шматок коду вже в десятий файл. Реалізація Codable з кастомними ключами, створення ініціалізаторів для структур із десятками властивостей, написання повторюваних обгорток для логування чи валідації — все це забирає час і, чесно кажучи, просто дратує. Саме цю проблему покликані вирішити макроси Swift.

Макроси з'явилися на WWDC23 як частина Swift 5.9, і це стало, мабуть, однією з найзначніших змін у мові за останні роки. На відміну від багатьох інших нововведень, макроси не просто додали новий синтаксис — вони відкрили цілком новий рівень метапрограмування, дозволивши генерувати код на етапі компіляції з повним збереженням типобезпеки.

Apple одразу показала, що ставиться до цього серйозно. Макрос @Observable замінив громіздкий ObservableObject, #Preview спростив створення прев'ю для SwiftUI, а #Predicate забезпечив типобезпечну фільтрацію даних у SwiftData. Отже, давайте розберемося, як макроси працюють, які типи існують, як створити власний макрос і як ефективно використовувати цю технологію у реальних проєктах.

Що таке макроси Swift

Якщо коротко — макроси Swift це механізм генерації коду на етапі компіляції. Коли компілятор бачить виклик макросу, він передає відповідний синтаксичний фрагмент у плагін, який аналізує його та повертає згенерований код. Потім цей код вбудовується у програму замість (або на додачу до) оригінального виклику.

Тут важливо розуміти принципову відмінність від макросів препроцесора C та C++. У мові C макроси — це просте текстове підставлення, без жодного розуміння структури коду. І це породжує класичні проблеми:

// Приклад проблеми з макросами C
// #define SQUARE(x) x * x
// SQUARE(2 + 3) розгорнеться у 2 + 3 * 2 + 3 = 11, а не 25

Макроси Swift працюють зовсім інакше. Вони оперують не текстом, а абстрактним синтаксичним деревом (AST) через бібліотеку SwiftSyntax. Макрос отримує структурований, типізований фрагмент коду, аналізує його як дерево вузлів і генерує новий, синтаксично коректний код. І компілятор перевіряє згенерований код так само ретельно, як написаний вручну — з повною перевіркою типів, областей видимості та всіх інших правил мови.

Ще одна приємна штука — прозорість. У Xcode можна натиснути правою кнопкою на будь-який макрос і вибрати «Expand Macro», щоб побачити точний згенерований код. Жодної магії — все на виду.

Макроси також мають суворі обмеження безпеки: вони виконуються як ізольовані процеси (sandbox), не мають доступу до файлової системи чи мережі, можуть лише читати наданий їм фрагмент коду та повертати новий. Тобто побічних ефектів бути не може, а результат завжди детермінований.

Типи макросів

Swift визначає два основні типи макросів: автономні (freestanding) та прикріплені (attached). Кожен має свої підкатегорії — давайте розберемо їх.

Автономні макроси (Freestanding Macros)

Автономні макроси викликаються самостійно, без прикріплення до конкретної декларації. Їхній синтаксис починається із символу # (аналогічно до вже знайомих #file чи #line). Існує два підтипи:

Макроси-вирази (Expression Macros) — оголошуються з атрибутом @freestanding(expression) і генерують значення певного типу. Можна використовувати скрізь, де очікується вираз — у присвоєнні, аргументі функції, умові тощо.

// Оголошення макросу-виразу
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(
    module: "MyMacrosPlugin",
    type: "StringifyMacro"
)

// Використання
let (result, code) = #stringify(2 + 3)
// result == 5, code == "2 + 3"

Макроси-декларації (Declaration Macros) — оголошуються з атрибутом @freestanding(declaration) і створюють нові декларації: функції, змінні, типи. Використовуються скрізь, де допустима декларація на верхньому рівні або всередині типу.

// Оголошення макросу-декларації
@freestanding(declaration, names: arbitrary)
public macro generateEnumCases(_ cases: String...) = #externalMacro(
    module: "MyMacrosPlugin",
    type: "GenerateEnumCasesMacro"
)

// Використання
#generateEnumCases("success", "failure", "loading")
// Згенерує окремі case-и для enum

Прикріплені макроси (Attached Macros)

Прикріплені макроси застосовуються до існуючої декларації (структури, класу, функції, властивості) і додають або модифікують код у її контексті. Синтаксис починається з @, як у звичайних атрибутів. Swift визначає шість ролей:

  • Peer (@attached(peer)) — створює нові декларації поруч із декларацією, до якої прикріплений. Наприклад, може згенерувати асинхронну версію функції на тому ж рівні.
  • Accessor (@attached(accessor)) — додає акцесори (getter, setter, willSet, didSet) до властивості. Ідеально для обгорток навколо UserDefaults або логування змін.
  • MemberAttribute (@attached(memberAttribute)) — додає атрибути до всіх членів типу. Скажімо, можна автоматично додати @objc до всіх методів класу.
  • Member (@attached(member)) — додає нові члени (властивості, методи, ініціалізатори) до типу. Мабуть, найпопулярніша роль — використовується для автогенерації ініціалізаторів, CodingKeys тощо.
  • Extension (@attached(extension)) — створює розширення для типу. Дозволяє додати відповідність протоколам, допоміжні методи та обчислювані властивості.
  • Conformance (@attached(conformance)) — додає відповідність протоколу. Часто використовується разом із member або extension для повної реалізації протоколу.

Цікава деталь: один макрос може мати кілька ролей одночасно. Наприклад, вбудований @Observable поєднує ролі member, memberAttribute та extension для повної трансформації класу.

Створення першого макросу

Теорія — це добре, але найкращий спосіб зрозуміти макроси — зробити один самому. Давайте крок за кроком побудуємо простий макрос #stringify, який приймає вираз, обчислює його значення та повертає кортеж із результатом і текстовим представленням виразу.

Крок 1: Створення Swift Package

Макроси реалізуються як Swift-пакети з особливою структурою. У Xcode виберіть File → New → Package і оберіть шаблон «Swift Macro» — Xcode згенерує пакет із правильною структурою. Якщо хочете створити пакет вручну, потрібно мати три таргети: бібліотеку, плагін-макрос і тести.

Крок 2: Налаштування Package.swift

Файл маніфесту пакета має включати залежність від swift-syntax та правильно визначені таргети:

// swift-tools-version: 5.9
import PackageDescription
import CompilerPluginSupport

let package = Package(
    name: "MyMacros",
    platforms: [.macOS(.v10_15), .iOS(.v13)],
    products: [
        .library(
            name: "MyMacros",
            targets: ["MyMacros"]
        ),
    ],
    dependencies: [
        .package(
            url: "https://github.com/swiftlang/swift-syntax.git",
            from: "509.0.0"
        ),
    ],
    targets: [
        // Таргет-плагін, де реалізується логіка макросу
        .macro(
            name: "MyMacrosPlugin",
            dependencies: [
                .product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
                .product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
            ]
        ),
        // Бібліотека з оголошеннями макросів
        .target(
            name: "MyMacros",
            dependencies: ["MyMacrosPlugin"]
        ),
        // Тести
        .testTarget(
            name: "MyMacrosTests",
            dependencies: [
                "MyMacrosPlugin",
                .product(
                    name: "SwiftSyntaxMacrosTestSupport",
                    package: "swift-syntax"
                ),
            ]
        ),
    ]
)

Крок 3: Оголошення макросу

У файлі бібліотечного таргету (MyMacros/MyMacros.swift) оголошуємо сигнатуру макросу. Це те, що побачать користувачі — публічний API:

// Sources/MyMacros/MyMacros.swift

/// Макрос, який приймає вираз будь-якого типу та повертає кортеж
/// із обчисленим значенням і рядковим представленням виразу.
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(
    module: "MyMacrosPlugin",
    type: "StringifyMacro"
)

Конструкція #externalMacro вказує компілятору, де шукати реалізацію: у модулі MyMacrosPlugin, у типі StringifyMacro.

Крок 4: Реалізація логіки макросу

А ось тут починається найцікавіше. У таргеті плагіна створюємо структуру, яка реалізує протокол ExpressionMacro. Саме тут відбувається вся «магія» — трансформація синтаксичного дерева:

// Sources/MyMacrosPlugin/StringifyMacro.swift

import SwiftSyntax
import SwiftSyntaxMacros
import SwiftCompilerPlugin

public struct StringifyMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) -> ExprSyntax {
        // Отримуємо перший аргумент макросу
        guard let argument = node.arguments.first?.expression else {
            fatalError("Компілятор повідомив про помилку: макрос потребує аргумент")
        }

        // Повертаємо кортеж із значенням та його рядковим представленням
        return "(\(argument), \(literal: argument.description))"
    }
}

// Реєстрація плагіна
@main
struct MyMacrosPluginEntry: CompilerPlugin {
    let providingMacros: [Macro.Type] = [
        StringifyMacro.self,
    ]
}

Розглянемо детальніше. Метод expansion(of:in:) — єдиний обов'язковий метод протоколу ExpressionMacro. Він отримує два параметри:

  • node — синтаксичний вузол виклику макросу, через який ми отримуємо доступ до аргументів.
  • context — контекст розгортання, що дозволяє генерувати унікальні імена та створювати діагностичні повідомлення.

Метод повертає ExprSyntax — фрагмент синтаксичного дерева. Зверніть увагу, як ми використовуємо інтерполяцію рядків Swift для побудови цього дерева — дуже зручний спосіб генерації коду.

Крок 5: Використання макросу

Тепер макрос можна використовувати у будь-якому проєкті, який імпортує MyMacros:

import MyMacros

let (value, description) = #stringify(2 + 3)
print("Вираз: \(description)")   // Вираз: 2 + 3
print("Результат: \(value)")     // Результат: 5

let numbers = [1, 2, 3, 4, 5]
let (filtered, expr) = #stringify(numbers.filter { $0 > 2 })
print(expr)      // numbers.filter { $0 > 2 }
print(filtered)  // [3, 4, 5]

Практичні приклади

Добре, базову механіку ми розібрали. Тепер давайте подивимось на щось більш практичне — приклади, які реально демонструють цінність макросів у повсякденній розробці.

Макрос #URL для валідації URL на етапі компіляції

Ось одна з найболючіших дрібниць у iOS-розробці: створення об'єктів URL із рядків. Стандартний ініціалізатор URL(string:) повертає опціональне значення, і розробники часто ліплять примусове розгортання (!), що може призвести до крашу в рантаймі. Макрос #URL перевіряє валідність URL ще на етапі компіляції:

// Оголошення макросу
@freestanding(expression)
public macro URL(_ stringLiteral: String) -> URL = #externalMacro(
    module: "MyMacrosPlugin",
    type: "URLMacro"
)

// Реалізація
import SwiftSyntax
import SwiftSyntaxMacros
import Foundation

public struct URLMacro: ExpressionMacro {
    public static func expansion(
        of node: some FreestandingMacroExpansionSyntax,
        in context: some MacroExpansionContext
    ) throws -> ExprSyntax {
        // Перевіряємо, що аргумент — рядковий літерал
        guard let argument = node.arguments.first?.expression,
              let segments = argument.as(StringLiteralExprSyntax.self)?.segments,
              segments.count == 1,
              case .stringSegment(let literalSegment)? = segments.first
        else {
            throw MacroError.message(
                "#URL потребує статичний рядковий літерал"
            )
        }

        // Перевіряємо валідність URL
        guard let _ = Foundation.URL(string: literalSegment.content.text) else {
            throw MacroError.message(
                "Невалідний URL: \"\(literalSegment.content.text)\""
            )
        }

        return "URL(string: \(argument))!"
    }
}

enum MacroError: Error, CustomStringConvertible {
    case message(String)
    var description: String {
        switch self {
        case .message(let text): return text
        }
    }
}

І тепер замість небезпечного примусового розгортання:

// Старий підхід — потенційний краш
let url = URL(string: "https://api.example.com/users")!

// Новий підхід із макросом — помилка компіляції при невалідному URL
let url = #URL("https://api.example.com/users")

// А це просто не скомпілюється:
// let badURL = #URL("не URL взагалі")
// Помилка: Невалідний URL: "не URL взагалі"

Чесно кажучи, після того як я почав використовувати подібний макрос у своїх проєктах, кількість неочікуваних крашів через невалідні URL зменшилась до нуля.

Макрос @CodableKeys для автогенерації CodingKeys

Коли JSON використовує snake_case, а Swift-властивості — camelCase, доводиться вручну писати перерахування CodingKeys. Це нудно і схильно до помилок. Макрос автоматизує весь процес:

// Оголошення макросу
@attached(member, names: named(CodingKeys))
public macro CodableKeys() = #externalMacro(
    module: "MyMacrosPlugin",
    type: "CodableKeysMacro"
)

// Реалізація
import SwiftSyntax
import SwiftSyntaxMacros

public struct CodableKeysMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        // Отримуємо всі збережені властивості
        let members = declaration.memberBlock.members
        let storedProperties = members.compactMap { member -> String? in
            guard let property = member.decl.as(VariableDeclSyntax.self),
                  property.bindings.first?.accessorBlock == nil,
                  let name = property.bindings.first?
                      .pattern.as(IdentifierPatternSyntax.self)?
                      .identifier.text
            else { return nil }
            return name
        }

        // Генеруємо CodingKeys з перетворенням camelCase → snake_case
        let cases = storedProperties.map { name in
            let snakeCase = name.toSnakeCase()
            if snakeCase == name {
                return "case \(name)"
            } else {
                return "case \(name) = \"\(snakeCase)\""
            }
        }

        let enumDecl = """
            enum CodingKeys: String, CodingKey {
                \(cases.joined(separator: "\n        "))
            }
            """

        return [DeclSyntax(stringLiteral: enumDecl)]
    }
}

// Допоміжна функція перетворення camelCase у snake_case
extension String {
    func toSnakeCase() -> String {
        var result = ""
        for (index, character) in self.enumerated() {
            if character.isUppercase {
                if index > 0 {
                    result += "_"
                }
                result += character.lowercased()
            } else {
                result += String(character)
            }
        }
        return result
    }
}

Використання стає елементарним:

@CodableKeys
struct UserProfile: Codable {
    let firstName: String
    let lastName: String
    let emailAddress: String
    let phoneNumber: String?
    let createdAt: Date
}

// Макрос згенерує:
// enum CodingKeys: String, CodingKey {
//     case firstName = "first_name"
//     case lastName = "last_name"
//     case emailAddress = "email_address"
//     case phoneNumber = "phone_number"
//     case createdAt = "created_at"
// }

Макрос @AutoInit для автогенерації ініціалізатора

Swift автоматично генерує поелементний ініціалізатор для структур, але є нюанси: він працює лише якщо ви не написали власний ініціалізатор, і має рівень доступу internal. Для публічних API доведеться писати ініціалізатор вручну. А якщо у вас модель із 15 полями — це вже неприємно. Макрос @AutoInit вирішує цю проблему:

// Оголошення
@attached(member, names: named(init))
public macro AutoInit() = #externalMacro(
    module: "MyMacrosPlugin",
    type: "AutoInitMacro"
)

// Реалізація
import SwiftSyntax
import SwiftSyntaxMacros

public struct AutoInitMacro: MemberMacro {
    public static func expansion(
        of node: AttributeSyntax,
        providingMembersOf declaration: some DeclGroupSyntax,
        in context: some MacroExpansionContext
    ) throws -> [DeclSyntax] {
        // Визначаємо рівень доступу типу
        let accessLevel: String
        if let structDecl = declaration.as(StructDeclSyntax.self) {
            accessLevel = structDecl.modifiers.first(
                where: { modifier in
                    ["public", "internal", "fileprivate", "private"].contains(
                        modifier.name.text
                    )
                }
            )?.name.text ?? "internal"
        } else if let classDecl = declaration.as(ClassDeclSyntax.self) {
            accessLevel = classDecl.modifiers.first(
                where: { modifier in
                    ["public", "open", "internal", "fileprivate", "private"]
                        .contains(modifier.name.text)
                }
            )?.name.text ?? "internal"
        } else {
            throw MacroError.message(
                "@AutoInit можна застосовувати лише до структур та класів"
            )
        }

        // Збираємо збережені властивості
        let members = declaration.memberBlock.members
        let properties: [(name: String, type: String, defaultValue: String?)] =
            members.compactMap { member in
                guard let property = member.decl.as(VariableDeclSyntax.self),
                      let binding = property.bindings.first,
                      binding.accessorBlock == nil,
                      let pattern = binding.pattern
                          .as(IdentifierPatternSyntax.self),
                      let typeAnnotation = binding.typeAnnotation
                else { return nil }

                let name = pattern.identifier.text
                let type = typeAnnotation.type.trimmedDescription
                let defaultValue = binding.initializer?
                    .value.trimmedDescription

                return (name, type, defaultValue)
            }

        // Генеруємо параметри ініціалізатора
        let parameters = properties.map { prop in
            if let defaultValue = prop.defaultValue {
                return "\(prop.name): \(prop.type) = \(defaultValue)"
            } else {
                return "\(prop.name): \(prop.type)"
            }
        }.joined(separator: ",\n        ")

        // Генеруємо тіло ініціалізатора
        let assignments = properties.map { prop in
            "self.\(prop.name) = \(prop.name)"
        }.joined(separator: "\n        ")

        let initDecl = """
            \(accessLevel) init(
                \(parameters)
            ) {
                \(assignments)
            }
            """

        return [DeclSyntax(stringLiteral: initDecl)]
    }
}

Тепер створення публічних моделей стає набагато приємнішим:

@AutoInit
public struct NetworkConfiguration {
    public let baseURL: URL
    public let apiKey: String
    public let timeout: TimeInterval = 30.0
    public let maxRetries: Int = 3
    public let logLevel: LogLevel = .info
}

// Макрос згенерує:
// public init(
//     baseURL: URL,
//     apiKey: String,
//     timeout: TimeInterval = 30.0,
//     maxRetries: Int = 3,
//     logLevel: LogLevel = .info
// ) {
//     self.baseURL = baseURL
//     self.apiKey = apiKey
//     self.timeout = timeout
//     self.maxRetries = maxRetries
//     self.logLevel = logLevel
// }

// Використання з дефолтними значеннями
let config = NetworkConfiguration(
    baseURL: #URL("https://api.example.com"),
    apiKey: "sk-12345"
)

// Або з кастомними значеннями
let customConfig = NetworkConfiguration(
    baseURL: #URL("https://staging.example.com"),
    apiKey: "sk-test",
    timeout: 60.0,
    maxRetries: 5,
    logLevel: .debug
)

Тестування макросів

Тестування макросів — це не просто «було б непогано», а критично важлива частина процесу. Макроси генерують код, який безпосередньо впливає на поведінку програми, і помилки в них бувають дуже підступними. На щастя, SwiftSyntax надає непогані інструменти для тестування.

Стандартне тестування з assertMacroExpansion

Модуль SwiftSyntaxMacrosTestSupport надає функцію assertMacroExpansion, яка порівнює згенерований код із очікуваним результатом:

import SwiftSyntaxMacrosTestSupport
import XCTest

// Імпорт плагіна для отримання типів макросів
@testable import MyMacrosPlugin

final class StringifyMacroTests: XCTestCase {

    // Словник, що зіставляє імена макросів з їхніми реалізаціями
    let testMacros: [String: Macro.Type] = [
        "stringify": StringifyMacro.self,
    ]

    func testStringifyWithArithmetic() throws {
        assertMacroExpansion(
            """
            #stringify(2 + 3)
            """,
            expandedSource: """
            (2 + 3, "2 + 3")
            """,
            macros: testMacros
        )
    }

    func testStringifyWithFunctionCall() throws {
        assertMacroExpansion(
            """
            #stringify(array.count)
            """,
            expandedSource: """
            (array.count, "array.count")
            """,
            macros: testMacros
        )
    }
}

final class URLMacroTests: XCTestCase {

    let testMacros: [String: Macro.Type] = [
        "URL": URLMacro.self,
    ]

    func testValidURL() throws {
        assertMacroExpansion(
            """
            #URL("https://www.apple.com")
            """,
            expandedSource: """
            URL(string: "https://www.apple.com")!
            """,
            macros: testMacros
        )
    }

    func testInvalidURLProducesDiagnostic() throws {
        assertMacroExpansion(
            """
            #URL("not a valid url $$%")
            """,
            expandedSource: """
            #URL("not a valid url $$%")
            """,
            diagnostics: [
                DiagnosticSpec(
                    message: "Невалідний URL: \"not a valid url $$%\"",
                    line: 1,
                    column: 1
                )
            ],
            macros: testMacros
        )
    }
}

Зверніть увагу на кілька моментів. По-перше, ми створюємо словник testMacros, що зіставляє ім'я макросу (без # чи @) із типом реалізації. По-друге, для перевірки діагностичних повідомлень використовується параметр diagnostics з масивом DiagnosticSpec.

Тестування з бібліотекою swift-macro-testing

Команда PointFree створила бібліотеку swift-macro-testing, яка значно спрощує написання тестів завдяки механізму snapshot testing. Замість ручного написання очікуваного результату, бібліотека записує його автоматично при першому запуску:

import MacroTesting
import XCTest

@testable import MyMacrosPlugin

final class AutoInitMacroSnapshotTests: XCTestCase {

    override func invokeTest() {
        withMacroTesting(macros: [AutoInitMacro.self]) {
            super.invokeTest()
        }
    }

    func testAutoInitWithStruct() {
        assertMacro {
            """
            @AutoInit
            public struct User {
                public let id: Int
                public let name: String
                public let email: String
            }
            """
        } expansion: {
            """
            public struct User {
                public let id: Int
                public let name: String
                public let email: String

                public init(
                    id: Int,
                    name: String,
                    email: String
                ) {
                    self.id = id
                    self.name = name
                    self.email = email
                }
            }
            """
        }
    }

    func testAutoInitWithDefaultValues() {
        assertMacro {
            """
            @AutoInit
            struct Settings {
                let theme: String = "light"
                let fontSize: Int = 14
            }
            """
        } expansion: {
            """
            struct Settings {
                let theme: String = "light"
                let fontSize: Int = 14

                internal init(
                    theme: String = "light",
                    fontSize: Int = 14
                ) {
                    self.theme = theme
                    self.fontSize = fontSize
                }
            }
            """
        }
    }
}

Чим це зручно? При першому запуску тесту поле expansion заповнюється автоматично, а далі порівнюється із записаним результатом. Для складних макросів із великим обсягом згенерованого коду це реально економить купу часу.

Вбудовані макроси Swift

Apple активно використовує макроси у власних фреймворках, і варто розглянути ключові приклади — хоча б для натхнення.

@Observable

Макрос @Observable, що з'явився в iOS 17 та macOS Sonoma, замінив протокол ObservableObject та обгортку @Published. Він автоматично трансформує клас, додаючи механізм відстеження змін:

// Що ви пишете:
@Observable
class UserViewModel {
    var name: String = ""
    var email: String = ""
    var isLoggedIn: Bool = false
}

// Що генерує макрос (спрощено):
class UserViewModel {
    @ObservationTracked
    var name: String = ""

    @ObservationTracked
    var email: String = ""

    @ObservationTracked
    var isLoggedIn: Bool = false

    @ObservationIgnored
    private let _$observationRegistrar = ObservationRegistrar()

    internal nonisolated func access<Member>(
        keyPath: KeyPath<UserViewModel, Member>
    ) {
        _$observationRegistrar.access(self, keyPath: keyPath)
    }

    internal nonisolated func withMutation<Member, MutationResult>(
        keyPath: KeyPath<UserViewModel, Member>,
        _ mutation: () throws -> MutationResult
    ) rethrows -> MutationResult {
        try _$observationRegistrar.withMutation(
            of: self, keyPath: keyPath, mutation
        )
    }
}

extension UserViewModel: Observable {}

Під капотом цей макрос поєднує ролі @attached(member), @attached(memberAttribute) та @attached(extension). Він додає реєстратор, обгортає кожну властивість механізмом відстеження та додає відповідність протоколу Observable. Результат? SwiftUI перемальовує тільки ті View, які реально залежать від змінених властивостей. Продуктивність помітно краща.

#Preview

Макрос #Preview замінив структуру PreviewProvider і — ну, просто погляньте, наскільки все стало простіше:

// Старий підхід
struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
            .previewDisplayName("Головний екран")
    }
}

// Новий підхід із макросом
#Preview("Головний екран") {
    ContentView()
}

// Можна створити кілька прев'ю
#Preview("Світла тема") {
    ContentView()
        .preferredColorScheme(.light)
}

#Preview("Темна тема") {
    ContentView()
        .preferredColorScheme(.dark)
}

// Навіть для UIKit
#Preview("UIKit контролер") {
    let viewController = UIViewController()
    viewController.view.backgroundColor = .systemBlue
    return viewController
}

Під капотом #Preview генерує необхідну структуру PreviewProvider з усіма налаштуваннями. Класичний приклад того, як макроси прибирають шаблонний код, зберігаючи повну функціональність.

#Predicate

Макрос #Predicate, що з'явився разом зі SwiftData, дозволяє створювати типобезпечні предикати для фільтрації даних:

import SwiftData

@Model
class Book {
    var title: String
    var author: String
    var publicationYear: Int
    var rating: Double

    init(title: String, author: String, publicationYear: Int, rating: Double) {
        self.title = title
        self.author = author
        self.publicationYear = publicationYear
        self.rating = rating
    }
}

// Створення предиката з макросом
let minRating = 4.0
let recentYear = 2020

let highRatedRecent = #Predicate<Book> { book in
    book.rating >= minRating && book.publicationYear >= recentYear
}

let searchTerm = "Swift"
let searchPredicate = #Predicate<Book> { book in
    book.title.localizedStandardContains(searchTerm)
}

// Використання з SwiftData
let descriptor = FetchDescriptor<Book>(
    predicate: highRatedRecent,
    sortBy: [SortDescriptor(\.rating, order: .reverse)]
)

#Predicate перетворює замикання Swift на об'єкт Predicate, який може бути транслятований у SQL-запит або інший формат, зрозумілий для бази даних. При цьому компілятор перевіряє типи та імена властивостей — на відміну від старих рядкових NSPredicate, де помилку можна було виявити лише в рантаймі.

Найкращі практики та обмеження

Макроси — потужна штука, але, як і будь-який потужний інструмент, вони вимагають відповідального підходу. Ось ключові рекомендації, зібрані з досвіду спільноти.

Найкращі практики

Зберігайте простоту. Кожен макрос має виконувати одну чітко визначену задачу. Не створюйте «мегамакроси», що намагаються зробити все одразу. Краще кілька простих, які можна комбінувати.

// Поганий підхід — один макрос для всього
@SuperModel  // Генерує Codable, Hashable, ініціалізатор, опис...

// Кращий підхід — окремі макроси для окремих задач
@AutoInit
@CodableKeys
struct User: Codable, Hashable {
    let id: Int
    let name: String
}

Давайте зрозумілі діагностичні повідомлення. Коли макрос не може обробити переданий код, він має чітко пояснити, що пішло не так. Використовуйте context.diagnose() для створення повідомлень із точним вказівком на проблемне місце:

public static func expansion(
    of node: AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
    // Перевіряємо, що макрос застосований до структури або класу
    guard declaration.is(StructDeclSyntax.self)
        || declaration.is(ClassDeclSyntax.self)
    else {
        let diagnostic = Diagnostic(
            node: Syntax(node),
            message: MyDiagnosticMessage(
                message: "@AutoInit можна застосувати лише до struct або class",
                id: "autoInitInvalidTarget",
                severity: .error
            )
        )
        context.diagnose(diagnostic)
        return []
    }

    // ... решта логіки
}

Тестуйте все, що можна. Макрос може отримати на вхід дуже різноманітний код. Тестуйте не лише стандартні випадки, а й граничні: порожні типи, обчислювані властивості, generic-типи, вкладені типи, різні рівні доступу.

// Тестуйте граничні випадки
func testEmptyStruct() throws {
    assertMacroExpansion(
        """
        @AutoInit
        struct Empty {}
        """,
        expandedSource: """
        struct Empty {

            internal init() {
            }
        }
        """,
        macros: testMacros
    )
}

func testStructWithComputedProperty() throws {
    assertMacroExpansion(
        """
        @AutoInit
        struct User {
            let firstName: String
            let lastName: String
            var fullName: String {
                "\\(firstName) \\(lastName)"
            }
        }
        """,
        expandedSource: """
        struct User {
            let firstName: String
            let lastName: String
            var fullName: String {
                "\\(firstName) \\(lastName)"
            }

            internal init(
                firstName: String,
                lastName: String
            ) {
                self.firstName = firstName
                self.lastName = lastName
            }
        }
        """,
        macros: testMacros
    )
}

Документуйте, що генерує ваш макрос. Оскільки реалізація прихована, важливо описати, що саме макрос робить. Додайте DocC-документацію з прикладами вхідного та вихідного коду.

Використовуйте context.makeUniqueName() для уникнення конфліктів імен. Коли макрос генерує допоміжні змінні чи функції, цей метод створює гарантовано унікальні імена:

let uniqueVarName = context.makeUniqueName("storage")
// Згенерує щось на кшталт "__macro_local_7storagefMu_"

Обмеження макросів

При всій потужності, макроси мають суттєві обмеження:

  • Тільки етап компіляції. Макроси не мають доступу до рантайм-значень, не можуть робити мережеві запити чи читати файли. Вся доступна інформація — це синтаксичне дерево переданого коду.
  • Адитивність. Макроси можуть лише додавати код. Видаляти або модифікувати існуючий — ні. Наприклад, макрос не змінить реалізацію функції, а лише додасть нову поруч.
  • Складність налагодження. Коли згенерований код містить помилку, дебаг може бути складнішим, ніж зі звичайним кодом. Xcode показує розгорнутий код, але покрокове налагодження самого макросу вимагає додаткових зусиль.
  • Час компіляції. Бібліотека swift-syntax — досить велика залежність, і її компіляція може тривати кілька хвилин (особливо перша збірка). Це варто мати на увазі.
  • Обмежена інформація про типи. Макроси працюють на рівні синтаксису, не семантики. Тобто макрос бачить текстове представлення типів, але не знає, чи реалізує тип певний протокол або яке тіло має суперклас.
  • Крива навчання. Робота зі SwiftSyntax вимагає розуміння структури AST, а це нетривіально. API досить об'ємне, і на початку може бути складно. Рекомендую використовувати Swift AST Explorer для візуалізації синтаксичних дерев — дуже допомагає.

Коли варто використовувати макроси, а коли ні

Макроси — ідеальний вибір, коли:

  1. Є шаблонний код, що повторюється в багатьох місцях із мінімальними варіаціями.
  2. Потрібна валідація на етапі компіляції (як у прикладі з #URL).
  3. Треба генерувати код на основі структури існуючих типів (@AutoInit, @CodableKeys).
  4. Хочете забезпечити дотримання певних конвенцій у кодовій базі.

А ось коли макроси краще не використовувати:

  1. Задачу можна вирішити звичайними засобами — протоколами, дженериками, розширеннями.
  2. Логіка потребує рантайм-інформації, недоступної під час компіляції.
  3. Шаблонний код зустрічається лише в одному-двох місцях — витрати на створення макросу просто не виправдані.
  4. Макрос приховає важливу логіку, яку розробникам потрібно бачити безпосередньо.

Висновок

Макроси Swift — це, мабуть, одне з найцікавіших доповнень до мови за останні роки. Вони дають можливості метапрограмування, які раніше були просто недоступні, зберігаючи фірмову типобезпеку та прозорість Swift. Від простих утиліт на кшталт #stringify чи #URL до масштабних трансформацій типу @Observable — спектр застосувань дійсно широкий.

За час після появи макросів у Swift 5.9 екосистема помітно зросла. Спільнота створила десятки бібліотек із готовими макросами для різних задач, а Apple продовжує розвивати технологію, розширюючи набір ролей та покращуючи API SwiftSyntax.

Головне — розуміти, коли макроси доречні, а коли краще обрати простіший шлях. Почніть з малого: створіть простий макрос для конкретної проблеми у вашому проєкті, протестуйте його і поступово нарощуйте досвід. Зі часом ви зможете створювати все потужніші абстракції, зменшуючи обсяг шаблонного коду.

Чесно кажучи, макроси — це не просто ще одна фіча. Це новий спосіб мислення про код, який дозволяє писати менше, а виражати більше. І зараз — відмінний час, щоб почати їх вивчати.