Overloading on the isolation of a closure parameter

Here's the code that I perhaps naively attempted to write:

public func expectError(
    from closure: ()async throws->()
) async throws {
    
    do {
        try await closure()
        throw "ExpectedErrorButGotNone".asErrorMessage()
    } catch { }
}

@MainActor
public func expectError(
    from closure: @MainActor ()throws->()
) throws {
    
    do {
        try closure()
        throw "ExpectedErrorButGotNone".asErrorMessage()
    } catch { }
}

public func expectError(
    from closure: ()throws->()
) throws {
    
    do {
        try closure()
        throw "ExpectedErrorButGotNone".asErrorMessage()
    } catch { }
}

The second and third overloads are considered duplicates and produce a compilation error. Is there any way to have all three abilities available to me? The three abilities being:

  1. I can pass an async closure and of course have to await the function call.
  2. I can pass a @MainActor closure and only have to await the function call if I'm not calling it from a @MainActor isolated context.
  3. I can pass a non-isolated closure and I do not have to await the function call.

Is there a reason why what I'm asking for is logically unsound?

Anyone?

Non-expert here but it appears @MainActor is not fully part of a function signature in 5.10

@MainActor func a() {}
    
let b: () -> () = a //warning: converting @MainActor ()->() to ()->() loses MainActor, this is an error in Swift 6

So in 5.10 this tells me that @MainActor will be automatically converted to non-MainActor with a warning; but maybe in 6.0 the compiler will discriminate. I’m at 5.10 and don’t know how to use godbolt to recognize @MainActor, but maybe in 6.0 the compiler does. So are you using 5.10 or 6.0?

If the compiler doesn’t distinguish with 6.0 then I’d say the solution is to not overload and give a different name to the MainActor version.

1 Like

I’m using 5.10, it would be awesome if overloading Just Worked in 6.0.

@hborla I’m sure you’re busy developing important things, and I don’t know if tagging you out of the blue like this is sometimes acceptable or mostly always not how it should be done. If you’d prefer that I don’t do this then I won’t do it again. Anyway, if you have the bandwidth to provide more insight on this I would love to know more about this issue

I’m using 5.10, it would be awesome if overloading Just Worked in 6.0.

In Xcode 16.0 / Swift 6.0 only the third function causes a compiler error (redeclaration of the second function). Not sure if that's different from 5.10.

But I think that makes sense, doesn't it? You need to be able to pass an actor-isolated closure like @MainActor () -> Void to a closure of type () -> Void. Otherwise, you'd need to manually specialize your functions for every single global actor you wanted to use.

So, given that you need the third function to be able to accept closures of type @MainActor () throws -> () too, the compiler can't possibly know whether you're trying to call the isolated or the nonisolated function, as your argument is valid for both of them. Can't really see a way around that.


For what is worth, I think you can remove the @MainActor function altogether, there are better ways to architecture code than manually specializing functions for different global actors. In fact, you can already use the third one without awaiting if you're on the Main Actor (for MainActor isolated closures) nor for non-isolated closures:

public func expectError(from closure: () throws -> ()) throws {
    ...
}

Task { @MainActor in
    // No await needed here (already in the Main Actor!)
    try expectError { @MainActor in
        doMainActorStuff()
    }
    // No await needed here (nonisolated closure)
    try expectError { 
        doNonisolatedStuff()
    }
}

The only difference is that you can't cleanly await if you're outside the Main Actor.

2 Likes

Would you look at that, you're right. If I call the non-isolated overload from a @MainActor isolated context then I'm able to pass in a @MainActor ()throws->() with no warning. I'm happy to know that there's an even simpler way to make everything "just work" than what I was proposing, but it does reveal that there are subtle behaviors of the concurrency features in Swift that I do not currently understand. If anyone wants to give a breakdown of how exactly this is achieved I would love to understand it more deeply.

I'm not sure I understand you here. If you're willing to await then I believe that the async overload has you covered no matter what.