Функціонал макросів у мові програмування Swift

💡 Усі статті, обговорення, новини про Mobile — в одному місці. Приєднуйтесь до Mobile спільноти!

Привіт! Мене звати Данило, я iOS-розробник у команді Lift (екосистема Genesis), де ми створюємо застосунок для фото- й відеоредагування. Понад дев’ять років працюю в iOS-розробці — ще з часів Swift 2, тож застав як легасі на Objective-C, так і активну еволюцію Swift.

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

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

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

  • зменшити шаблонний код;
  • уникати дублювання;
  • спростити структуру програми.

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

Розглянемо декілька прикладів реальних макросів:

1. @Observable (із фреймворку Observation) — додає підтримку спостереження за змінами властивостей класу. У Xcode можна подивитись, що саме макрос додає у клас: просто клікніть правою кнопкою по макросу → Expand Macro.

2. @SFSymbol можна застосувати до enum з назвами іконок із бібліотеки SF Symbols. Він автоматично створює властивості типу Image для кожного елемента, спрощуючи ініціалізацію з відповідною SF-іконкою у SwiftUI. Якщо в enum додати назву, що не відповідає жодній іконці у бібліотеці, отримаємо помилку компіляції: макрос згенерує діагностичне повідомлення під час розгортання. Воно сприймається компілятором до початку збирання основного проєкту, що дозволяє заздалегідь виявити помилки.

// INPUT:
@SFSymbol
enum Symbols: String {
    case circle
    case circleFill = "circle.fill"
    case shareIcon = "square.and.arrow.up"
    case globe
}

// EXPANDED:
extension Symbols {
    var image: Image {
        Image(systemName: self.rawValue)
    }

    var name: String {
        self.rawValue
    }

    #if canImport(UIKit)
    func uiImage(configuration: UIImage.Configuration? = nil) -> UIImage {
        UIImage(systemName: self.rawValue, withConfiguration: configuration)!
    }
    #else
    func nsImage(accessibilityDescription: String? = nil) -> NSImage {
        NSImage(systemSymbolName: self.rawValue, accessibilityDescription: accessibilityDescription)!
    }
    #endif

    func callAsFunction() -> String {
        return self.rawValue
    }
}

3. @Buildable — генерує структуру-білдер із готовими ініціалізаторами для створення об’єктів. Наприклад, для Person буде створено PersonBuilder з усіма необхідними методами.

import Buildable

@Buildable
struct Person {
    let name: String
    let age: Int
}

let person = PersonBuilder(age: 42).build()

Рекомендую ознайомитись із репозиторієм Swift Macros на GitHub. У ньому зібрана велика колекція макросів — як простих, так і складних. Вони можуть стати джерелом натхнення або готовими рішеннями, які легко інтегрувати у свій проєкт. Також ці приклади можна використовувати як референси при створенні власного макросу.

Відмінність від директив препроцесора та property wrappers

У Swift є кілька інструментів для зміни поведінки коду, крім макросів — директиви препроцесора та property wrappers. Кожен із них працює на своєму рівні.

Директиви препроцесора використовуються для умовної компіляції. Вони не можуть генерувати новий код — лише приховують або вмикають уже написаний. Наприклад, через #if можна вибирати між заздалегідь описаними фрагментами коду.

#if DEBUG
    print("Debug mode is ON")
#else
    print("Release mode")
#endif

Property wrappers — це механізм для інкапсуляції поведінки властивостей. Вони працюють у рантаймі та дозволяють змінити внутрішню логіку без впливу на зовнішній інтерфейс. Це утилітарний механізм для роботи саме з властивостями.

@propertyWrapper
struct Uppercased {
    private var value: String = ""

    var wrappedValue: String {
        get { value.uppercased() }
        set { value = newValue }
    }
}

struct User {
    @Uppercased var name: String
}

var user = User()
user.name = "john"
print(user.name)  // JOHN

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

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

Є дві основні категорії типів макросів у Swift.

Freestanding макроси викликаються через символ # і назву макроса. Такі макроси є окремими виразами, які розгортаються без прив’язки до конкретних структур чи класів. Наприклад, #function розгортається в назву функції, де він був викликаний, а #warning генерує компіляторне попередження з відповідним текстом.

