SE-0310: Effectful Read-only Properties

Hello Swift community,

The review of SE-0310 "Effectful Read-only Properties"" begins now and runs through April 27, 2021.

Reviews are an important part of the Swift evolution process. All review feedback should be either on this forum thread or, if you would like to keep your feedback private, directly to the review manager. When emailing the review manager directly, please keep the proposal link at the top of the message.

What goes into a review?

The goal of the review process is to improve the proposal under review through constructive criticism and, eventually, determine the direction of Swift. When writing your review, here are some questions you might want to answer in your review:

  • What is your evaluation of the proposal?
  • Is the problem being addressed significant enough to warrant a change to Swift?
  • Does this proposal fit well with the feel and direction of Swift?
  • If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?
  • How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

More information about the Swift evolution process is available at

https://github.com/apple/swift-evolution/blob/master/process.md

Thank you,

Doug Gregor
Review Manager

22 Likes

No, there is no omission: note the question mark after "throws".

1 Like
class Socket {
  // ...
  public var alive: Bool {
   @asyncHandler get { // would this work?
      let handle = detach { await self.checkSocketStatus() }
      return await handle.get()
      //     Would this work?
    }
  }

  private func checkSocketStatus() async -> Bool { /* ... */ }
}

If we had @asyncHandler would the above example still be a motivation?

I am more familiar with languages that do not support async properties so I am on the fence about its utility. Personally I feel that it makes Swift harder to reason about.

Let’s say I have to interact with an sdk property that is async only. What are the mechanisms available to me to be able to call this property if I am not in an async context?

I don't feel that the proposal sufficiently justifies why a property that is asynchronous or throws would not be better expressed as a method. As it points out, people expect properties to be cheap, O(1), not worth manually extracting common subexpressions. Throwing and async properties inherently break all those assumptions.

It seems to me that this makes "thinking about code using properties" harder for little to no benefit. At least if you have to type the () you're made aware that some nontrivial computation may occur.

14 Likes

This experimental "async handler" annotation on functions is only valid if the function does not return a value, so it is not useful for property getters... unless if you define a Void returning property (which I imagine is quite uncommon, but allowed in the language).

The same mechanisms that you would use if you needed to call an async function and you're within a synchronous context. You can spawn a Task, such as with detach (formerly called Task.runDetached) to create a new async context.

3 Likes

-1

I like and appreciate the way the proposal is using the getter and formatting it like a function, however not utilizing the ability to omit a getter is disappointing.

A function would be less confusing to read than this at the cost of parentheses at the call site.

Since a property reads right to left, putting async and throws at the declaration would be clumsy, but I probably won’t ever write code using this getter style in the proposal.

The getter is relaying information that should be in the declaration. The getter and setter should not be changing what was declared in my opinion.

2 Likes

-1

My sentiment is the same as @OneSadCookie. I see a high likelihood of this being largely unused and frowned upon in style guides if it’s implemented. I don’t think the problem being addressed is large enough to justify the confusion I think it would introduce.

4 Likes

In general, I'm in favor of this and would immediately make use for it in the Redis module RedisTypes which tries to model Redis types as Swift familiar structures.

The linked example tries to match the Collection API with a count computed property that today returns an EventLoopFuture<Int> but with this proposal could be a simple var count: Int async throws


We could be misinterpreting the proposal as it doesn't seem to explicitly call this out, but I agree with the sentiment provided that requiring an explicit get body is extremely disappointing and likely to affect ecosystem adoption and acceptance.

What would the reason be for not allowing:

var asyncProperty: Bool async throws { /* ... */ }

Swift today treats both forms as a read-only properties (syntax sugar), and this would break that for a non-obvious reason.

2 Likes

With functions we read left to right with the value passing onward. With properties the code block is being push back into its declaration, or right to left: the value flows back toward var.

To me it would be:

throws async var asyncProperty: Bool {/* */}

I think new syntax would be required in order to maintain the ascetic ordering the async/await proposal declared. Perhaps introducing a function arrow for var.

var asyncProperty async throws -> Bool {/* */}

But then why not just make it a function? And thats where I’m at. It should just be a function. The proposal also suggests setters could get keywords too which means a var could have 2 completely different meanings for setting and getting.

I realize the new concurrency proposals make asynchronous code simple and easy, and that opens the door for this proposal; but I’m not seeing the benefit. The proposal doesn't explain what we’re fixing or making better. It’s just that it’s possible now, with the new concurrency model, and I don't think thats a good reason to make properties complex like this.

But again, I do appreciate the approach taken. Its a clean and enjoyable solution. If this was somehow required, I’d be happy with it; but i just don't think it’s the right thing for properties.

3 Likes

I feel that while this change could be good it leaves me like Objective-C 2.0 properties after a while they were available. Good, but making the language a bit harder to teach and reason about vs simplicity of var access and standardised getter and setters we had before.

