Вступ
Якщо ви пишете на 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 для візуалізації синтаксичних дерев — дуже допомагає.
Коли варто використовувати макроси, а коли ні
Макроси — ідеальний вибір, коли:
- Є шаблонний код, що повторюється в багатьох місцях із мінімальними варіаціями.
- Потрібна валідація на етапі компіляції (як у прикладі з
#URL). - Треба генерувати код на основі структури існуючих типів (
@AutoInit,@CodableKeys). - Хочете забезпечити дотримання певних конвенцій у кодовій базі.
А ось коли макроси краще не використовувати:
- Задачу можна вирішити звичайними засобами — протоколами, дженериками, розширеннями.
- Логіка потребує рантайм-інформації, недоступної під час компіляції.
- Шаблонний код зустрічається лише в одному-двох місцях — витрати на створення макросу просто не виправдані.
- Макрос приховає важливу логіку, яку розробникам потрібно бачити безпосередньо.
Висновок
Макроси Swift — це, мабуть, одне з найцікавіших доповнень до мови за останні роки. Вони дають можливості метапрограмування, які раніше були просто недоступні, зберігаючи фірмову типобезпеку та прозорість Swift. Від простих утиліт на кшталт #stringify чи #URL до масштабних трансформацій типу @Observable — спектр застосувань дійсно широкий.
За час після появи макросів у Swift 5.9 екосистема помітно зросла. Спільнота створила десятки бібліотек із готовими макросами для різних задач, а Apple продовжує розвивати технологію, розширюючи набір ролей та покращуючи API SwiftSyntax.
Головне — розуміти, коли макроси доречні, а коли краще обрати простіший шлях. Почніть з малого: створіть простий макрос для конкретної проблеми у вашому проєкті, протестуйте його і поступово нарощуйте досвід. Зі часом ви зможете створювати все потужніші абстракції, зменшуючи обсяг шаблонного коду.
Чесно кажучи, макроси — це не просто ще одна фіча. Це новий спосіб мислення про код, який дозволяє писати менше, а виражати більше. І зараз — відмінний час, щоб почати їх вивчати.