[Pitch] Property delegate composability, backing storage, and $

Hi all,

Based on the discussions in the property delegates pitch and review, it seems that we should push further on the composition angle. So, I had an idea that could use some refinement.

Let's start with the notion that we have two trivial property delegate types, A and B:

@propertyDelegate
struct A<T> {
  var value: T
}

@propertyDelegate
struct B<T> {
  var value: T
}

We could allow multiple property delegates to be composed. That composition effectively has to be linear, nesting one delegate instead the other. The synthesized getter/setter would look through the full chain of .value, e.g.,

@A @B var foo: Int

becomes:

var foo_storage: A<B<Int>>
var foo: Int {
  get { return foo_storage.value.value }
  set { foo_storage.value.value = newValue }
}

How do we get to the API for A and B? Well, the prefix $ from the proposal could be repurposed to mean "suppress one .value", so $foo refers to the foo_storage.value (i.e., the B<Int>) and $$foo refers to foo_storage (i.e., the A<B<Int>>) directly.

You could take this one step further, such that value members that themselves have attached delegates could be considered to be composed. For example:

@propertyDelegate
struct C<T> {
  @B var value: T
}

@C var bar: Int

we could say that bar is translated to:

var bar_storage: C<Int>
var bar: Int {
  get { return bar_storage.$value.value }
  set { bar_storage.$value.value = newValue }
}

Then, $bar is the B<Int> and $$bar is the C<Int>. It gives the same expressiveness as the delegateValue property in the current proposal, but bases it on the same "suppress .value" unwrapping mechanism as composition.

I think I like this approach because it keeps the conciseness of $ for the common case (no composition, no tricks), allows composition while maintaining a direct translation model (nesting + multiple .values), and makes the delegateValue effect not feel like a one-off trick.

I haven't tried to hack up an implementation, but it seems straightforward. Thoughts?

Doug

8 Likes

My immediate reaction is to take a step back and first ask whether addressing safe composition should be a design priority or left up to documentation and discipline. Given the amount of discussion we have had in the past on this topic I think this issue should at least be addressed.

Your design chooses the latter. There are definitely benefits to this approach - the composition machinery can be a lot simpler! What are your reasons for making that choice? What are your thoughts on the examples of problematic composition that have been discussed in the past?

Another small question of clarification: it looks like delegateValue is replaced by using a delegate on the value of a delegate. Is that correct? Can you show the Box / Ref example using this approach just to make more clear how it corresponds to and replaces delegateValue?

I’ll have more feedback and questions later.

I said this in an Apple-internal conversation, but I'll put it here for posterity: the idea of $ acting "outward" rather than "inward" seems very counterintuitive to me in the non-composed case. If I write @C var bar: Int, I have two types available to me: C, and Int. The idea that I can get at another type B before I get to C is a weird one. It also means that adopting property delegates for B and C if they didn't have them before would be a breaking change, which seems undesirable.

As for the composition syntax, it doesn't scale to property delegates with explicit initialization either. I see that this is one reason in favor of "property delegates using property delegates expose their inner delegates" idea, but still. I can think of property delegates people want to compose, but I'm not convinced this is the way to do it; other forms of custom attributes might serve some of those needs better (like customizing Codable for a single field).

8 Likes

I suspect that a lot of compositions will "just work", and a significant number of people who chimed in on the pitches and review seemed to feel that composition was important. Perhaps the known-bad compositions like Lazy<Atomic<T>> are rare enough and obvious enough that they shouldn't scare us away from the feature in general.

Yes, that's the intent. Box would be a bit odd:

@propertyDelegate
class Box<Value> {
  @Ref var value: Value

  init(initialValue: Value) {
    var heapValue = initialValue
    $value = Ref(read: { heapValue }, write: { heapValue = $0 })
  }
}

So if we have a

@Box var boxedInt: Int

boxedInt lets you access the Int, $boxedInt gives you the Ref<Int>, $$boxedInt gives you the Box<Int>.

We'd have to linearize the $ in the case where you use one of these and compose it, but that's a matter of specifying something we can communicate.

Doug

