`try await` result ergonomics

In converting an existing codebase (not large, but with a variety of completion-handler-based asynchronicity strategies), I ran into a couple of ergonomic issues. I don't recall these being discussed during the pitch phase of Swift concurrency, so I'm offering them as ideas for general discussion here and in related forum threads.

I've run into scenarios which boil down to this fragment:

try await Task {
    return try await someAsyncThrowingFunction()
}

which can equivalently be written:

try await Task {
    try await someAsyncThrowingFunction()
}

It bothers me that there is so much try await boilerplate here, relative to the rest of that code. The inner closure's try await seems like a redundancy.

It seems to me that, at the point where a function or closure is returning a value, it's unnecessary to indicate a suspension point in the return statement, because:

  1. There's no following code inside the function/closure, so knowledge of a suspension point inside the function/closure doesn't benefit anyone reading the code.

  2. Presumably, there must be a suspension point in the caller at the point of the call, but it's not likely that readers of the caller's code is going to end up getting confused somehow.

Am I wrong in thinking (a couple of fairly obvious special cases aside, which I'll get to later) that there's no real danger in omitting the await on a return statement?

By a similar argument, the try doesn't seem necessary either. Whether the function/closure exits by returning a value or by throwing, the transfer of control doesn't bypass any code within the function/closure.

If Swift allowed the omission of try await in this scenario, I think it would eliminate a lot of useless try await boilerplate that I find myself writing. The result would be something like:

try await Task {
    someAsyncThrowingFunction()
}

To my eyes, this is clearly-enough marked.

Note that I'm suggesting this change for all uses of return, not just at the geographic end of a function's source code. The rule would be that if the return transferred control out of the function/closure without involving any code at a different location in the source code, those keywords could be omitted.

As special cases where the omission would not be allowed, I can think of two possibilities:

  1. If the transfer of control involves executing a defer block, the keywords should be required.
  2. If the returned expression could throw an error that's caught by a catch block, the keywords should be required.

There may be others.

I realize that there are very many Swift developers who strongly prefer not eliding syntax, so the keyword omission would be a stylistic choice (subject to control via linters?).

1 Like

I’m not sure about the more general approach you are proposing, but I’m strongly in the camp of it being OK to omit try and await for single-expression closures. I would liken it more to the behavior of omitting ‘return’ in those cases. This is especially true when you consider that we can already can omit the try and await if the argument is an autoclosure.

7 Likes

It’s useful feedback but… the code snippets used to illustrate this are super confusing — can you show the snippet before the “boils down to…”?

I mean, why would you crate a new task like that — that doesn’t make sense to create a task to immediately await on it. (Note also that such api doesn’t exist, the await must be on the task value; which adds to the confusion of the shown snippets not illustrating actual real code).

Could you provide code snippets before the “it boils down to…”?

Sorry for the confusing example. I wasn't really focused on the "outer" try await, just trying to indicate that there was context suggesting that async things were happening.

Here's a very simple example:

    func asyncFunction() async throws {
        …
    }
    func syncFunction() {
        Task {
            try await asyncFunction()
        }
    }

For all practical purposes, there's no suspension point there, because there's no code that can execute after the awaited call but before the closure returns. It just … looks strange.

Here's a longer example that's closer to code that I ended up writing:

    var isImpossible: Bool
    enum SomeError: Error { case impossible, ridiculous }
    func asyncResult() async throws -> Int { … }
    func someFunction() -> AsyncThrowingStream<Int, Error> {
        return AsyncThrowingStream<Int, Error> { continuation in
            Task {
                return try await withThrowingTaskGroup(of: Int.self) { [self] group in
                    if isImpossible {
                        throw SomeError.impossible
                    }
                    group.addTask {
                        return try await asyncResult()
                    }
                    while let result = try await group.next() {
                        if result >= 0 {
                            continuation.yield(result)
                        }
                        else {
                            continuation.finish(throwing: SomeError.ridiculous)
                        }
                    }
                    continuation.finish()
                }
            }
        }

There are two statements containing return try await. In both cases, the potential suspension point doesn't seem meaningful, and an error doesn't change the path of execution in the returning closure.