So today, we have functions that largely perform an operation, however some subset of callers may be interested in more detail about what was done. This pattern is common enough that we have a keyword for it: @discardableResult
.
In async world, we imagine that tasks run longer than synchronous functions, and so the part that builds the result may be longer in like proportion. However, if many callers don't need the result, they may become disinclined to pay for what may be a nontrivial task. This happens for sync code already, but is going to happen more with async.
Consider a typical application HTTP request with signature
func make<T: Type>(request: URLRequest) async throws -> T
Many callers may be satisfied to know we got some response, and perhaps that the headers said it was 200 OK. Your file was saved, your message was sent. Some individual caller may want to download all 2GB of response and parse it into JSON. The cost of doing the latter for a caller who wanted only the former is nontrivial. Yet a third case might be simply firing off the response and not waiting for the result at all.
Arguably, this is a job for different functions:
func make<T: Type>(request: URLRequest) async throws -> T
func make(request: URLRequest) async throws
func make(request: URLRequest) //no-await or fire-and-forget case
Beyond tripling the API surface and having to remember which version to call, and keeping track of whether the third "non-async" function waits for the response or returns immediately before the request is even made, there are several practical problems:
- Callers have to disambiguate these somehow. Ordinarily we expect to be able to bind a value to a function call and infer the value's type, but here the first 2 functions differ by return type, so type inference is not available. In addition, the latter 2 functions have the same return type, so I'm not even sure how to disambiguate them.
- Writing and maintaining these functions is tough. Ideally one function would call the others, but it's difficult to see how for these 3. If we write the second to call the first we do extra work. If we write the first to call the second, it's unclear how to build the result. Maybe there's some common task we can factor out into a private function detail used by everyone, but it's highly specific to each problem.
There are some other ways to do this like adding parameters etc. but I think these problems are a) likely to be pretty common for async code and b) likely to cause "ceremony" for both clients and implementors, which is the kind of thing we are trying to avoid by introducing async in the first place.
The obvious way to handle the situation for the non-async third case is to allow launching a new coroutine through an explicit syntax like nonawait makeRequest(myRequest)
. This produces the fire-and-forget version and so we can eliminate dealing with that function. There are some details to wrestle with here (what's the qos of the new coroutine?) so it may be better-suited for a platform function than a language keyword.
In terms of the remaining 2 await
cases, one solution would be to add a control flow primitive to the called context, reflecting if the calling context will discard the result. That is, (spitballing syntax)
@disardableResult func make<T: Type>(request: URLRequest) async throws -> T {
let result = try await request.make()
if result.code != 200 { throw Errors.BadResponse(result.code) }
//return early if caller does not use return value.
//Since value is unused we can avoid specifying any value for this statement
return @discardableResult
//caller wants the value; go to the bother of making one
return try T(parsing: await result.receiveData())
}
This sort of control flow would unify the first 2 functions into a single definition that will work for either kind of caller, sidestepping all the practical problems discussed prevoiusly.
Some legacy code may want advance notice about whether the result is discarded. For example, an HTTP library may expect to know whether or not we want the full response, at the time the request is made, to pick an allocation size etc. So maybe we want an alternative or additional control flow primitive:
@disardableResult func make<T: Type>(request: URLRequest) async throws -> T {
if #discardableResult {
try await request.make(response: false)
return @discardableResult
}
return try T(parsing: await request.make(response: true))
}
I realize some of this is well outside the scope of the pitch, but I think these issues are likely to arise pretty quickly as we adopt async, and it would be nice to have the tools to keep the implementation manageable.