Async feedback: overloads that differ only in async

Hello,

SE-0296 Async/await states:

Note that we follow the design of throws in disallowing overloads that differ only in async:

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

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

This creates an issue for evolving the GRDB SQLite toolkit, and I look for advice.

I wish users could use the same method names in both synchronous and asynchronous contexts. For example: read:

let connection = /* some database connection */

// Synchronous context
func syncFunction() throws {
    let value = try connection.read { ... }
}

// Asynchronous context
func asyncFunction() asyncthrows {
    let value = try await connection.read { ... }
    //              ~~~~~
}

But I face "Invalid redeclaration" compiler errors, as expected according to the proposal.

Context: why my request is meaningful

The desire for both facets comes from as a trade-off:

SQLite is a synchronous C API. Some users rely on synchronous database accesses. For example, users who write scripts. Generally speaking, SQLite skills are rewarded in GRDB, and some users just expect synchronous APIs to exist. On top of that, SQLite is fast, so asynchronous accesses are not always needed.

For example:

// Certainly not worse than calling Data(contentsOf: URL)
func fetchPlayerCount() throws -> Int {
    try connection.read(Player.fetchCount)
}

GRDB also exposes asynchronous APIs, for two reasons. Some database accesses are slow, and off-loading database jobs off the main queue would require too much ceremony if asynchronous APIs were not readily available. The second reason is that the scheduling of some database accesses are not important. For example, when you access the database after a network access, one mainly cares about not blocking the main thread.

Asynchronous accesses are typically found in the Combine GRDB publishers:

func downloadAndSavePublisher() -> AnyPublisher<Void, Error> {
    downloadPublisher()
        .flatMap { value in
            connection.writePublisher { try save($0, value) }
        }
        .eraseToAnyPublisher()
}

The above function would really be enhanced with async/await:

// Much Better
func downloadAndSave() async throws {
    let value = try await download()
    try await connection.write { try save($0, value) }
}

But I need to be able to define overloads for read and write :sob:

What are my options?

  1. Should I rename my async variants with some funny name? await asyncRead() ? But the proposal itself wants to avoid C#'s pervasive Async suffix..

  2. Should I wait until overloads become allowed?

    For example, the new Core Data apis described in the WWDC21 conference Bring Core Data concurrency to Swift and SwiftUI face the same problem. They worked around the overload error by defining async methods with a different signature, but they still have a problem with default values. await moc.perform { ... } does not pick the expected async method, due to the existence of the non-async moc.perform { ... } method:

    func myFunction(moc: NSManagedObjectContext) async {
        // Compiler warning: No 'async' operations occur within 'await' expression
        await moc.perform { ... }
        
        // No warning, but it's not possible to use the default
        // value of the `schedule` argument.
        await moc.perform(schedule: .immediate) { ... }
        await moc.perform(schedule: .enqueued) { ... }
        
    }
    

    Will the language evolve and allow overloads that differ only in async, in order to accomodate for Core Data apis?

    Or will Core Data "fix" its API with some "funny names"?

    Meanwhile, a slide from the conference is misleading. `await perform` form does not work as expected.

I do not expect hints and clues about the future of Core Data apis, of course. But I would appreciate hints and guidance from the authors of async/await!

10 Likes

Update: this compiler warning: "No 'async' operations occur within 'await' expression" was a side effect of my improper setup (missing availability checks).

The fixed version emits no warning:

func asyncFoo(moc: NSManagedObjectContext) async {
    await moc.perform { }
}

func syncFoo(moc: NSManagedObjectContext) {
    moc.perform { }
}

That's exactly what I want! :star_struck: How can I reproduce the same setup? Please!

A dummy parameter and @_disfavoredOverload did the trick:

public protocol DatabaseReader: AnyObject {
    @_disfavoredOverload
    func read<T>(_ block: (Database) throws -> T) throws -> T
}

#if swift(>=5.5)
@available(macOS 12, iOS 15, tvOS 15, watchOS 8, *)
extension DatabaseReader {
    public func read<T>(_ignored: Void = (), _ value: @escaping (Database) throws -> T) async throws -> T { ... }
}
#endif

Now I can both read { ... ) and await read { ... } :tada:

However, I had to both use @_disfavoredOverload (non-public), and expose a "dirty" async variant.

Now I think I can directly call @John_McCall and @Douglas_Gregor. There exists a workaround to the overloads that differ only in async limitation. So people will use it. I plan to use it. Would you consider removing the limitation, so that we are not forced to use this ugly workaround?

14 Likes

I like your work around. This is discussed in SE-0297. Methods in the same module cannot overload only on 'async'-ness, but methods bridged from ObjC or in a different Swift module may.

Thank you! I do not like my workaround, although I am happy I could find one.

My hope is to convince the Core Team that some APIs need both sync and async versions of the same function. I used Core Data NSManagedObjectContext/perform as a prominent example.

If something can be done until the end of the beta stage, I'd be happy. This would be welcomed by authors of Swift librairies who want to support previous Swift and OS versions, support the new async flavor for better ergonomics, and won't use actors because actors are async-only.

1 Like

It feels to me like the async/await approach also massively reduces the ceremony, so your snippet with a sync-only API becomes:

// Still Better
func downloadAndSave() async throws {
    let value = try await download()
    async { try connection.write { try save($0, value) } }
}

Thanks Alexandre.

I admit your comment confuses me a lot, and makes me question the very relevance of this thread. Is there any advantage in providing explicit async methods?

I was considering supporting cancellation, for example.

I wish the original authors of the proposal would provide their own feedback, but they look pretty busy.

I may well have misunderstood :slight_smile:

My general idea was that in the motivating reasons, the first was that offloading slow operations may be advantageous, and the second is that the scheduling of operations is not important. Given how easy it now is to spin off a new asynchronous bit of work in an already asynchronous function that these reasons are not as strong as they used to be prior to the newly introduced features.

Supporting cancellation seems like a great reason, but that can still be done in a synchronous function.

I think it's worth considering, yes, and we've noted before that we'll certainly consider amending proposals as more usage experience comes in. XCTest has a similar issue where there are very good reasons to need to overload async and non-async (the pre-async version has the good name and needs to stick around). Would you be willing to write up an amendment to SE-0296, along the lines of what we did to amend SE-0306 for better nonisolated let semantics, and a member of the Core Team will run a review for it next week?

Doug

8 Likes

Hello Doug, thanks for the encouragements, and for the "sample code". I'll do it shortly :+1:

Done: [SE-0296] Allow overloads that differ only in async by groue · Pull Request #1392 · apple/swift-evolution · GitHub

5 Likes

More broadly - should such overloads need some way to indicate that they should only be used within synchronous contexts? I would think blocking a shared cooperative queue because you forgot an await would be bad.

4 Likes

In my mind the sync overload could be a blocking one, or a fire-and-forget one.

In case of blocking, like with Gwendal’s example, and more generally when the function returns a result, it doesn’t make much sense to call it from an async context.

However it can make sense to call a fire-and-forget function from an async context.

Then maybe those overloads should be only intended for blocking vs async ?

1 Like