- Proposal: SE-0415
- Authors: Doug Gregor
- Review Manager: Tony Allevato
- Status: Implemented (Swift 6.0)
- Feature Flag:
BodyMacros
- Review: pitch, review, returned for revision, second review, acceptance
- Introduction
- Proposed solution
- Detailed design
- Source compatibility
- Effect on ABI stability
- Effect on API resilience
- Future directions
- Alternatives considered
- Revision history
Macros augment Swift programs with additional code, which can include new declarations, expressions, and statements. One of the key ways in which one might want to augment code---synthesizing or updating the body of a function---is not currently supported by the macro system. One can create new functions that have their own function bodies, but not provide, augment, or replace function bodies for a function declared by the user.
This proposal introduces function body macros, which do exactly that: allow the wholesale synthesis of function bodies given a declaration, as well as augmenting an existing function body with more functionality. This opens up a number of new use cases for macros, including:
- Synthesizing function bodies given the function declaration and some metadata, such as automatically synthesizing remote procedure calls that pass along the provided arguments.
- Augmenting function bodies to perform logging/tracing, check preconditions, or establish invariants.
- Replacing function bodies with a new implementation based on the one provided. For example, moving the body into a closure that is executed somewhere else, or treating the body as written as a domain specific language that the macro "lowers" to executable code.
This proposal introduces function body macros, which are attached macros that can augment a function (including initializers, deinitializers, and accessors) with a new body. For example, one could introduce a Remote
macro that packages up arguments for a remote procedure call:
@Remote
func f(a: Int, b: String) async throws -> String
which could expand the function to provide a body, e.g.:
func f(a: Int, b: String) async throws -> String {
return try await remoteCall(function: "f", arguments: ["a": a, "b": b])
}
One could also use a macro to introduce logging code on entry and exit to a function, expanding the following
@Logged
func g(a: Int, b: Int) -> Int {
return a + b
}
into
func g(a: Int, b: Int) -> Int {
log("Entering g(a: \(a), b: \(b))")
defer {
log("Exiting g")
}
return a + b
}
Or one could provide a macro that makes it easier to assume that a function that cannot be marked as @MainActor
using assumeIsolated
:
extension MyView: SomeDelegate {
@AssumeMainActor
nonisolated func onSomethingHappened(event: Event) {
myView.title = newTitle(processing: event)
}
}
which could expand to:
extension MyView: SomeDelegate {
nonisolated func onSomethingHappened(event: Event) {
MainActor.assumeIsolated {
myView.title = newTitle(processing: event)
}
}
}
Function body macros can be applied to accessors as well, in which case they go on the accessor itself, e.g.,
var area: Double {
@Logged get {
return length * width
}
}
When using the shorthand syntax for get-only properties, a function body macro can be applied to the property itself:
@Logged var area: Double {
return length * width
}
Function body macros are declared with the body
role, which indicate that they can be attached to any kind of function, and can produce the contents of a function body. For example, here are declarations for the macros used above:
@attached(body) macro Remote() = #externalMacro(...)
@attached(body) macro Logged() = #externalMacro(...)
@attached(body) macro AssumeMainActor() = #externalMacro(...)
Like other attached macros, function body macros have no return type.
Body macros are implemented with a type that conforms to the BodyMacro
protocol:
/// Describes a macro that can create the body for a function.
public protocol BodyMacro: AttachedMacro {
/// Expand a macro described by the given custom attribute and
/// attached to the given declaration and evaluated within a
/// particular expansion context.
///
/// The macro expansion introduces code block items that will become the body for the
/// given function. Any existing body will be implicitly ignored.
static func expansion(
of node: AttributeSyntax,
providingBodyFor declaration: some DeclSyntaxProtocol & WithOptionalCodeBlockSyntax,
in context: some MacroExpansionContext
) throws -> [CodeBlockItemSyntax]
}
That function may have a function body, which will be replaced by the code items produced from the macro implementation.
At most one body
macro can be applied to a given function. It receives the function declaration to which it is attached as it was written in the source code and produces a new function body.
When a function body macro is applied, the macro-expanded function body will need to be type checked when it is incorporated into the program. However, the function might already have a body that was written by the developer, which can be inspected by the macro implementation. The function body as written must be syntactically well-formed (i.e., it must conform to the Swift grammar) but will not be type-checked, so it need not be semantically well-formed.
This approach follows what other attached macros do: they operate on the syntax of the declaration to which they are attached, and the declaration itself need not have been type-checked before the macro is expanded. However, this approach does lend itself to potential abuse. For example, one could create a SQL
macro that expects the function body to be a SQL statement, then rewrites that into code that executes the query. For example, the input could be:
@SQL
func employees(hiredIn year: Int) -> [String] {
SELECT
name
FROM
employees
WHERE
YEAR(hire_date) = year;
}
However, this would only work for places where the SQL grammar is a subset of the Swift grammar. Collapsing the same function into two lines would produce an error because it is not syntactically well-formed Swift:
@SQL
func employees(hiredIn year: Int) -> [String] {
SELECT name FROM employees // error: consecutive statements on a line must be separated by ';'
WHERE YEAR(hire_date) = year;
}
The requirement for syntactic wellformedness should help rein in the more outlandish uses of function body macros, as well as making sure that existing tools that operate on source code will continue to work well even in the presence of body macros.
Function body macros introduce a new macro role into the existing attached macro syntax, and therefore does not have an impact on source compatibility.
Macros are a source-to-source transformation tool that have no ABI impact.
Macros are a source-to-source transformation tool that have no effect on API resilience.
Function body macros as presented in this proposal are limited to declared functions, initializers, deinitializers, and accessors. In the future, they could be expanded to apply to closures as well, e.g.,
@Traced(z) { (x, y) in
x + y
}
This extension would involve extending the BodyMacro
protocol with another expansion
method that accepts closure syntax. The primary challenge with applying function body macros to closures is the interaction with type inference, because closures generally occur within an expression and some of the macro arguments themselves might be part of the expression. In the example above, the z
value could come from an outer scope and be the subject of type inference:
f(0) { z in
@Traced(z) { (x, y) in
x + y
}
}
Macros are designed to avoid multiply instantiating the same macro, and have existing limitations in place to prevent the type checker from getting into a position where it is not obvious which macro to expand or the same macro needs to be expanded multiple times. To extend function body macros to closures will require a solution to this type-checking issue, and might be paired with lifting other restrictions on (e.g.) freestanding declaration macros.
The first reviewed revision of this proposal contained preamble macros, which let a macro introduce code at the beginning of a function without changing the rest of the function body. Preamble macros aren't technically necessary, because one could always write a function body macro that injects the preamble code into an existing body. However, preamble macros provide several end-user benefits over function body macros for the cases where they apply:
- Preamble macros can be composed, whereas function body macros cannot.
- Preamble macros don't change the code as written by the user, so they provide a better user experience (e.g., for diagnostics, code completion, and so on).
Preamble macros would be expressed as its own attached macro role (preamble
), implemented with a type that conforms to the PreambleMacro
protocol. Details are available in the prior revision.
Preamble macros have been moved out to Future Directions because they represent a possible future, but not an obviously right one: preamble macros might not add sufficient expressivity to cover the cost of the complexity they introduce, and another kind of macro (like the "wrapper" macro below) might provide a more reasonable tradeoff between expressivity and complexity.
A number of use cases for body macros involve "wrapping" the existing body in additional logic. For example, consider an alternative formulation of the Traced
macro (let's call it @TracedWithSpan
) could make use of the withSpan
API such that a function such as:
@TracedWithSpan("Doing complicated math")
func h(a: Int, b: Int) -> Int {
return a + b
}
will expand to:
func h(a: Int, b: Int) -> Int {
withSpan("Doing complicated math") {
return a + b
}
}
This withSpan
function used here is one instance of a fairly general pattern in Swift, where a function accepts a closure argument and runs it with some extra contextual parameters. As we with the preamble
macro role mentioned above, we could introduce a special macro role that describes this pattern: the macro would not see the function body that was written by the developer at all, but would instead have a function value representing the body that it could call opaquely. For example, the TracedWithSpan
example function h
would expand to:
func h(a: Int, b: Int) -> Int {
withSpan("Doing complicated math", body: h-impl)
}
With this approach, the original function body for h
would be type-checked prior to macro expansion, and then would be handed off to the macro as an opaque value h-impl
to be called by withSpan
. The macro could introduce its own closure wrapping that body as needed, e.g.,
@TracedWithSpan("Doing complicated math", { span in
span.attributes["operation"] = "addition"
})
func myMath(a: Int, b: Int) -> Int {
return a + b
}
could expand to:
func myMath(a: Int, b: Int) -> Int {
return withSpan("Doing complicated math") { span in
span.attributes["operation"] = "addition"
return myMath-impl()
}
}
The advantage of this approach over allowing a body
macro to replace a body is that we can type-check the function body as it was written, and only need to do so once---then it becomes a value of function type that's passed along to the underlying macro. Also like preamble macros, this approach can compose, because the result of one macro could produce another value of function type that can be passed along to another macro. Python decorators have been successful in that language for customizing the behavior of functions in a similar manner.
As noted previously, not checking the body of functions that was written by the user and then replaced by a body
macro has some down sides. For one, it allows some abuse, where code that wouldn't make sense in Swift is permitted to be written by the user and then significantly altered by the body
macro. Moreover, wherever the macro is performing some modification that makes ill-formed code into well-formed code (even by something as simple as introducing a span
variable like @Traced
does), tools that cannot reason about the macro expansion might be less useful: code completion won't know to provide span
as a possible completion, nor will it know what type span
would have. Therefore, the experience of writing code that makes use of body
macros could be significantly worse than that for normal Swift code.
On the other hand, type-checking the function bodies before macro expansion has other issues. Type checking is a significant part of compilation time, and having to type-check the body of a function twice---once before macro expansion, once after---could be prohibitively expensive. Type-checking the function body before macro expansion also limits what can be expressed by body macros, including making some use cases (like the @Traced
macro described earlier) impossible to express without more extensions to the model.
- Revision 3:
- Narrowed the focus down to
body
macros. - Moved preamble macros into Future Directions, added discussion of wrapper macros.
- Narrowed the focus down to
- Revision 2:
- Clarify that preamble macro-introduced local names can shadow names from outer scopes
- Clarify the effect of function body macros on single-expression functions and implicit returns
- Revision 1:
- Allow preamble macros to introduce names.
- Introduce
@AssumeMainActor
example macro for body macros that perform replacement. - Switch
@Traced
example over to be a preamble macro with push/pop operations, so it can nicely introducespan
. - Allow function body macros to be applied to properties that use the shorthand getter syntax.