SE-0415: Function Body Macros

Hello, Swift community!

The review of SE-0415: Function Body Macros begins now and runs through December 20th, 2023.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to me as the review manager by DM. When contacting the review manager directly, please put "SE-0415" in the subject line.

Trying it out

If you'd like to try this proposal out, you can download a toolchain supporting it from Swift.org; the most recent Trunk Development (main) snapshots support the feature. You will need to add -enable-experimental-feature BodyMacros to your build flags.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at:

https://github.com/apple/swift-evolution/blob/main/process.md

Thank you,

Tony Allevato
Review Manager

16 Likes

Preamble macros will not be able to catch errors thrown from the function body which makes them actually not a really good fit for tracing/metrics/logging. We would need a new kind of defer block that gets access to the thrown error, if any error was thrown for example something like this:

func foo() async throws {
    defer { (error: Error?) -> throws(any Error) Void in
         if let error {
             reportError(error)
             throw error
         }
    }
    ...
}
10 Likes

It's good to see closures addressed in future directions, but I think this feature seems rather incomplete without closure support. It could even be surprising and confusing for people who are learning about it. Is there a plan for how soon this can be addressed?

One immediate concern that came to mind was that macros introducing local variables (especially if shadowed) is likely to leave readers lost. Is it possible to allow access to them through a $ accessor perhaps? i.e. $span

Right. Preamble macros also cannot inspect the return value, which defer has no access to. For those, you'll need a body macro.

No, there is no specific plan.

This issue exists with all macros that can introduce new declarations, which includes most of the attached macros and also freestanding declaration macros. We count on the explicit sigil (@) in the source code to indicate where expansion occurs, and on tools showing the results of macro expansion when needed.

Doug

3 Likes

This will be a great addition - quick question;

Could computed variables be declared via the "no-body" syntax functions can be declared with?

For example:

extension MyType {
    @Foo var name: String

    // expands to
    var name: String {
        "Foo"
    } 
}