This proposal kind of begs the question a bit too as it uses a not approved pitch as premise for its conclusion, meaning the throws and rethrows support added to computed properties. In those examples I felt that getters and setters with a private backing variable would have been the proper solution for such work and/or a redesign of the protocol in the SDK preferable to something that looks like a variable (with essentially the same O(1) expectations) but can throw errors when accessed and now can execute async code.

This could be useful as I said, but it feels another step that adds complexity to the language to save on verbosity. Verbosity is explicit though, at least.

+1
I don’t know how actor computed properties would work without that. It seems necessary. I also have been needing throwing properties, they can be useful in rare cases.
I disagree with people above saying the effect modifiers should be in the declaration: they modify the getter, and as such should be on the get, and need the extra get. Any other placement would be awkward and not swifty. It requires extra verbosity but that’s good, it’s not the common case so it doesn’t need a lot of sugar imo.

9 Likes

-1.
Pretty much the same to @OneSadCookie and @Steven0351's explanation here.
This change would be very confusing, in contrast, benefit the changes provide aren't convincing enough to go over the confuses it introduce.

I find it odd that the proposal doesn‘t even mention the possibility to use a method without arguments instead of a property.

I would have hoped for an in-depth argumentation that weighs the pros and cons of the proposed solution vs simply using methods.

I think it would be good to use the concurrency features for some time and then decide if it is worth adding async properties.

4 Likes

+1

I see this as the way to do

var user: Future<LoggedInUserDetails> { get }

using "async/await callbacks" instead, like

var user: LoggedInUserDetails { get async }

My only doubt is whether it would be better to instead support function invocations without parentheses like in Pascal :slight_smile:

func user async -> LoggedInUserDetails { ... }
1 Like

Effectful properties and subscripts do not inherently break this time-complexity assumption. Effects specifiers on a get accessor only indicate what might happen during the access that the use-sites must take into account:

  • For throws, the user must be prepared for an Error value to be returned instead of the expected value.
  • For async, the user must be within an asynchronous context, because the accessor may require a suspension of the current task to produce a result, for example, to gain exclusive access to an actor's state. In fact, async properties prevent users from accidentally blocking on a computed property, because async requires that the users of the property take its possible suspension into account (this is explained in the AVAsynchronousKeyValueLoading example).

It's still up to the programmer to stay within the guidelines that users expect, an amortized O(1) time for the accessor, regardless of the effects the accessor may exhibit. This is achieved in the usual way for any non-trivial computed property or subscript, by returning a cached answer when possible.

To expand a bit on one of the examples in the proposal: for AVAsynchronousKeyValueLoading, which has AVAsset as one of its conformers. One of AVAsset's potentially blocking computed properties is var duration: CMTime. If the asset isn't downloaded already, it will block, so it's still O(1) amortized. Should that property instead be the method func getDuration() -> CMTime instead? I think that's a stylistic decision that is up the API author.

Of course, if it's not possible to cache the result and a non-trivial computation is always required, then a computed property or subscript is indeed not a good choice. Edit: well, possibly not a good choice. For example, subscript on various data structures is not O(1) and that is a reasonable expectation. Chris also pointed out var count as well.

6 Likes

I think weighing the pros and cons of computed properties vs methods is not relevant for this proposal, because that is a stylistic choice and is orthogonal to whether the property or subscript is allowed to be throws or async (see my reply here).

But, what I think is relevant are the pros and cons of allowing vs disallowing effects specifiers on computed properties or subscripts. Since async is already covered in the proposal, let's consider throws on a subscript. We'll start with this simple n-ary tree:

enum Node<T> {
    case leaf(T)
    indirect case interior(val: T, children: [Node])
}

struct Tree<T> {
  private var node : Node<T>
  init(_ node: Node<T>) { self.node = node }
}

let tree = Tree(.leaf(5))

and suppose we would like to define a subscript to access a tree's immediate child by an Int index. There are two reasons why this might fail: you're asking for a child from a leaf node, or there is no child with that index:

enum TreeError : Error {
  case NotInteriorNode
  case ChildIndexOutOfBounds
}

We could, of course, treat both of these failures opaquely and simply return nil if the access fails:

extension Tree {
  // Version 1: access a child node, indicating an error opaquely with nil
  subscript(v1 childIdx : Int) -> Tree<T>? {
    get { 
      switch node {
        case .leaf:
          return nil
        case let .interior(_, children):
          guard childIdx < children.count else {
            return nil
          }
          return Tree(children[childIdx])
      }
    }
  }
}

The downside is the loss of information about the failure, but on the plus side, we have ? to enable a clean syntax for chained accesses:

// no specific error information at the use-sites, but easily chained with `?`
let _ = tree[v1: 0]?[v1: 1]?[v1: 2]

But, if we, as the API authors, would like to inform users of the kind of error, they are stuck with solutions that amount to using Result<> instead:

