Wprowadzenie — czym są makra w Swift i po co w ogóle się nimi zajmować
Jeśli kiedykolwiek pisałeś w Swift i złapałeś się na kopiowaniu tych samych CodingKeys po raz piętnasty, ręcznym implementowaniu Equatable dla kolejnej klasy, albo na budowaniu boilerplate'u dla UserDefaults — to mam dobrą wiadomość. Makra w Swift zostały stworzone dokładnie po to, żeby tego nie robić.
Wprowadzone w Swift 5.9 (propozycje SE-0382, SE-0389 i SE-0397), makra pozwalają generować kod w czasie kompilacji. Koniec z powtarzaniem się. Koniec z literówkami w dwudziestym enum case.
I tu ważna uwaga — to nie są makra znane z C czy C++, gdzie preprocesor robi głupie kopiuj-wklej tekstu. Makra w Swift są bezpieczne typowo, działają jako zewnętrzne programy podczas fazy budowania i operują na pełnym drzewie składniowym (AST) dzięki bibliotece SwiftSyntax. Mówimy tu o metaprogramowaniu na poziomie kompilatora, z pełną kontrolą nad tym, co zostanie wygenerowane.
W tym przewodniku przejdziemy przez wszystko krok po kroku: typy makr, tworzenie pierwszego makra od zera, testowanie, debugowanie i najlepsze praktyki. Wszystkie przykłady to działający kod — możesz go wziąć i użyć w swoim projekcie od razu.
Rodzaje makr w Swift — freestanding i attached
Swift dzieli makra na dwie główne kategorie. Każda ma inne zastosowanie i inną składnię.
Makra freestanding (wolnostojące)
Makra wolnostojące pojawiają się samodzielnie w kodzie i zawsze zaczynają się od znaku #. Działają jak wyrażenia lub deklaracje, które kompilator rozwija w czasie kompilacji.
Najprostszy przykład? Wbudowane makro #stringify:
// Freestanding expression macro
let (result, code) = #stringify(2 + 3)
// result == 5
// code == "2 + 3"
Inne popularne przykłady to #warning("TODO: dokończyć implementację") czy #URL("https://api.example.com/users") — makro, które waliduje URL jeszcze zanim aplikacja się uruchomi. Przyznam, że to jedno z moich ulubionych zastosowań.
Makra attached (dołączone)
Makra dołączone działają jak atrybuty — umieszczasz je przed deklaracją ze znakiem @. Mogą modyfikować, rozszerzać lub uzupełniać deklarację, do której zostały dołączone.
// Attached macro — dodaje nowych członków do struktury
@AutoCodable
struct User {
let name: String
let email: String
let age: Int
}
Makra dołączone dzielą się na kilka podtypów, zwanych rolami:
- @attached(member) — dodaje nowe właściwości, metody lub typy zagnieżdżone do deklaracji
- @attached(accessor) — dodaje gettery i settery do właściwości
- @attached(memberAttribute) — stosuje atrybuty do istniejących członków typu
- @attached(peer) — generuje nowe deklaracje obok istniejącej (np. asynchroniczną wersję funkcji)
- @attached(extension) — dodaje zgodności z protokołami lub rozszerzenia do typu
- @attached(body) — wprowadza lub modyfikuje ciało funkcji (SE-0415, zaakceptowane w 2024)
Co ciekawe, jedno makro może pełnić kilka ról jednocześnie — np. być zarówno @attached(member), jak i @attached(extension). To daje sporą elastyczność.
Tworzenie pierwszego makra krok po kroku
No dobra, pora na praktykę. Zbudujemy makro #URL, które waliduje adresy URL w czasie kompilacji — jeśli podany string nie jest prawidłowym URL-em, kompilator zgłosi błąd. Żadnych crashy w runtime.
Krok 1: Utwórz pakiet Swift Macro
Makra w Swift muszą być tworzone jako oddzielne pakiety. Masz dwa sposoby na start:
Sposób A — w Xcode:
Wybierz File → New → Package... i szablon Swift Macro. Xcode wygeneruje całą strukturę projektu za Ciebie.
Sposób B — z terminala:
mkdir URLMacro
cd URLMacro
swift package init --type macro
Po utworzeniu pakietu powinieneś zobaczyć mniej więcej taką strukturę:
URLMacro/
├── Package.swift
├── Sources/
│ ├── URLMacro/ // Deklaracja makra (sygnatura)
│ │ └── URLMacro.swift
│ └── URLMacroMacros/ // Implementacja makra (logika)
│ └── URLMacroMacro.swift
└── Tests/
└── URLMacroTests/ // Testy
└── URLMacroTests.swift
Krok 2: Zdefiniuj sygnaturę makra
W pliku URLMacro.swift deklarujesz publiczny interfejs — czyli to, co będą widzieć użytkownicy Twojego makra:
/// Makro walidujące URL w czasie kompilacji.
/// Zwraca `URL` — gwarantowany prawidłowy adres.
@freestanding(expression)
public macro URL(_ stringLiteral: String) -> URL =
#externalMacro(module: "URLMacroMacros", type: "URLMacroImplementation")
Kilka rzeczy, na które warto zwrócić uwagę:
@freestanding(expression)— oznacza, że to makro wolnostojące zwracające wyrażenie#externalMacro— wskazuje moduł i typ, w którym siedzi implementacja- Sygnatura definiuje typ parametrów i wartość zwracaną — jak w zwykłej funkcji
Krok 3: Zaimplementuj logikę makra
Teraz najciekawsza część. W pliku URLMacroMacro.swift piszesz właściwą implementację, korzystając z protokołu ExpressionMacro i biblioteki SwiftSyntax:
import SwiftCompilerPlugin
import SwiftSyntax
import SwiftSyntaxBuilder
import SwiftSyntaxMacros
public struct URLMacroImplementation: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
// 1. Pobierz argument (string literal)
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.requiresStaticStringLiteral
}
let urlString = literalSegment.content.text
// 2. Waliduj URL w czasie kompilacji
guard URL(string: urlString) != nil else {
throw MacroError.invalidURL(urlString)
}
// 3. Zwróć bezpieczne wyrażenie
return "URL(string: \(argument))!"
}
}
enum MacroError: CustomStringConvertible, Error {
case requiresStaticStringLiteral
case invalidURL(String)
var description: String {
switch self {
case .requiresStaticStringLiteral:
return "#URL wymaga statycznego literału string"
case .invalidURL(let url):
return "Nieprawidłowy URL: \"\(url)\""
}
}
}
// Rejestracja makra w pluginie kompilatora
@main
struct URLMacroPlugin: CompilerPlugin {
let providingMacros: [Macro.Type] = [
URLMacroImplementation.self,
]
}
Krok 4: Użyj makra w kodzie
Po zbudowaniu pakietu możesz użyć makra w swoim projekcie — i tu zaczyna się magia:
import URLMacro
// Kompiluje się poprawnie — URL jest prawidłowy
let apiEndpoint = #URL("https://api.example.com/users")
// Błąd kompilacji! Nieprawidłowy URL
let badURL = #URL("to nie jest url")
// error: Nieprawidłowy URL: "to nie jest url"
Widzisz różnicę? Bez makra błędny URL powodowałby crash w runtime — może u użytkownika, może w najgorszym momencie. Z makrem kompilator łapie problem jeszcze zanim aplikacja zostanie uruchomiona. Szczerze mówiąc, po tym jak zacząłem tego używać, nie wyobrażam sobie wracać do URL(string:)! z nadzieją, że string jest poprawny.
Makro attached — praktyczny przykład z @AutoEquatable
Czas na coś bardziej zaawansowanego. Zbudujemy makro dołączone, które automatycznie generuje zgodność z protokołem Equatable dla klas. Struktury w Swift dostają to za darmo, ale klasy? Niestety nie — i pisanie operatora == ręcznie dla klasy z dziesięcioma polami to nie jest nic przyjemnego.
Deklaracja makra
@attached(extension, conformances: Equatable)
@attached(member, names: named(==))
public macro AutoEquatable() = #externalMacro(
module: "AutoEquatableMacros",
type: "AutoEquatableMacro"
)
Zwróć uwagę na dwie role: extension (dodaje zgodność z protokołem) i member (dodaje operator ==). Jedno makro, dwa zadania.
Implementacja makra
import SwiftSyntax
import SwiftSyntaxMacros
public struct AutoEquatableMacro: MemberMacro, ExtensionMacro {
// Generowanie rozszerzenia z Equatable
public static func expansion(
of node: AttributeSyntax,
attachedTo declaration: some DeclGroupSyntax,
providingExtensionsOf type: some TypeSyntaxProtocol,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [ExtensionDeclSyntax] {
let extensionDecl = try ExtensionDeclSyntax(
"extension \(type.trimmed): Equatable {}"
)
return [extensionDecl]
}
// Generowanie operatora ==
public static func expansion(
of node: AttributeSyntax,
providingMembersOf declaration: some DeclGroupSyntax,
conformingTo protocols: [TypeSyntax],
in context: some MacroExpansionContext
) throws -> [DeclSyntax] {
// Pobierz nazwę typu
guard let classDecl = declaration.as(ClassDeclSyntax.self) else {
throw MacroError.onlyApplicableToClass
}
let className = classDecl.name.text
// Znajdź wszystkie stored properties
let properties = classDecl.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { $0.bindings.first?.accessorBlock == nil }
.compactMap { $0.bindings.first?.pattern.as(
IdentifierPatternSyntax.self
)?.identifier.text }
// Wygeneruj porównania
let comparisons = properties
.map { "lhs.\($0) == rhs.\($0)" }
.joined(separator: " &&\n ")
let equalOperator: DeclSyntax = """
public static func == (lhs: \(raw: className), rhs: \(raw: className)) -> Bool {
\(raw: comparisons)
}
"""
return [equalOperator]
}
}
enum MacroError: CustomStringConvertible, Error {
case onlyApplicableToClass
var description: String {
switch self {
case .onlyApplicableToClass:
return "@AutoEquatable można stosować tylko do klas"
}
}
}
Użycie w kodzie
@AutoEquatable
class UserProfile {
let id: UUID
let name: String
let email: String
init(id: UUID, name: String, email: String) {
self.id = id
self.name = name
self.email = email
}
}
// Kompilator automatycznie wygeneruje:
// extension UserProfile: Equatable {
// public static func == (lhs: UserProfile, rhs: UserProfile) -> Bool {
// lhs.id == rhs.id &&
// lhs.name == rhs.name &&
// lhs.email == rhs.email
// }
// }
let user1 = UserProfile(id: uuid, name: "Jan", email: "[email protected]")
let user2 = UserProfile(id: uuid, name: "Jan", email: "[email protected]")
print(user1 == user2) // true
Trzy pola to jeszcze nie tragedia, ale wyobraź sobie klasę z piętnaście właściwościami. Albo dwadzieścia. Makro oszczędza realny czas i eliminuje ryzyko, że zapomnisz dodać nowego pola do porównania po refaktorze.
Makro @attached(peer) — generowanie asynchronicznej wersji funkcji
Makra peer tworzą nowe deklaracje obok istniejącej. Szczerze? To chyba jeden z najbardziej praktycznych typów makr, jeśli pracujesz z kodem, który wciąż korzysta z completion handlerów.
@attached(peer, names: overloaded)
public macro AddAsync() = #externalMacro(
module: "AddAsyncMacros",
type: "AddAsyncMacro"
)
// Użycie:
@AddAsync
func fetchUser(id: String, completion: @escaping (Result<User, Error>) -> Void) {
// implementacja z completion handlerem
}
// Makro automatycznie wygeneruje:
func fetchUser(id: String) async throws -> User {
try await withCheckedThrowingContinuation { continuation in
fetchUser(id: id) { result in
continuation.resume(with: result)
}
}
}
Ten wzorzec jest nieoceniony podczas migracji starszego kodu opartego na callbackach do async/await. Zamiast ręcznie pisać wrappery dla dziesiątek (albo setek) funkcji, jedno makro robi robotę za Ciebie.
Makra body — modyfikowanie ciała funkcji (SE-0415)
To najnowszy typ makr, zaakceptowany w 2024 roku. Makra body pozwalają wprowadzać lub modyfikować ciało funkcji — idealne do logowania, tracingu czy instrumentacji.
@attached(body)
public macro Logged() = #externalMacro(
module: "LoggingMacros",
type: "LoggedMacro"
)
// Użycie:
@Logged
func processPayment(amount: Decimal, currency: String) -> PaymentResult {
// Twoja logika biznesowa
return PaymentResult.success
}
// Makro wstrzykuje logowanie:
func processPayment(amount: Decimal, currency: String) -> PaymentResult {
log("→ processPayment(amount: \(amount), currency: \(currency))")
defer { log("← processPayment zakończone") }
// Twoja logika biznesowa
return PaymentResult.success
}
Jeśli znasz dekoratory z Pythona — to dokładnie ten sam koncept. Opakowujesz istniejącą logikę dodatkowym zachowaniem bez dotykania oryginalnego kodu. Czysto i elegancko.
Testowanie makr — klucz do niezawodności
Makra operują na drzewie składniowym, więc ich testowanie jest w gruncie rzeczy dość przyjemne — sprowadza się do porównywania wejściowego kodu z oczekiwanym rozwinięciem. SwiftSyntax udostępnia do tego funkcję assertMacroExpansion.
import SwiftSyntaxMacrosTestSupport
import XCTest
final class URLMacroTests: XCTestCase {
let testMacros: [String: Macro.Type] = [
"URL": URLMacroImplementation.self,
]
func testValidURL() throws {
assertMacroExpansion(
"""
#URL("https://example.com")
""",
expandedSource: """
URL(string: "https://example.com")!
""",
macros: testMacros
)
}
func testInvalidURL() throws {
assertMacroExpansion(
"""
#URL("nieprawidłowy url")
""",
expandedSource: """
#URL("nieprawidłowy url")
""",
diagnostics: [
DiagnosticSpec(
message: "Nieprawidłowy URL: \"nieprawidłowy url\"",
line: 1, column: 1
)
],
macros: testMacros
)
}
}
Warto też sprawdzić bibliotekę swift-macro-testing od Point-Free — oferuje automatyczne snapshoty rozwinięć makr, co znacznie upraszcza utrzymywanie testów przy zmianach w implementacji.
Na co zwracać uwagę przy testowaniu
- Pozytywne scenariusze — upewnij się, że makro generuje poprawny kod dla typowych danych wejściowych
- Walidacja błędów — sprawdź, czy makro poprawnie reaguje na nieprawidłowe użycie
- Przypadki graniczne — puste dane, nietypowe typy, wielokrotne zastosowanie makra na tym samym typie
- Expand Macro w Xcode — kliknij prawym przyciskiem na makro i wybierz „Expand Macro", żeby podejrzeć wygenerowany kod na żywo
Debugowanie makr w Xcode
Debugowanie makr potrafi być frustrujące — w końcu działają w czasie kompilacji, nie w runtime. Ale są sposoby, żeby sobie z tym poradzić.
Expand Macro — podgląd wygenerowanego kodu
Najszybszy sposób na sprawdzenie, co Twoje makro wygenerowało: kliknij prawym przyciskiem na użycie makra w edytorze Xcode i wybierz „Expand Macro". Xcode pokaże dokładnie, jaki kod został stworzony — z pełnym podświetlaniem składni. To zawsze mój pierwszy krok, kiedy coś działa nie tak, jak powinno.
Breakpointy w implementacji makra
Ponieważ makra to oddzielne programy, możesz ustawić breakpointy bezpośrednio w ich implementacji. Kiedy projekt używający makra się kompiluje, debugger zatrzyma się w Twoim kodzie. To działa naprawdę dobrze, choć wymaga chwili, żeby się do tego przyzwyczaić.
context.makeUniqueName() — unikanie konfliktów nazw
Podczas generowania kodu w makrach nie koduj na sztywno nazw zmiennych. Użyj metody context.makeUniqueName("prefix"), która generuje unikalne nazwy gwarantujące brak kolizji:
let tempVarName = context.makeUniqueName("temp")
// Generuje np. __macro_local_temp_0 — gwarantowana unikalność
Najlepsze praktyki przy tworzeniu makr
Po kilku miesiącach pracy z makrami i śledzeniu dyskusji w społeczności Swift, wyłania się kilka kluczowych zasad. Oto te, które naprawdę mają znaczenie:
1. Generuj tylko to, co konieczne
Makra powinny eliminować powtarzalny boilerplate, nie zastępować każdy fragment kodu. Jeśli jakiś pattern pojawia się w dwóch miejscach — nie twórz makra, to przesada. Ale jeśli w dwudziestu? Wtedy zdecydowanie warto.
2. Zapewnij czytelne komunikaty błędów
Kiedy ktoś użyje Twojego makra nieprawidłowo, powinien od razu wiedzieć, co zrobił źle. Zamiast generycznego „expansion failed" — podaj konkretny opis problemu i podpowiedź, jak go naprawić. Twoi współpracownicy (i przyszły Ty) będą wdzięczni.
3. Nie ukrywaj zbyt wiele logiki
Makro, które z jednej adnotacji generuje 200 linii kodu, brzmi fajnie w teorii. W praktyce? Utrudnia zrozumienie, co tak naprawdę robi Twój program. Staraj się, by wygenerowany kod był przewidywalny i łatwy do podejrzenia przez „Expand Macro".
4. Dokumentuj wygenerowany kod
Dodaj komentarze DocC do deklaracji makra. Wyjaśnij, jaki kod zostanie wygenerowany i jakie są wymagania wobec typu, na którym makro jest stosowane.
5. Stabilizuj wzorzec przed tworzeniem makra
Nie twórz makra dla czegoś, nad czym wciąż pracujesz. Serio. Makra powinny kodyfikować ustalone, sprawdzone wzorce — nie eksperymentalne pomysły, które mogą się jutro zmienić.
Ekosystem gotowych makr — co warto znać
Społeczność Swift zdążyła już stworzyć sporo gotowych makr, które możesz od razu dodać do swoich projektów. Kilka najciekawszych:
- CodingKeys — automatyczne generowanie
CodingKeysz konwersją snake_case na camelCase - MemberwiseInit — ulepszone inicjalizatory memberwise z kontrolą dostępu (bo wbudowany
initbywa zbyt ograniczony) - EnvironmentValues — eliminuje boilerplate przy definiowaniu kluczy i wartości SwiftUI Environment
- SFSymbols Macro — typowo-bezpieczne odwoływanie się do SF Symbols z walidacją w czasie kompilacji
- URLMacro — walidacja URL w czasie kompilacji, zero narzutu w runtime
- SmartLogMacro — strukturalne logowanie z obsługą prywatności i przekierowaniem do bibliotek logowania
Warto zajrzeć na repozytorium krzysztofzablocki/Swift-Macros na GitHubie — to chyba najlepsza zbiorka dostępnych makr z podziałem na kategorie. Świetne źródło inspiracji i gotowych rozwiązań.
Kiedy NIE używać makr
Makra to potężne narzędzie, ale — jak każde potężne narzędzie — nie jest odpowiedzią na wszystko. Oto sytuacje, w których lepiej poszukać innej drogi:
- Prosty protokół wystarczy — jeśli domyślna implementacja protokołu rozwiązuje problem, nie komplikuj życia makrem
- Generics załatwią sprawę — parametryzacja typów często eliminuje powtarzalność bez generowania kodu
- Wzorzec jest niestabilny — jeśli API wciąż się zmienia, makro będzie wymagało ciągłych poprawek (a debugowanie makr nie jest zabawne)
- Debugowanie jest priorytetem — wygenerowany kod jest trudniejszy do śledzenia krok po kroku niż napisany ręcznie
- Zespół nie zna SwiftSyntax — utrzymanie makra wymaga znajomości tej biblioteki, co podnosi próg wejścia dla nowych osób w projekcie
Konfiguracja Package.swift — zależności i kompatybilność
Prawidłowa konfiguracja pakietu to fundament. Oto minimalny Package.swift, od którego możesz zacząć:
// 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: [
// Biblioteka z deklaracjami makr
.target(name: "MyMacros", dependencies: ["MyMacrosPlugin"]),
// Plugin kompilatora z implementacjami
.macro(
name: "MyMacrosPlugin",
dependencies: [
.product(name: "SwiftSyntaxMacros", package: "swift-syntax"),
.product(name: "SwiftCompilerPlugin", package: "swift-syntax"),
]
),
// Testy
.testTarget(
name: "MyMacrosTests",
dependencies: [
"MyMacrosPlugin",
.product(
name: "SwiftSyntaxMacrosTestSupport",
package: "swift-syntax"
),
]
),
]
)
Wersja SwiftSyntax powinna odpowiadać wersji Swift, której używasz: 509 dla Swift 5.9, 510 dla Swift 5.10, 600 dla Swift 6.0 i 602 dla najnowszych wydań. Niezgodność wersji to jeden z najczęstszych problemów przy rozpoczynaniu pracy z makrami.
Często zadawane pytania (FAQ)
Czy makra Swift działają w runtime czy w czasie kompilacji?
Wyłącznie w czasie kompilacji. Kompilator uruchamia plugin makra jako oddzielny proces, który analizuje drzewo składniowe (AST), generuje nowy kod i wstrzykuje go do projektu przed właściwą fazą kompilacji. W gotowej aplikacji nie ma żadnego narzutu — wygenerowany kod jest identyczny z kodem, który mógłbyś napisać ręcznie.
Jakie narzędzia są potrzebne do tworzenia makr?
Potrzebujesz Xcode 15 lub nowszego (Swift 5.9+) oraz biblioteki SwiftSyntax jako zależności w Swift Package Manager. Makra muszą być osobnymi pakietami — nie mogą być częścią głównego targetu aplikacji. Opcjonalnie (ale naprawdę polecam) zainstaluj Swift AST Explorer — to narzędzie webowe do podglądu drzewa składniowego kodu, nieocenione przy pisaniu transformacji.
Czy mogę używać makr w istniejącym projekcie iOS?
Tak, i to dość prosto. Makra dodajesz jako zależność Swift Package — lokalną (w ramach workspace'u) albo zdalną (z repozytorium Git). Po dodaniu pakietu wystarczy zaimportować moduł i używać makr jak zwykłych atrybutów czy wyrażeń. Minimalne wymaganie dla projektu docelowego to iOS 13 / macOS 10.15.
Czym makra Swift różnią się od makr w C/C++?
W skrócie — praktycznie wszystkim. Makra w C/C++ to proste zastępowanie tekstu przez preprocesor. Nie mają pojęcia o typach ani kontekście. Makra Swift działają na pełnym drzewie składniowym (AST), są bezpieczne typowo, uruchamiane w sandboxie i mają dostęp do kontekstu kompilacji. Błędy wyświetlają się z precyzyjnymi komunikatami, a nie jako kryptyczne wiadomości preprocesora.
Czy makra wpływają na czas kompilacji?
Tak, mogą go wydłużyć — kompilator musi uruchomić oddzielny proces dla każdego rozwinięcia makra. W praktyce wpływ jest minimalny dla prostych makr, ale może być odczuwalny przy dużej liczbie złożonych rozwinięć. Warto monitorować czas budowania w Xcode (Product → Perform Action → Build With Timing Summary) i stosować makra tam, gdzie oszczędność boilerplate'u rzeczywiście uzasadnia ten koszt.