Pitch: Property Delegates

What about a more explicit and Swifty syntax:

@storage(Lazy, Delayed) var foo: Int = 1738
3 Likes

I've been wondering the exact opposite thing, i.e. why can't that be valid? It seems more natural to me.

1 Like

I kinda have mixed feelings about a similar feature:
I love the concept of the proposal, and I think that it's absolutely positive to have a mechanism that allows to conveniently add proxy behaviours for properties/types. It removes so much boilerplate and mental load around some kind of tasks (and chances for tripping up in the way) that they would solve so many little problems we have nowadays.

My negative here is around the implementation idea of it. Not the exact implementation of the proposal, but the way it's approached. To me this looks like the usual "I have a concrete instance but I want to use it's general interface" issue that we solve with different tools from time to time, like protocols or superclasses/clusters. The aim of this proposal to me is to give a nicer interface to use "wrapper" types, or to be more precise to be able to use a wrapper type wherever the type itself can be used.

...doesn't this mean that practically we want a "proxy" wrapper to be a subtype of its own wrapped type? I have a feeling it's quite more complex than this, but this feels more like what this should achieve.

I also have a feeling that this feature would have some kind of overlap with a newtype

3 Likes

If that was valid, then what would this be desugared to?

var a = Foo()
var b: Int by a = 123

I think @DevAndArtist is on an interesting path here. This whole thing could be made conceptually lighter by only having property aliases, like this:

private var _storage = Lazy<Int>(.random(0 ... 10))
public var property by _storage.value

Unlike the current proposal, there is no magic $-prefixed property for the storage. The by keyword just create an alias to the storage property you specify.

A benefit is you'd be allowed to expose multiple properties of the storage through aliases. For instance:

private var _data = Data() { didSet { update() } }
public var name by _data.name
public var score by _data.score
public var isFinished by _data.flags[.isFinished]

This is a pattern I'm often using. Currently I have to write a boilerplate getter & setter for each forwarding property.

Property delegates are basically the same thing as this except the property creates its own storage, forcing property and storage to be coupled together. I think it'd be best if the coupling wasn't mandatory.

4 Likes

As clearly seen in the thread it took me quite a few iterations to come up with this. I think we don't even need a @propertyDelegate attribute at this point as it just creates noise if we were going such path. What does @propertyDelegate really gives us? In my opinion only restrictions on how we have to build the storage type, but even that is highly restricted and not flexible. All the storage types presented in the original proposal can be build without the need of the attribute.

I would go that far and claim that we can use key-paths to describe the origin of the storage and let the synthetization simply create key-path referenced links. Having also the ability to partly opt-out of the some synthetization will bring willSet/didSet observers back as those are simply an overriden set.

var property: T by <key-path-placeholder> {
  // synthetized
  get {
    return self[keyPath: storageKeyPath]
  }
  
  // synthetized
  // optionally if key-path is (reference-)writable
  set {
    self[keyPath: storageKeyPath] = newValue
  }
}

The only issue I have with this is the future of throwable accessors. See my concerns here:


Your examples will translate to:

public var name by \._data.name {}
public var score by \._data.score {}
public var isFinished by \._data.flags[.isFinished] {}

I don't think we need to have \. and {} in this syntax, but conceptually we're thinking the same.

Those are some great thoughts and thinking out of the box. I've been pondering over if this could be related to the ideas from Key-Path Member Lookup. There seems to be some overlap and we should consider if these two proposals could be different sides of a same, yet undefined, abstraction.

It's a really wild idea, but one could implement Lazy as a @keyPathMemberLookup type:

@keyPathMemberLookup
enum Lazy<Value> {
    case uninitialized(() -> Value)
    case initialized(Value)

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

    var value: Value {
        mutating get {
            switch self {
            case .uninitialized(let initializer):
                let value = initializer()
                self = .initialized(value)
                return value
            case .initialized(let value):
                return value
            }
        }
        set {
            self = .initialized(newValue)
        }
    }

    subscript<U>(keyPathMember keyPath: WritableKeyPath<T, U>) -> U {...}
}

In which case the declaration would be as simple as

var name: Lazy<String>

while still getting the underlying type API through the property.

name.isEmpty

The limitation of @keyPathMemberLookup is that it does not support methods in the current proposal, but one would expect that to be possible in future.

There is also a question on how to do the memberwise initializer and if the value should be exposed, but those seem to be solvable problems.

1 Like

