Въведение в Swift макросите
Swift макросите дойдоха със Swift 5.9 и, честно казано, промениха доста нещата. Те дават възможност на разработчиците да генерират код по време на компилация — край на безкрайния copy-paste на boilerplate. Програмите стават по-чисти, по-изразителни и по-лесни за поддръжка. А със Swift 6.2 нещата станаха още по-добри — runtime-introspectable макроси, по-бързи компилации... накратко, макросите узряха сериозно.
Ако работите със SwiftUI и SwiftData, определено сте се сблъсквали с @Observable и @Model. Но знаете ли какво точно се случва под капака? И — може би по-важното — можете ли да си направите собствени макроси за специфичните проблеми на вашия проект?
Точно това ще разгледаме тук. От основни концепции и видове макроси, през практически примери с пълен код, до тестване и добри практики. Целта е да имате здрава основа, с която да тръгнете.
Какво представляват Swift макросите?
Накратко — метапрограмиране. Код, който генерира друг код. Но за разлика от старите C макроси (които работеха с груба текстуална замяна), Swift макросите оперират върху абстрактното синтактично дърво (AST) на кода. Това означава, че те наистина разбират структурата на програмата и генерират синтактично и семантично коректен Swift код.
Ето основните им предимства:
- Елиминиране на boilerplate — автоматично генериране на повтарящи се конструкции
- Проверка по време на компилация — грешките се хващат преди да стартирате програмата
- Type safety — генерираният код е типово безопасен
- Прозрачност — разгъвате макроса в Xcode и виждате какво генерира
- Тестируемост — макросите се тестват с обикновени unit тестове
Два основни вида макроси
Swift макросите се делят на две категории: самостоятелни (freestanding) и прикачени (attached). Всяка категория си има свои роли и приложения, така че нека ги разгледаме една по една.
Самостоятелни макроси (Freestanding Macros)
Тези макроси се извикват с префикс # и могат да се ползват навсякъде, където е валидна съответната синтактична конструкция. Имат две роли:
@freestanding(expression)— създава израз, който връща стойност@freestanding(declaration)— създава една или повече декларации
Ето един готин пример — самостоятелен макрос, който валидира URL адреси по време на компилация:
// Декларация на макроса
@freestanding(expression)
public macro URL(_ stringLiteral: String) -> URL =
#externalMacro(module: "MyMacrosImpl", type: "URLMacro")
// Използване
let url = #URL("https://swiftcrafted.com")
// Компилаторът проверява дали URL-ът е валиден!
Ако подадете невалиден URL, компилаторът ще ви спре още преди програмата да тръгне. Много по-безопасно от runtime проверки, нали?
Прикачени макроси (Attached Macros)
Прикачените макроси се слагат с @ върху декларации — структури, класове, свойства и т.н. Имат цели пет различни роли:
@attached(member)— добавя нови членове (свойства, методи) вътре в типа@attached(peer)— добавя нови декларации до съществуващата@attached(accessor)— добавя getter/setter към свойство@attached(memberAttribute)— добавя атрибути към членовете на типа@attached(extension)— добавя extension конформанси
Интересното е, че един макрос може да комбинира няколко роли наведнъж. @Observable например използва @attached(member), @attached(memberAttribute) и @attached(extension) едновременно.
Как работи @Observable отвътре
Хайде да надникнем зад кулисите. Когато напишете:
@Observable
class UserViewModel {
var name: String = ""
var email: String = ""
var isLoggedIn: Bool = false
}
Макросът генерира приблизително следния код (доста повече от очакваното, а?):
class UserViewModel: Observable {
@ObservationTracked
var name: String = ""
@ObservationTracked
var email: String = ""
@ObservationTracked
var isLoggedIn: Bool = false
@ObservationIgnored private let _$observationRegistrar =
Observation.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)
}
}
Можете да видите всичко това директно в Xcode — десен бутон върху макроса, "Expand Macro" и готово. Тази прозрачност е огромно предимство пред „магическите" решения от по-старите версии на Swift.
Създаване на собствен макрос: стъпка по стъпка
Добре, стига теория. Време е да си направим собствен макрос! Ще създадем @AutoCodable — макрос, който автоматично генерира custom CodingKeys enum за вашите Codable типове. Конвертира camelCase свойства към snake_case JSON ключове, без да пишете нищо ръчно.
Стъпка 1: Създаване на Swift Package
Макросите се реализират като Swift пакети. Създайте нов пакет чрез Xcode (File → New → Package) или от терминала:
mkdir MyMacros && cd MyMacros
swift package init --type macro --name MyMacros
Шаблонът --type macro автоматично конфигурира правилната структура. Ето как изглежда Package.swift:
// Package.swift
// 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: "600.0.0"
),
],
targets: [
.macro(
name: "MyMacrosImpl",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
]
),
.target(
name: "MyMacros",
dependencies: ["MyMacrosImpl"]
),
.testTarget(
name: "MyMacrosTests",
dependencies: [
"MyMacrosImpl",
.product(
name: "SwiftSyntaxMacrosTestSupport",
package: "swift-syntax"
),
]
),
]
)
Обърнете внимание на целта .macro — това е специален тип от Swift 5.9, който компилаторът разпознава като плъгин за макроси.
Стъпка 2: Деклариране на макроса
Декларацията е публичният интерфейс — това, което потребителите виждат и ползват. Слага се в основния target:
// Sources/MyMacros/MyMacros.swift
/// Автоматично генерира CodingKeys enum за Codable типове,
/// като поддържа snake_case трансформация на ключовете.
@attached(member, names: named(CodingKeys))
public macro AutoCodable() = #externalMacro(
module: "MyMacrosImpl",
type: "AutoCodableMacro"
)
Атрибутът @attached(member, names: named(CodingKeys)) казва на компилатора, че макросът ще добави нов член наречен CodingKeys. Параметърът names е важен — без него компилаторът няма да разпознае новите имена.
Стъпка 3: Имплементация на макроса
А сега — сърцето на нещата. Тук е цялата логика за трансформация:
// Sources/MyMacrosImpl/AutoCodableMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros
import SwiftSyntaxBuilder
public struct AutoCodableMacro: MemberMacro {
public static func expansion(
of attribute: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Извличаме всички stored properties
let members = declaration.memberBlock.members
let storedProperties = members.compactMap { member -> (String, String)? in
guard let property = member.decl.as(VariableDeclSyntax.self),
property.bindingSpecifier.tokenKind == .keyword(.var) ||
property.bindingSpecifier.tokenKind == .keyword(.let),
let binding = property.bindings.first,
let identifier = binding.pattern.as(
IdentifierPatternSyntax.self
)?.identifier.text
else {
return nil
}
// Конвертираме camelCase към snake_case
let snakeCaseKey = identifier.camelCaseToSnakeCase()
return (identifier, snakeCaseKey)
}
// Генерираме CodingKeys enum
let cases = storedProperties.map { (name, key) in
if name == key {
return "case \(name)"
} else {
return "case \(name) = \"\(key)\""
}
}.joined(separator: "\n ")
let codingKeys: DeclSyntax = """
enum CodingKeys: String, CodingKey {
\(raw: cases)
}
"""
return [codingKeys]
}
}
// Помощна функция за конвертиране
extension String {
func camelCaseToSnakeCase() -> String {
let pattern = "([a-z0-9])([A-Z])"
let regex = try! NSRegularExpression(pattern: pattern)
let range = NSRange(startIndex..<endIndex, in: self)
return regex.stringByReplacingMatches(
in: self, range: range, withTemplate: "$1_$2"
).lowercased()
}
}
Стъпка 4: Регистриране в CompilerPlugin
Компилаторът трябва да знае за макроса ви. Регистрирайте го така:
// Sources/MyMacrosImpl/Plugin.swift
import SwiftCompilerPlugin
import SwiftSyntaxMacros
@main
struct MyMacrosPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
AutoCodableMacro.self,
]
}
Стъпка 5: Използване на макроса
И ето го момента на истината:
@AutoCodable
struct UserProfile: Codable {
var firstName: String
var lastName: String
var emailAddress: String
var createdAt: Date
}
// Макросът генерира:
// enum CodingKeys: String, CodingKey {
// case firstName = "first_name"
// case lastName = "last_name"
// case emailAddress = "email_address"
// case createdAt = "created_at"
// }
Никакво ръчно писане на CodingKeys. Макросът се грижи за всичко и гарантира консистентност.
Създаване на Expression макрос: #stringify
За нещо по-леко, нека разгледаме един по-прост пример — самостоятелен expression макрос, който превръща израз в tuple от стойността му и текстовото му представяне:
// Декларация
@freestanding(expression)
public macro stringify<T>(_ value: T) -> (T, String) =
#externalMacro(module: "MyMacrosImpl", type: "StringifyMacro")
// Имплементация
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))"
}
}
// Използване
let result = #stringify(2 + 3)
print(result) // (5, "2 + 3")
Това е ключовата разлика между макросите и обикновените функции — макросът вижда синтактичното представяне на аргумента, не само стойността му. Обикновена функция просто няма как да знае, че сте написали „2 + 3".
Работа със SwiftSyntax
SwiftSyntax е библиотеката, която движи всичко. Тя предоставя Swift типове за буквално всеки елемент от синтактичното дърво на езика. Ако искате да пишете сериозни макроси, трябва да я познавате добре.
Основни типове в SwiftSyntax
Ето типовете, с които ще се сблъсквате най-често:
DeclSyntax— базов тип за декларации (функции, променливи, типове)ExprSyntax— базов тип за изрази (извикване на функция, литерал, операция)TypeSyntax— базов тип за типови анотацииVariableDeclSyntax— декларация на променлива (var/let)StructDeclSyntax— декларация на структураClassDeclSyntax— декларация на класFunctionDeclSyntax— декларация на функцияAttributeSyntax— атрибут (като@Observable)
Навигиране в синтактичното дърво
SwiftSyntax предлага удобни методи за навигация и трансформация. Ето няколко типични примера:
// Проверка на типа на декларация
if let structDecl = declaration.as(StructDeclSyntax.self) {
let structName = structDecl.name.text
print("Обработваме структура: \(structName)")
}
// Обхождане на членовете на тип
for member in declaration.memberBlock.members {
if let varDecl = member.decl.as(VariableDeclSyntax.self) {
for binding in varDecl.bindings {
if let identifier = binding.pattern.as(
IdentifierPatternSyntax.self
) {
print("Намерено свойство: \(identifier.identifier.text)")
}
}
}
}
// Проверка за конкретни атрибути
let hasObservable = declaration.attributes.contains { attr in
attr.as(AttributeSyntax.self)?.attributeName
.as(IdentifierTypeSyntax.self)?.name.text == "Observable"
}
Генериране на код с SwiftSyntaxBuilder
SwiftSyntaxBuilder ви дава удобен синтаксис за създаване на нови възли чрез string interpolation. Честно казано, това прави писането на макроси доста по-приятно, отколкото бихте очаквали:
// Генериране на функция
let function: DeclSyntax = """
func description() -> String {
return "Name: \\(self.name), Age: \\(self.age)"
}
"""
// Генериране на свойство
let property: DeclSyntax = """
static let defaultValue = \(raw: typeName)()
"""
// Генериране на extension
let ext: DeclSyntax = """
extension \(raw: typeName): CustomStringConvertible {
var description: String {
\(raw: descriptionBody)
}
}
"""
Излъчване на диагностики от макроси
Тук нещата стават наистина важни. Вместо макросът ви да генерира невалиден код и компилаторът да изплюе объркващи грешки, по-добре е самият макрос да каже конкретно какъв е проблемът:
import SwiftDiagnostics
enum AutoCodableDiagnostic: String, DiagnosticMessage {
case notAStructOrClass
case computedPropertyIgnored
var severity: DiagnosticSeverity {
switch self {
case .notAStructOrClass: return .error
case .computedPropertyIgnored: return .warning
}
}
var message: String {
switch self {
case .notAStructOrClass:
return "@AutoCodable може да се прилага само върху struct или class"
case .computedPropertyIgnored:
return "Computed свойствата ще бъдат игнорирани от @AutoCodable"
}
}
var diagnosticID: MessageID {
MessageID(domain: "AutoCodableMacro", id: rawValue)
}
}
// Използване в expansion метода
public static func expansion(
of attribute: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Проверка дали е struct или class
guard declaration.is(StructDeclSyntax.self) ||
declaration.is(ClassDeclSyntax.self) else {
context.diagnose(Diagnostic(
node: attribute,
message: AutoCodableDiagnostic.notAStructOrClass
))
return []
}
// ... останалата логика
}
Бонус: диагностиките могат да включват и fix-it предложения — автоматични корекции, които потребителят прилага с едно кликване в Xcode. Доста елегантно.
Тестване на макроси
Няма как без тестове. Сериозно, при макросите тестването е особено важно, защото грешка в макроса се размножава навсякъде, където го ползвате. SwiftSyntax предоставя assertMacroExpansion — специална функция точно за тази цел:
import SwiftSyntaxMacrosTestSupport
import XCTest
final class AutoCodableMacroTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"AutoCodable": AutoCodableMacro.self,
]
func testBasicExpansion() throws {
assertMacroExpansion(
"""
@AutoCodable
struct User: Codable {
var firstName: String
var lastName: String
}
""",
expandedSource: """
struct User: Codable {
var firstName: String
var lastName: String
enum CodingKeys: String, CodingKey {
case firstName = "first_name"
case lastName = "last_name"
}
}
""",
macros: testMacros
)
}
func testNonStructEmitsDiagnostic() throws {
assertMacroExpansion(
"""
@AutoCodable
enum Status {
case active
case inactive
}
""",
expandedSource: """
enum Status {
case active
case inactive
}
""",
diagnostics: [
DiagnosticSpec(
message: "@AutoCodable може да се прилага само върху struct или class",
line: 1,
column: 1
)
],
macros: testMacros
)
}
func testPropertyWithoutTransformation() throws {
assertMacroExpansion(
"""
@AutoCodable
struct Item: Codable {
var name: String
var url: String
}
""",
expandedSource: """
struct Item: Codable {
var name: String
var url: String
enum CodingKeys: String, CodingKey {
case name
case url
}
}
""",
macros: testMacros
)
}
}
За по-сложни сценарии, библиотеката swift-macro-testing от Point-Free предлага подобрена версия, която автоматично записва разгънатия код. Ако пишете много макроси, струва си да я разгледате.
Практически пример: @Builder макрос
Нека вдигнем нивото с по-сложен пример — @Builder макрос, който генерира Builder pattern автоматично. Това е един от любимите ми use case-ове за макроси, защото Builder pattern изисква доста boilerplate:
// Декларация
@attached(member, names: named(Builder))
@attached(extension, names: named(builder))
public macro Builder() = #externalMacro(
module: "MyMacrosImpl",
type: "BuilderMacro"
)
// Имплементация
public struct BuilderMacro: MemberMacro {
public static func expansion(
of attribute: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
context.diagnose(Diagnostic(
node: attribute,
message: BuilderDiagnostic.notAStruct
))
return []
}
let structName = structDecl.name.text
// Събираме свойствата
let properties = structDecl.memberBlock.members.compactMap {
member -> (name: String, type: String)? in
guard let varDecl = member.decl.as(VariableDeclSyntax.self),
let binding = varDecl.bindings.first,
let name = binding.pattern.as(
IdentifierPatternSyntax.self
)?.identifier.text,
let type = binding.typeAnnotation?.type.description
else { return nil }
return (name, type.trimmingCharacters(in: .whitespaces))
}
// Генерираме Builder клас
let builderProperties = properties.map { prop in
"private var _\(prop.name): \(prop.type)?"
}.joined(separator: "\n ")
let builderMethods = properties.map { prop in
"""
@discardableResult
func \(prop.name)(_ value: \(prop.type)) -> Builder {
_\(prop.name) = value
return self
}
"""
}.joined(separator: "\n ")
let buildAssignments = properties.map { prop in
"""
guard let \(prop.name) = _\(prop.name) else {
throw BuilderError.missingField("\(prop.name)")
}
"""
}.joined(separator: "\n ")
let initParams = properties.map { "\($0.name): \($0.name)" }
.joined(separator: ", ")
let builder: DeclSyntax = """
class Builder {
\(raw: builderProperties)
enum BuilderError: Error {
case missingField(String)
}
\(raw: builderMethods)
func build() throws -> \(raw: structName) {
\(raw: buildAssignments)
return \(raw: structName)(\(raw: initParams))
}
}
"""
return [builder]
}
}
// Използване
@Builder
struct ServerConfig {
var host: String
var port: Int
var useTLS: Bool
var timeout: TimeInterval
}
// Генерираният Builder позволява:
let config = try ServerConfig.Builder()
.host("api.example.com")
.port(443)
.useTLS(true)
.timeout(30.0)
.build()
Вградени макроси от Apple
Apple не само ни дава инструментите — те самите ги използват активно. Ето макросите, с които ще работите почти всеки ден:
@Observable (Observation framework)
Замества стария ObservableObject протокол от Combine. Автоматично проследява промените в свойствата и казва на SwiftUI кога да обнови view-тата. Важното тук е, че е значително по-ефективен — следи достъпа на ниво отделно свойство, а не на целия обект.
@Model (SwiftData)
Трансформира обикновен Swift клас в persistent модел. Добавя конформанс към PersistentModel, Observable, Hashable и Identifiable — доста удобно, вместо да се занимавате с всичко ръчно.
#Predicate (SwiftData)
Самостоятелен expression макрос, който превръща Swift изрази в type-safe предикати за заявки:
let predicate = #Predicate<User> { user in
user.age >= 18 && user.isActive
}
// Компилаторът проверява типовете и свойствата
@Test и #expect (Swift Testing)
Новият Swift Testing framework е изцяло построен върху макроси. Резултатът е впечатляващо чист синтаксис:
@Test("Проверка за валиден email")
func testEmailValidation() {
let validator = EmailValidator()
#expect(validator.isValid("[email protected]"))
#expect(!validator.isValid("invalid-email"))
}
Подобрения в Swift 6.2
Swift 6.2 донесе няколко промени, които правят макросите по-приятни за работа.
Runtime-introspectable макроси
Нова възможност, която позволява не само генериране на код по време на компилация, но и динамично инспектиране по време на изпълнение. Това отваря врати за по-изразителни DSL-и и по-добра интеграция с dev инструменти.
Подобрено време за компилация
Това е голямата новина за мнозина. SwiftPM вече поддържа предварително компилирани swift-syntax зависимости. Преди трябваше да се изтегли и компилира целият пакет от сорс код, което забавяше чистите build-ове (особено в CI). Сега тази стъпка е елиминирана и може да спестите минути от всяка компилация.
Подобрена съвместимост
Swift 6.2 е source compatible със Swift 6.0 и 6.1. Макросите ви от по-ранни версии ще продължат да работят без промени.
Добри практики при създаване на макроси
Ето няколко насоки, които ще ви спестят главоболия:
1. Винаги излъчвайте диагностики
Никога не оставяйте макроса да генерира невалиден код. Хващайте грешките и показвайте ясни съобщения. Бъдещото ви „аз" ще ви благодари.
2. Тествайте всеки сценарий
Не само „щастливия" път. Какво става при празен тип? При computed свойства? При generic типове? Тествайте граничните случаи — там се крият бъговете.
3. Документирайте генерирания код
Добавяйте документационни коментари към декларацията на макроса. Описвайте какво ще се генерира и какви са ограниченията.
4. Използвайте makeUniqueName
MacroExpansionContext има метод makeUniqueName(), който генерира уникални имена за променливи. Така няма конфликти с имена, които вече съществуват:
let uniqueVar = context.makeUniqueName("storage")
// Генерира нещо като: __macro_local_7storagefMu_
5. Минимизирайте генерирания код
Генерирайте само необходимия минимум. Ако можете да извлечете обща логика в helper функции или протоколи — направете го. Макросът трябва само да свърже нещата.
6. Поддържайте актуална версия на SwiftSyntax
SwiftSyntax се обновява с всяка версия на Swift. За Swift 6.2 ви трябва swift-syntax 600.x. Проверявайте съвместимостта, за да избегнете неочаквани проблеми.
Дебъгване на макроси
Да, дебъгването на макроси може да е малко... предизвикателно. Но Xcode ви дава няколко полезни инструмента:
- Expand Macro — десен бутон върху макроса и виждате какво е генерирал
- Breakpoints — да, можете да сложите breakpoint в expansion метода и да стъпвате през кода
- Unit тестове — честно казано, това е най-ефективният начин за дебъгване
- Print statements — в тестова среда
print()си върши работата за инспектиране на синтактичните възли
// Полезна техника за дебъгване — инспектиране на AST
func expansion(...) throws -> [DeclSyntax] {
// Показва структурата на синтактичното дърво
print(declaration.debugDescription)
// Показва форматирания сорс код
print(declaration.description)
// ...
}
Кога да използвате макроси и кога — не
Макросите са мощни, но не са решение за всичко. Ето кратък ориентир:
Използвайте макроси когато:
- Имате повтарящ се boilerplate, който следва предвидим модел
- Искате проверка по време на компилация (валидиране на URL, regex и подобни)
- Трябва да генерирате код базиран на структурата на типа
- Искате да осигурите консистентност (CodingKeys, Equatable и т.н.)
По-добре избягвайте макроси когато:
- Протокол с default extension може да свърши работа
- Проблемът се решава с generics или property wrappers
- Логиката е динамична и зависи от runtime стойности
- Макросът ще се ползва само на едно-две места (не си заслужава усилието)
Заключение
Swift макросите наистина промениха играта. Те преместват boilerplate генерирането на нивото на компилатора и ни позволяват да пишем по-чист, по-изразителен и по-безопасен код. А с подобренията в Swift 6.2 — по-бързи компилации и runtime introspection — стават все по-практични за ежедневна работа.
Нека обобщим какво научихме:
- Swift макросите работят върху AST, осигурявайки type safety и коректност
- Има два основни вида — самостоятелни (
#) и прикачени (@) с множество роли - SwiftSyntax е основният инструмент за разбор и генериране на код
- Правилните диагностики са критични за добрата разработческа опитност
- Тестването с
assertMacroExpansionосигурява надеждност - Apple активно ползва макроси в @Observable, @Model, @Test и #Predicate
Вече имате всичко необходимо, за да тръгнете. Започнете с нещо леко — може би макрос за автоматично генериране на CustomStringConvertible — и постепенно преминете към по-амбициозни неща. Макросите са бъдещето на Swift метапрограмирането и времето, което инвестирате сега, ще ви се отплати многократно.