Attached макроси застосовуються у вигляді атрибутів до кодових конструкцій: класів, структур, функцій, властивостей тощо. Викликаються через символ @, після якого йде назва макроса, а в дужках — параметри, якщо вони передбачені.

У цьому прикладі до структури UserViewModel застосовано макрос @UpdatableWith, який приймає параметр типу User. Цей макрос, будучи attached-макросом, генерує нові декларації всередині структури, до якої він прикріплений.

@UpdatableWith(User.self)
struct UserViewModel {
    var id: UUID
    var name: String
    var age: Int
}

Attached-макроси при оголошенні мають вказувати одну або кілька ролей, визначаючи, що саме буде додано до сутності, до якої він привʼязаний. Таким чином, макрос попереджає розробника про те, які зміни він внесе в кодову структуру.

Існує п’ять основних ролей макросів:

Peer — додає нові сутності того ж рівня, що й цільова.

Accessor — змінює або додає блоки доступу до властивостей.

MemberAttribute — додає атрибути до існуючих властивостей або методів.

Extension — створює extension для типу, до якого застосовано макрос.

Member — генерує нові властивості або методи всередині типу.

Ролі можна поєднувати: один макрос може одночасно мати ролі member і extension, тобто додавати як нові властивості, так і розширення типу.

Приклад реалізації макроса

Розглянемо макрос, який ми нещодавно впровадили. Мотивація була така: у нас часто виникала потреба працювати з enum, кейси яких містять асоційовані значення. У таких випадках не можна використовувати звичайний оператор порівняння. Наприклад, маючи значення такого enum, неможливо просто порівняти його з кейсом, який містить асоційоване значення — це призведе до помилки компіляції.

Натомість компілятор змушує нас використовувати доволі громіздку конструкцію if case. Для перевірки значення enum із асоційованими значеннями потрібно створити локальну змінну, яка приймає це значення, і вже через неї виконувати перевірку. Це незручно.

func someFunc(param: TestEnum) {
    if case .one(let var1) = param {
        // do something
    }
}

Ми вирішили автоматично генерувати для enum з асоційованими значеннями опціональні властивості — по одній для кожного кейсу. Їх тип відповідає типу значення в кейсі. Перевірка значення enum зводиться до простого if case1 != nil, що спрощує і читання, і доступ до даних без конструкції if case.

Розглянемо реалізацію цього макроса. Реалізація макросів оформлюється у вигляді окремого SPM-пакета, який можна підключити до інших пакетів або модулів вашого проєкту. Xcode має готовий шаблон для цього: у меню File > New > Package можна обрати шаблон Swift Macro. Він створює структуру з трьох основних таргетів.

Перший таргет TestMacro містить декларації макросів. У Swift декларації макросів відокремлені від їхніх реалізацій, подібно до мов C-подібного синтаксису. Це пов’язано з тим, що макроси компілюються окремо ще до збирання основного проєкту. В іншому коді імпортуються лише декларації, а виклик реалізації відбувається динамічно.

Другий таргет TestMacroClient — клієнтський. Він призначений для тестування та використання макросів під час розробки. Третій TestMacroMacros містить реалізації макросів — це основне місце, де описується логіка.

Наш макрос CaseAccessible, який ми хочемо застосовувати до enum, є attached-макросом, оскільки повинен генерувати нові елементи всередині типу. Тому при його оголошенні вказуємо, що він є attached. Йому надається роль member, адже він додає нові сутності до enum. Додатковий параметр names визначає, які саме назви властивостей будуть згенеровані всередині enum. Оскільки ми заздалегідь не знаємо, які кейси міститиме enum, а отже, і які властивості потрібно буде згенерувати, використовуємо ключове слово arbitrary. Воно означає, що неймінги не визначені наперед.

Далі, за допомогою ключового слова macro, оголошуємо декларацію макроса — аналогічно до оголошення функції в Swift. У цьому випадку макрос не приймає параметрів. За допомогою іншого макроса вказуємо, де шукати реалізацію: вказується модуль TestMacroMacros і тип, який відповідає за реалізацію.