If it was a key-path after by I would argue that we need it. Also if it wasn't a key-path you can get the impression that you can call methods with your syntax.

public var name by _data.getName(at: Index(0)) {}

Key-Paths don't support methods yet, and it's not clear how that would behave when we get the support.


The {} is datable. I left it there to signal that this is a computed property, compiler please infer get and set for me. Also you can override the synthetization anytime. ;)

This solution clearly has its trade-offs compared the synthetize it all behavior of the proposal, but it's by far more flexible, already statically safe, we don't lose access to self as the storage at the end of the key-path can still be lazy.

The first idea of this I had here:

Whatever storage types we create should be up to us and not restricted by any language attribute.

I agree, there seems to be quite an overlap:

  • @keyPathMemberLookup accepts that a value is of a concrete type, but allows direct access to nested properties.
  • @propertyDelegate uses a concrete type but mostly (apart from the $ storage) hides it around the raw type, which of course has access to its properties.
  • @propertyStorage does the same as @propertyDelegate but simplifies it by not automating the "hiding" bit, which also allows more freedom around how to do it.

The common points seem to be that there is a statically-known concrete wrapper type stored, and behaviour from the wrapped object that needs to be accessed.
The main difference I can see is that in the KeyPath case we want the direct access to be on the wrapper, while in this one we want direct access on the wrapped value.

The distinction can be easily made by deciding if the property can be passed to a function that accepts the wrapped type.

I think that both types of access make sense for different use-cases, but there certainly seems that there could be a common abstraction over both (and in this case possibly a lot of syntax as well?)

I think the direction of property forwarding @DevAndArtist and @michelf are discussing is very interesting as a potential underlying basis for property delegates. Property forwarding is a significant portion of the syntactic sugar property delegates while covering use cases property delegates do not. They also provide a natural fallback for use cases which require modifers or attributes on the underlying storage without requiring the entire forwarding pattern to be written out manually.

However, I do think it would be unfortaunte to give up the modifier-like syntax of property delegates. I think we would need to look for a way to also support that sugar for simple use cases, with manual written storage only necessary in more advanced use cases. I think a modifier-like syntax would be much easier for a beginner to use and understand than a forwarding mechanism. It is also much more concise.

I agree. This is a property forwarding feature. I don’t think the implementation needs to be defined (in terms of key paths or otherwise) or considered when deciding on syntax.

1 Like

Can you provide an example on how you think you would get the behavior of property observers back? I mean I can think of some, but I would like to know if we're on the same page here. ;) I don't necessarily see this as forwarding, but a way of telling the compiler to synthetize the computed property accessors.


I think we should start from this direction and add sugar on top, instead of starting with sugar and then struggling with too many edge-cases that will pop up.

I also notified the similarities, but the difference is much more significant than what you allude to here. @keyPathMemberLookup will only forward key paths. It will not forward methods.

The way I see the difference is that @keyPathMemberLookup is a feature that can be used by the implementer of a type to forward property access, etc to add to its own API. The underlying interpretation of the key paths is an implementation detail and is not necessarily visible to users of the type. On the other hand, property delegates are used by users of a type to wrap its storage for a specific property. In this case it is the wrapper that is often an implementation detail with the wrapped value itself being the primary emphasis.

Do you have something specific in mind? It isn’t at all clear to me how that would be accomplished. While they are similar, they play very differerent roles as I described above.

Can you elaborate on this question and why you are asking it in reply to my response? The synthesis you posted would still be one way to implement this.

It synthesizing getter and setter implementations that forwards to storage specified by an expression.

Do you have any ideas for also supporting concise syntax for simple use cases? I am specifically concerned with synthesizing the backing storage with a specified wrapper type and initial value (basically the core features of this pitch).

One implementation would be as I posted above, the other if you don't want to use key-path would just generate the written out path to the storage.

var property: T by \.storage.intermediate.value {
  // synthetized
  get {
    return self[keyPath: storageKeyPath]
  }
} 

var property: T by storage.intermediate.value {
  // synthetized
  get {
    return storage.intermediate.value
  }
} 

