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

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

Вступ

Якщо ви пишете на 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.

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

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

Про Автора Editorial Team

Our team of expert writers and editors.