SE-0258: Property Delegates

My proposals for alternate spellings:

  • Property Wrappers
  • Property Modifiers
  • Property Decorators

Even with the current naming, I'm +1 on the proposal!

3 Likes

I am terrifed that, if this proposal is accepted as is, a day will soon come when I see, for example, reactive codebases litered with $ signs (the BehaviourRelay example in the proposal writ large). It terrifies me because I love the elegance of Swift code.

On a scale between property storage and property behaviours, I'm concerned the elegance of Swift will be ruined by libraries leaning too close toward using this feature for property behaviours, where the only way to access those behaviours is by the internal ly scoped $property . It's jQuery all over again.

This concerns me too. One reason Go is so loved by developers is that it's opinionated in avoiding cleverness. With Swift now opening up to a myriad ways of writing something, this in particular sticks out as a potentially abused feature to introduce cleverness.

9 Likes

+1 on the proposal, except $.

$0 in closures and $foo for storage are not same thing, and overall rather not have $ profilerate in Swift codebases.

Access to storage shouldn’t need to be optimally straightforward, so storage(of: foo) or something similar is appropriate. People can write their own additional shorthand wrappers for it if they really need to.

5 Likes

As excited as I am about the proposal, I still think this is a really interesting alternative worth thinking about.

Just to leave my 5c on this. I don't really agree, IMO is not about accessing the storage itself, but about accessing some richer API that the property delegate offers.

2 Likes

In any way whatsoever, the $ notation is magical and not discoverable. To make the whole problem consistent to the programmer, we can stick to a single notation: @storage.

A field would be declared like:

@storage(Lazy) var foo

A storage would be accessed like:

@storage(foo)

This way, the @storage notation is the single access point to the documentation on property delegates. And it increases the progressive disclosure of the storages because it is a visible pattern.

Doug wanted a short notation to avoid boilerplates. But maybe this problem can't have a notation that is both short and satisfactory.

1 Like

Discoverability could be helped in other ways that don't need to make the syntax more verbose. For example, option clicking on the attribute could inform you that it's a property delegate and link you to the implementation / doc blocks.

In my opinion, the syntax similarity to Custom Attributes is one of this feature's strong points. A couple reasons:

1: There is a clear way to pass arguments:

@UserDefault(key: "FOO_FEATURE_ENABLED", defaultValue: false)
var isFooFeatureEnabled: Bool

2: It is nearly as concise as current keyword-based solutions:

lazy var foo: Int
@Lazy var foo: Int
1 Like

Having used the toolchain, the $ property is pretty discoverable, as it shows up in the autocomplete list for the enclosing type.

1 Like

But it's not discoverable when writing enclosingType.delegatingProperty., does it? The delegate's API would be more discoverable if it were available as $-prefixed identifiers off of the main property. I'm not positive that's a good idea but as I mentioned in my review, I do think it is worthy of more discussion than it has received.

Taking off my review-manager hat, I think it's an interesting idea, but it's also pretty weird in its own right, and it shares the problem with some other proposals of making it impossible to directly refer to the storage other than by calling a method on it, e.g. to form a key path to it or to pass it as an ordinary argument.

5 Likes

Right, there are tradeoffs. I'm not strongly advocating that this is the right solution. But I would like to see it explored further and the tradeoffs evaluated. If this was the only open question in my mind I might attempt to do that here. But I think there are enough open questions that I don't think a review thread is the right place to resolve them. I really think we should have let this bake a little longer before bringing it up for review.

4 Likes

Okay. I think we're hearing that fairly clearly from multiple people in this review.

4 Likes

While I like the overall direction of the proposal, I feel pretty strongly that it needs another round of iteration before we should accept it.

In particular, I find the idea of exposing the synthesized storage by shadowing the variable name and prefixing with a $ is problematic. In additional to being too magical and clever, the mental model itself is prone to confusion, and as others have pointed out, there are issues around figuring out the visibility (having another access declaration on the same line would be a syntactic nightmare, I think).

I strongly believe that the synthesized storage should not be made directly available at all, and the programmer should create their own backing if they need to access it. I also believe it is important for the writer of the delegate to have more control of what parts of the delegate get exposed to whom.

There are several other ways to expose the necessary functions in a way which is unambiguous with the property's own methods. I've mentioned a few in the thread, but there are lots of others.

For example:
We could have an annotation (strawman: @delegateFunc) that tells the compiler to expose a particular function/property of a delegate to outside users of the decorated property. Then we could use a post-fix operator (either @ or $) to refer to the exposed parts of the storage

@propertyDelegate
struct MyDelegate {
    @delegateFunc func foo() {...}
    func bar() {...}
}

