Anpassade Swift-makron med SwiftSyntax: Komplett Guide till att Skriva Egna Makron i Swift 6.2
Skriv dina egna Swift-makron med SwiftSyntax i Swift 6.2 och Xcode 26. Genomgång av paketstruktur, freestanding vs attached, diagnostik och testning, med två kompletta exempel du kan köra direkt.
Ett anpassat Swift-makro är en kompileringstid-transformation som tar ett syntaxträd som indata och producerar Swift-kod som utdata, exekverad i en isolerad kompilatorplugin. Sedan Swift 5.9 skriver du dem med biblioteken SwiftSyntax, SwiftSyntaxMacros och SwiftCompilerPlugin. Den här guiden går igenom hela vägen: paketstruktur, de sju makrorollerna, diagnostik, testning med assertMacroExpansion, och två fullständiga exempel som du kan köra direkt i Swift 6.2 och Xcode 26.
Ett Swift-makro körs i en separat kompilatorplugin (inte i din app) och tar emot samt returnerar syntaxträd från SwiftSyntax.
Det finns två grundsorter: @freestanding (som ersätter sitt anrop) och @attached (som lägger till kod bredvid det den dekorerar).
Ett makropaket kräver tre mål: biblioteket som exponerar makrot, kompilatorpluginen som implementerar det, och (helst) ett testmål med SwiftSyntaxMacrosTestSupport.
Använd MacroExpansionContext.diagnose med DiagnosticMessage för att ge riktiga felmeddelanden istället för fatalError.
Testa expansionen med assertMacroExpansion, som jämför källtext mot förväntad genererad kod istället för AST:er, så testerna är läsbara.
I Swift 6.2 körs makron under strikt concurrency; håll din implementering statelös och undvik globala variabler i pluginen.
Vad är ett anpassat Swift-makro?
Ett anpassat Swift-makro är en kompileringstid-transformation som körs innan typkontrollen är klar och som genererar Swift-källkod, vilken sedan kompileras som om du hade skrivit den för hand. Ingången är alltid ett SyntaxNode, ett omutabelt syntaxträd från biblioteket SwiftSyntax, och utgången är antingen ett uttryck, en deklaration, medlemmar eller en extension, beroende på makrots roll.
Sedan Swift 5.9 släpptes 2023 har makron varit ett förstaklassskoncept, men det tog ärligt talat några versioner innan verktygen kändes vuxna. I Swift 6.2 är API:et stabilt: SwiftSyntax 601.0.x fungerar mot Xcode 26, kompilatorpluginen körs alltid utanför processen (även när du bygger från kommandoraden), och Xcode ber om användarens medgivande första gången ett makro laddas, samma modell som för byggverktyg. Om du har följt Objective-C-arvet i Swift så är det här kanske det första riktigt nya som språket har fått som inte har någon rimlig motsvarighet i klassisk Cocoa-kod. Preprocessorn i Objective-C var textbaserad; makron i Swift arbetar på faktiska syntaxträd, vilket är hela poängen med varför de går att typkolla.
Det du inte kan göra med makron: läsa filsystemet, göra nätverksanrop, importera andra Swift-moduler för att inspektera deras typer, eller hålla mutabelt tillstånd mellan expansioner. Kompilatorpluginen är avsiktligt tunn. Om du redan har läst min genomgång av Observation-ramverket och @Observable-makrot, så har du faktiskt redan sett vad ett välskrivet @attached(memberAttribute)-makro kan åstadkomma i praktiken.
Paketstruktur för ett makropaket
Ett Swift-makro bor alltid i ett Swift-paket, inte i en Xcode-projektfil, eftersom det behöver deklarera en CompilerPlugin-produkt. Xcode 26 har en mall (File → New → Package → Swift Macro) som skapar hela strukturen åt dig. Om du föredrar att göra det för hand ser Package.swift ut så här:
Tre mål, tre roller: biblioteket exponerar deklarationen (@freestanding(expression) macro stringify<T>(_ value: T) -> (T, String) = #externalMacro(module: "SwiftCraftedMacrosPlugin", type: "StringifyMacro")), pluginen innehåller expansionen, och testerna verifierar utfallet. Hoppa inte över testmålet. Du kommer ångra det. Makron är notoriskt lätta att bryta genom en till synes oskyldig ändring.
Skillnaden mellan @freestanding- och @attached-makron
Det finns två övergripande smaker av makron och sju roller totalt. Skillnaden är enkel att lära sig, men lätt att glömma bort i redigeringsstunden.
Nej, bara syntax (men extension-rollen får typnamnet)
Vanligt användningsfall
DSL:er, #warning-liknande, kodgenerering på uttrycksnivå
Härledd Codable, mockning, boilerplate
Får läggas till en typ?
Inte som ändring, kan bara infogas
Ja, kan skapa medlemmar, accessorer, protokollkonformans
Regeln jag använder själv: om resultatet är koden (till exempel att expandera #url("https://…") till en typkontrollerad URL), välj @freestanding. Om resultatet är ett tillägg till en typ eller egenskap (till exempel att generera init(from decoder:)), välj @attached. En @attached(extension)-roll är särskilt användbar när du vill lägga till en protokollkonformans utan att röra den ursprungliga typens deklaration.
Skriva ett @freestanding(expression)-makro
Det klassiska "hello world"-makrot är #stringify. Det tar ett uttryck och returnerar en tupel med både värdet och källkoden som en sträng. Här är hela implementationen:
// Sources/SwiftCraftedMacrosPlugin/StringifyMacro.swift
import SwiftSyntax
import SwiftSyntaxMacros
public struct StringifyMacro: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
guard let argument = node.arguments.first?.expression else {
throw MacroError.missingArgument
}
return "(\(argument), \(literal: argument.description))"
}
}
enum MacroError: Error, CustomStringConvertible {
case missingArgument
var description: String {
switch self {
case .missingArgument: "stringify kräver exakt ett argument"
}
}
}
Det som är värt att titta noga på är strängbokstavssyntaxen "(\(argument), \(literal: argument.description))". Det där är inte en vanlig Swift-sträng. Det är en ExprSyntax-bokstav, tack vare att SwiftSyntax definierar ExpressibleByStringInterpolation. Interpolationen \(literal:) serialiserar värdet som en giltig Swift-strängbokstav (den lägger till citattecken och escapar). Utan den skulle en indata som innehöll ett citattecken producera trasig kod. Jag lärde mig det på hårda vägen när jag skickade in en URL med en query-parameter och hela bygget dog i timmarna innan en release.
Anropssidan ser då ut som let (v, s) = #stringify(2 + 3), där s blir strängen "2 + 3". Simpelt, men det illustrerar hela flödet: syntax in, syntax ut, kompilatorn interpolerar resultatet på anropsplatsen.
Skriva ett @attached(member)-makro
Ett mer verklighetsnära exempel är ett @AutoInit-makro som genererar en synlig init för en struct. Jag har sett människor kopiera-klistra samma initierare tio gånger i en modul. Det här ersätter det.
// Sources/SwiftCraftedMacrosPlugin/AutoInitMacro.swift
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] {
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
throw MacroError.notAStruct
}
let storedProperties: [(name: String, type: String)] = structDecl.memberBlock.members
.compactMap { $0.decl.as(VariableDeclSyntax.self) }
.filter { $0.bindingSpecifier.tokenKind == .keyword(.let)
|| $0.bindingSpecifier.tokenKind == .keyword(.var) }
.flatMap { varDecl -> [(String, String)] in
varDecl.bindings.compactMap { binding in
guard let ident = binding.pattern.as(IdentifierPatternSyntax.self)?
.identifier.text,
let type = binding.typeAnnotation?.type.description
.trimmingCharacters(in: .whitespaces)
else { return nil }
return (ident, type)
}
}
let params = storedProperties
.map { "\($0.name): \($0.type)" }
.joined(separator: ", ")
let assigns = storedProperties
.map { "self.\($0.name) = \($0.name)" }
.joined(separator: "\n ")
let initDecl: DeclSyntax = """
public init(\(raw: params)) {
\(raw: assigns)
}
"""
return [initDecl]
}
}
Notera MemberMacro-protokollet och de tre parametrarna: node är själva attributet (@AutoInit), declaration är den struct, class eller enum som attributet sitter på, och context används för diagnostik och för att skapa unika identifierare. Vi filtrerar bort beräknade egenskaper genom att bara plocka ut lagrade VariableDeclSyntax-noder med en typannotation, för utan typ vet vi inte parameterlistan.
names: named(init) är obligatoriskt. Kompilatorn måste veta i förväg vilka namn ett attached-makro får introducera, annars vägrar den att expandera det. För vildkort-scenarier finns arbitrary, men det slår av vissa optimeringar och bör undvikas. Om du bygger något större värt att strukturera som en serie makron, kolla in mönstret jag använde i SwiftData-guiden för iOS 26, där @Model gör exakt det här fast med fem samtidiga roller.
Diagnostik och felmeddelanden
Att kasta MacroError.notAStruct som ovan fungerar, men användaren får ett fel utan filplats och utan förslag. MacroExpansionContext tillhandahåller diagnose(_:) som skickar riktiga diagnostiska meddelanden med filkoordinater, fixa-det-här-förslag och note-referenser. Så här ser det ut i praktiken:
import SwiftDiagnostics
struct MustBeStructError: DiagnosticMessage {
let message = "@AutoInit kan bara läggas på en struct"
let diagnosticID = MessageID(domain: "SwiftCraftedMacros", id: "must-be-struct")
let severity: DiagnosticSeverity = .error
}
// Inne i expansion(...):
guard let structDecl = declaration.as(StructDeclSyntax.self) else {
context.diagnose(Diagnostic(
node: Syntax(declaration),
message: MustBeStructError()
))
return []
}
Skillnaden i editor-upplevelse är natt och dag. Felmeddelandet visas nu direkt under raden i Xcode, med rätt filnamn och kolumn. Om du vill vara riktigt hjälpsam, para ihop diagnostiken med en FixItMessage så att användaren kan applicera en åtgärd med ett klick. Apples egna makron gör det överallt. @Observable föreslår till exempel att du byter class mot final class när det passar, som Apples officiella Observable-dokumentation beskriver.
Så testar du Swift-makron
Testverktyget SwiftSyntaxMacrosTestSupport tillhandahåller funktionen assertMacroExpansion, som tar in källkod och förväntad utdata som strängar och jämför dem token för token. Skillnaden mot att jämföra AST:er är att felmeddelandet blir läsligt: du ser en diff av två textbitar, inte en dump av ett träd. Ett test för #stringify ser ut så här:
import SwiftSyntaxMacrosTestSupport
import XCTest
@testable import SwiftCraftedMacrosPlugin
final class StringifyMacroTests: XCTestCase {
func testStringifyExpandsAdditionExpression() {
assertMacroExpansion(
"""
let result = #stringify(2 + 3)
""",
expandedSource: """
let result = (2 + 3, "2 + 3")
""",
macros: ["stringify": StringifyMacro.self]
)
}
}
Nyckelvärdet i macros-ordboken är namnet som används på anropsplatsen (utan # eller @), inte den fullt kvalificerade typen. Det gör att du kan testa flera makron med lokala aliaser om du behöver, vilket är praktiskt i integrationstester.
För @AutoInit-makrot vill du testa både lyckade och misslyckade fall. För felfallen kontrollerar du diagnostikmeddelanden:
func testAutoInitRejectsClass() {
assertMacroExpansion(
"""
@AutoInit
class NotAStruct {
let name: String
}
""",
expandedSource: """
class NotAStruct {
let name: String
}
""",
diagnostics: [
DiagnosticSpec(message: "@AutoInit kan bara läggas på en struct", line: 1, column: 1)
],
macros: ["AutoInit": AutoInitMacro.self]
)
}
Kör testerna med swift test från paketets rotmapp, eller från Xcode 26 direkt. På Apple Silicon tar ett fullständigt makrotestpaket typiskt 3–5 sekunder att köra, vilket är snabbt nog för att köra i förändringsvakten (watch mode). För en bredare genomgång av testverktygen i det moderna Swift-ekosystemet, se min tidigare artikel om Swift Testing-ramverket. @Test-makrot där är i sig ett bra exempel på ett väldesignat @attached(peer)-makro.
Vanliga fallgropar och prestanda
Efter att ha skrivit ett dussintal produktionsmakron finns det ett antal saker jag ser gå fel om och om igen. De vanligaste:
1. Att förlita sig på typinformation
Makron ser bara syntax, inte typer. Om användaren skriver @AutoInit på en struct som ärver från en annan modul via en typalias, ser ditt makro bara typealias-namnet, inte den underliggande typen. Om ditt makro behöver typinformation för att fungera korrekt, dokumentera begränsningen tydligt i en kommentar och validera det du kan.
2. Att glömma access-modifierare
Om målet är public och du genererar en init utan att kopiera modifieraren, blir initieraren internal. Kompilatorn klagar då på att en public typ har en mindre synlig initierare. Lös det genom att inspektera declaration.modifiers och matcha vad du hittar där.
3. Statliga variabler i pluginen
Kompilatorpluginen körs som en separat process, men den kan hålla globalt tillstånd inom en byggsession. Det är en fälla. Under inkrementell kompilering är ordningen på anrop icke-deterministisk, och du får omöjligt-att-diagnostisera flakighet. Håll all logik ren och statelös. Jag råkade själv ut för det här på ett tidigare projekt, och det tog en hel eftermiddag innan jag insåg att ordningen bara var slumpartad mellan bygg.
4. Prestanda vid stora paket
Varje makroexpansion kräver en pipe-round-trip mellan kompilatorn och pluginen. På ett paket med tusentals expansioner märks det. Håll din expansion-funktion enkel: undvik regex, undvik onödiga String-allokeringar, och föredra SyntaxNode-traversering framför att konvertera till text. Apples egen dokumentation om SwiftSyntaxMacros-modulen har en bra genomgång av vilka noder som är billiga att jobba mot.
5. Att inte versionsfrysa swift-syntax
Om en användare av ditt paket har en annan version av swift-syntax i sitt eget projekt kan Swift Package Manager välja fel version. Ange alltid en snäv intervall (from: "601.0.0") och släpp nya versioner av ditt makropaket när swift-syntax gör ett major bump. Se swift-syntax release notes för mappningstabellen mellan Swift-versioner och paketversioner.
Vanliga frågor
Kan Swift-makron generera nya typer?
Ja, ett @freestanding(declaration)-makro får introducera nya typer, och ett @attached(peer)-makro får skapa en syskontyp bredvid den dekorerade deklarationen. Du måste dock deklarera namnen i förväg via names:-argumentet på attributet, om de inte är helt slumpartade. Då används arbitrary, men det slår av vissa optimeringar.
Är Swift-makron säkra att använda i produktion?
Ja. Kompilatorpluginen körs isolerad utanför kompilatorprocessen, utan tillgång till filsystem eller nätverk. Xcode ber dessutom om användarens medgivande första gången ett makro laddas från ett paket. All genererad kod går genom vanlig typkontroll, så makron kan inte "smyga" in osäker kod.
Fungerar Swift-makron på Linux?
Ja, sedan Swift 5.9 fungerar makron på både Apple-plattformar och Linux, förutsatt att swift-syntax-versionen matchar din Swift-toolchain. På Linux körs kompilatorpluginen som en Linux-binär, vilket innebär att du måste bygga paketet på samma plattform där du använder det.
Vad är skillnaden mellan ett makro och en property wrapper?
En property wrapper är en runtime-mekanism som transformerar hur en egenskap läses och skrivs, medan ett makro är en kompileringstid-transformation som genererar Swift-källkod. Makron är mer flexibla (de kan skapa initierare, protokollkonformans och hela typer), men de är också svårare att skriva och testa. En bra tumregel: om du bara vill omsluta ett värde, använd en property wrapper. Om du vill undvika boilerplate på deklarationsnivå, använd ett makro.
Kan jag felsöka ett Swift-makro?
Ja, men inte i själva byggkontexten. Det enklaste sättet är att köra dina tester under lldb (swift test -Xswiftc -g) och sätta brytpunkter i expansionsmetoden. För mer avancerad felsökning kan du köra kompilatorpluginen manuellt via kommandoraden och pipe:a in en syntaxdump med swift-syntax-verktyget.
Bygg Live Activities i iOS 26 från grunden med ActivityKit, Dynamic Island och SwiftUI. Guide till token- och broadcast-push via APNs, App Intents, stale-läge, VoiceOver och throttling-felsökning med kod som fungerar direkt.
Komplett guide till SwiftData i iOS 26: @Model, @Query, CloudKit-synk, relationer och migration från Core Data. Inkluderar kodexempel, prestandatips och felsökning.
Lär dig bygga App Intents i SwiftUI för iOS 26 — från Siri-genvägar och Spotlight till Visual Intelligence, interaktiva snippets och on-screen entities med praktiska kodexempel.