extension Tree {
  // Version 2: access a child node, indicating the kind of error with Result
  subscript(v2 childIdx : Int) -> Result<Tree<T>, Error> {
    get { 
      switch node {
        case .leaf:
          return .failure(TreeError.NotInteriorNode)
        case let .interior(_, children):
          guard childIdx < children.count else {
            return .failure(TreeError.ChildIndexOutOfBounds)
          }
          return .success(Tree(children[childIdx]))
      }
    }
  }
}

There are only downsides to this, because both the implementation of the subscript and the use-sites need to deal with wrapping and unwrapping the Result, and the chained accesses are clunky:

// With Result, using its `get()` method to throw the Error for maximum brevity.
// Awkward to chain accesses and obtain the actual returned value, but does
// preserve the specific Error value so the user can act accordingly.
do {
  let _ = try tree[v2: 0].get()[v2: 1].get()[v2: 2].get()
} catch {
  // ...
}

This leads API authors to simply returning nil on failure for computed properties and subscripts, without providing additional information. If the syntactic sugar around unwrapping Optional is also not desirable, then they're left with invoking a fatalError, which is what Array in the standard library does for its subscript(_:Int).

This proposal provides a third option by allowing us to define a throwing subscript like so:

extension Tree {
  // Version 3: access a child node, indicating the kind of error by throwing
  // This is made possible by the effectful properties proposal.
  subscript(v3 childIdx : Int) -> Tree<T> {
    get throws { 
      switch node {
        case .leaf:
          throw TreeError.NotInteriorNode
        case let .interior(_, children):
          guard childIdx < children.count else {
            throw TreeError.ChildIndexOutOfBounds
          }
          return Tree(children[childIdx])
      }
    }
  }
}

So that we can get the best of everything: a clean chained syntax with error information available (or discardable with try?), plus no explicit wrapping/unwrapping:

// With the proposal, we get the best of both: clean chaining and accesses,
// while taking full advantage of the error handling mechanisms in Swift.
do {
  let _ = try tree[v3: 0][v3: 1][v3: 2]
} catch {
  // ...
}

Now, these same arguments for throws subscripts apply to computed properties too. The only difference is that the source of the error is different: in these examples, the TreeError.ChildIndexOutOfBounds failure is only due to a bad input to the subscript from the user. The "inputs" to a computed property are the state of the object, which may be in various kinds of invalid states. For this simple tree example, there is only one invalid state when accessing the value stored at the node:

extension Tree {
  // a throwing accessor, instead of using an Optional<T> or Result<T, E>,
  // so that use-sites can understand the failure reason.
  var value : T {
    get throws {
      switch node {
        case .leaf:
          throw TreeError.NotInteriorNode
        case let .interior(val, _):
          return val
      }
    }
  }
}
5 Likes

I am overjoyed to see this proposal come to review.

The problem addressed is significant: already, there are APIs in the concurrency proposals which would most logically be properties rather than methods (I’m thinking of AsyncSequence in particular) and would benefit from the feature proposed here. So, not only is it building out an existing feature in a way that fits the current feel and direction of Swift, it is also a timely proposal.

It would take me a beat to think about whether other languages I’ve used now have similar features: if they do, it’s so natural a fit I haven’t felt its presence in those languages so much as its absence in Swift.

I have followed this proposal since the pitch stage and read the version for review carefully.

I hope that adoption of this proposal will also mean that those APIs in the corresponding concurrency proposals which have to date required being made methods instead of properties can revert to their intended form.

I am puzzled at the notion that () should be required to suggest the presence of effects at the use site: this has never been the case for Swift. We have explicit markers for effects at the use site, try and await , and this proposal in no way deviates from existing practice.

15 Likes

I don't see why we'd need new syntax. You're right that it should probably be async throws var asyncProperty: Bool { /* ... */ }

because we already have another "effectful property" in the language: lazy

However, none of this has any impact on subscripts, which are already written as functions in their declaration syntax... so why do we explicitly need a get { } definition within those?

2 Likes

I am very happy to see this proposal. It is a straight-forward extension of our current model, enables new API modeling capabilities, is something we should have had for a long time (for throwing), and is commonly requested. The approach to solve this (syntax and semantics) are good.

This solve a rising problem with async APIs where the presence of async would gratuitously force things into being methods instead of allowing them to be consistent with properties. For example, the count of a collection should be a property regardless of whether it happens to be async.

Further, as Xiaodi points out, there isn't a correlation between performance and "being a property". We have O(n) properties in widely used Swift APIs (including Collection.count, see "Expected Performance" in the Collection documentation.

My only concern with this is that it is a half step - allowing getters but not setters is an incremental move in the right direction, but we should also get setters one day.

-Chris

11 Likes

Thank you for the detailed response. You have convinced me.

I am now in favor of this proposal.

1 Like