SE-0415: Function Body Macros

Also want to add that there are lots of APIs that can’t be expressed as preambles but can be expressed as wrappers. For example imagine applying the tracing example to a view:

@Traced var body: some View {
  ...
}
// expands to
var body: some View {
  WithTracing {
    ...
  }
}

(note that in this case a view modifier is insufficient because you want to capture the actual construction process. Another good example of this is TCA’s WithViewStore or its Observation backport that relies on a WithObservationTracking wrapper.)

Meanwhile, other candidates for preamble macros require constructs that don’t exist in the language yet — such as the aforementioned defer async or some sort of function-wide catch handler — whereas wrapper macros would be able to implement these ideas with safe, idiomatic constructs that exist in the language today.

1 Like

It certainly could introduce subtle differences in behavior. We'd need to figure out how to copy over all of the relevant attributes and modifiers, whether they are explicitly written or inferred. Even then, a local function isn't identical to a method or global function due to captures (which could happen here).

This is the root of my concern with trying to grow preamble macros---which do have predictable behavior---to something that can handle results and thrown errors. It's fine if a body macro changes things, because body macros can be full rewrites.

None of the designs we've seen will behave the same way in all cases.

I do think it might be possible to get a chance to look at the error when an error is thrown, based on something like what @wadetregaskis showed earlier. Wrapping the body in a do..catch block doesn't change any behavior, because it's not creating a new function the way a closure or local function is. So one can have the macro expand to something like:

// preamble from macro expansion
do {
  // unmodified original function body
} catch let error {
  // on-error handling code from the macro expansion
  throw error
}

where error is some identifier chosen by the compiler and handed down to the macro implementation. Now, this "on-error handling code" can look at the error (using that identifier). However, it would need to have its control-flow restricted so that it must always fall out to the compiler-generated throw error at the end, so it can't throw something else, or return.

The same trick doesn't work with non-throwing returns, because you'd have to effectively re-write all of the returns in the original function body to something else. So this only solves the exit-via-thrown-error case.

Interesting idea.

Doug

1 Like

Agreed.

Many of the immediate use cases I can think of for function body macros really just boil down to wrapped code for the sole purpose of doing something with the return value.

If defer gave access to the return value (e.g. returnValue, reminiscent of the implicit newValue in property setters), that could more simply solve for these cases. Of course, the availability of the returnValue would be conditional, applying to defer blocks at a function body scope (and not e.g. in a block or loop).

1 Like

What will this code look like when there's multiple defer-reliant decorators in play, plus any number of defer statements from the original function itself?

I'll admit I was originally not a fan of the "indentation is the devil" mentality that has pervaded Swift virtually since its beginning, but I've come to appreciate things like guard as a reasonable alternative in a lot of cases.

Superficially defer is in the same vein, and if used judiciously it can indeed be a reasonable alternative. But distinctly unlike guard, it doesn't scale well. As soon as you have more than one defer statement inside a function, the complexity starts going up superlinearly.

Now, granted in principle decorator macros should abstract this away entirely… except, in practice we often do actually have to expand the macro, such as for debugging. So it's important that said code be reasonably straightforward and unambiguous. i.e. more linear and WYSIWYG control flow.

Fair. I think a reasonable restriction would be that you can't return something different from a defer, or assign to the returnValue property. That way, all defer blocks will have a consistent view of the return value, that isn't influenced by their ordering.

Perhaps returnValue could be available in defer scopes in non throwing functions and result or something similar could be available in defer scopes in throwing functions, where the type of result is Result<ReturnType, ErrorType>.

Isn't this only true if wrapper/decorator macros are implemented by outlining the user's code into a local function? If the "trivial" approach of literally rewriting the function body (that @wadetregaskis previously outlined) was followed instead then preamble macros would be a strict subset of wrapper macros. There would indeed be cases where this changes semantics (captures etc.), but it's not like other macros perfectly preserve semantics either — that seems to me like the nature of Swift's macro system so far operating at a syntactic level — and changing semantics is often the intent. Even accessor macros fundamentally change semantics but constructs like @Observable wouldn't exist without support for this.

IMHO the majority of users won't be looking at the macro declarations in a library's swiftinterface to determine whether the macro they're using is declared with body or decorator. The moment one sees an @Macro above a method they'll already assume that it can alter the semantics of the code. At that point, the existence of preamble macros instead of decorator macros can be seen as an artificial limitation for both macro authors and library consumers.

I'd like to add my voice to the chorus saying preambles are not a sufficiently powerful tool to be the primary way for function macros to work & compose. As this thread has highlighted, there are many use cases for function macros that cannot be expressed as simple preambles (many of them also not expressible using proposed defer-return-value stuff).

Rather than leave this feature in a state of very limited usefulness until further improvements are made (and even then limited!), I think it would be much more pragmatic to simply allow stacking full-rewrite macros. While this would be the first instance of macro implementations receiving access to already-rewritten macro code, I don't think that downside (of debatable gravity) outweighs the advantages in flexibility.

I think that the way full-body macros would be most likely to conflict is if you applied multiple macros that fundamentally rewrite your code, but I believe that this would be exceedingly rare in practice. Instead, I think it's far more likely that users would be combining at most one such macro with (if at all) one or more simpler ones. Simpler, however, does not necessarily mean "expressible as a preamble"—e.g. imagine a macro that logs some tracing info before and after each await suspension point—so attempts to make things foolproof like that are flawed, likely to lead to developers annoyed by not being able to combine macros with clearly orthogonal effects.

Another way to mitigate this problem might be for macro authors to themselves specify whether their macro should be composable with others or changes too much to compose transparently. Alternatively, the compiler could warn about composing full-body macros with a fix-it to silence by adding some marker that this is intentional.

While I am a fan of making defer more powerful, I don't think we should be relying on that when there are readily available ways to cover these use cases right now, whose tradeoffs can absolutely be mitigated.

4 Likes

Wrapping a function body is a feature I am waiting for to use with my simple pipeline processing framework which controls and logs the execution of “steps”, allowing to write

@Step
func h(a: Int, b: Int, duringExecution execution: Execution) -> Int {
    return a + b
}

instead of

func h(a: Int, b: Int, duringExecution execution: Execution) -> Int? {
    execution.effectuate(checking: StepID(crossModuleFileDesignation: #file, functionSignature: #function)) {
        return a + b
  }
}

Tangentially, this does raise a question about ordering. With multiple decorators in play, there might be additional suspension points added after your macro. The user might find it unexpected that they're not instrumented too (and this kind of bug is particularly easy to hit and non-obvious to diagnose, so a nasty one for users that they'll likely only discover in production).

There is of course a workaround (probably applicable most of the time) which is to re-order the decorators, which is how it's done in every existing language I've come across, but I wonder if there's a better way? e.g. a macro type which tells the compiler "run this mutation function on every AST element matching criteria X, Y, Z" and can thus leave ordering to the compiler.

1 Like

This proposal has been returned for revision. Thank you to everyone who participated in the review!

4 Likes