I got lost here. My intuition wants to treat those are having two separate storages but not nested.

I would expect the storage of value (@B) to be completely opaque to me as a user unless I explicitly exposed it as something like:

@propertyDelegate 
struct C<T> { 
    @B var value: T 
    var storageToB {$value}
} 
1 Like

I agree. I think there's a very simple approach to safe composition, which is that you just look for member delegate types combinatorially: if you have @A @B, you look up both A.B and B.A, and it's an error if they successfully resolve to different types.

Member types can be added in an extension, so it's trivial to declare that a composition is valid retroactively.

Or you can go a step further and just say that the way you do composition is you write @A.B in the first place.

2 Likes

I don‘t follow everything here but you made me question one thing.

Do property delegates and custom attribute types support type aliases?

typealias D = C // where C is a propery delegate type

// is this valid?
@D var int: Int = 42

This would be necessary if one would need to resolve some existing or colliding namespaces and retroactive extensions.

I'm not sure I follow what you mean here. It sounds like you're saying:

@propertyDelegate struct Foo { ... }
@propertyDelegate struct Bar { ... }

// error: Foo and Bar are not compatible
//           Foo does not declare Foo.Bar and Bar does not declare Bar.Foo
@Foo @Bar var fooBar: Int

// ========
// elsewhere:
extension Foo { typealias Bar = X }
extension Bar { typealias Foo = Y }

// error: Foo and Bar are not compatible Foo.Bar != Bar.Foo
@Foo @Bar var fooBar2: Int

// ========
// elsewhere:
extension Foo { typealias Bar = X }
extension Bar { typealias Foo = X }

// ok: Foo.Bar == Bar.Foo == X
@Foo @Bar var fooBar2: Int

Is the idea here that we use the nested type to specify what delegate is used when these delegates are composed? So in the last example above X would have to be a property delegate type and would be used when Foo and Bar are composed? If so, how would this approach work when there are 3 or 4 delegates being composed simultaneously?

If this understanding is correct, do you have concrete examples where the composed delegate type would be different than a simple stacking of the composed delegates? And doesn't this design basically force composition to be commutative? Is the idea that it would be too subtle to support different semantics for different orders of delegate layering?

Also keep in mind that the delegate type could have a different number of generic type parameters that do not necessarily align with potential generic type parameters of the type they are nested in (if there are any generic type parameters). A solution of composition through nesting seems quite mind bending and involves a lot of extra boilerplate.

If we’re going to have users request composition by just writing multiple attributes, I think we really want that to be commutative, yes. This is a disadvantage of the attribute syntax, although even with a by ... syntax that makes the nesting ordering clearer, I’d expect that most programmers would struggle to use non-commutative composition correctly.

Please note that I’m not saying that all of the nesting combinations have to actually resolve. I would expect under this design that one of the delegates would opt in to its compositions by declaring a few member delegates. I just wanted to mention how ambiguities would be resolved.

Supporting typealiases for property delegates should be straightforward.

But honestly I don’t think composition is worth sugaring.

// Is this valid? with out @propertyDelegate  I would suspect yes. 
struct C<T> { 
    @B var value: T 
} 

// what about this?
struct C<T> { 
   @propertyDelegate
    struct B<T> { ....}
    @B var value: T 
} 

I don’t know what you’re asking me in the first one. In the second one, I think you want to make the inner delegate non-generic, since it’s already generic from the enclosing context.

The way generic delegates work in the absence of explicit type arguments is you infer type arguments by matching the concrete property type against the type of the delegate’s value property. That can infer type arguments at any level of nesting.

2 Likes

I think we‘re missing an important point in this thread.

Why do we exactly want to compose property delegates?

The original A and B is extremely complex and not flexible or scalable, as it again goes into the opposite direction because first it requires every property delegate to contain a generic type parameter that must align with value's type. Furthermore A<B<Value>> requires new synthetization rules to be implemented, but in the original proposal we wanted rather the composed type to be like AB<Value> where the new type inherits all init‘s from A as if T was Value and value property is baked by A<B<Value>>.

