Pitch: Property Delegates

Adding a property delegate should be ABI-compatible with the obvious caveats (resilient library, non-fixed-layout type, still publishes the accessors it did before). Changing or removing a property delegate should only break ABI if the delegate itself is public, which is why getting the delegate access control behavior right is so important—it may make sense to publish the delegates of some public properties, but doing that locks in that delegate forever.

Thanks for doing this. This is a very long alternate proposal, so I'm going to try to address the major points and---in doing so---frame them in terms of a delta on top of the property delegates proposal I've been developing so it's easier to reason about.

The automatic synthesis of the backing storage is fairly key to the usability of this feature. It's the common case, but I think it's reasonable to say that it shouldn't be the only case. In other words, if you want to explicitly specify the backing storage property, I think that's a reasonable extension to the model I proposed. I showed one possible syntax in my reply to Chris, and having separate declarations makes things like the existing lazy work. Riffing on your example a bit:

lazy var bazStorage: Delegate<Int> = Delegate(...) // okay
public var baz: Int by bazStorage

bazStorage is the backing property; declare it however you want. baz is the publicly-facing property whose getter and setter will be synthesized to refer to bazStorage.value. Doing this means we could drop the by public stuff, because if you want to publish the storage property, you can go ahead and write it. It also means that the default storage property with the $ name could remain internal-only, so it never leaks out into a different module.

You asked why my proposal contains the restriction that the delegate type must be generic with a single type parameter. It's so that the property can declare it's user-visible type (which is the most important thing) separately from the usually-implementation-detail behavior type, like so:

public var x: Int by Lazy = computeMe()

The most important thing is the var x: Int, because x has type Int for clients. That type should not be buried after the by, or require you to look into the delegate type. You have this example:

var baz: Delegate.Value

To me, that's not a great way to express the API: most clients of baz won't care what Delegate is, and indeed it's likely to even be completely hidden (because, say, it's a caching thing or other implementation-detailed indirection), so no user should have to "click through" to the definition of Delegate to get this information. Delegate might not even be public API.

FWIW, I'm not a fan of matching up names like this. If we're going to say that you can explicitly declare the backing storage property, great, but let's be explicit about it.

This is part of my original proposal; was it not clear or are you being thorough?

This is as reasonable extension. There's really no technical reason for the "no accessors at all" restriction in my proposal. We can loosen that, so you can write the accessors you want to (say, providing set but having get be synthesized) and it's plausible that willSet/didSet can work as well.

Yeah, I should have spelled out this restriction in the original proposal. It's in the latest version.

I'm having a hard time following this part of your proposal. You introduce an @initForSynthetization attribute that doesn't do anything; I agree that it isn't needed, but why is it there at all?

I also don't really know what problem you are solving in this part of your proposal. My proposal has two forms: a "direct" initialization of the delegate instance by putting parentheses after the delegate type, e.g.,

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

and, when the delegate type can be initialized based only on the underlying value, an = syntax that goes through the delegate's init(initialValue:), e.g.,

var x: Int by Lazy = 17
// equivalent to...
var x: Int by Lazy(initialValue: 17)

If we allow the storage property to be explicitly specified, then the initialization of the storage would go on that declaration.

Doug

No, it would not make your shared library incompatible, assuming that your shared library was shipped with resilience enabled.

Doug

This is currently prohibited, but I think it's possible to support.

Doug

Unless I'm misreading it, the section pointed to doesn't explicitly state this.

Not being able to do this would be a bummer for CoreData type applications, since it would make it difficult to, say, update an "updatedDate" property when you changed some other property.

Also, in the CoreData scenario, it is desirable to bypass the "front door" when pulling data out of the database to populate MOs. Say you want to be able to set all of an object's attributes without sending out Observing notifications. Could this be accomplished?

It says:

  • A property with a delegate may not declare any accessors.

That's why you have access to the backing storage property (via $foo): you can put any of the API for dealing with the database there on the delegate, which could include API to bypass the normal notification mechanism.

Doug

Just a thought, but could we make it automatic? If a type is marked as a @propertyDelegate then it's treated as it's generic type:

var foo = Random<Int>() // foo is Int, backed by Random<Int>
var bar = Atom(10) // bar is Int, backed by Atom<Int>
var mux: Int = Lazy(100) // you can also be explicit

That makes it harder to give good error messages when you screw up, and increases the work you have to do in incremental builds when inferring types across file boundaries. I think it's better if we stay explicit about what is and isn't part of the API of a type, even within the module.

