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

I will be happy to help again if the core team feels like it is necessary to do so, especially if it can avoid an "Accepted with modifications" conclusion :-)

Yes.

And yes.

Doug

4 Likes

I agree!

We'd definitely appreciate that! I consider this a clarification of the existing semantics, so you can update the PR with this if you'd like.

Doug

3 Likes

Do you know if the suggested fix-its will include both options by default or if there is any way to know that you've only implemented one of the two function requirements of the protocol? Or is there any way to mark that you'd like the sync version to also apply for the async. I often rely pretty heavily on the compiler to help me implement protocols, so I would hope that it would fill in both as options, letting you delete one, or warning you in some way that you might be shadowing an async function requirement.

1 Like

Done in this commit. cc @xwu

(EDIT: fixed commit link after a force-push)

1 Like

Great. And would you mind including the example where a closure can be used to select the synchronous overload in an asynchronous context (and, I think, binding the function to an explicitly typed variable—unless I misunderstand)?

This example is already there, I guess you missed it. If you have other requests, let's use the same process: someone suggests an improvement, then one author of the proposal agrees, and I help if I can. Thank you.

1 Like

Sorry, I’m still not seeing what you’re referring to; the commit you’ve linked me to has 23 lines added (at least that’s what GitHub is showing me). Can you point out where the example is? Thanks!

I think @gwendal.roue is referring to this snippet:

func f() async {
   let f2 = {
     // In a synchronous context, the non-async overload is preferred:
     doSomething()
  }
  f2()
}

Does this not satisfy the request of an "example where a closure can be used to select the synchronous overload in an asynchronous context"? Or were you thinking something like this:

func f() async {
  let doSomething: () -> Void = doSomething
  doSomething()
}
1 Like

Both, but indeed I misread the second code example--maybe it was the lack of code highlighting that tripped me up (I didn't see the async because it's highlighted in the first example but not the second, and the comment then says "In a synchronous context..."). Thanks!

1 Like

This question hasn't been answered, but I suppose that that proposal reviews are not discussions. As such, I'll say this another way:

I am -1 on the proposal unless we extend this to the throw effect, if we add both then I am +1.

Rationale: I believe that it is important to allow async overloading, but even more important to keep the language consistent about how it handles effects. Inconsistency makes the language more complex and makes future things more difficult to add.

Is there any disadvantage to allowing overloading on throws?

-Chris

Is it OK to condition the acceptance of this amendment to the relaxing of the overload rules for the throws effect, given this review ends tomorrow and we don't have much time?

Anyway, schedule is not that important, and I would support Chris' idea. The discussed SE-0296 proposal describes how the compiler selects one and only overload given the context of the caller. Some functions are async, some are non-async, and this is how the compiler can select an overload:

func f() {
  // non-async function -> non-async overload selected
  doSomething()
}

func f() async {
  // async function -> async overload selected
  await doSomething()
}

This extends well to the selection of the one and only throwing/non-throwing overload:

func f() {
  // non-throwing function -> non-throwing overload selected
  doSomething()
}

func f() throws {
  // throwing function -> throwing overload selected
  try doSomething()
}

Some details would need to be clarified, though, especially regarding rethrows functions.

The non-throwing overload has to be selected in order to avoid throwing when the an input closure does not throw:

func doSomething() { ... }
func doSomething() throws { ... }

func f(_ closure: () throws -> Void) rethrows {
  doSomething() // non-throwing overload selected
}

It gets funnier when the overloaded function is itself rethrows:

func doSomething(_ closure: () -> Void) { ... }
func doSomething(_ closure: () throws -> Void) rethrows { ... }

func f(_ closure: () throws -> Void) rethrows {
  try doSomething(closure) // throwing overload selected
  doSomething { } // non-throwing overload selected
}

Maybe the same question will happen for reasync when it is introduced.

1 Like

Thanks Gwendal

Right, this is the sort of thing we need a solid model for. We're likely to want to continue building out the "reasync" model as well, so learning from the existing throws world can help make sure the model we're going towards is fully considered,

