Макросы в Swift: Практическое руководство по использованию

Недавно я столкнулся с задачей, которая требовала написания большого объема шаблонного кода. Вспомнив, что в 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, разработчики постарались реализовать генерацию шаблонного кода с возможностью компиляторных проверок, насколько это возможно. Однако здесь есть свои особенности:

Как начать работу с макросами?

  1. SwiftSyntax
    Для начала работы с макросами нужно подключить библиотеку SwiftSyntax. Эта библиотека является основой для взаимодействия с исходным кодом программы и макросами.

  2. SPM (Swift Package Manager)
    Макросы доступны только в рамках Swift Package Manager. Начиная с версии Swift 5.9, в файле Package.swift появилась возможность добавлять новый тип таргета — CompilerPlugin, который позволяет подключить к вашему модулю целевой таргет .macro, где будет храниться реализация макросов.

  3. Разделение объявления и реализации макросов
    Для каждого макроса нужно создавать отдельные файлы для объявления и реализации. Это напоминает подход в Objective-C с .h и .m файлами, где один файл описывает публичный интерфейс, а второй — внутреннюю реализацию.

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

Существует два основных типа макросов:

  1. Freestanding макросы
    Это макросы, которые можно вызывать независимо в коде. Их можно рассматривать как функции, но с расширенными возможностями.

  2. Attached макросы
    Эти макросы привязываются к конкретному объекту или функции, расширяя их функционал. Они чем-то напоминают property wrappers, но дают ещё больше возможностей.

Создание пакета

Чтобы добавить макросы в проект, нужно использовать пакет SPM (Swift Package Manager). У вас есть два варианта: либо добавить новый таргет в уже существующий пакет, либо создать новый пакет.

Если вы выбрали второй вариант, всё, что нужно сделать, — это выбрать File > New > Package в Xcode и затем выбрать тип пакета Swift Macro. Xcode автоматически сгенерирует для вас шаблон пакета.

Генерация Swift Macro

Генерация Swift Macro

Если у вас уже есть существующий пакет, некоторые шаги придётся выполнить вручную. Сначала откройте файл 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. Этот тип макросов делится на два подтипа:

  1. Expression — макрос, который выполняет какое-то выражение. Его можно представить как вызов функции. Это единственный тип макроса, который может возвращать результат.

    @freestanding(declaration, names: named(MyClass))
    public macro declarationMacro() = #externalMacro(module: "MyProjectMacros", type: "DeclarationMacro")
    
    #declarationMacro
    
    // Может например сгенерировать обьект типа MyClass
    /*
    class MyClass {
      func $s22DeclarationMacroClient03_F8D28BC059F4523B96C95750FD5F825D2Ll10FuncUniquefMf0_6uniquefMu_() {
      }
    }
    */
    
  2. Declaration — как следует из названия, такие макросы генерируют независимый код для объявления объектов или функций.

@freestanding(expression)
public macro expressionMacro(_ value: Int) -> String = #externalMacro(module: "MyProjectMacros", type: "ExpressionMacro")

#expressionMacro(12)
// Может создать код котораый будет конвертировать значени в строку.
/*
  "Your value: 12"
*/

Теперь перейдём к attached макросам. В этом случае существует 5 различных видов:

  1. 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
        }
      }
    
  2. Member — расширяет функционал объекта или свойства, к которому прикреплён, но не может вводить новые типы или структуры за пределами этого объекта.

    Пример
    @attached(member)
    public macro member() = #externalMacro(module: "MyProjectMacros", type: "MyMemberMacro")
    
    @member
    struct MyStruct {
      // Макрос сгенерирует дополнительный код внутри структуры
    }
  3. Member attribute — генерирует код, относящийся не к объекту целиком, а к конкретному свойству, к которому был применён макрос. Этот макрос работает для всего свойства, но не фокусируется на его отдельных частях.

    Пример
    @attached(memberAttribute)
    public macro memberAttribute() = #externalMacro(module: "MyProjectMacros", type: "MyMemberAttributeMacro")
    
    struct MyStruct {
      @memberAttribute var isValid: Bool
      // Макрос сгенерирует дополнительный функционал для этой проперти.
      // Напирмер логику валидации для проперти
    }
  4. Accessor — позволяет генерировать логику для аксессоров свойства, таких как get,  set,  willSet и didSet. В отличие от member attribute, этот макрос применяется только к аксессорам, а не ко всему свойству.

    Пример
    import SwiftCompilerPlugin
    import SwiftSyntaxMacros
    import SwiftSyntax
    
    @main
    struct MyProjectMacros: CompilerPlugin {
      var providingMacros: [Macro.Type] = [
        // Туту будет список ваших макросов, сейчас он пуст.
      ]
    }
    
  5. 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, это может показаться сложным. Чтобы не перегружать статью, я не буду подробно объяснять, как работать с синтаксическим деревом. За более детальной информацией можно обратиться к официальной документации.

Поскольку вариантов использования макросов огромное количество, разбирать каждый из них нет смысла. Вместо этого давайте сосредоточимся на ключевых концепциях: из каких основных протоколов состоят макросы и как их отлаживать.

Основные моменты:

Ваша цель — на основе входных данных сгенерировать код и вернуть нужный результат, как это делается в обычной функции.

  1. Declaration:
    Основной элемент, с которым вам предстоит работать, — это DeclGroupSyntax. Он содержит всю информацию об объекте, к которому относится макрос. Этот элемент можно удобно преобразовать в тип объекта, с которым макрос должен работать. Например:

if let structDecl = declaration.as(StructDeclSyntax.self) {
    // Ваш код работы с структурой
}

Здесь мы явно проверяем, что макрос добавляется к структуре. Если это не так, компилятор выдаст ошибку.

  1. 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. Также не стесняйтесь писать в комментариях — я с радостью сделаю детальный разбор работы макросов под капотом.

Так же подписывайтесь на мой ТГ канал, там я стараюсь понятым языком писать о технологиях, в небольших постах.

© Habrahabr.ru