[Pitch #2] Property Delegates by Custom Attributes

Thanks @Douglas_Gregor, for your work on this. I'm really looking forward to using this in our codebases. I personally prefer the new custom attribute syntax & I'm neutral on the $ vs _ debate.

In line with what @davedelong said above:

Internal access makes sense as a general default for types & properties, but is it the right default for backing storage of a Property Delegate? We typically want to hide storage to encourage encapsulation, but we're specifying a default that enables the opposite.

I understand the desire to keep the "everything is internal by default" model, but are we trying to create consistency across two things that would benefit from having different defaults? My concerns are that the internal default will result in:

  • additional boilerplate. Developers will need to add additional modifiers/property wrappers for the typical use cases.
  • more mistakes in mixed level teams, where some developers don't yet appreciate the value of encapsulation and quickly reach into another objects storage.

Quick question: Is it correct to say, defaulting to private storage has the following benefits?

  • Promotes encapsulation of storage, but developers can easily expose properties & methods with the same functionality. e.g func resetCounter() { self.$value = nil }
  • If we went with private as the default now, it would be non source/ABI breaking to change the default from private to internal in the future, but not the other way around.

Are the other reasons to start with internal for the property storage that aren't mentioned elsewhere?

4 Likes

The existing (and widely accepted) best practice when using one property as backing storage for another, is to make the backing property private and prefix its name with an underscore. If we introduce a way for the compiler to synthesize backing properties, as this pitch proposes, then we should follow that precedent. There is no reason to deviate from what the community expects and does already.

Backing storage should use a private member with an underscored name.

If someone wants to expose the backing storage at a higher visibility, they can simply create a separate computed property with the access level they want, and have it trivially forward to the backing storage. Most likely, they donā€™t even want to reveal full direct access to the backing storage, and so the separate property (or method or subscript) would actually offer a more targeted view into just the relevant aspect.

In the most common case, the backing storage should be private and underscored. And even in the uncommon case it is trivial to write a forwarding wrapper, so private and underscored storage is still sufficient.

14 Likes

I donā€™t think this is in any way a theoretical desire or requires practical experience. Property delegates donā€™t do anything we canā€™t do manually today. When we write the code manually we are able to hide this implementation detail if necessary (and usually do). This experience should be sufficient to demonstrate that this is not a theoretical desire. It is a pragmatic desire to hide implementation details, something we do in the existing code this proposal intends to replace with syntactic sugar.

8 Likes

We can declare a let property with a delegate? I thought they were just sugar for a computed property that forwards to the delegate. Is this only supported when the delegateā€™s value is itself a let property? If so, what are the use cases for this? If a let property with a delegate that has a var value is supported how does that square with the mutability model?

This is only permitted to 'out-of-line initialization of properties', I think @Douglas_Gregor potentially made a mistake in his post.

I really love this example! I thought key path member lookup was a cool feature and would find great use cases but I admit I had trouble imagining what might become idiomatic usage of it until I saw this Ref example. This is really, really cool!

It also explains the purpose of storageValue quite well and I really like this addition now that I understand it better. It allows a property delegate to encapsulate its implementation publish an intentional API for the point of use.

With this feature in place I think it makes sense to only expose the synthesized storage property when storageValue is implemented by the property delegate. When the delegate itself should be exposed as a synthesized storage property it would be trivial to opt in with:

var storageValue: Self { 
    get { return self }
    set { self = newValue }
}

In this approach a synthesized storage property would only exist when it is actually useful - i.e. when it does more than just expose var value. Delegates that don't have any additional behavior (something may be common) would just not implement storageValue.

Under the currently proposed design delegates have to opt-in to hiding themselves, but even then cannot suppress the synthesized property. The closest they can come is to use storageValue: Void.

You're referring to this example, right?

A property that has a delegate can be initialized after it is defined, either via the property itself (if the delegate type has an init(initialValue:)) or via the synthesized storage property. For example:

@Lazy let x: Int
// ...
x = 17   // okay, treated as $x = .init(initialValue: 17)

Out of line initialization just means you need to initialize before reading. It is orthogonal whether the property is declared let or var. I really don't see how this example is compatible with Swift's mutability model. We don't have a notion of computed let in the language today and this appears to do exactly that. In order to have a reasonable notion of computed let the compiler would need to be able to enforce value semantics. I know that isn't being proposed here (it's something I would love to see but still feels a ways off).

Exactly. As far as my knowledge goes an out-of-line initialization means that the constant it kind of like a variable until it's initialized, which probably means that it can have a getter and setter which are invisible to the user.

This reminds me a lot of Stroustrup's (?) quote about C++ template syntax, where people initially wanted it to be very obvious that something Different was going on, and so they have the whole template keyword, and now people complain that templates are too heavyweight and they could have just put the template parameters inline, at least in the common cases.

3 Likes

Huh, that's an interesting idea! By "only expose", do you mean that $foo wouldn't be available at all when the delegate type has no storageValue, or that it would be private? I'm leaning toward the latter because I feel like it's still important to be able to do out-of-line initialization even when your property delegate type doesn't have API.

I like how this approach puts more control into the hands of the authors of delegate types, and nicely separates the "delegate types that have interesting API should make that API usable" from the "delegate types with no API should have their storage hidden" cases.

Doug

3 Likes

I like this compromise. It strikes a nice contextual balance between convenience and encapsulation.

I was thinking the former actually. The $foo identifier would not be available. You make a good point about out of line initialization though. Isn't that already broken when the storage value is a different type than the property delegate, as in your Box / Ref example though? I wonder if there is another way to solve out of line initialization of the delegate itself.

While I don't love the var, I do really like the idea of the property delegate deciding its storage synthesis settings / access level across all of it's usages. I'm pretty sure that with all of the motivating examples the storage would be consistently private/internal/public across everywhere it's used.

Maybe instead of doing it at the call site like:
private(storage) public @Lazy var prop

we define it with the @propertyDelegate, i.e:
@propertyDelegate(storage:[.fileprivate])

and then all users of the property delegate synthesize a fileprivate storage.

I wasn't talking about anything to do with access control. I was talking about not synthesizing the $foo property at all unless a property delegate opts-in by implementing storageValue. When this property is synthesized the user of the property delegate should have control of its visibility. There will be cases where the API of the property delegate is either not used or only used in the implementation of the type that declaring the property backed by the delegate.

Sure, but talking about whether to synthesize or not being a decision of the property delegate implementation makes me think it's worth also defining the access level of said synthesized storage in the property delegate implementation as well.

i.e. instead of deciding to synthesize based on the existence of the storageValue var, we decide whether to synthesize the storage and specify its property using an enum:

enum Storage {
  case `public` // storage synthesized and public
  case `fileprivate` // storage synthesized and fileprivate
  case none // storage not synthesized
}

and the that value gets used when declaring the delegate:

@propertyDelegate(storage: .none)

Edit: I'm thinking of this as similar to "scopes" in the Static Custom Attributes pitch

This doesn't make sense to me. Having a useful API (or not) beyond exposing a value is a decision made by the property delegate. How broadly visible that API should be is a concern of the client code.

3 Likes

It sounds like instead of .none, the .private case should behave exactly the same and the name is more consistent to the language. So:

enum Storage {
  case `public` // storage synthesized and public
  case `fileprivate` // storage synthesized and fileprivate
  case `private` // storage not synthesized
}

but personally I would rather still keep it on the caller side which gives more flexibility and with the mix of your proposition how to describe it:

public @Lazy(storage=private) var prop
private(storage) public @Lazy var prop

This nation even though fits to the Swift "style" in my opinion has to downsides:

  1. It will be confusing according to the order of access level according to the property and the storage all the time for developers, so we might try to write:
  2. It is a bit hard to grasp by the human at which access level at I'm trying to focus on especially in a case:
    • public public(storage) @Lazy var prop
    • public(storage) public @Lazy var prop

You'd effectively have to initialize the Box in the initial declaration, and the author of the property delegate type could offer to allow additional setup via the type of storageValue if it's important. We could make the backing storage a private var $$foo ;)

Doug

Yeah, it's a mistake. I'm fixing both compiler and the proposal now. Thank you!

Doug

1 Like

Hi all. We seem to have converged on most aspects of this proposal, so I'll ask the Core Team about scheduling a review. The implementation should be enough to kick the tires with the Ubuntu 16.04 Linux toolchain or macOS toolchain, although there are two annoying limitations:

  • init(initialValue:) that takes an @autoclosure is currently broken

  • Property delegates cannot be used on local variables; only properties of types work well right now (it lazy is similarly broken and always has been!)

    Doug

10 Likes