struct MyType {
    @MyDelegate var zaz:Int

     func someFunc () {
         self.zaz$.foo()  //This can be accessed
         self.zaz$.bar() //This can be accessed within the property's type
     }
}

myTypeVar.zaz$.foo() //This can still be accessed because of the annotation
myTypeVar.zaz$.bar() //ERROR: this is unavailable outside of the property's type

This lets the delegate control what gets exposed. (Also, if we use @ instead of $, then $ will still be completely free for future use)

The advantage of a post-fix operator, as opposed to a shadow variable (i.e. foo$ instead of $foo) is actually pretty substantial (despite being almost the same syntactically):

  • The mental model is easier to understand. I am doing something to the property itself as opposed to referencing a different invisible variable. (e.g. myProp$.reset() is resetting the property)
  • When we do get composition, it will naturally compose to allow deeper access (e.g. myProp$$.deeperFunc())

Tl;dr: We need to spend a little more time thinking through the details of the syntax. One more round. I don't feel like it is fully baked yet...

1 Like

I would argue that if you want to do things to the storage like pass it around or have keyPaths to it, then you should have to create it yourself.

I would like to see something like:

private var myStorage = MyDelegate(startingValue: 11)
@MyDelegate(using: myStorage) var foo:Int

Then you have full control over the storage. If you don't provide your own storage, then it would be synthesized by the compiler (but without an accessible name).

The point of CopyOnWrite having separate reading and writing interfaces is that copy-on-write logic is applied when you use the writing interface, but not when you use the reading one. You could expose the writing interface as $foo or $foo.uniqued, but you can't expose it as foo, because then it would be a copy-on-read delegate instead.

1 Like

See, this seems backwards to me. $x already has precedence in the language as autogenerated closure variables, as well as in the debugger, and there there doesn't seem to be much of a learning curve applying the same logic to the autogenerated backing store of a delegated property. They seem very similar to me, except the property delegate is even easier to use since it shows up in autocomplete and the closure capture variables don't.

On the other hand, your suggestion requires a new postfix operator with potentially arbitrary nesting. Even if we wanted this, that doesn't seem like a good application of $. There's likely a lighter, less visibly jarring operator we can use instead. But such an operator, while somewhat precedented by ?., is just as magical as $property, since it's exposing API the user didn't expect. In fact, every way of allowing access to this underlying API will be magical, since the user just used an attribute and may have no idea of the API that comes along with it.

4 Likes

I see what I was missing now, this is just a reskin of the valueForReading / valueForWriting pattern. I’m not sure why I didn’t notice that earlier.

I think using $foo when writing is pretty subtle, especially when there is no support in the type system for preventing the use of APIs that will modify the object. It certainly doesn’t feel to me like anywhere near adequate motivation for magic involved in the delegateValue feature.

This is obviously subjective, but to be honest I don’t think this is a good use of property delegates at all. Given the importance of using the correct accessor and lack of type system support I would choose a design that requires users to write foo.valueForReading and foo.valueForWriting` explicitly at usage sites like this.

protocol Copyable: AnyObject {
  func copy() -> Self
}

struct CopyOnWrite<Value: Copyable> {
  init(_ value: Value) {
    self.value = value
  }
  
  private var value: Value

  var valueForReading: Value {
      return value
  }
  
  var valueForWriting: Value {
    mutating get {
      if !isKnownUniquelyReferenced(&value) {
        value = value.copy()
      }
      return value
    }
    set {
      value = newValue
    }
  }
}

This design makes it very clear to readers what is happening at usage sites. It is less likely that the wrong accessor would be used and much more likely that if mistakes are made a reader will notice them.

3 Likes

Completely agree. I also think a new keyword like from or by at the declaration site would do much to reduce verbosity and density:

I'd also add that the synthesized storage would be still be accessible through runtime reflection.

How is that any less verbose than the implemented syntax? You've replaced an @ with a keyword. And frankly, generated access to the storage API is most of the value here, so removing it seems like a non starter to me.

I was referring to @Jon_Hull's example where the developer who wants access to the delegate has to define it. Sorry for the confusion. Specifically:

public var foo: Int from myStorage

...is less verbose and more readable than:

private var myStorage = MyDelegate(startingValue: 11) @MyDelegate(using: myStorage) var foo:Int

Where the delegate is declared can be elsewhere in the same scope as the delegated property (it doesn't have to be smashed together).

It's also arguably the most controversal part of the proposal. The developer declaring the storage rather than the compiler synthesizing access to it helps you reason about a code base easier, solves any problems about discoverability, is clear about what access levels delegates should have, perserves elegance and makes the code much easier to read, teach/explain.

1 Like

And makes the proposal largely pointless, since that's the status quo.