[Amendment] SE-0296: Allow overloads that differ only in async

Hello Swift Community,

We are starting a review for an amendment to the accepted proposal SE-0296 "async/await". The review runs from now through June 28, 2021.

The initial pitch of async/await included the ability to overload async and non-async functions, e.g.,

func doSomething() -> String { /* ... */ }       // synchronous, blocking
func doSomething() async -> String { /* ... */ } // asynchronous

// error: redeclaration of function `doSomething()`.

Based on the discussion of the first pitch, this ability was removed from the proposal. However, experience augmenting existing Swift libraries with async/await functionality has demonstrated that this overloading might be useful, so we would like to reconsider the limitation.

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 the review manager. If you do email me directly, please put "SE-00296" somewhere in the subject line.

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:

swift-evolution/process.md at master ¡ apple/swift-evolution ¡ GitHub

Thank you for participating in improving Swift!

Doug Gregor
Review Manager

24 Likes

This makes a lot of sense to me, resolving the overload depending on whether the context is sync vs async is also extremely sensible.

Should the same affordance be extended to throw'ing functions?

10 Likes

Instead, we propose an overload-resolution rule to select the appropriate function based on the context of the call. Given a call, overload resolution prefers non-async functions within a synchronous context (because such contexts cannot contain a call to an async function). Furthermore, overload resolution prefers async functions within an asynchronous context (because such contexts should avoid stepping out of the asynchronous model into blocking APIs). When overload resolution selects an async function, that call is still subject to the rule that it must occur within an await expression.

To clarify, does this mean we would expect:

func f() async {
  await doSomething() // OK, resolves to async overload
  doSomething() // Still resolves to async overload, **error** because we are missing 'await'
}

What about in a closure? Would this infer a sync closure type and then allow the call?

func f() async {
  let f2 = { doSomething() }
  f2()
}

This is an interesting case for code-completion. In a context that is definitively sync or async, we could strikethrough or hide the invalid overload, since only one of them is valid to call. If a closure that has not otherwise been inferred as async allows both the async and non-async overloads, we will end up showing both, and they will both insert the same code doSomething(), which will be synchronous unless you insert the missing await yourself. This would be a lot better if we automatically inserted the await during completion.

CC @rintaro

2 Likes

That's correct. For reference, none of the overload-resolution rules are new, and you could get this same overload set either by importing Objective-C APIs or by putting the two doSomething methods into different modules:

// Module A
public func doSomething() -> String

// Module B
public func doSomething() async -> String

/// Module C
import A
import B

func f() async {
  await doSomething() // OK, resolves to async overload
  doSomething() // Still resolves to async overload, **error** because we are missing 'await'
}

This amendment makes it possible to define both doSomething() functions within the same module.

That's correct. There is no await inside the closure, so it will be a synchronous closure and resolve to the synchronous doSomething().

Doug

5 Likes

To do so means we would need to extend similar overload-resolution affordance to throw'ing functions. However, the "if we're in a throwing context, prefer the throwing variants" approach might not work as well because we have try? and try! to suppress errors at an expression level, whereas there is no equivalent for async/await.

In we do extend this affordance for throwing, it would likely also need to be part of a major language version bump because the change in overload-resolution rules would probably change the meaning of or break existing code.

Doug

4 Likes

+1 to this proposed amendment, for some concrete reasons motivated by how XCTest could potentially use this with overridable (open) APIs:

  1. This would allow XCTest to offer an async variant of its frequently-used setUp API with the “ideal” spelling below, whose base name matches the original setUp method and only adds async throws:

    open func setUp() async throws
    

    This would complement the traditional func setUp() and the newer, throwing variant, func setUpWithError() throws. When the latter was added, it had to be suffixed with “WithError” for similar reasons as those motivating this amendment, but if this is approved, a new async variant could be added with the simplest possible signature, having the same base name as the original method instead of needing a distinct name such as setUpAsync. Since this API is overridable, having a shorter name is nice since the full signature appears in subclasses.

  2. This would help prevent accidentally calling the wrong superclass method when updating existing overrides. Some XCTestCase subclasses use multiple levels of inheritance and call super in their overridden setUp method body. If the async setUp variant had a different base name like setUpAsync, the following bug would be possible:

    override func setUpAsync() async throws {
        super.setUp()  // Wrong: This compiles, but calls `setUp` instead of `setUpAsync`
    }
    

    But if the async method has the same name, you get a nice compiler error alerting you to the bug the moment you add async throws to the override’s signature:

    override func setUp() async throws {
        super.setUp()  // error: expression is 'async' but is not marked with 'await'
    }
    

    This kind of mistake (changing the method signature but forgetting to update the call to super) is easy to overlook while updating existing code, so this build-time error seems like a significant benefit.