It could be retrofitted. I put some partially-baked ideas into future directions. I'd also like to reiterate Jordan's point that we should try to keep the scope of this feature focused: we want to make sure we have a path forward for other important use cases, but we don't have to satisfy every use case in the first proposal.

Doug

The automatic synthesis is also key to what I want to do with this feature. I think it should be the default behaviour.

I think this also defeats the purpose of this proposal. I want to use property delegate to hide implementation details of certain properties. This makes the initialisation of the backing type more visible than I would want to.

@Douglas_Gregor Couldn't we only have the restriction that the backing type needs to have an associated type of the property type, allowing backing types that only work on specific types:

import Cocoa

protocol PropertyDelegate {
    associatedtype ValueType

    init(initialValue: @autoclosure @escaping () -> ValueType)
    var value: ValueType { mutating get set }
}

// MyStorage only supports Strings
struct MyStorage: PropertyDelegate {
    init(initialValue: @autoclosure @escaping () -> String) {
        // ...
    }

    var value: String {
        // ...
    }
}

// CustomStorage supports all Hashable types
struct CustomStorage<T: Hashable>: PropertyDelegate {
    init(initialValue: @autoclosure @escaping () -> T) {
        // ...
    }

    var value: T {
        // ...
    }
}

var x: String by MyStorage = ""
var y: Int by MyStorage = "" // error: property delegate 'MyCache' does not support type 'Int'
var z: Int by CustomStorage = ""
var w: NonHashableType by CustomStorage // error: type 'NonHashableType' does satisfy requirements of property delegate 'CustomStorage'
1 Like

What would it hurt to change the signature of the getter and setter to include self and the keyPath for the initial implementation?

I don't know what you're getting at here. Can you fill out the example?

Doug

From a performance standpoint, you'd be forming a key path and passing a copy of self that might not be needed. From a design standpoint, you're committing to something that might not be the right solution.

Doug

Sorry, I mis-copied. The example is now complete.

Why do you feel this way? Swift doesn't use the word "macro" at this point, so we can define it to mean whatever we want. This is doing a simple hygienic substitution, this seems like the common definition of macro to me. I agree that C preprocessor fans won't be happy, but we will probably never do anything to make them happy. :-)

Right, it works great for this, because it composes exactly like the attribute syntax (while being syntactically distinct from it) and goes in the same place. Putting the property macro after the decl with the by syntax (or any other keyword) is much worse from this perspective (IMO) in ways that transcend the naming of the keyword.

Another significant issue is that we have a lot of precedent for these things (@IBOutlet, lazy etc) which are all to the left of the declaration. Putting some things things to the left but other things to the right would be inconsistent.

These aren't attributes themselves though, this is a very different concept. If we want to use existing attribute syntax we'd have to use something like @behavior(Lazy) which is syntactically a lot more verbose.

I think the proposed model for access control is problematic, as mentioned below.

I personally regret private(set) and would do something different if we had to do it all over again - I don't think that is great precedent to follow here, and the proposal doesn't even align with it.

I disagree with your rationale that we're "declaring" two things inherently, that is just how your current proposal works. I consider the "declaration" of the $ identifier a significant problem with the proposal.

My intention was to say that #Forward is literally a property macro as your proposal already describes, which takes an autoclosure that captures the property.

No, I fully get why you want to expose direct access to the storage - I just have a significant problem with exposing it as a separate decl. These are conjoined things - the storage is an aspect of the declaration. There are lots of potential syntax's to consider here, e.g. yourObject.foo#storage that could tie this together into a coherent design. I'm not arguing for a particular syntax, just saying that it would be great to not do the $ hack with two decls.

-Chris

8 Likes

Another option is to not give access to the underlying storage... unless it was declared separately. For instance:

// no access to the storage:
public var property: Int by Lazy<Int> = .random(0 ... 10)

vs.

// can access the storage through `storage`:
public private(set) var storage = Lazy(.random(0 ... 10))
public var property by storage.value

I think it's important to realize that if there is a way to define the storage property separately like this, it means you don't have to bake all the features into the single-line shortcut syntax. The shortcut syntax only has to cover the most common usages.

I also like that this makes the feature purely an implementation helper, not something that'll be visible in APIs. I think exposing properties that you can't define yourself (because they have a $ in the name) is weird.

2 Likes

I was thinking more about the default synthesis behavior for Equatable, Hashable, and Codable, and I'm a little concerned. For Equatable and Hashable, some of these types could very well still support init(initialValue:) but not be interchangeable...or conversely, they could do extra canonicalization on their values that would make hashing or equality cheaper than going through the accessor.