(note this is in an extension so a stored property wouldn't be allowed here but leaving out the curly braces after the definition is more readable than not)

1 Like

…but body macros cannot be composed together, so you can't have e.g. @Logged and @Traced on the same function, or either in combination with @Retrying etc.

That's a pretty notable limitation.

Could preamble macros be replaced with wrapper macros, which essentially expand like:

@Logged
func g(a: Int, b: Int) throws -> Int {
   try add(a, b)
}

…to:

func g(a: Int, b: Int) throws -> Int {
    log("Entering g(a: \(a), b: \(b))")

    do {
        let result = try {
            try add(a, b)
        }()

        log("Exited g(a: \(a), b: \(b)) with result: \(result)")
        return result
    } catch {
        log("Exited g(a: \(a), b: \(b)) with error: \(error)")
        throw error
    }
}

These compose more neatly since they naturally nest.

You can also see why it's highly desirable to have a macro for things like this, because there's quite a lot of boilerplate for this simple pattern.

3 Likes

That's totally my fault for missing this heh. I thought we'll get away with tracing and preambles but there's a hard requirement to end a trace with an error if the function throws -- so we would not be able to make a preamble sadly -- and would need to stick with function body replacement (with the known downsides of bad composition).

If there was some "defer { (error: Error?) in }" that'd work again, but that's ofc not being proposed as part of this proposal.

No, replacing a stored property with a computed property requires an accessor macro.

There's an "alternatives considered" section for this. I thought about it for a while and couldn't come up with a design that was a meaningful improvement over a body macro that calls a function with the original function body stuffed into a closure.

To have a macro produce boilerplate like what you show above, the macro implementation would be given some (opaque) variable names for the normal result and the error result, and then separately return code blocks to be executed on successful return (which gets the result value) and on thrown error (which gets the error value).

Doug

Are you implying there is some way to compose multiple body macros together on one function? The proposal says there isn't.

No. But I think composition with this kind of wrapping of the user's code is a little odd. You're not just putting new code before and after the user's code, you're stashing it into a closure (which can have semantic impacts).

There are several directions we could go here:

  • Extend defer to have some spelling for accessing the return value or thrown error value. That could be generally useful, and would compose with this proposal. It has the advantage of not having to change the structure of the user's code.

  • Add another function body macro role that does "wrapping" of the body. We know there are use cases, and it addresses the issue, but I don't think we've seen a good design and there might not be one.

  • Define composition of body macros in this proposal such that later body macros see the function bodies produced by earlier body macros. This deviates slightly from the way other macros work, in that macros generally don't see the outputs of other macros, but is not terribly complicated.

    Doug

4 Likes

If this route were taken would the limitation of existing macros be re-evaluated/lifted?

I was recently working on a member attribute macro and wanted to annotate members with a "marker" of sorts so that a peer/accessor macro could witness this annotation and change its expansion accordingly. The macro test I wrote actually did behave this way, where the peer/accessor could "see" the attribute added by the member attribute macro, but despite some passing assertions, the actual macro did not behave this way.

3 Likes

I'm not enthusiastic about doing that. Macros are intended not to step on each other---you can expand one macro and see what it does, without having to carefully follow the order of expansion to get to the result. Body macros today don't compose today because they're following that same philosophy.

Since the member-attribute macro would be present on an enclosing declaration, it should be possible to address this by looking at the parent context when the API becomes available. It means duplicating some logic, but it also means you can reason about the inner macro expansion without having performed the outer macro expansion.

Doug

With a future-looking perspective in mind, I'd personally lean towards the "more powerful defer" option, even if it'd mean it'd have to come sometime later.

It has a number of benefits:

  • it is a nice improvement in the language in general
    • we've also had thoughts about using defer to "guarantee cleanup, even in cancelled tasks" which also may imply defer changes
    • for the same reasons allowing async code in defer might be good -- so there could be defer async
    • overall it seems like defer getting more powerful would be a generally welcome thing, not only for the macro reasons
  • it fits and empowers preamble macros
    • more features that fit eachother nicely rather than more specialized macro kinds sounds like a good route forward for the long term

We may have to live with a body replacement macro for tracing for the short term, but the new defer capabilities could then arrive sometime and we'd switch over to a more composable one...

(we'd still need the task local's unsafe APIs but we can do those rather quickly).

So... trying not to derail the current review: I think this would be okey to keep as is, and try to follow up with more powerful defer -- might be good to get a take from the core team if that's somethign we could consider.

9 Likes

No real comments on the proposal as is, but another possible future direction: using the “introduced names” clause to include locals derived from parameters. I guess people can get around this by using labeled tuples, but it would nice™ not to have to.

4 Likes

I think the proposal gets it exactly right! It has a convenient API in preamble macros that are easy to implement and can cover many use cases. At the same time, body macros allow implementers to create very powerful APIs if they are willing to put more work into the implementation.

I provided merely a symbolic example. It could be implemented in other ways if necessary.

This doesn't work for many of the use-cases I have in mind, though. e.g. a @Retrying macro has to look at the result of the wrapped code and then make choices about control flow (i.e. whether to actually exit the function or to retry the body).

Tangentially, I'm also not immediately convinced that defer will ever gain such functionality - the ability to 'catch' and inspect returned & thrown values - because outside of macros with their special constraints, I think there's already existing ways to achieve the same goals with defer as-is. e.g. write the code to save the result in a Result and simply refer to that in the defer block. So I foresee debate about the merits of emphasising defer further.

But is that not a superset of a preamble macro? Thus why I raise this now, to avoid potentially vestigial macro types.

This nesting of wrappers is very well explored in other languages (e.g. Python's function decorators) and to my knowledge hasn't caused any problems.

fyi, I'm having two issues while trying the implementation:

  1. Client code is unable to see macros declared in a library. I have to declare them in the client code. (I'm able to see other macros declared in the same module.) Perhaps a side-effect of @_spi(..)?
  2. Body macros end up duplicating, with a dangling closure. (Tests work fine because the correct statements are returned.)

I'm using a recent commit from swift-syntax for lack of a tag.

To reproduce, see https://github.com/wti/SE0415Demo

Did I miss something?

(otw, other observers might want to start from this package as a semi-working example, until better starters are published.)

Could accessing the return value maybe addressed like this pattern


@MyMacro
func method(a: String, b: Int) -> ReturnValue {
    return // body
}

// Expands To

func method(a: String, b: Int) throws -> ReturnValue {
    // generate a local function with the body of the original method
    // any local variables introduced after this dont affect the typechecking of this 
    func _method(a: String, b: Int) -> ReturnValue {
        return // body
    }
    // do something before
    do { 
        let returnValue = try _method(a: a, b: b) 

        // do something with the return value
        return returnValue 
    } catch {
        // do something with error
        throw error
    }
}

Dont know if this introduces some significant difference in behaviour.

I think (not fully thought through) this would also allow composing macros, by recursively applying the macros on the nested method thats generated.

2 Likes

I'm a big fan of this concept, but I want to reiterate my feedback from the pitch:

I would be strongly in favour of this proposal if preamble macros were replaced with wrapper macros, given that the latter are a strict superset of the former (unless I'm missing something?) To reiterate, AFAICT if a macro author wishes to write a preamble-style macro they can create a wrapper macro that returns something like

{ preamble; return h-impl }()

I think there's a lot of patterns that fit the wrapper form but not the preamble form. For example, there's currently an open PR on the Swift repo to implement support for the preamble form in task locals:

Seems to me that Wrapper macros would preclude the need for new APIs like this, offer a safer interface, and work in far more situations. Reiterating my feedback from the pitch again, this also makes it a far simpler decision when choosing which style of function body macro you want:

  • wrapper: can compose, can't rewrite the body. improved local reasoning.
  • body: can't compose, but can fully rewrite the body. reduced local reasoning.
2 Likes