Макросы в 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<Int>(_ 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 — позволяет генерировать логику для аксессоров свойства, таких как getsetwillSet и 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. Также не стесняйтесь писать в комментариях — я с радостью сделаю детальный разбор работы макросов под капотом.

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


ссылка на оригинал статьи https://habr.com/ru/articles/852118/