I don't like the latter as it just repeats the same thing we already have written instead of like the former just use the keypath. It's like if you compare MyVeryLongCocoaTouchClassName vs. Self. The path can already be complex (it doesn't have to for common cases).

Okay so you see it as a synonym, gotcha. I thought this would be a new 'kind' of property forwarding instead of synthetization behind the scenes.

The main issue with the current proposal is that it wants to synthetize everything if possible. That is a great goal, but it's not trivial. You can notice it by the restrictions the proposal introduces such as the storage must be a generic type and it must have only a single generic type paramanter. The initialization issue was solved by a syntax such as var property: Int by Lazy(initialValue: ...) where you directly initialize the storage from the computed property declaration.

If we really want some sugar here, we would need to find a way that expresses where the storage should end up. That again is quite limited as it will be nearby. That simply eliminates my previous sentence in this paragraph. :smiley: Then you can still write

var property: Int by Storage.init(/*anything except self */)

That should translate to the more advanced property forwarding:

var _property = Storage.init(...)
var property: Int by \._property.value {
  // synthetized
  get { return self[keyPath: storageKeyPath] }
  // synthetized if key-path is (reference-)writable
  set { self[keyPath: storageKeyPath] = newValue }
}

Storage is required to follow only a few rules as I mentioned above, but I'll copy paste them here again:

  • @propertyStorage can mark any type (not retroactively).
  • A type annotated with @propertyStorage must implement a property named value .
  • value property must have the same access level as the type.

That sugar has issues such as:

  • no access to self
  • storage cannot be lazy
  • access level is hidden

If you would try to solve these issues, you will end up with cluttering the property declaration with too many things and defeat the purpose of the main idea:

public var property: Value by internal private(set) lazy Storage(self)

Here you can fall-back to the other solution:

internal private(set) lazy _storage = Storage(self)
public var property: Value by \._storage.value {}

OTOH, we implemented it this way because it's useful to do so. As the discussion in this thread has raised, there are other potential properties that would want to reference self as well, so it doesn't seem like an outlier. I think it's fine to acknowledge that not being able to do so is a limitation of this design, since the design still covers a useful cross section of "behaviors", but that a future extension is necessary to cover other cases.

1 Like

As a feature name, I feel like "property macros" is too generic---it both overpromises (I should be able to define a macro to do anything with a property) and says too little (well, what can I do with them?). That said, I agree that the #Lazy syntax evokes macro expansion, which fits the way in which property delegates are used. It scales okay to the initialization case:

enum GlobalSettings {
  #UserDefault(key: "FOO_FEATURE_ENABLED", defaultValue: false)
  static var isFooFeatureEnabled: Bool
  #UserDefault(key: "BAR_FEATURE_ENABLED", defaultValue: false)
  static var isBarFeatureEnabled: Bool
}

Others have suggested using attribute syntax (e.g., @Lazy) or embracing the $ also for the declaration side ($Lazy); I prefer it to those options because we've already set aside # for things in this space. That said, it makes placing any modifiers (like public to making the storage property visible) a bit awkward---if they go in the parentheses, they look like initialization arguments, but there's no other natural space for them.

We already have "multiple access control modifiers" via private(set), so the notion is not newly-introduced here. Moreover, we are declaring two entities---that's fundamentally how we describe this feature---and it is reasonable that authors could choose whether the delegate is part of their API or not.

Presumably, this means that #Forward is another new feature, but it's using the same syntax as a property delegate. We'll need to be careful in this syntactic space. Also, I think the relationship expressed above is backwards: we want to define fooStorage as a normal (stored?) property, and sugar the declaration of foo that delegates to fooStorage.

Syntax aside, I think your meta-point is reasonable: rather than trying to pack configurability of the storage property into the original property declaration, we could say that there is a syntax for "delegate to this other named property". With the by syntax, this can fall out as a result of name lookup referring to a property:

public var fooStorage: UserDefaults<Int> = ...
public var foo: Int by fooStorage   // okay, we found a property; delegate to that

With a # syntax, we'd probably need something special, e.g.,

public var fooStorage: UserDefaults<Int> = ...
#delegate(to: fooStorage) public var foo: Int

Yes, we could have some kind of #storage(of:). to access the backing storage. I find that to be rather verbose for the use case, but perhaps I haven't sold the use cases well enough to motivate using $. I'll see if I can do better in a revision of the proposal.

Doug

Let's say you already shipped a shared library, and then you modified one of your ivars with a property delegate. Would that make your shared library incompatible?

1 Like

Are property delegates compatible with the willSet/didSet methods? Like, can I still do something like this?

var foo by Lazy = 1738 {
    willSet{...}
    didSet{...}
}

To implement KVO, we'd want to get passed self and the keyPath to whatever changed. Since the current proposal is to just have a property called "value", that's not something we could retrofit. It would have to be agreed on up front.

1 Like