Недавно я столкнулся с задачей, которая требовала написания большого объема шаблонного кода. Вспомнив, что в 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, разработчики постарались реализовать генерацию шаблонного кода с возможностью компиляторных проверок, насколько это возможно. Однако здесь есть свои особенности:
Как начать работу с макросами?
-
SwiftSyntax
Для начала работы с макросами нужно подключить библиотеку SwiftSyntax. Эта библиотека является основой для взаимодействия с исходным кодом программы и макросами. -
SPM (Swift Package Manager)
Макросы доступны только в рамках Swift Package Manager. Начиная с версии Swift 5.9, в файлеPackage.swift
появилась возможность добавлять новый тип таргета —CompilerPlugin
, который позволяет подключить к вашему модулю целевой таргет.macro
, где будет храниться реализация макросов. -
Разделение объявления и реализации макросов
Для каждого макроса нужно создавать отдельные файлы для объявления и реализации. Это напоминает подход в Objective-C с.h
и.m
файлами, где один файл описывает публичный интерфейс, а второй — внутреннюю реализацию.
Типы макросов
Существует два основных типа макросов:
-
Freestanding макросы
Это макросы, которые можно вызывать независимо в коде. Их можно рассматривать как функции, но с расширенными возможностями. -
Attached макросы
Эти макросы привязываются к конкретному объекту или функции, расширяя их функционал. Они чем-то напоминаютproperty wrappers
, но дают ещё больше возможностей.
Создание пакета
Чтобы добавить макросы в проект, нужно использовать пакет SPM (Swift Package Manager). У вас есть два варианта: либо добавить новый таргет в уже существующий пакет, либо создать новый пакет.
Если вы выбрали второй вариант, всё, что нужно сделать, — это выбрать File > New > Package в Xcode и затем выбрать тип пакета Swift Macro. Xcode автоматически сгенерирует для вас шаблон пакета.
Если у вас уже есть существующий пакет, некоторые шаги придётся выполнить вручную. Сначала откройте файл 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. Этот тип макросов делится на два подтипа:
-
Expression — макрос, который выполняет какое-то выражение. Его можно представить как вызов функции. Это единственный тип макроса, который может возвращать результат.
@freestanding(declaration, names: named(MyClass)) public macro declarationMacro() = #externalMacro(module: "MyProjectMacros", type: "DeclarationMacro") #declarationMacro // Может например сгенерировать обьект типа MyClass /* class MyClass { func $s22DeclarationMacroClient03_F8D28BC059F4523B96C95750FD5F825D2Ll10FuncUniquefMf0_6uniquefMu_() { } } */
-
Declaration — как следует из названия, такие макросы генерируют независимый код для объявления объектов или функций.
@freestanding(expression) public macro expressionMacro<Int>(_ value: Int) -> String = #externalMacro(module: "MyProjectMacros", type: "ExpressionMacro") #expressionMacro(12) // Может создать код котораый будет конвертировать значени в строку. /* "Your value: 12" */
Теперь перейдём к attached макросам. В этом случае существует 5 различных видов:
-
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 } }
-
Member — расширяет функционал объекта или свойства, к которому прикреплён, но не может вводить новые типы или структуры за пределами этого объекта.
Пример
@attached(member) public macro member() = #externalMacro(module: "MyProjectMacros", type: "MyMemberMacro") @member struct MyStruct { // Макрос сгенерирует дополнительный код внутри структуры }
-
Member attribute — генерирует код, относящийся не к объекту целиком, а к конкретному свойству, к которому был применён макрос. Этот макрос работает для всего свойства, но не фокусируется на его отдельных частях.
Пример
@attached(memberAttribute) public macro memberAttribute() = #externalMacro(module: "MyProjectMacros", type: "MyMemberAttributeMacro") struct MyStruct { @memberAttribute var isValid: Bool // Макрос сгенерирует дополнительный функционал для этой проперти. // Напирмер логику валидации для проперти }
-
Accessor — позволяет генерировать логику для аксессоров свойства, таких как
get
,set
,willSet
иdidSet
. В отличие от member attribute, этот макрос применяется только к аксессорам, а не ко всему свойству.Пример
import SwiftCompilerPlugin import SwiftSyntaxMacros import SwiftSyntax @main struct MyProjectMacros: CompilerPlugin { var providingMacros: [Macro.Type] = [ // Туту будет список ваших макросов, сейчас он пуст. ] }
-
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, это может показаться сложным. Чтобы не перегружать статью, я не буду подробно объяснять, как работать с синтаксическим деревом. За более детальной информацией можно обратиться к официальной документации.
Поскольку вариантов использования макросов огромное количество, разбирать каждый из них нет смысла. Вместо этого давайте сосредоточимся на ключевых концепциях: из каких основных протоколов состоят макросы и как их отлаживать.
Основные моменты:
Ваша цель — на основе входных данных сгенерировать код и вернуть нужный результат, как это делается в обычной функции.
-
Declaration:
Основной элемент, с которым вам предстоит работать, — этоDeclGroupSyntax
. Он содержит всю информацию об объекте, к которому относится макрос. Этот элемент можно удобно преобразовать в тип объекта, с которым макрос должен работать. Например:
if let structDecl = declaration.as(StructDeclSyntax.self) { // Ваш код работы с структурой }
Здесь мы явно проверяем, что макрос добавляется к структуре. Если это не так, компилятор выдаст ошибку.
-
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/
Добавить комментарий