Introduced at WWDC 2023, Swift 5.9, now available in beta, brings a major extension to the language capabilities through support for generating code at compile-time using macros.
Macros aim to extend the capabilities of a programming language by introducing constructs similar to language primitives and eliminate as much boilerplate as possible. Swift macros work at the AST (abstract syntax tree) level to enable to generate code at compile time that is fed back to the compiler. They should not be confused with C/C++ macros, which only carry through some kind of specialized string replacement, and are more akin to macros in languages such as Rust or Scala, although with their own particular twist.
Macros [...] transform one AST into another AST without depending on any external state, and without making changes to any external state.
Swift has two kinds of macros: freestanding macros, which appear on their own, without being attached to a declaration; and attached macros, which modify the declaration of the program entity that follows them. Syntactically, freestanding macros are prefixed by #
, attached macros by @
:
#aFreestandingMacro("with argument")
@AttachedMacro<T> struct AStruct {
...
}
To expand a macro, the Swift compiler goes through a sequence of steps, starting with building the program AST. Parts of the AST are sent to the macro implementation, which uses it to build its own expanded form. The compiler finally uses the expanded AST to replace the macro call and then ensures the resulting code is still valid. Macros can appear inside other macros, in which case the outer macro is expanded first and can thus modify the inner macro before it is expanded.
Contrary to other features of the language, Swift macros require you to separately specify a declaration and an implementation.
A macro declaration is introduced by the macro
keyword and defines the places in the code where it can be called and the kind of code it generates. The following declaration for the Standard Library macro @OptionSet
, which is meant as an easier way to work with the OptionSet
protocol, tries to exemplify that:
@attached(member)
@attached(conformance)
public macro OptionSet<RawType>() =
#externalMacro(module: "SwiftMacros", type: "OptionSetMacro")
According to its declaration, the @OptionSet
macro adds new members to its attached object and one or more protocol conformances. The declaration specifies also where the macro implementation can be found, e.g. in the "SwiftMacros" module, and which type provides its implementation, e.g. "OptionSetMacro".
As you may have suspected by now, implementing a macro is not the most straightforward process, though, compared to other languages where the macro definition can be embedded in the same code that uses it. In fact, a macro in Swift is implemented as a library that provides a type, e.g., a struct
, and belongs to its own target in your Xcode project. This can be made sense of by thinking that the macro expansion step is dealt with as an Xcode build phase, for which it greatly helps to have separate libraries to invoke.
The easiest way to create a macro is with the Swift Package manager using swift package init --type macro
. This will provide a nice template for your macro declaration and implementation.
Without going into much detail about how a macro is implemented, it can be interesting to observe how strictly tied to your program AST macros are. In the following example, you can see how to declare a type that implements a macro expansion. It must provide an expansion(of:in:)
method that receives the AST and the macro context as arguments and provides an ExprSyntax
:
public struct FourCharacterCode: ExpressionMacro {
public static func expansion(
of node: some FreestandingMacroExpansionSyntax,
in context: some MacroExpansionContext
) throws -> ExprSyntax {
...
}
Using the FreestandingMacroExpansionSyntax
argument, the macro implementation can access the arguments passed to the macro as typed values. The return value is an ExprSyntax
, from the SwiftSyntax project, that represents an expression in an AST. Since, ExprSyntax
conforms to StringLiteralConvertible
, the easiest way to build such an AST is as a String
that you return from expansion(of:in:)
and is automatically converted into an ExprSyntax
.
As a last remark about Swift macros, if you throw an exception from a macro expansion
implementation, it will be treated as a compile error at the malformed macro site, which promises to make macro debugging less of a headache than in other languages.
If you are interested in experimenting with Swift macros, you can download Xcode 15 beta or install the latest Swift toolchain in Xcode 14.