Pitch: Property Delegates

I went ahead and prototyped support for using property delegates via attribute syntax. Essentially, this means that one would write:

@Lazy var x = 10

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

which is equivalent to the current proposal's

var x = 10 by Lazy

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

There are some restrictions that come with this design: we no longer have syntactic space for the by private / by public part of the feature. On the other hand, it dovetails with the discussion of custom attributes. I'll bring up the general idea over there.

Doug

15 Likes

This syntax looks really nice IMO. :-)

One concern I have with the attribute like syntax that it gives you an impression you could stack multiple property delegates together, which then likely will be diagnosed, but I can forsee that a lot of users will tap into that trap for a few times.

2 Likes

We'll have this problem with lazy, @NSCopying, @NSManaged, and other existing properties / modifiers already.

Doug

Yes please! This prefix syntax with "@" is so much better than postfix syntax using "by".

Prefix syntax is also more similar to how "lazy", "@IBOutlet" and others are currently used in Swift.

A possible idea for the private/public, even though it's not probably liked by everyone is to riff on private(set) so you would have private(UserDefault) used same way, i.e.

private(UserDefault) static var isFooFeatureEnabled: Bool

What about a slightly different formulation of the attribute approach that combines both concepts?

@storageBy(Lazy)
var x = 10
1 Like

As Doug already pointed out the issues remains with any attribute, while I think the originally pitched syntax limits the usage through the grammar to one delegate type. It's not a big deal though. ;)

That’s fair, but semantically, I think the restriction is better hinted at by this formulation since the attribute is “storageBy” not “Lazy”. I’d think people would be much less likely to think they could stack multiple @storageBy’s than one @Lazy and one @UserDefault.

2 Likes

I read the post Doug did in the custom attribute thread I personally I come to the conclusion that the attribute style of property delegates would be the ideal solution here, wether this is @Lazy, @storageby(Lazy) or @storage(by: Lazy) it does not really matter as it's in the end the readers preference.

If we would go down that road it leaves the syntax space for 'property forwarding' that we could tackle separately later, because not all properties are backed nearby the original property and not every storage is or can be a property delegate.

I like where this is going with custom attributes, but instead of treating every property delegate type a different attribute and having property delegates as a language feature, could this maybe be implemented as just another static attribute? Doug's custom static attribute suggestion seems to be a good enough abstraction to leverage for this.

A static attribute would be defined in the standard library that describes how the property is to be synthesized.

@staticAttribute(usage: [.property])
struct Delegated<Delegate: PropertyDelegate> {

    var delegate: Delegate
    var delegateAccess: Access

    init(to delegate: Delegate, delegateAccess: Access) { ... }
    init(to delegate: Delegate.Type, delegateAccess: Access) { ... }
}

enum Access {
    case `public`
    case `internal`
    case `fileprivate`
    case `private`
    case hidden
}

PropertyDelegate would become a normal protocol. Users could then describe the property synthesis by applying the attribute:

class Test {

    @Delegated(to: Lazy.self, delegateAccess: .public)
    public var data: Data
}

The Swift compiler would interpret the attribute. For the given example, it would synthesize the following:

class Test {

    public var $data: Lazy<Data>

    public var data: Data {
        get {
            return $data
        }
        set {
           $data = newValue
        }
    }
}

Delegated could be expanded with additional optional properties like delegateName if the user needs an alternative name, potentially not prefix with $.

The whole idea is very similar to ObjC way of synthesizing properties.

1 Like

What would the access level of the storage be? Identical to the property itself? Since we already have parameterized access levels perhaps we could piggyback on that with private(storage) or private(delegate). I think it's pretty important to have control over the access level.

Assuming we go in this direction for property delegates, the by <expression-or-key-path> syntax would be available if we want to some provide sugar for more advanced forwarding use cases. I wouldn't be surprised to see motivation for this arise as people start writing property delegates. In some cases they will discover they have a use case with requirements that go beyond the capabilities of property delegates. Forwarding is a way to provide about half of the usage-site syntactic sugar of property delegates. That means you don't face a steep cliff from all of the sugar to no sugar if a property delegate doesn't work out.

3 Likes

The access level for the storage property would be max(internal, access-of-declared-property), which is the default in the current proposal.

Perhaps. Without control over the access level, we'd have to fall back to manually implementing the property and its backing storage.

I think it's worth being clear that introducing the by syntax later will have a steeper cliff to climb, because it'll have fewer use cases and we'll have set the precedent of using attributes for property delegates.

But, yes, there is still syntactic space for adding forwarding and such later.