(Note: My examples here apply equally to tearDown, not shown.)

8 Likes

If this is in a context without other trys undecorated with ? or !, wouldn't that make that context a "nonthrowing context?"

+1. This is great and I’m so glad we’re generalizing this rule outside of the obj-c imports! :heart:

—

One interaction I thought of was actors and our “implicitly async”, but it all ends up fine and understandable. :+1:

For context:

actor A { 
  func hi() {}
  func hi() async {} 
}

This means that effectively it is not possible to call the “synchronous” hi from outside the actor — because we always MUST call actor functions from outside as await hi() and thus we always call the async one. This is fine and good I think actually, so no concerns about this extension from my side :+1:

7 Likes

+1. I really like this change. The .NET developer in me looks forward to not having to suffix async function variants with Async, which will feel more at home when working with APIs that I only ever use asynchronously.

4 Likes

If I wanted to assign one of these two functions to a closure variable, how would I express which one of the two I want?

e.g.

let myClosure = doSomething  // How do I pick async vs non-async?
1 Like

I would assume you have to provide an explicit type.

let myClosure: () -> Void = doSomething
let myClosureAsync: () async -> Void = doSomething

But I'm not sure.

2 Likes

I wonder if SE-0315: Placeholder types could help, allowing you to write something like let myClosure: _ async = doSomething, though that doesn't seem to help specifying the "non async" case.

4 Likes

+1, this sounds very useful.

+1 this seems quite useful - I wish we would have come up with this sooner… it might have allowed for different decisions to have been made

Yes, and it feels consistent with other effects.

Yes if you count swift’s throw effect

An in depth read. It does make me have the question “should we allow computed properties to be overloaded?”

Isn't it actually inconsistent with throws, the only other effect we've got? Or do you mean the obj-c import?

  • What is your evaluation of the proposal?

+1 (I wrote the diff)

  • Is the problem being addressed significant enough to warrant a change to Swift?

Yes. Such overloads already exist in the wild. For example, NSManagedObjectContext from Core Data provides both moc.perform { ... } and await moc.perform { ... }. The async version uses an extra argument with a default value, and this is how it avoids the redeclaration error. At the call site, though, everything works as if we had a pair that differ only in async.

  • Does this proposal fit well with the feel and direction of Swift?

It is consistent with ObjC imports that already profit from it, and it helps avoiding the .NET naming convention.

2 Likes

+1 to the proposal, of course. The lack of imagination about what people might want to overload has led to some boring threads about workarounds on this site that haven't amounted to problem-solving change. If you've got the resources to do so, please enable this. :smiley_cat::+1:

These are magnificent questions, and are on the way to the next two related proposals we need.

  1. Because many methods are going to be abandoned in favor of properties, now that get accessors have one new feature and one formerly-missing old one, we'll need the same overloading capabilities for those. (Keypath syntax does not handle this completely; get accessors basically can't be referred to directly. Functions will be easier to deal with but the users' shift will be to properties.)
  2. Generic computed variables have always been missing, but I expect more people will notice, more, with get accessors being more useful.

I think all of that could go into one grammar discussion thread.

Even with the placeholder types, it still would have to be written like this:

let myClosure: (_) -> _ = doSomething
let myClosureAsync: (_) async -> _ = doSomething
4 Likes

Will protocol requirements be allowed to differ only in asyncness?

E.g.

protocol P {
    func f()
    func f() async
}

Currently async requirements can be satisfied by synchronous functions. If the above is allowed, then can a synchronous function satisfy both overloads?

E.g.

struct S: P {
    func f() {} // enough to satisfy both 'f()' and 'f() async'?
}
4 Likes

This is very helpful information that, I hope, will be incorporated into the amended proposal text. It bears calling out explicitly that, in an async context, writing a bare function call to the sync function will not work, but that there are ways to invoke it.

For greater clarity, is @Jon_Shier correct that writing let f2: () -> Void = doSomething; f2() will also work in the same way in an async context?