[Pitch] @Reasync and @ReasyncMembers macros

What I was trying to show was that async could be generic. The syntax that I showed with the Bool generic parameter is the same syntax that is already used for integer parameters on InlineArray. The only changes that would need to be made to let this work are enabling Bool for value generics and allowing the async(isAsync) syntax to conditionally enable async for different specializations of the function.

Right I understand, and I think the direction is very interesting!

But I do not think it is equivalent to what InlineArray is doing, because that mechanism requires that the generic parameter is statically known. I do not believe that the type system supports doing this. For example, the following does not compile, and I think getting it to work would qualify as adding the "value-dependent type" concept.

let count = 5
let array: InlineArray<count, Int>

But again, I could be wrong!

Edit: Wait, I think I am wrong. My apologies!

Very sorry, again, for getting confused and distracting the conversation here. Generic arguments != function arguments apparently. I'll figure this Swift thing out one of these years.

If I may, I do still have a question about this. I assume the vision here is that the boolean generic argument will be inferred by the compiler at callsites?

And at that point, can you help me understand how this is meaningfully-different from a reasync effect specifier? Under what conditions would a generic-based conditional async look different? How would it allow for more expressiveness?

I fully believe that I'm just being slow and not seeing it. But I find the suggestion so interesting that I really want to better understand the concept.

2 Likes

rethrows <=> reasync, implicitly for the whole function, based on its actual closures.
Bool generics + async(Bool) or similar <=> typed throws, explicitly based on type inference from particular parameters.

But, the reason that rethrows + typed throws work is that all the variants can be compiled to one single implementation. And my understanding of the reason that reasync has been pushed back on is that an async function and a sync function fundamentally can't be compiled to one single implementation — the async version is split up into independent functions at suspension points, and the sync version is not. reasync or async(Bool) would effectively have to emit two copies of the function, one for sync and one for async.

That's why the macro version is attractive: if you have to emit two copies anyway, why not keep it out of the language and just do it syntactically, with existing tools?

I'd still rather see this as a package (possibly under swiftlang) than embedded in the stdlib, though. I think the expectation of a stdlib macro would be that it works rather better than a syntax-based solution could ever hope to.

4 Likes

What I'm really looking for is a specific example with this generic-based syntax that can do something that reasync could not. I think I (now, finally) understand the concept. But I do not understand how it would be used practically in a way that provides more functionality.

contrived example, but

func do(
    thisFirst: () async -> Void,
    thenStartThis: () async -> Void,
) async -> Task<Void, Never> {
    await thisFirst()
    return Task(operation: thenStartThis)
}

If we just have reasync, then this is synchronous only if both closure parameters are synchronous, when it should be synchronous whenever the first closure parameter is synchronous; if we have a generics based solution then we can describe the desired behavior. Stealing the hypothetical syntax from upthread:

func do<let isAsync: Bool>(
    thisFirst: () async(isAsync) -> Void,
    thenStartThis: () async -> Void,
) async(isAsync) -> Task<Void, Never> {
    await(isAsync) thisFirst()
    return Task(operation: thenStartThis)
}
3 Likes

AFAIK typed throws are true generic function that can be specialized at compile time. When the function’s body is available to the compiler (it is in the same module or is inlinable), the compiler literally makes a copy of the function with the generics replaced with concrete types. That’s specialization. When the compiler can’t specialize, it just treats all parameters as existential boxes (basically any). So, I see no reason why we couldn’t do the same for async. It could just strip out the suspension points as they aren’t needed for no-async code. And, the unspecialized variant could just leave the suspension points, but directly call the next chunk afterwards.

I’m not actually sure how rethrows does this internally, but it must be similar to typed throws because it can be migrated to typed throws without breaking ABI.

A suspension point isn't an instruction in a function body, the function is literally broken up into multiple functions at suspension points. So the compiled version of an async function looks nothing like the compiled version of a syntactically-similar synchronous function. Godbolt: Compiler Explorer

1 Like

Yes, but I think that splitting happens after generic specialization during compilation. The function is represented normally throughout generic specialization, all you have to do is not split the function up unless the condition in async(isAsync) is true.

For the unspecialized version, the function could just run like any async function, and at each suspension point it can directly call into the next chunk. It doesn’t need to actually suspend.

1 Like

The symbol graph and nested type issues affecting all peer macros will be fixed pending approval of two new PRs.

Symbol graph

In the meantime, the workaround when generating documentation with DocC is to create a separate DocC article for the synchronous overload. DocC recognizes the peer symbol and will attach the article to it when building the site.

Nested types

In the meantime, the workaround is to declare nested types outside the macro-attributed function.

Just to add to this - we've done similar things in Vapor and having a sync and async version of the same function has caused a ton of problems, API breaks and confusion in the compiler, especially when it comes to passing closures around. It would be good to solve these issues in the compiler first before adopting something like this

Could you share some specific examples of problems you ran into? These issues would already exist in any handwritten sync/async pair, so they're worth understanding regardless of how the declarations are produced.

3 Likes

IIRC initialisers don't work correctly and it always picks the sync version. It also introduced some breaking changes as we thought the compiler would pick the correct function when it had the option but this wasn't always the case so downstream users would get errors.

Closures have issues because it will fail to pick the async one, so you have to be explicit about it. E.g.

app.test(.GET, "route") { req in
    let result = try await someAsyncCall() // ERROR
}

app.test(.GET, "route") { req async throws in
    let result = try await someAsyncCall() // ERROR
}

Though I believe closures are out of scope for this?