`override` functions behaves differently in `async` and `throws`

Hi, I have a such sample code

class A {
    func funcAsync() async {
        print("a: async")
    }
    func funcThrows() throws {
        print("a: throws")
    }
}

class B: A {
    override func funcAsync() async {
        print("b: async")
    }
    override func funcThrows() throws {
        print("b: throws")
    }
}

Task {
    let b = B()
    await b.funcAsync()
    try b.funcThrows()
}

which is work fine and produce exected result - “b: async“, “b: throws“.

but if I remove async and throws from class B. for `override func funcAsync()` it’s an error,

but it’s not for override func funcThrows() - I think we can ignore try as I think it’s expected if the function is not throws

another case if I remove override

Can you please explain why such inconsistency, if they are just attributes.

1 Like

This is a very good question!

No idea "why". Note that if you mark the throwing functions with @objc the behaviour is different and (in a way) slightly more aligned with async functions (you won't be able overriding a throwing @objc function with a non throwing one).

You're allowed to overload on async but not on throws:

func a() {}
func a() throws {} // error, func a() already exists

func b() {}
func b() async {} // accepted, creates an overload
1 Like

yes, I got it based on some tries. But just want to understand why the behavior is different. if they are both attributes. or maybe links to documentation about it(if such exist)

Wow. So. I decided I wanted to poke around with Swift, stumbled into the Using Swift section, and fell right into this because the title looked interesting. Glad I did, this was pretty neat! This turned into an itch I had to scratch, so I figured I’d take a swing with what I managed to figure out.

The docs kind of bury the reason here, but it seems to come down to the function’s type. async and throws are part of the function’s type signature, right? So then there are rules around what function type’s can override or overload other functions based on these type signatures.

If we look at the declaration docs for throws, it calls out the rules for overloading and overriding throws functions. Throwing methods can not override non-throwing methods, but the inverse is fine which we see in your first test. Non throwing methods are a sub-type of throwing, which is fine and so it works!

We also see in your second test another point mentioned in the docs, that marking a function as throws is not sufficient to overload, but marking a param to the function throws is. So only removing throws does not sufficiently change the function type to trigger an overload. Cool, this all seems to make sense to me.

Now if we look at the declaration docs for async, it contains a similar section. In this case you can overload a function based only on async. Well, that explains your second test, it’s overloaded and allowed. The compiler should choose the proper overload based on call-site context, so we’re all good here. (Note: There are some bugs around this behavior where the compiler does not “do the right thing”, but that seems to be a different issue.).

So, if we look at the override section, it says async can’t override sync, but sync can override async, right? Well, uh.. apparently not? That’s the whole issue with your first test isn’t it? So what gives?

Here is where it starts going sideways according to my understanding.. The compiler tests added with the implementation of the async/await proposal appear to have always indicated that overriding an async method with a sync method should fail for class inheritance, which seems to go counter to the docs. Further still, the same restriction did originally apply to protocols, but were changed and do allow you to override an async method with a sync method.

So I guess I’m sitting right there with you, feeling no smarter in the end, and wondering if this is a bug or a docs error.. If anyone has insight on what I missed or can help explain the specific behavior with classes, that’d be great. All I could find was in the original proposal where it again mentions async being part of the function type, but that still doesn’t explain the quirk?

3 Likes

Thank you for digging this up!

According to the documentation all of these should be valid, right?

class Base1 {
    func foo() async {}
    func foo() {} // overload
}
class Base2 {
    func foo() async {}
}
class Derived2: Base2 {
    func foo() {} // overload in a derived class
}
class Base3 {
    func foo() async {}
}
class Derived3: Base3 {
    override func foo() {} // override
}

To be completely honest, in many such cases (I don’t know about this one in particular, though), there’s no actual deep philosophical reason “why”. Not every edge case in the language is thoroughly and rigorously debated and designed during development.

The carveout to allow a throws function to be overridden by a non-throws one was certainly something that had to be explicitly implemented when throws was added to the language. But perhaps when implementing async, someone forgot to add a similar carveout, or perhaps they didn’t know it even existed for throws, or perhaps they knew and wanted to do it for async too, but SILGen lacked support for the necessary function conversion thunk at the time (async functions have a different calling convention from sync, so to replace an async method with a sync method in the vtable, you need to wrap the latter in a thunk. With throws vs non-throws it’s simpler because you can cast a function pointer of the latter type to the former without wrapping it in a thunk). Sometimes the docs explain the intended behavior and not what’s implemented, and other times the docs and evolution proposals have mistakes in them too.

It’s worth filing a bug for the inconsistent behavior either way.

3 Likes

Yeah, this does seem like oversight here, since the async proposal explicit calls out that async protocol requirements can be fulfilled by synchronous implementations, citing the same rationale that would imply overriding class methods should work, but then does not discuss the intended rules for the latter case either way.

1 Like

Evolution proposals really should spell out both behaviors explicitly though, because sometimes they’re different. For example, given this:

class A {}

class B: A {}

We allow this:

class C {
  func f() -> A { fatalError() }
}

class D: C {
  override func f() -> B { fatalError() }
}

But not this:

protocol P {
  func f() -> A
}

struct S: P {
  func f() -> B { fatalError() }
}

On the other hand, this works:

protocol P {
  func f() -> A
}

struct S: P {
  func f<T>() -> T { fatalError() }
}

But this doesn't:

class C {
  func f() -> A { fatalError() }
}

class D: C {
  override func f<T>() -> T { fatalError() }
}
6 Likes

I think you’re spot on here, and this is likely more of a bug (or rather a blind spot?) than an actual intentional restriction based on the async proposal and how I interpret it. That the docs appear to also support this interpretation at least helps understand that something isn’t quite right here.

It seems to me that either:

  • The docs are incorrect and the syncasync sub-type is somehow unsound, meaning a sync function is not always suitable in place of an asyncfunction.
  • The compiler is incorrect, and currently incorrectly restricts the override in a context where it should not.

@tera The legend appears! Tera had actually noted this exact behavior in a separate thread discussing why a sync method is an acceptable witness to an async protocol requirement. Maybe there’s a subtle difference between a sub-class override and protocol implementation I’m not thinking of? Even still, I’d think that would be key to document, and I haven’t yet found anything of value there.

To your point Tera, My understanding is that those should work. The overload should be handled according to the context in which it’s used, while the override case should also be valid due to sync being a sub-type of async, as is the case with protocol witnessing.

Ultimately, I agree with Slava_Pestov, I think this is bug worthy. To get confirmation on the intended behavior first. Once that can be clarified absolutely, either the docs can be updated to be more clear/correct, or if this is a bug it can be corrected.

1 Like

It’s interesting that you shared this, because in my searching I actually stumbled across a discussion that somewhat touched this topic, specifically the generic function allowed as a witness to a non generic function, and allowing a witness to return a covariant type.

I admittedly didn’t delve too far into it, but may when I have a bit of time. Swift is a fun and powerful language, but there are definitely some edges (as with any language) which are proving quite fun to learn about.

At the risk of stating the obvious having unit tests out of spec requirements helps to not forget about those things. Then either the code is fixed, or (if there's a compelling reason) the spec is fixed, at least the code and the spec agree.

Note that this documented behaviour of async makes async and throw different in regards to "overridability" rule. I don't think we have some overarching rule about "having consistency at all costs" in the language (if there were we'd have to fix either throw or async spec / implementation for them to make the two match).

Personally I'd prefer a more restricted (as in throw) version.

Example:

func foo() -> Int
func bar() async -> Int
func bar() -> Int // overload

let x = someSyncCall(foo(), bar())

Consider foo() signature is changed from sync and async, so now I have to put await:

// either:
let x = await someSyncCall(foo(), bar())
// or:
let x = someSyncCall(await foo(), bar())

Both variants allowed and depending on where I put await (which might be governed by coding conventions, or, say, I decide to put await upfront in case there are more then one to keep the line short) – which 'bar()` is getting called will be effected as well, which could go unnoticed / unexpected.

1 Like

That would first require having a spec ;) However, new feature additions to the compiler certainly do come with tests to exercise valid and invalid examples, and insufficient tests often get flagged in code review and revised. The problem is that the surface area of the language has grown so large that there isn’t any one person who has a complete picture of the combinatorial explosion of all possible interactions between all features at all times (I certainly don’t), so these things get missed on occasion. I think having a comprehensive language reference manual where all examples were actually checked unit tests, with a requirement that every accepted evolution proposal must provide an update to the manual, would help alleviate this issue.

3 Likes