@attached(member, names: arbitrary)
public macro CaseAccessible() = #externalMacro(
    module: "TestMacroMacros",
    type: "CaseAccessibleMacro"
)

У таргеті з реалізаціями створюється структура CaseAccessibleMacro, яка реалізує протокол MemberMacro. Цей протокол, як і інші, входить до складу бібліотеки SwiftSyntax. Вона забезпечує об’єктно-орієнтовану роботу з синтаксичним деревом Swift і містить корисні утиліти для створення макросів. У цьому випадку — протоколи для відповідних ролей макросів.

import SwiftSyntax
import SwiftSyntaxMacros

public struct CaseAccessibleMacro: MemberMacro {
    ...
}

Кожен із протоколів містить обов’язкові вимоги, які реалізація макроса має виконати. У випадку MemberMacro це одна вимога — статична функція expansion, яка повертає масив декларацій, тобто тих нових елементів (властивостей), які макрос додає до enum.

public static func expansion(
    of node: AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
    ...
}

Функція expansion приймає три параметри:

  • node — представляє синтаксичну конструкцію виклику макроса. Його можна використовувати для перевірки правильності використання макроса — далі ми побачимо, як саме.
  • declaration — це декларація, до якої прикріплено макрос. У нашому випадку це enum.
  • context — містить допоміжні інструменти, зокрема функції для генерації унікальних імен. Це особливо корисно, коли ми не хочемо, щоб ім’я згенерованої властивості конфліктувало з уже існуючим.

Якщо в Xcode перейти до декларації типу AttributeSyntax, який відповідає за виклик макроса, можна побачити довгий і, на перший погляд, складний коментар. Але він описує, як макрос декомпонується на частини: @, назва, дужки, параметри. У SwiftSyntax цей тип відображає структуру виклику атрибута й розбиває її на окремі елементи. Втім, знати ці деталі необов’язково — більшість синтаксичних декларацій можна створювати через звичайний string literal з інтерполяцією.

Практично всі типи в SwiftSyntax можна ініціалізувати через string literal. Це означає, що для створення, наприклад, accessor-блоку не потрібно знати назви внутрішніх структур бібліотеки — достатньо написати рядок з тим, як цей блок має виглядати у фінальному коді, і вставити потрібні значення через інтерполяцію.

let accessorBlockStr = """
{
    get {
        if \(caseGetterStr) {
            \(caseGetterReturnStr)
        } else {
            nil
        }
    }
    set {
        if let newValue {
            \(caseSetterStr)
        }
    }
}
"""

return AccessorBlockSyntax(stringLiteral: accessorBlockStr)

Це працює практично з будь-якими деклараціями, тож реалізацію макроса можна оформити як одне багаторядкове значення String з інтерполяцією динамічних значень — без жодної роботи з внутрішніми типами SwiftSyntax. Наприклад, щоб додати властивість до типу, достатньо описати її у строковому літералі й передати у DeclSyntax.

public static func expansion(
    of node: AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
    let declString = """
    {
        var someProp: Int = 0
    }
    """

    return [DeclSyntax(stringLiteral: declString)]
}

Як виглядає макрос CaseAccessible: тіло методу expansion починається з перевірки аргументів. Параметр node відповідає за виклик макроса, і в нашому випадку ми хочемо заборонити передавання параметрів, оскільки макрос їх не підтримує. Якщо користувач передасть аргументи, ми генеруємо помилку з відповідним повідомленням.

Далі перевіряється, що макрос застосований саме до enum, а не до класу чи структури. Для цього використовується параметр declaration. Якщо ні — повертаємо помилку. Далі читаємо модифікатор доступу enum (якщо він є): public, internal або private. Це потрібно для того, щоб згенеровані властивості мали такий самий рівень доступу, як і сам тип.

Потім перебираємо всі кейси enum і для кожного створюємо властивість опціонального типу з геттером і сеттером. У результаті до enum застосовано макрос CaseAccessible, який автоматично додає до нього властивості для всіх кейсів з асоційованими значеннями.

