SE-0317: Async Let

try is used to mark places that can throw errors. The declaration of veggies cannot throw an error; only the uses of veggies can throw errors, which is why we have:

async let veggies = chopVegetables()
//...
try await veggies

It's like the difference between forming a closure that can throw, and calling that closure, as @jazzbox points out:

let fn = { throw SomeError() } // no `try` here
try fn() // the try goes here, where you can observe the error

I've been trying to make property wrappers work as an async let replacement for the better part of a year, and I've come to the conclusion that property wrappers aren't the right tool for this task: they cannot provide the semantics we need for child tasks without a pile of magic attributes to customize their behavior, at which point we've lost all but the most superficial benefits of using property wrappers. I'll explain point-by-point since you have a long list:

I doubt this would pan out. Aside from the special rules about initialization and the need to try or await on access, async let is just a let throughout the core parts of the compiler. Most of the complexity of the implementation is in later compiler stages, to ensure that we create/cancel/finish child tasks at the right places and that we take advantage of the scoped model to eliminate excess heap allocations. That complexity doesn't go away if we use property wrapper syntax here, but because you need to invent various magic attributes to get the right semantics, you actually have more work to do (and more chances for failure) because you have to work backwards from pseudo-generalized code to the specific efficient implementation.

Property wrappers need to be heavily customized to get the semantics we need for child tasks. Some issues I found while exploring this:

  • Property wrappers always create a backing storage property (_veggies). We would need to invent an attribute to make that backing storage property inaccessible to users, because we don't want to imply that there is a way to refer to the child task. The fact that one cannot access the futures behind child tasks is one of the core aspects of structured concurrency.

  • You need some kind of deinit on the backing storage property to handle implicit cancellation. We don't have those on structs or enums, so either we need to implement that (which I suspect we'll do some day as part of move-only types) or we need some kind of forced stack allocation of classes to avoid the cost of heap traffic here.

  • The deinit isn't even enough for waiting for the task at the end of the scope, because it cannot be async and our reference-counting model wouldn't wait until the end of the scope to call deinit anyway. So, we'd need to invent some kind of attribute, protocol, or ad hoc protocol for "run some code at the end of the lexical scope" that ties in with these property wrappers. I'm not sure that's something we would even want as a general feature: we've pretty consistently stated that using "with" blocks to introduce a lexical scope is the only wait to get guaranteed lifetimes for operations.

  • Property wrappers currently only work with var, because the actual variable being declared is a computed property. Either your examples need to use var or we need to consider allowing property wrappers to be defined as a let.

The benefits we get from using property wrappers here are mostly superficial. I think we'll end up in the uncanny valley where what we have looks like a property wrapper, but is indeed so customized for this one purpose that the resemblance doesn't aid understanding: it would be better to present this as its own separate language feature.

The overloading is pulling together the structured asynchronous and concurrent constructs under "things that are await'able".

As others have noted, we could do the async throws let thing and it would both be consistent across effects and also address the concerns about not knowing that the initializer could throw.

This point is actually completely independent of property wrappers. We could require

async let veggies = { try await chopVegetables() }

However, we should not require the braces. The type of veggies is [Vegetable]. It is not ThrowingFuture<[Vegetable]> or () async throws -> [Vegetable]. The initializer expression for a declaration should match the type of the pattern in the declaration, so the initializer expression should be the expression that produces [Vegetable].

Now, we could of course bend the rules for async let and require the braces despite the above. It's actually harder to bend the rules with property wrappers, because property wrappers really want the type of the wrappedValue property and the type of the parameter of init(wrappedValue:) to match. That means you don't really get this syntax:

@ThrowingFuture var veggies = { try await chopVegetables() } // doesn't really work

you need to either do something like this:

@ThrowingFuture { try await chopVegetables() }
var veggies

or use autoclosures to get brace-free syntax:

@propertyWrapper
struct ThrowingFuture<T> {
  var wrappedValue: T { get async throws { ... } }
  init(wrappedValue: @escaping @Sendable @autoclosure () async throws -> T) { ... }
}

@ThrowingFuture var veggies = try await chopVegetables()

We can decide to do this independently of whether we use property wrappers. I think async throws let offers the most promise.

We have an established pattern for this: { ... multi-line-closure ... }()

The writing here makes it sound more complicated than it is; mostly, it's describing the impact of transferring the effects from the initializer to each of the bound variables.

As I said much earlier, we went far down the path of trying to make property wrappers work, based on the same intuition you're describing here. It's part of why Swift 5.4 got property wrappers support for local variables, and effectful properties came along. But the semantic model of async let isn't actually amenable to property wrappers, and structured child tasks described via local values are important enough to justify their own sugar.

Doug

17 Likes