Codable's actually even more interesting. My main negative concern is that we not leak the "$foo" name into archives, and instead use the name of the fronting property instead. But I think this is also an opportunity to handle some of the cases where people want a value to be encoded specially:

var name: String? by ExplicitNullEncoding // always put "null" in the archive even if nil, unlike the default behavior

var index: Int by AlwaysAStringEncoding // "2" instead of a numeric 2

var date: Date by UnixTimeEncoding // seconds since 1970-01-01 instead of an ISO date

(UnixTimeEncoding is an interesting case of a delegate type that would not actually be generic, but the types still all match up. So maybe this one in particular would come later.)

So I propose a different rule for the default-synthesized protocols: they always use the delegate type's definition of Equatable, Hashable, or Codable, and if the delegate type doesn't have one you don't get a default implementation. (You could weaken that to saying that if the delegate type doesn't have one you get the value's implementation, but I don't love that because it seems more subtle. Also, you have the extra constraint for Decodable because it needs init(initialValue:).) And additionally, for Codable your CodingKey is still the fronting property, not the synthesized delegate property.

5 Likes

FYI, I just pushed an update to the proposal that allows didSet/willSet, as well as implementing one of get or set (while the other will get synthesized). The new section is here and the implementation supports it too.

Doug

8 Likes

I’m worried about the amount of boilerplate this would require well-behaved delegates to implement; the “prefer the delegate but fall back to the value” rule would be much easier.

On the other hand, maybe it would still be sufficiently easy if we modified the default auto-derivation to forward directly to value in @propertyDelegate types. Then you could just declare a few empty conditional conformances and be done with it.

I'm happy to see more progress on this, I guess babbling our ideas and use cases does help shaping the proposal.

Here are three things that I want you still to consider:

  1. Allow specifying the generic type(s) on the delegate explicitly (if it's not possible already):

    struct S<T> {
      var property_1 by DelayedMutable<T>
      var property_2: T by DelayedMutable<T>
    }
    
  2. Relaxation of the single generic type parameter requirement.

    Is it possible to relax this rule in a init(initialValue:) fashion? Here I mean to make the single generic type parameter optional but required for certain syntax forms.

    // - single generic type parameter required
    // - implicitly inferred though `init(initialValue:)`
    var property_3 by SingleParDelegate = value
    
    // - single generic type parameter required
    // - implicitly inferred though the explicit type before `by`
    var property_4: Int by SingleParDelegate
    

    The compiler would then allow you to omit the generic type parameter iff the type annotated with @propertyDelegate has only a single generic type parameter which is used for value's type.

    In any other cases the compiler will look at value's type and match or even infer the type for the computed property. If the property delegate has more then one generic type parameter, the user must be explicitly provide them.

    @propertyDelegate
    struct StringContainer {
       var value: String { ... }
       ...
    }
    
    // implicitly inferred the type to `String`
    // `StringContainer` must have greater or equal access level as `property_5` 
    var property_5 by StringContainer = ... 
    
    // types successfully matched by the compiler
    var property_6: String by StringContainer = ... 
    
    @propertyDelegate
    struct ComplexGenericContainer<A, B, C> {
       var value: A { ... }
       ...
    }
    
    // type inferred as `Int` because `A` is the type of `value` in the delegate
    var property_7 by ComplexGenericContainer<Int, Bool, String> = ...
    
     // types successfully matched by the compiler
    var property_8: Int by ComplexGenericContainer<Int, Bool, String> = ...
    

    This probably means more implementation complexity but it makes the whole feature feel more relaxed when it comes to creation of custom containers.

  3. Would it make sense to include 'property forwarding' as a future direction of this proposal? If the current proposal is not enough to solve more complex cases it would be great if we could fall-back to a more advanced forwarding mechanism that does not require the storage/delegate to live nearby. Of course this should not be implemented with the current proposal. ;)

    Possible syntax variations (same opt-out synthetization rules and control about accessors):

    // 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
      }
    }
    
    var property: Value by <key-path-placeholder> { /* as above*/ }
    
    // using a direct route to the storage
    var property by storage.intermediate.property {
      // implicitly synthetized
      get { 
        return storage.intermediate.property
      }
    
      // implicitly synthetized if storage property has a setter
      set { 
        storage.intermediate.property = newValue
      }
    }
    
    var property: Value by <storage-route-placeholder> { /* as above*/ }
    
1 Like