[Pitch] @completionHandlerAsync attribute

Hi all,

Looking for feedback on a @completionHandlerAsync attribute.

Introduction

Add a new @completionHandlerAsync attribute to designate a synchronous function as having an asynchronous alternative, ie. one that uses the new async language feature (SE-0296) but otherwise provides “equivalent” functionality to the attributed function.

Motivation

As projects migrate towards the new concurrency language features, it could be helpful to both users and tooling to mark non-async and async function pairs. The presence of this attribute would enable tooling to provide functionality to assist with the migration effort:

  1. Output a compiler warning (and possibly fixit) for any call to an @completionHandlerAsync attributed function in an asynchronous context
  2. Aid in refactorings to replace calls to completion-handler functions with their async alternatives

Proposed solution

A new function attribute @completionHandlerAsync that takes the name of the async alternative function and (optionally) the position of the completion handler parameter, assumed to be the last parameter if not given.

@completionHandlerAsync("processImplicit(data:)")
func processImplicit(data: Data, completion: @escaping (ProcessedData) -> Void) { ... }
func processImplicit(data: Data) async -> ProcessedData { ... }

@completionHandlerAsync("processExplicit(data:)", completionHandlerIndex: 1)
func processExplicit(data: Data, completion: @escaping (ProcessedData) -> Void) { ... }
func processExplicit(data: Data) async -> ProcessedData { ... }

@completionHandlerAsync("processNonTrailing(data:)", completionHandlerIndex: 0)
func processNonTrailing(completion: @escaping (ProcessedData) -> Void, data: Data) { ... }
func processNonTrailing(data: Data) async -> ProcessedData { ... }

Detailed design

An attribute of the form @completionHandlerAsync(<name>[, completionHandlerIndex: <n>]) , permitted where:

  • it’s attached to a non- async , Void -returning function with at least n - 1 parameters (where n is the index of the last parameter if not given),
  • name is a string literal containing the non-ambiguous compound name of the async alternative function that must be located in the same context as the attributed function,
  • the completion handler argument referenced by completionHandlerIndex is an @escaping and non- @autoclosure Void -returning function type.

This attribute should be implicitly added to Objective-C completion-handler methods that are also imported as async . See the Concurrency Interoperability with Objective-C proposal for more details on those (SE-0297).

Attributes on protocol or class methods should be inherited by their implementation and overrides. Attributing the implementation/override is an error if one is already specified by the parent, ie.

protocol MyProto {
  @completionHandlerAsync("process(data:)", completionHandlerIndex: 1)
  func process(data: Data, completion: (ProcessedData) -> Void)
  func process(data: Data) async -> ProcessedData
}

struct MyStruct: MyProto {
  @completionHandlerAsync("process(data:)", completionHandlerIndex: 1) // error
  func process(data: Data, completion: (ProcessedData) -> Void) { ... }
  func process(data: Data) async -> ProcessedData { ... }
}

A partial implementation of @completionHandlerAsync has been implemented under the -enable-experimental-concurrency flag (thanks @etcwilde). Inheriting the attribute on implementations and overrides is yet to be implemented.

Source compatibility and effect ABI

The proposed solution is purely additive and has no affect on the ABI.

Alternatives considered

Defaults

The completion handler will almost always be the last parameter of a function to take advantage of Swift’s trailing closure syntax. Given that, an implicit default seems ideal. It wouldn’t be unusual to have overloaded functions, or to want a separate async function name - hence the explicit function name.

Implicit Pairing

Instead of an attribute, the compiler could implicitly pair functions where one “looks like” the async alternative. This could never be perfect though, so could result in both spurious and missed warnings and refactorings.

5 Likes

This sounds great and should be very useful :+1:

So the "process(data:)" is a string, but really it is going to be typechecked for existence of the function anyway. I wonder if we could skip the "" and just have it be @...(process(data:), ...) if that is possible? I'm not sure if that would cause hell for parsing those or not as much.

We discussed with Ben and perhaps the name may use some iteration still? Originally this was discussed as @asyncAlternative which sounds good as but had the issue that it was confusing to some if "this" function is the async alternative, or the one pointed at is the alternative.

Perhaps we can find some good name for this still though, like @alternative(async: hello(hi:)) or @preferredAlternative(async: hello(hi:))? Not sure if taking up the alternative name is better or worse than taking the very specific use-case to heart and saying preferredAsyncAlternative(hello(hi:)) though :thinking:

It might be easier to say this is the @asyncAlternative after all...?

Thanks :slight_smile:

Yep, type checking will perform a name lookup and fail if it doesn't exist/is ambiguous. It's definitely possible to not require a string, feedback on that would be helpful. There's some precedence for it not being a string, ie. @objc takes a selector.

Other suggestions were adding has and Version rather than Alternative, ie. @hasAsyncAlternative or @hasAsyncVersion.

1 Like

