Hi everyone,
I'd like to share a proposal that allows some properties to have effects specifiers (throws
and async
) added to them. Any thoughts or feedback would be greatly appreciated. Please see here for the complete and most up-to-date version of this proposal.
As an appetizer, below you will find the first few sections of the proposal:
Introduction
Nominal types such as classes, structs, and enums in Swift support computed properties, which are members of the type that invoke programmer-specified computations when getting or setting them; instead of being tied to storage like stored properties. The recently accepted proposal SE-0296 introduced asynchronous functions via async
, in conjunction with await
, but did not specify that computed properties can support effects like asynchrony. Furthermore, to take full advantage of async
properties, the ability to specify that a property throws
is also important. This document aims to partially fill in this gap by proposing a syntax and semantics for effectful read-only computed properties.
Terminology
A read-only computed property is a computed property that only defines a get
-ter, which can be mutating
. Throughout the remainder of this proposal, any unqualified mention of a "property" refers to a read-only computed property. Furthermore, unless otherwise specified, the concepts of synchrony, asynchrony, and the definition of something being "async" or "sync" are as described in SE-0296.
An effect is an observable behavior of a function. Swift's type system tracks a few kinds of effects: throws
indicates that the function may return along an exceptional failure path with an Error
, rethrows
indicates that a throwing closure passed into the function may be invoked, and async
indicates that the function may reach a suspension point.
The Swift concurrency roadmap outlines a number of features that are referenced in this proposal, such as structured concurrency and actors. Overviews of these features are out of the scope of this proposal, but basic understanding of the importance of these features is required to fully grasp the motivation of this proposal.
Motivation
An asynchronous function is designed for computations that may or always will suspend to perform a context switch before returning. Of primary concern in this proposal are scenarios where the future use of Swift concurrency features are limited due to the lack of effectful read-only computed properties (which I will refer to as simply "effectful properties" from now on), so we will consider those first. Then, we will consider programming patterns in existing Swift code where the availability of effectful properties would help simplify the code.
Future Code
An asynchronous call cannot appear within a synchronous context. This fundamental restriction means that properties will be severely limited in their ability to use Swift's new concurrency features. The only capability available to them is spawning detached tasks, but the completion of those tasks cannot be awaited in synchronous contexts:
// ...
class Socket {
// ...
public var alive : Bool {
get {
let handle = Task.runDetached { await self.checkSocketStatus() }
// /-- ERROR: cannot 'await' in a sync context
// v
return await handle.get()
}
}
private func checkSocketStatus() async -> Bool { /* ... */}
}
As one might imagine, a type that would like to take advantage of actors to isolate concurrent access to resources, while exposing information about those resources through properties, is not possible because one must use await
to interact with the actor from outside of its isolation context:
struct Transaction { /* ... */ }
enum BankError : Error { /* ... */}
actor class AccountManager {
// `lastTransaction` is viewed as async from outside of the actor
func lastTransaction() -> Transaction { /* ... */ }
}
class BankAccount {
// ...
private let manager : AccountManager?
var lastTransactionAmount : Int {
get {
guard manager != nil else {
// /-- ERROR: cannot 'throw' in a non-throwing context
// v
throw BankError.notInYourFavor
}
// /-- ERROR: cannot 'await' in a sync context
// v
return await manager!.lastTransaction().amount
}
}
}
While the use of throw
in the lastTransactionAmount
getter is rather contrived, realistic uses of throw
in properties have been detailed in a prior pitch and detached tasks can be formulated to throw a CancellationError
.
Furthermore, without effectful read-only properties, actor classes cannot define any computed properties that are accessible from outside of its isolation context as a consequence of their design: a suspension may be performed before entering the actor's isolation context. Thus, we cannot turn AccountManager
's lastTransaction()
method into a computed property without treating it as async
.
Existing Code
According to the API design guidelines, computed properties that do not quickly return, which includes asynchronous operations, are not what programmers typically expect:
Document the complexity of any computed property that is not O(1). People often assume that property access involves no significant computation, because they have stored properties as a mental model. Be sure to alert them when that assumption may be violated.
but, computed properties that may block or fail do appear in practice (see the motivation in this pitch).
As a real-world example of the need for effectful properties, the SDK defines a protocol AVAsynchronousKeyValueLoading
, which is solely dedicated to querying the status of a type's property, while offering an asynchronous mechanism to load the properties. The types that conform to this protocol include AVAsset, which relies on this protocol because its read-only properties are blocking and failable.
Let's distill the problem solved by AVAsynchronousKeyValueLoading
into a simple example. In existing code, it is impossible for property get
operation to also accept a completion handler, i.e., a closure for the property to invoke with the result of the operation. This is because a computed property's get
operation accepts zero arguments (excluding self
). Thus, existing code that wished to use computed properties in scenarios where the computation may be blocking must use various workarounds. One workaround is to define an additional asynchronous version of the property as a method that accepts a completion handler:
class NetworkResource {
var isAvailable : Bool {
get { /* a possibly blocking operation */ }
}
func isAvailableAsync(completionHandler: ((Bool) -> Void)?) {
// method that returns without blocking.
// completionHandler is invoked once operation completes.
}
}
The problem with this code is that, even with a comment on isAvailable
to document that a get
on this property may block, the programmer may mistakenly use it instead of isAvailableAsync
because it is easy to ignore a comment. But, if isAvailable
's get
were marked with async
, then the type system will force the programmer to use await
, which tells the programmer that the property's operation may suspend until the operation completes. Thus, this effect specifier enhances the recommendation made in the API design guidelines by leveraging the type checker to warn users that the property access may involve significant computation.
Proposed solution
For the problems detailed in the motivation section, the proposed solution is to allow async
, throws
, or both of these effect specifiers to be marked on a read-only computed property's get
definition:
// ...
class BankAccount {
// ...
var lastTransactionAmount : Int {
get async throws { // <-- proposed: effects specifiers!
guard manager != nil else {
throw BankError.notInYourFavor
}
return await manager!.lastTransaction().amount
}
}
}
At corresponding use-sites of these properties, the expression will be treated as having the effects listed in the get
-ter, requiring the usual await
or try
to surround it as-needed:
let acct = BankAccount()
let amount = 0
do {
amount = try await acct.lastTransactionAmount
} catch { /* ... */ }
extension BankAccount {
func hadRecentWithdrawl() async throws -> Bool {
return try await lastTransactionAmount < 0
}
}
The usual short-hands for do-try-catch, try!
and try?
, work as usual.
Computed properties that can be modified, such as via a set
-ter, will not be allowed to use effects specifiers on their get
-ter, regardless of whether the setter would require any effects specifiers. The main purpose of imposing such a restriction is to limit the scope of this proposal to a simple, useful, and easy-to-understand feature. Limiting effects specifiers to read-only properties in this proposal does not prevent future proposals from offering them for all computed properties. For more discussion of why effectful setters are tricky, see the "Extensions considered" section of this proposal.
Please see here for the complete and most up-to-date version of this proposal, which includes the detailed design and other considerations, such as those for actors!
Thanks,
Kavon