Swift 5.5 async operation as Optional default
Asked Answered
F

3

7

I have an async function that returns an optional String.

func traverse(file:URL, for string:String) async throws -> String? {
...
}

I want to run this function and if nil, run it with different params like this:

async let langRes = try traverse(file: languageFile, for: string)
async let engRes = try traverse(file: englishFile, for: string)
let result = try await langRes ?? engRes

but the optional default engRes gives the error:

'async let' in an autoclosure that does not support concurrency

Something like this works fine:

let result = try await langRes ?? ""

It just doesn't like using an async function for the default/fallback.

I know I can do it like this:

var result = try await langRes
if result == nil {
    result = try await engRes
}

but it would be nice to use the optional chaining fallback ??

Do I have the syntax wrong or will it just not work?

Fjord answered 2/8, 2021 at 16:30 Comment(6)
You don't say await when you say async let. You say await later when you pick up the value.Samford
Ah yes, I didn't realise I left the await in there. However even if I remove it, I get the same error. I'll update the code in the question.Fjord
Yeah, I see now, you're making a really interesting observation. See my answer.Samford
Okay but you can't say optionalString ?? optionalString even in the normal world, so your ?? makes no sense.Samford
I could add a ?? “” on the end.Fjord
OK in that case see my answer.Samford
S
4

You are perfectly correct: you can't use ?? the way you want to. The second member (after the ??) is indeed an autoclosure, so that it is not evaluated unless it has to be. But you cannot have that when using await. So you will just have to suck it up and use a more clumsy way of talking, just as you have suggested. Personally I would probably write:

    async let langRes = traverse(file: languageFile, for: string)
    async let engRes = traverse(file: englishFile, for: string)
    let result : String
    if let temp = try await langRes { result = temp }
    else if let temp = try await engRes { result = temp }
    else { result = "" }

Note, however, that you have to decide whether this is really a good use of async let, because what you are doing is launching two concurrent processes, for both langRes and engRes, regardless of whether the first one succeeds in getting you a non-nil result. It seems to me that what you want is probably actually successive await calls so that you don't even start the second one if the first one succeeds.

Samford answered 2/8, 2021 at 16:46 Comment(3)
Maybe Apple will fix this later, but at the moment it's what we've got.Samford
Thank you. Yes it would be good if Apple allowed this, however as you suggested (and I failed to realise) I don’t always need to kick off the 2 async operations. I assumed they kicked off as the await was called later, but obviously not. I think maybe I should change it to only call the second if the first returns nil.Fjord
Yeah, still a really interesting syntactical observation though, so I'm glad it came up.Samford
A
1

As you've noted in your comments to matt, this basic approach is not likely what you want. This always kicks off both requests, and so always has to wait for both to finish before returning (even if you never need them both).

You can get something like what you want this way, but it's pretty messy (which is why I suspect they haven't integrated it yet):

Create a new operator ¿¿ that combines async operations:

precedencegroup AsyncNilCoalescingPrecedence {
    associativity: right
    higherThan: NilCoalescingPrecedence
}

infix operator ¿¿ : AsyncNilCoalescingPrecedence

public func ¿¿<T>(optional: T?, defaultValue: () async throws -> T?) async rethrows -> T? {
    if let result = optional { return result } else { return try await defaultValue() }
}

Your default value should be in a closure so it doesn't run automatically:

let langRes = try await traverse(file: languageFile, for: string)
let engRes = { try await traverse(file: englishFile, for: string) }

And then you could do what you want (returning a String):

let result = try await langRes ¿¿ engRes ?? "default"

That said, I'd say that anything that tries to solve this with a ??-like operator is going to wind up being very subtle and easy to accidentally kick off tasks you didn't mean. Writing it matt's way makes it much more obvious that what you're doing is actually wrong.

Another similar approach would be:

let result: String
if let r = try await traverse(file: languageFile, for: string) { result = r }
else if let r = try await traverse(file: englishFile, for: string) { result = r }
else { result = "default" }

That said, I'd probably consider just extracting a loop instead:

func traverse(files: [URL], for string:String) async throws -> String {
    for file in files {
        if let result = try await traverse(file: file, for: string) { return result }
    }
    return "default"
}

let result = traverse([langRes, englishRes], for: string)

I believe it should be be possible to create something like:

let result = try await [langRes, englishRes].lazy
    .compactMap({try await traverse($0, for: string)})
    .first ?? "default"

But I don't think AsyncSequence is quite up to that yet, without adding a lot of extensions. IMO, mapping a Sequence with an async closure should create an AsyncMapSequence. But it currently doesn't. Also, it feels like it should be possible to call first on an AsyncSequence, but that's also not possible (you'd have to call first(where: {true}) or something silly like that as best I can tell).

(Agreed with matt that this is a great question. This is worth discussing on the Swift forum. It's the kind of thing that should have a clear pattern.)

Alida answered 2/8, 2021 at 19:45 Comment(3)
Thanks for the suggestions. I can't even pretend to understand your first suggestion and don't like to copy/paste what I don't understand. I think the if/else is probably the best solution here and most descriptive as I'm basically falling back to an English language file if the selected language file doesn't have what's needed. If it was to ever become added to Swift, I think it would be great if a nil coalescing fallback that is also async could only run if required.Fjord
Yeah; it'd probably need something like @autotask rather than @autoclosure. It's possible, but I'm curious if we can find a better approach than extending ??.Alida
@RobNapier do you know if this was ever discussed in the forums?Navada
M
0

This can be done by overloading the ?? operator.

func ?? <T>(_ lhs: T?, _ rhs: @autoclosure () async throws -> T?) async rethrows -> T? {
    guard let lhs else {
        return try await rhs()
    }
    return lhs
}

To use it:

var myRemoteValue: Bool? {
    get async throws {
        true
    }
}

let myLocalValue: Bool? = false

let finalValue = try await myLocalValue ?? (await myRemoteValue)

It's obviously not the cleanest due to the duplication of the await keyword and additional required parenthesis, but it does work until it is improved in the language.

Mandolin answered 15/11, 2023 at 23:53 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.