Doug

This is starting to get quite verbose (compare that to @Lazy or lazy).

Doug

3 Likes

This has been stewing in my mind for days now. Most of the argument has been about the spelling of this feature. From Java, I've seen how annotations can totally take over the front part of your declaration, obscuring the thing that you actually want to see, and becoming so verbose that it takes over multiple lines, destroying the actual flow of your code. (The annotation becomes the language.)

So how about something totally different?

A property behavior is pretty much exactly this:

var humbug = Behave<Int>(5)

So why don't we just call it that? The compiler could just know implicitly that, because Behave is a delegate property, every time you referred to humbug, you actually meant the wrapped value.

There's one problem with this, in that you're hiding this fact from the reader. So how about we change the "var" keyword to "via", as in you're getting this value via the wrapper?

via humbug = Behave<Int>(5)

This would also make it pretty clean to do multiple levels of wrapping, like

via humbug = Oh<Behave<Int>>(5)

An alternative spelling might be

var $humbug = Oh<Behave<Int>>(5)

This way, it's an extra signal that when you're talking to $humbug, it's the wrapper, but when you use just humbug, it's the actual value.

3 Likes
final class System<Interface> {
   private let core = Core()
   var isRunning: Bool by \.core.storage.isRunning
}

I have kind of something like this with like 30 or more redirected properties, which I need to expose indirectly. You can imagine that this is quite tedious repetition of typing work. I would love some simplifications made available by the language. Property delegates won't solve that issue for me.

2 Likes

With an extension of property delegates to support receiving self in some fashion, I could imagine a property delegate letting you say something like @Forwarding(to: \.core.storage.isRunning) var isRunning. But you're correct that the initial feature won't get you there.

2 Likes

Seems to me this is an orthogonal feature that we could solve with a special, compiler-understood static attribute.

@forwarded(to: \.core.storage.isRunning)
var isRunning: Bool

But that's more compiler magic.

2 Likes

Either that or a synthetization of the getter and setter with using a key-path as I mentioned above like 100 times. :grin:

// using a key-path
var property by <key-path-placeholder> {
  // implicitly synthetized
  get { 
    return self[keyPath: <key-path-from-above-placeholder>]
  }

  // implicitly synthetized (iff key-path is (reference-)writable)
  set { 
    self[keyPath: <key-path-from-above-placeholder>] = newValue
  }
}

But @forwarded(to: \.core.storage.isRunning) is fine by me too.

1 Like

These last few posts got me thinking. I'm going to propose a different design where the delegate and the storage are two separate things. The delegate defines the storage by having a Storage type (or typealias) inside of it and access the storage to get and set the value using a subscript:

@propertyDelegate 
struct Lazy<Container, Value> {
  enum Storage {
    case uninitialized
    case initialized(Value)
  }

  var initialStorage: Storage
  var initialValue: () -> Value

  init(initialValue: @autoclosure @escaping () -> Value) {
    self.initialStorage = .uninitialized
    self.initialValue = initialValue
  }

  func get(from container: inout Container, valuePath: KeyPath<Container, Value>) {
      switch container[keyPath: valuePath] {
      case .uninitialized:
        container[keyPath: valuePath] = initialValue(value)
        return value
      case .initialized(let value):
        return value
  }
  func set(newValue: Value, in container: inout Container, valuePath: KeyPath<Container, Value>)
      container[keyPath: valuePath] = .initialized(newValue)
  }
  func reset() {
      container[keyPath: valuePath] = .uninitialized
  }
}

Then this code:

$Lazy var property: Int = 5

Would generate something like this:

// the delegate lives as a static variable
static var _property_delegate: Lazy<Self, Int> = Lazy(initialValue: 5)
// the storage is an instance variable
var _property_storage: Lazy<Self, Int>.Storage = Self._property_delegate.initialStorage
// the computed property accesses the value through the delegate
var property: Lazy<Self, Int>.Value {
   mutating get {
      // mutating only when Lazy.get takes `self` as `inout`
      return Self._property_delegate.get(from: self, valuePath: \._property_storage])
   }
   set {
      // mutating only when Lazy.set takes `self` as `inout`
      Self._property_delegate.set(newValue, in: self, valuePath: \._property_storage)
   }
}

Splitting the storage and the delegate allows two things:

  1. access to self in the delegate's getter & setter. In case of Lazy, it means we can pass self to the closure if desired.

  2. storing global per-property metadata and using it in the getter & setter. In case of Lazy, it means we don't have to store the closure for the initial value within every instance.