Hello,
I've been finalizing the implementation of SE-0382 "Expression Macros", and hit on an interesting design question related to default arguments.
The language semantics of default arguments are the same as for functions. So let's say we have a macro m1
with one parameter that has a default:
@freestanding(expression)
macro m1(bestNumber: Int = 17)
Now, we go ahead and use this macro… maybe without arguments, because we can:
#m1
Now, how should we communicate the use of the default argument to the implementation of the macro, in the expansion(of:in)
call? One possibility is that we “rewrite” the expansion syntax node that we pass along to the macro implementation, so it sees:
#m1(bestNumber: 17)
I’ve been assuming that’s the right answer, because then the macro implementation itself can ignore the possibility of default arguments. That’s cool, but it’s also weird, because doing it correctly (so that we have valid code) potentially has to do a bunch of restructuring. Consider this macro declaration and use:
@freestanding(expression)
macro m2(body: () -> Void, count: Int = 1)
#m2 { print("here") }
To pass along the default argument, #m2
now has to be restructured into
#m2(body: { print("here") }, count: 1)
However, now it feels like we’re erasing information from the call site that the macro might care about, because the user clearly wrote a trailing closure and we are not passing it along as a trailing closure. That seems to be at odds with our general notion that we pass unmodified syntax trees to macros, and might have unfortunate effects on source-location computations that the macro might do.
To me, this implies that we should leave the macro expansion syntax as written, without trying to wedge in the default arguments. By itself, this means that the macro implementation would essentially have to re-implement the default arguments. That's doable, but seems unfortunate.
I suggest that we could extend MacroExpansionContext
with an operation that provides the default argument based on a given parameter to the macro. For example, it might look like this:
protocol MacroExpansionContext {
// ...
/// Retrieve the default argument for the argument with the label `named` within the given macro expansion node.
func defaultArgument(
in macro: some FreestandingMacroExpansionSyntax,
named: String
) async -> ExprSyntax?
}
In our m1
example, the macro implementation could use
guard let count = context.defaultArgument(in: node, named: "count") else { ... }
I don't love this API for a couple of reasons:
- Its signature is insufficiently general, because we should also be able to get the default argument from an attached macro. We could provide an overload that takes an
AttributeSyntax
, but it feels odd. - Its true generalization promises more semantic information that we can provide right now. If we really generalized this, it would also support queries on function calls and subscript expressions that occur within the arguments. We don't have a type to express that in swift-syntax, nor the machinery in the compiler to answer this full query.
- The by-name query for arguments (
named: "count"
) feels imprecise. If we were in a compiler, and had access to both the macro declaration (macro m1(...)
) and its matching call, we would use the parameter declarations as the key. But macro implementations don't have access to the macro declaration. The by-name query also makes it impossible to query default arguments for unlabeled arguments (macro m3(_: Int = 17)
).
What do y'all think?
Doug