public static func expansion(
    of node: AttributeSyntax,
    providingMembersOf declaration: some DeclGroupSyntax,
    in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
    guard node.arguments == nil else {
        throw CustomError.message("CaseAccessible doesn’t support arguments")
    }

    guard let enumDecl = declaration.as(EnumDeclSyntax.self) else {
        throw CustomError.message("CaseAccessible can be applied to enums only")
    }

    let accessModifier = enumDecl.modifiers.first

    return enumDecl.cases
        .flatMap {
            constructVariableDeclarations(
                forEnumCase: $0,
                accessModifier: accessModifier,
                context: context
            )
        }
        .map(DeclSyntax.init)
}

Надалі цей макрос можна використовувати в інших частинах проєкту — просто імпортувавши відповідний пакет.

// До застосування макроса
@CaseAccessible
enum TestEnum {
    case one(var1: String)
    case two
    case three(var1: String, var2: Int)
    case four(String, String, Int, Int)
}

// Після розгортання
extension TestEnum {
    var one: String?
    var three: (var1: String, var2: Int)?
    var four: (String, String, Int, Int)?
}

Так відбувається реалізація та використання макросів у Swift.

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

Перше доволі неочевидне обмеження — макроси працюють виключно з абстрактним синтаксичним деревом Swift. Вони бачать код як текстову структуру, розбиту на декларації, і не мають доступу до реального виконання або значень змінних. Наприклад, якщо макрос застосовано до функції з параметрами а і b типу Int, він не знає, що таке Int або які значення можуть передаватися в цю функцію. Він бачить лише структуру — сигнатуру функції з певними параметрами.

Друге обмеження — макроси можуть генерувати нові елементи коду (функції, властивості), але не можуть змінювати наявну реалізацію. Наприклад, вони не можуть додати новий рядок коду в тіло вже існуючої функції. Це вважається недопустимим, оскільки вже зачіпає логіку та може створювати сайд-ефекти.

Tips & Tricks

1. Якщо ви хочете глибше зануритись у роботу макросів і побачити, як виглядає код Swift із погляду SwiftSyntax, рекомендую інструмент Swift AST Explorer. Він дозволяє вставити будь-який Swift-код і побачити, як він розбивається на синтаксичні компоненти. Це не лише корисно для розуміння структури коду, а й полегшує розробку та налагодження макросів, даючи чітке уявлення про типи, які використовуються всередині SwiftSyntax.

2. Бібліотека Swift Macro Testing від PointFree надає простий і зручний API для юніт-тестування макросів. Наприклад, задавши два рядки — вхідний, де використовується макрос, і очікуваний, у що він має розгорнутися, — можна легко перевірити коректність роботи:

func testMacroGeneratesNothingForEnumWithoutAssociatedValues() {
  assertMacro {
    """
    @CaseAccessible
    enum Test {
      case one
      case two
      case three
    }
    """
  } expansion: {
    """
    enum Test {
      case one
      case two
      case three
    }
    """
  }
}

У цьому прикладі — негативний тест: перевіряється, що макрос CaseAccessible не змінює enum, кейси якого не мають асоційованих значень. Це проста, але ефективна бібліотека, яка дозволяє протестувати макроси заздалегідь і забезпечити їх стабільну інтеграцію у продакшен-код.

Підключити її можна напряму через Package.swift:

.package(url: "https://github.com/pointfreeco/swift-macro-testing.git", from: "0.4.0")

Макроси — потужний інструмент для автоматизації рутинного коду, але з ними важливо не перегнути: що більше макросів і частіше змінюється код, до якого вони прив’язані, то довше триває збірка. Тому варто використовувати їх там, де потрібно автоматизувати повторюваний шаблонний код. Водночас вбудовані макроси (які надаються Xcode і Swift) не впливають на білдтайм — вони вже скомпільовані та оптимізовані. Проблеми виникають саме з кастомними макросами, де збільшення кількості викликів прямо впливає на час збірки.

👍ПодобаєтьсяСподобалось8
До обраногоВ обраному3
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
#if canImport(UIKit)

шел 2025-й год...

ваш макрос CaseAccessible выглядит так, словно вы язык через колено ломаете

Перепрошую, одруківка в тексті. Макрос називається SFSymbol. У сніппеті коду з прикладом усе вірно

Підписатись на коментарі