How about
@callback(f(a:b:), at: #)
or
@callback(handler: f(a:b:), at: #)
?

FWIW, string name is used @available(..., renamed:) as well. It's string because it needs to support renaming a function to a getter. For example:

class C {
  var value: Int { 42 }

  @available(*, deprecated, renamed: "getter:value(self:)")
  func getValue() -> Int { self.value }

  func foo() {
    _ = self.getValue()
  }
}

The syntax is bizarre, but anyway, this code emits a warning with a fix-it rewriting self.getValue() to self.value.

So @completionHandlerAsync (or whatever) should also support getter:

  @completionHandlerAsync("getter:value(self:)")
  func getValueAsync(callback: (Int) -> Void ) { ... }

  var value: Int { get async { ... } }
3 Likes

I see, thanks for clarifying!

Can getter be expressed without using a string literal?

  @completionHandlerAsync(value.get)
  func getValueAsync(callback: (Int) -> Void ) { ... }

#selector(...) is a precedent that does not use a string literal to refer to a declaration. Similarly, @derivative(of: ...) also does not use a string literal, although the feature is not official.

IMHO, a string literal just makes it look less type-checked.

5 Likes

(To clarify, I'm not suggesting we should use string literals)

It's probably possible to express a getter without a string literal. Also, if we type check it, we should be able to infer that it's a getter because we know value is var or func.

(One interesting fact about @available(renamed:) is that it doesn't type check the new name. i.e. @available(*, deprecated, renamed: "foobar()") is not an error and emits fix-it even without actual foobar declaration.)

I guess another reason of using a string literal was to have a common logic to handle objc __attribute__((swift_name("..."))) attribute and/or apinotes. But I'm not sure. Perhaps @jrose knows the history?

#selector uses getter:/setter: prefix. But @completionHandlerAsync(getter:valueName) is not ideal as it's confusable with a attribute argument label...

@derivative(of: ) uses postfix .get/.set?

Related discussion for syntax of referencing getter/setters:
https://github.com/apple/swift-evolution/blob/main/proposals/0021-generalized-naming.md
https://forums.swift.org/t/proposal-expose-getter-setters-in-the-same-way-as-regular-methods/501
https://forums.swift.org/t/review-se-0044-import-as-member/1805/5

1 Like

Another syntax that I just remembered is “@dynamicReplacement(for: hello(_:))”.

Fairly rarely used but another point for trying to make it work without string literals.

I’ll check in an hour or how getters are expressed there (if at all possible!).

1 Like

Yes, it does today. It is handled here as parseQualifiedDeclName.

1 Like

The handling in @derivative seems the most reasonable to me, though it also allows a base type which @completionHandlerAsync shouldn't (?). I could be missing something, but it seems a little weird to allow it in the @derivative can anyway, it looks like it enforces the two functions to have the same parents as well.

@dynamicReplacement doesn't handle accessors from what I can see.

Yeah, doesn't seem to do so -- it can replace an entire property, but can't specify just a getter.

The method signatures of asynchronous vs synchronous functions will often use a predictable pattern (same name, inputs, and outputs). Considering that, I’d like to see the name parameter to @completionHandlerAsync be optional (when possible).

Example:

@completionHandlerAsync
func processImplicit(data: Data, completion: @escaping (ProcessedData) -> Void) { ... }
func processImplicit(data: Data) async -> ProcessedData { ... }

I’m not a big fan of this being implicit for what it’s worth. It invites being lazy about it, doesn’t it?

We found that often functions get renamed because “getThing” is nicer as “await x.thing” etc. Someone has to manually audit those alternatives anyway in normal code, and in imported code it is done automatically by the importer so it being a bit more verbose does not really matter.

The spelling you propose is also a bit backwards… we’d like to say this method has an alternative, not that it is one — but this spelling makes is sound like it IS the “completion handler async”. In some internal discussions this did come up too and was proposed to be solved by “hasAsyncAlternative” which IMHO sounded pretty inelegant.

Just a personal thought though.

The hope is that including the name adds a moment of "should this really be the name?". As an example, let's say that the callback-based function was func whenReady(data: Data, completion: @escaping (ProcessedData) -> Void) { ... } - perhaps that would now be better as var data: ProcessedData { get async { ... } }.

I believe this was in reply to Raybo, but note that they were just using the originally proposed name :laughing:. Note that the spelling is indeed correct there, ie. the attributed function is completion handler async and has the alternative spelled in the parameter. Part of what makes this name hard is that the name is of the alternative and the index is of the attributed function. Perhaps we need a name where it then makes sense to name both parameters, ie. @something(alternative: <func>, completionHandlerIndex: <index>).

2 Likes

Right sorry, I guess I was voicing the general opinion against implicitly inferring the alternative. It also ends up very confusing (to me at least, as one datapoint), what is the preferred alternative and what is the old one if just the existing name were adopted, and we made the alternative implicit even.

To me, it's a valuable moment when one has to write that alternative pointed at -- it may be a good chance to realize the alternative API could be a bit nicer, say, using an async property getter or similar. I've seen a bunch of APIs would could benefit from such treatment.

The "having an alternative API that is more favorable" problem also shows up in other places. Returning to an old hobbyhorse: Migrating higher order function names to comply with API guidelines - #27 by masters3d

It seems to me that one of the main use case is migration where we want folks to move to asynchronous versions of an older synchronous API. I see this as the same problem as "deprecate but never delete" older API.

Can this feature be more engineered to be more general than just async/sync API?

Thanks,
Chéyo

3 Likes

What happens to this very specialized new language feature when everyone is done migrating? Why cannot this be an extension of the existing @available mini-language, which is already used for this kind of redirection, via deprecated?

2 Likes

I don’t remember for sure, but my best guess is this is an accident of incremental development: first the “renamed” in Clang’s availability attribute got copied over to Swift verbatim, and later support got added to actually use the Swift name of a Clang-renamed declaration. By then, though, it was already too late to make it not accept arbitrary strings. We could type-check it regardless, though, deprecating the use of renamed in Swift to reference anything but a Swift declaration in scope. We could even accept both quoted and non-quoted syntax for a while if we wanted.

3 Likes

Why not reuse the existing @available mechanism?
E.g. @available(*, async: "processImplicit(data:)")

6 Likes