Pitch: Property Delegates

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.

Do we really need to access the backing storage? I mean the whole idea is to "accommodate several important patterns for properties" and correct me if I'm wrong, the backing storage is going to be private or internal in most cases. There aren't many use-cases that we really need to access the backing storage.
That being said, this feature cannot stop anyone from writing this code:

var fooStorage: Lazy<Int> = .init(initialValue: 42)
var foo: Int {
    get { return fooStorage.value }
    set { fooStorage.value = newValue }
}

The whole idea of having $ as prefix of backing storage is cool, but I think it's just overdoing it.
If the backing storage needs to be exposed, I prefer it being defined explicitly rather than being synthesized implicitly. Either-way delegation would still be possible:

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

    var delegate: Delegate

    init(to delegate: Delegate) { ... }
}

class Test {

    public var dataStorage = Lazy<Data>()

    @Delegated(to: dataStorage)
    private var data: Data
}

Another approach would be to define the synthesized storage with different access-level. (but my guess is, it will add a lot of complexities to the compiler since using $ as prefix of variable is not allowed):

class Test {
    @Lazy var data1: Data
    
    private var $data2: Lazy<Data>
    @Lazy var data2: Data
}

will result in:

class Test {
    internal var $data1 = Lazy<Data>()
    
    internal var data1: Data {
        mutating get { return $data1.value }
        set { $data1.value = newValue }
    }
    
    private var $data2 = Lazy<Data>()
   
    internal var data2: Data {
        mutating get { return $data2.value }
        set { $data2.value = newValue }
    }
}
4 Likes

Thanks for your feedback! I do agree it's more verbose, but the access level parameter would be defaulted, so in most cases it would be just:

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

If we choose to call it storage, it becomes even simpler:

@Storage(Lazy.self) 
public var data: Data

As others have noted, access to the storage would rarely be need, so we could actually default the access level parameter to unexposed (hidden). A way to provide access could be to name the underlying storage property:

@Storage(Lazy.self, named: "_data") 
public var data: Data

That would expose it to max(internal, access-of-declared-property) visibility as self._data, but one could also change the access level:

@Storage(Lazy.self, named: "_data", visibility: .public) 
public var data: Data

Storage would also provide an initializer that accepts instances

@Storage(Lazy { ... }) 
public var data: Data

and maybe the one that allows key path references from self

@Storage(\.core.data) 
public var data: Data

I would be happy with built in @propertyDelegate of course, but I'm just trying to make sure we are not over-engineering this when a simple static attribute interpreted by the compiler provides more flexibility and solves most of the cases discussed so far at the cost of some verbosity.

I do agree it might be a too high price to pay, but it's worth considering.

1 Like

With access modifiers and attributes like @available, Swift is already there. Moving this one feature later (like the by syntax was doing) won't really solve the problem you're describing.

This makes the delegate type central, which is not what we want: we want the property type to be central, and the delegate type to be used for the backing property.

Doug

5 Likes

I don’t like this @ syntax because it doesn’t explicitly name the fact the storage of the property is delegated to or the storage of the property is provided by or from elsewhere. It’s harder to understand and read because the @ is confused with other compiler concepts and it’s harder to explain (express into a sentence of English, say) and teach.

With respect to exposing the storage using the $ syntax, could this be an alternative approach?

public var myProperty: Int from myStorage
private var myStorage: AtomicLazy<Int>()

The idea here is that implementations wanting access to the storage should declare and name a property that provides for another’s storage. Otherwise, the storage remains inaccessible:

public var myProperty: Int from AtomicLazy()

What I like about this is the developer can express that the overall implementation of the type with the property with delegated storage should (or does) not manipulate the storage directly.

2 Likes

I really love how your current iteration of the proposal ties property delegates with the attribute syntax and the potential custom attribute work. I think this is going in the right direction.

Concerning access control, shouldn’t the default be private instead of internal If those property delegates are implementation detail?

1 Like