SE-0310: Effectful Read-only Properties

My point is that if one doesn't want to use a language feature for one reason or another that doesn't mean that it shouldn't be available. I, personally, don't like functional programming very much because it feels (I use the term "feels" because I haven't done benchmarks and I haven't researched Swift's optimization runs to claim it as fact) overly wasteful of computing resources due to profuse usage of closures and repeated allocation of temporary arrays, but I don't argue against adding functional-style utilities like map, flatMap, compactMap and the entirety of Combine, which I'll happily throw away from my code once I get my long-awaited (pun intended :slightly_smiling_face:) concurrency model.

For me a stored property and computed property should be interchangeable.

If we introduce async get computed property then async stored properties should be supported. (Not sure what this would look like) This proposal should address stored properties async getter to be able to participate in this async world (maybe via some kind of async property wrapper) along with support for setter.

But if doesn’t make sense for stored properties to support async then I don’t believe the asymmetry is worth it only for get computed properties.

1 Like

It does makes sense for a @Sendable stored actor property. To access it from outside the actor you need to go through the async mailbox.

Very much +1 from me. When updating a library to use the new concurrency features a few months ago I immediately felt the absence of async getters; I wanted to expose some properties from types which had newly become actors but didn't want to convert everything to function calls since the getters were just acquiring a lock, reading and possibly lightly processing a value, releasing the lock, then returning. Given I'd just expected that I'd be able to expose async properties and was surprised to find I couldn't, this (IMO) obviously deserves inclusion in Swift.

Alternatives considered

The rethrows specifier is excluded from this proposal because one cannot pass a closure (or any other explicit value) during a property get operation.

However, you can pass a closure to a read-only subscript, either as an argument within the square brackets, or as a trailing closure.

9 Likes

Stored properties on actors are implicitly async when used from other actors. That's one of the whole ideas here. We already (as part of the actor proposal) need to write await thing.property, but we have no way to declare an async property on general types. This proposal fills in that hole (among many other benefits listed above).

-Chris

13 Likes

I have been thinking why I am on "this adds complexity" side.

The quotes borrowed from @OneSadCookie and @Panajev were the very reason.
One of "the line" for me is: Complex logic should be written as a method.

However, this is what Swift is today already:

var count: [Element] { ... }
func count() -> [Element] { ... }

Apparently both of the above are correct today very obviously, and it's up to developers which to choose.
And that process itself is doesn't get affected by this particular proposal.

Which means I couldn't come up with a concrete or convincing enough explanation — I think am positive with this proposal now.

And I think the place of "effects specifiers" is very precise and nice.

I think this is very necessary in order to make async APIs feel at home in Swift. I'm a bit less sure this is necessary when it comes to throwing, but I can't really find a reason to object.


My only concern with the syntax is: how does it evolve in protocol declarations? Currently we allow this:

protocol P {
  var p1: Int { get }
}

With this proposal we'll allow this:

protocol P {
  var p1: Int { get async throws }
}

And maybe in the future we'll allow this:

protocol P {
  var p2: Int { get async throws nonmutating set }
}

I feel it's a bit hard to tell where the get declaration ends and when the set declaration starts now that some attributes go after the get, but I don't really see how to improve it without bringing other inconsistencies. And we still haven't added typed throw to the keyword soup either.

I'm not concerned enough by this to say the current proposal needs another syntax, but it remains on the back of my mind that we may eventually have to do something about it.

4 Likes

@beccadax's proposal mentioned the alternative to put commas in protocol accessor declarations. Since it's currently possible to split get and set on different lines, I'd prefer allowing the semicolon ; between them. If we end up with multiple thrown errors using a syntax along the lines of

func foo() async throws E1, E2 -> Int { ... }

the comma may clash. So the following options would be equivalent:

protocol P {
  var p1: Int {
    get async throws
    nonmutating set
  }
  var p2: Int { get async throws; nonmutating set }
}
5 Likes

I agree we should support a separator in the protocol requirement specification.

However, get and set are more like items in a list than they are statements. I think it would be slightly better to use comma instead of semicolon here.

3 Likes

Good point. I can also imagine some use-cases for this too:

extension Array {
  // select possibly non-contiguous members based on predicate function
  subscript<T>(matching: (T) throws -> Bool) -> [T] { /**/ }
}

It will need to align with the fix for rethrows and since its new, would not support rethrows(unsafe).

I'll look into the feasibility of implementing this.

1 Like

What is your evaluation of the proposal?

+1

Is the problem being addressed significant enough to warrant a change to Swift?

I like Xiaodi Wu's phrasing: significant and timely.

Does this proposal fit well with the feel and direction of Swift?

There seem to be two broad opinions on this; I'm on the "yes" side. In terms of mental complexity, I think the mental model "properties and subscripts can have effects like methods can (here is how you write that down)" is less complex than "effects are things that methods can have; properties and subscripts can't do that". (I see the getter-only situation as a temporary stopping point on the way towards the more general model.)

But I'm a bit concerned about the keypaths story. Am I reading the proposal right that it would be impossible to form keypaths (of any kind) to or through an effectful property?

I feel like keypaths are a particularly elegant Swift feature, in their simplicity and general applicability. This exception would complicate that story significantly. If I update some property to be async it's natural to expect I will need to apply some await; it would be a more unpleasant surprise to discover that I can no longer pass that type into my nice generic utility that happens to use keypaths as an implementation detail.

(I do see that even if we had keypath support what my utility does with the keypath would have to change, but unlike adding await the only solution available seems to be rearchitecting away from keypaths entirely: that seems to push people away from the nice Swifty abstraction that keypaths offer.)

If you have used other languages or libraries with a similar feature, how do you feel that this proposal compares to those?

n/a

How much effort did you put into your review? A glance, a quick reading, or an in-depth study?

Careful reading of the proposal & this thread; I've followed the concurrency proposals in general but not put any code to the test.

As a feature I think it’s fine. I don’t see anything wrong with examples like try await asyncSequence.count, in fact I think the language would suffer quite a bit under the inconsistency not having it would bring.

As for the idea that computed properties are supposed to be O(1), that can still hold, assuming that O(1) simply means “constant time”. It just may happen to be a relatively large constant.

One thing I’m not sure about is whether we should be able to async just any old getter, or whether async getters must be in an actor?

I went into this skeptically (“read only effects” sounds like an oxymoron) but I’m now a pretty clear +1

2 Likes

Overall, +1.

One thing that concerns me is the adaptation to KeyPath in the future.

a non-trivial restructuring of the type system, or significant extensions to the KeyPath API, would be required

As you stated in the proposal, I also think it will be quite difficult to adapt to the current KeyPath API in terms of the current type system.
For example, with the current KeyPath API, it would be combinatorial explosion to express async getter, throws getter, and async throws getter with mutability and reference/value semantics. (And needs to care subtyping structure)
To avoid the explosion, one possible way would be to define trait protocols such as protocol WritableKeyPath<T> and protocol ReferenceKeyPath<T> and make it composable like WritableKeyPath<T> & ThrowingKeyPath<T>, I think. But this needs significant efforts in type-system.

@kavon So I would like to know what features do you think are needed in the type system to express the KeyPaths? And do you think it is feasible?

The rethrows specifier is excluded from this proposal because one cannot pass a closure (or any other explicit value) during a property get operation.

This is not correct; rethrows can be calculated by conformance. Consider the following:

@rethrows
protocol SomeSourceOfThrowing {
  func item() throws -> String
}

struct Container<Source: SomeSourceOfThrowing> {
   var source: Source
   var something: String { 
     get rethrows {
       try source.item()
     }
  }
}

Non theoretical accessors would be for example:

extension AsyncSequence {
  var first: Element? {
    get async rethrows {
      ...
    }
  }
}

The rest of this proposal seems fantastic and is something that will definitely provide some great positive impact to frameworks and apps written in swift.

2 Likes

I know this has already been accepted, but I didn't see any discussion of lazy async properties (or throwing properties) in either this thread or the proposal. lazy var foo() = await bar() doesn't seem to work in the compiler I'm testing ("'async' call cannot occur in a property initializer"). Is this a known limitation, an oversight or a bug?

Personally, I think being able to express the following in a more succinct (and safer) manner would be advantageous. Especially since lazy is often used to defer expensive calculations, a use-case await relates to as well.

private var _foo: Foo?
var foo: Foo {
  get async throws {
    if _foo == nil { _foo = try await makeFoo() }
    return _foo!
  }
}

UPDATE: Actually, I'm not even sure the above is sufficient. For non-actor types, you might need to create a synchronization context as well, perhaps something like:

private struct FooActor {
 func get(_ fooArguments: @autoclosure () -> FooArguments) async throws -> Foo {
    if foo == nil { foo = Foo(fooArguents()) }
    return foo!
  } 
  private var foo: Foo?
}
private lazy var fooActor = FooActor()
private var _foo: Foo?
var foo: Foo {
  get async throws {
    if _foo == nil { _foo = fooActor.foo(FooArguments()) }
    return _foo!
  }
}
1 Like