-Chris

1 Like

I don’t think consistency is enough motivation to allow overloading on throws. Why is it different? Because we already have tools to call a throwing function in a non-throwing context: Result and do/catch. There’s (deliberately) no equivalent for an async API. Meanwhile, we have all these synchronous functions with the “good” names; that’s the only reason we want to add overloading. That’s not a problem for throwing functions. (Well, it was when throwing was introduced, but not now.)

1 Like

I do agree with you that there is less of a "why now" driven by "all the good names are taken" argument, but that doesn't argue against generalizing throws -- it only provides justification for making a change (which I'm in favor of as I mentioned!).

I don't understand the other part of your argument. We do have an equivalent for Result, the oddly named Task type. Similarly, we have the oddly named "run detached" functionality which allows calling an async function from a sync function. Furthermore, the "async functions aren't as baked out as throwing functions" argument seems like it should be better solved by baking async functions, not by making them inconsistent with the more baked part of the language.

-Chris

1 Like

I guess I don’t consider Task and such equivalent because they don’t let you get a result synchronously (i.e. they don’t provide a join()). Which, 9 times out of 10, is a good thing, I’ll re-iterate!

Honestly, I’m ambivalent about this whole proposal. It’s a practical answer to a problem, though perhaps reasync would be a more interesting one for some of these (like the XCTest methods). But one of the reasons why it makes more sense for async than throws is that when you’re in an async function, you’re (probably) already thinking of suspending, and so biasing towards an async overload makes sense. The caller cannot really tell the difference, as long as you’re careful about your suspension points. But I’m not convinced that when you’re in a throwing function, you’d automatically rather call a throwing overload, and vice versa for a non-throwing function and a non-throwing overload. I think that’ll lead to people picking the wrong overload in a way that doesn’t apply for async. That’s assuming, of course, it makes sense to have a non-throwing version of a throwing method at all. In ObjC Cocoa we got them as leftovers from the migration from NSException to NSError, or in returning a Bool to producing an NSError; neither of these apply to modern ObjC Cocoa APIs, let alone Swift.

6 Likes

I'm also waiting for people who want typed throws to chime in. Typed throws would send us from two effects (throws and rethrows) to an infinity of effects, with complex sub-typing relationships, and very potentially a much more complex overloading rule.

I now think that the topic of "allowing overloads that differ only in throws" should be moved on its own dedicated thread. A very interesting one :+1:

I'm no less concerned than others about introducing a new language inconsistency. But async overloads do not need the rule to be extended to all effects to be useful.

I don’t have the time to write a complete response right now, but in swift-system it would be greatly preferable to write throws Errno to throws. This would avoid an existential box and improve callers understanding of the class of errors that could occur from the method. Additionally, typed throws aligns much better with Result which is a much more ergonomic than do/try/catch when manipulating errors.

2 Likes

I don't think that it is as important to allow overloads of throws functions as it is for async functions.

Consider the natural non-throwing and non-asynchronous overloads for the following functions:

func foo() throws -> Int { /* ... */ }
func bar() async -> Int { /* ... */ }

IMHO they would look like this:

func foo() -> Int? { /* ... */ }
func bar() -> Int { /* ... */ }

Notice that the overload of foo() is already allowed under the current overloading rules, because it has a different return type, while we get an error for the overload of bar().

This, in combination with the fact that there are multiple ways to call a throwing function in a non-throwing context, convinces me that we can with good conscience introduce async overloads without introducing the same for throws.

EDIT:
Of course I would also support the decision to introduce overloads that differ only in throws, if that is really necessary. I just do not see a compelling reason for it at the moment.

1 Like

I'm a fan of typed throws and hope we add it, but I don't think we should do what you're suggesting here. Overloading would only be allowed on "throws or not", we shouldn't have a way to overload on "what is thrown". We support overloading on return type and even that is dubious. I don't think there is any justification to support overloading on "what is thrown".

-Chris