[Pitch] Effectful Read-only Properties

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

23 Likes

I know you put lots of work on this and I enjoyed reading the proposal; but overall, I am -1 on the proposal. Swift should provide a way to call async functions from computed properties using @asyncHandler

I agree with you that blocking is not great on a computed property (specially on a UI thread) but if a computed property is blocking then it should not be a computed property imo.

The issue with the Actor's property being reached from outside the Actor should be addressed in the Actor pitch; Worst case we end up with Java style getters that happen to be async throwing. There is also the open question to re-entrancy.

A read-only computed property is a computed property that only defines a get -ter, which can be mutating .

I don't understand this. Can you show an example?

2 Likes

Great writeup, thanks Kavon — I think this makes sense :+1: Esp since call sites get the try/await anyway when so it’s “signal enough” that this isn’t just a trivial getter... :+1:

I have some minor questions (which don’t impact the core design much, but figured we can flesh it out here).

Would the following be legal or not:

protocol P {
  var x: X { get async }
}

actor A {
  var x: X { .init() } 
}

or are implementations of such declared property forced into the longer version of var x: X { get async { .init() } }? Mostly just a “quality of life” question, about how annoying it is to my end users to implement protocols if I define them proactively as async etc.

Otherwise looking great and I’m also +1 for not allowing an async getter where a setter is present :+1:

To address the re-entrancy and actor concerns @masters3d mentions, as I spent considerable time thinking about those: I don’t think it matters. If actors end up non-reentrant this proposal makes sense, if they end up reentrant this proposal still makes sense. If we allow @reentrant annotations and do non-reentrant, this proposal still makes sense :slight_smile:

7 Likes

Here's an example:

protocol X {
    var s: String { mutating get }
}

struct S: X {
    var ss: Int = 0
    var s: String {
        mutating get {
            ss += 1
            return ""
        }
    }
}

such property is allowed to be async or throwing. It's a bit weird that those exist, but I guess they make sense in a struct heavy world such as Swift :slight_smile: (this works today, check it out in the repl)

If it had a set too, it would not be allowed to have effects.

3 Likes

I just want to echo @ktoso in saying that this is a great write-up, the motivating issue is a timely one, and the solution presented makes sense.

Regarding the QoL question, I'd be satisfied with the long form (i.e., writing out get) at the moment; there are a number of attributes permitted on getters that require writing out get, so there's precedent for that. The fix-it for automatically inserting requirements for conformance could and should be refined to take the required effects into account, which can alleviate some of the issue, and if in time there's enough async getters to justify a shorthand, we can always bikeshed where to put the annotation then.

I do wonder (and I hate to ask about expanding the scope of proposals) whether it would make sense to consider throwing and async get-only subscripts in the same breath as get-only properties.

5 Likes

I see, I missed the precedent — even though I guess I just spelled it out in the next post I made it seems, mutating gets the same treatment right? Makes sense, so I guess my QoL concern is moot in face of prior similar patterns requiring the same explicitness :+1:

1 Like

this is a great proposal, thanks for tackling this. I'd suggest a few changes though:

  1. In motivation, I'd mention the async sequence proposal as further motivation for this. It had to turn a few get-only properties on sequence into functions because of lack of this.

  2. In motivation, I'd recommend mentioning throwing subscripts, something people have asked about many times. (but yes, ok, they typically want mutable throwing subscripts; one step at a time).

  3. Instead of forbidding setters on a property with an effectful get, I'd recommend allowing a setter, but forbidding it from having effects. Rationale: the current proposal is a pretty significant API evolution dampener. We currently have the situation where you can publish a get-only property and upgrade it to mutable in subsequent releases, but this proposal breaks that.

Allowing setters without effects is a slight creep from your proposal, but doesn't prevent future API evolution limitations: if we decide to allow effects on setters in the future, we should still allow non-effected setters due to subtyping.

In any case, I'm very happy to see this, this will make the language more consistent, and this is a nice step towards supporting the full model. Even though it is a big project to define the "full model" (as described in alternatives considered) please make sure that whatever is implemented now (at the ABI level) is extensible to support the full model in the future if at all possible.

-Chris

7 Likes

I don't see how this solves the problem. I think that the proposal has this right: we need a general solution to effects (including throws and rethrows) not just something for async.

-Chris

1 Like

Oh yes, please!

Perhaps I am missing the problem; I'd love to learn more about the problem. I do recognize that Actors might force our hand to provide a solution to async/throwing properties but as it is this solution could only give us async/throwing computed properties correct? Wouldn't we then we need a solution to stored properties in Actor code?