Take Lazy and UserDefault from the original proposal and try to compose them. This quickly becomes very very complicated.

If we truly want composition then we need a few more rules on how a property delegate must be created to be composable. For example we could require the property be a class where the compiler could generate custom sub-classes for composition purposes, and we need a way to pipe the property delegates behavior so we can still have a single storage property somewhere to avoid duplicate values.

Hi @Douglas_Gregor

Why is it important that this feature use the attribute syntax? What alternatives were considered?

Dave

3 Likes

This totally confuses me. I thought $boxedInt should give Box<Int> because all I know about boxedInt is its storage is Box<Value> , and $$boxedInt should give Ref<Int> because that's the storage of $boxedInt which is Box<Value>. What am I missing here?

2 Likes

The proposal has some alternatives---the Kotlin-inspired by syntax and the original property behavior's [behavior-name] syntax. However, custom attributes were by far the most popular, and I like them as a general extension point (vs. inventing new-new syntax for everything).

Doug

1 Like

Because you want to stack two delegates' effects together? I feel like there was a lot of signal from the pitch and review threads that imply that this is a desirable aspect of the feature, along with a lot of concern that if the proposal did not include a design for composition, we could be boxing ourselves in.

That's not necessarily the case. I have written up the rules for initialization in my updated pitch (e.g., see https://github.com/DougGregor/swift-evolution/blob/property-wrappers/proposals/0258-property-wrappers.md#initialization-of-synthesized-storage-properties), and it doesn't have any dependencies on a single generic type parameter: you infer type arguments based on the expression.

I don't understand what you mean. Indeed, I don't see any way to compose without nesting, because the property delegate instance conceptually "stores" the underlying value. You can have two distinct property delegate instances storing the same underlying value.

This is a completely different model than the property delegates I'm proposing, and IMO is far, far

I made composition out to be much harder than it needs to be by trying to expose the different "levels" of composition via succeeding $'s. I've backed off that to a simpler design in my latest pitch: it does the nesting, but

@A @B var foo: Int

becomes

var $foo: A<B<Int>>
var foo: Int {
  get { return foo_storage.value.value }
  set { foo_storage.value.value = newValue }
}

with no additional funny business: you can see the nested type via $foo and get the delegate instance you want with $foo or $foo.value.

Doug

1 Like

@Douglas_Gregor without a specific quote, I get the idea of nesting, but would it apply to all property delegates?

The presented example uses two simple property delegate types of S<T> generic signature, but what if you have non generic property delegates, how do they compose?

Am I right that the nesting property delegate type would require it's value to have a generic type, while the most nested property delegate can be non-generic?

@propertyDelegate
struct C<T, R> {
  var value: T 
}

@propertyDelegate
struct IntDelegate {
  var value: Int
}

// Backwards order
@C<B<IntDelegate>, String> // requires explicit types because of second generic parameter
@B // `<IntDelegate>` is inferred from next property delegate attribute
@IntDelegate 
var foo: Int

// results to
var $foo: C<B<IntDelegate>, String>
var foo: Int {
  get { return $foo.value.value.value }
  set { $foo.value.value.value = newValue }
}

Also can I just write this instead and expect the compiler to traverse all nested property delegates to find the most nested value of type Int?

@C<B<IntDelegate>, String> var foo: Int

This is just less boilerplate than the above composition.

I haven't read the new pitch yet, but I was a bit surprised to see this:

Given @John_McCall's comments:

Are you of the belief that composition should not need to be commutative? I agree with @John_McCall that it would probably be surprising to a lot of people for these attributes to be non-commutative since no other attributes are order sensitive (afaik). Maybe that's ok and people just need to learn how to hold the feature right, but I think this is an issue that should be addressed directly by the proposal.

1 Like

If property delegate composition was commutative then it would be by far too restricted. You simply can't compose @A and @IntDelegate to bake a property of type Int then. And as soon as both property delegate type have more than one generic type parameter requiring them to be commutatively composable brings even more issues. OTOH if we simply say that property delegate are composed from right to left or bottom to top, where the most nested delegate refers to the property type is the most right or most bottom delegate, it significantly simplifies the design.