Properties on actors are unrelated to this proposal, but I'll explain how they work:

Stored properties in an actor are always sync just like any other stored property - reading or writing to them doesn't imply any asynchronicity from within the actor, they are just normal local state.

The only time that you get async with them is when you access them from some other place that is outside of the actor (e.g. when they are public). Reaching across actor boundaries always turns sync things into async things (including sync functions etc), and so the get and set of a stored property should also be treated as an async access.

This is one reason why I think it is inevitable that this proposal will have to handle the case of async setters. That said, I can understand the goal of wanting to subset the complexity and do things one step at a time, we just should not cut off the subsequent steps.

-Chris

2 Likes

I concur. I was thinking about the discussion about AsyncSequence.first() while reading this proposal.

It was also mentioned in the concurrency manifesto:

Swift currently doesn't allow property accessors to throw or be async . When this limitation is relaxed, it would be straight-forward to allow actor var s to provide the more natural API.

Got it. I would be interested in seeing what a full model would look like from the user perspective. I would love to see an API similar to @propertyWrapper where as I user I could delegate the async/throwing to a type (this would not address subscripts thought).

Right, the proposal is missing some discussion of property conformance (I'll add a new section about that). For @xwu and others: My intention is to carry-over the conformance rules that apply to functions with effects. So, a protocol's requirements will be satisfied if the property's getter has the same or fewer effects.

So, in your example, A could conform to P. When accessing x through a value of type A, such as with A().x, it's the usual synchronous access. But, if you cast A to P, it's now async: await (A() as P).x. Here's a little example that typechecks today to demonstrate what I mean by carrying-over what happens for functions:

// All code here typechecks today.

protocol P {
  func f() async
}

protocol Q {
  func g() throws
}

protocol R {
  func h() async throws
}

class NoEffects : P, Q, R {
  func f() {}
  func g() {}
  func h() {}
}

class JustThrows : Q, R {
  func g() throws {}
  func h() throws {}
}

class JustAsync : P, R {
  func f() async {}
  func h() async {}
}

NoEffects().f()
NoEffects().g()
NoEffects().h()
_ = { await (NoEffects() as P).f() }
_ = { try! (NoEffects() as Q).g() }
_ = { try! await (NoEffects() as R).h() }
1 Like

Allowing non-effectful setters to exist alongside an effectful getter still means that read-write operations, like passing inout, are possible and have effects, which actually increases the scope substantially. We should just do one proposal later that adds all of that at once.

I do think this proposal should be generalized to cover non-mutable subscripts; we have intentionally avoided treating properties and subscripts differently in the access model, and there doesn't seem to be a good reason to do so here.

7 Likes

Thanks, sounds good to me and the rules make sense :+1:

1 Like

I agree that get-only subscripts should be fine to add to this proposal. I'll update the pitch to reflect this soon.

Also, something that the pitch did not cover is key-paths. I've updated the pitch with discussion about why they are not included. I'll copy the text here for convenience:


KeyPaths

A key-path expression is syntactic sugar for instances of the KeyPath class and its type-erased siblings. The introduction of effectful properties would require changes to the synthesis of subscript(keyPath:) for each type. It would also probably require restrictions on type-erasure for key-paths that can access effectul properties.

For example, because we do not allow for function overloading based only on differences in effects, some sort of mechanism like rethrows and an equivalent version for async (such as a "reasync") would be required on subscript(keyPath:) as a starting-point. While a key-path literal can be automatically treated as a function, a general KeyPath value is not a function, so it cannot carry effects in its type. This causes problems when trying to make, for example, a rethrows version of subscript(keyPath:) work.

We could also introduce additional kinds of key-paths that have various capabilities, like the existing WritableKeyPath and ReferenceWritableKeyPath. Then, we could synthesize versions of subscript with the right effects specifiers on it, for example, subscript<T : ThrowingKeyPath>(keyPath : T) throws. This would require KeyPath kinds for all three new combinations of effects beyond "no effects".

So, a non-trivial restructuring of the type system, or significant extensions to the KeyPath API, would be required to make key-paths work for effectul properties. Thus, for now, we will disallow accesses to effectful properties via key-paths. There already exist restrictions on key-paths to mutable properties based on the instance type (e.g., WritableKeyPath), so it would not be unusual to disallow key-paths to effectful properties.

7 Likes
Terms of Service

Privacy Policy

Cookie Policy