Future directions of Property Wrappers

I thought Jordan was going to post it, but instead I'll do it for him in a completely unstructured form (sorry):

class Speaker {
  // Once again, the problem with this in today's property wrappers
  // implementation is that every instance ends up storing the range, even
  // though by construction it can't ever be different from instance to
  // instance. That's a real waste. So what if we have a notion of "shared info
  // property wrappers" that live on the type and get instantiated?
  @Clamped(0...11) var volume: Int = 0
}

@sharedInfoPropertyWrapper
struct Clamped<Value: Comparable> {
  var initialValue: Value
  var range: ClosedRange<Value>

  // First off, the SharedInfo can have its own projectedValue property to
  // expose a static $foo property.
  var projectedValue: ClosedRange<Value> { range }

  // This initializer is the same as for today's property wrappers. However,
  // it's initializing the type-level wrapper, not the instance-level one. Like
  // current property wrappers, this can take an initial value or not.
  //
  // This makes a little more sense to me than my previous version purely
  // because of how the initializer is invoked at the use site.
  init(wrappedValue: Int, _ range: ClosedRange<Value>) {
    self.initialValue = range.clamping(wrappedValue)
    self.range = range
  }

  // The `instantiate()` function determines the type of the per-instance
  // storage, and is used by default to create instances. A client type can
  // always instantiate instances manually too in *its* initializer.
  func instantiate() -> Instance {
    return Instance(storage: sharedInfo.initialValue)
  }

  // This is what actually shows up as a stored property on the instance.
  struct Instance {
    var storage: Value

    // Use a subscript so that we have storage semantics but also have access
    // to the shared info.
    subscript(wrappedValueWithSharedInfo sharedInfo: Clamped<Value>) -> Value {
      get { storage }
      set { storage = sharedInfo.range.clamping(newValue) }
    }
  }
}

// One thing I'm not sure about is what to call the Clamped<Value> static
// property. The projection is probably `static var $volume: ClosedRange<Int>`,
// but having the shared info storage be `static var _volume: Clamped<Int>`
// seems a little weird. Nothing good comes to mind, however, especially when
// remembering that people can name their properties with non-English names. If
// we don't care about people accessing it directly we can do something like
// `static var volume$sharedInfo: Clamped<Int>` though.


// How does this scale to the (secret) mechanism for accessing the owner of a
// property? The best way is to provide the key paths that are currently in the
// (underscored) static subscript form of property wrappers directly to the
// initializer. Let's see that with "Observed".

protocol Observable: AnyObject {
  func notifyObserversAsync<Value>(for keyPath: KeyPath<Self, Value>)
}

@sharedInfoPropertyWrapper
struct Observed<Owner: Observable, Value> {
  var initialValue: Value
  var storageKeyPath: ReferenceWritableKeyPath<Owner, Instance>
  var wrappedValueKeyPath: ReferenceWritableKeyPath<Owner, Value>

  // We use a new kind of default argument here instead of magic names.
  // This allows regular arguments and property-wrapper-related implicit
  // arguments to coexist better than in the current scheme. Other magic 
  // arguments could include #propertyWrapperProjectionKeyPath and
  // #propertyWrapperOwnerType.
  init(
    wrappedValue: Value = #propertyWrapperInitialValue,
    storageKeyPath: ReferenceWritableKeyPath<Owner, Instance> = #propertyWrapper
StorageKeyPath
    wrappedValueKeyPath: ReferenceWritableKeyPath<Owner, Value> = #propertyWrapp
erValueKeyPath
  ) {
    self.initialValue = wrappedValue
    self.storageKeyPath = storageKeyPath
    self.wrappedValueKeyPath = wrappedValueKeyPath
  }

  func instantiate() -> Instance { return .init(storage: initialValue) }

  struct Instance {
    var storage: Value

    // This replaces the static subscript we have today. (It could live on
    // either the Instance or the outer wrapper type, since it doesn't have any
    // context of its own. I put it here for consistency with the non-static
    // one, but the outer one would get to use 'Self' in the signature, which is
    // a little nice.)
    //
    // One downside of this approach is that it's a little less easy to support
    // working with both mutable and immutable properties. With today's static
    // subscript, you can overload on the key path kind; in the shared-info
    // version, you'd have to store either a KeyPath or a
    // ReferenceWritableKeyPath and keep track. Or just store a KeyPath and try
    // downcasting (ick). And it still doesn't give you access to a struct
    // value.
    static subscript(
      _enclosingInstance instance: Owner,
      sharedInfo: Observed<Owner, Value>
    ) -> Value {
      get { instance[keyPath: sharedInfo.storageKeyPath].storage }
      set {
        instance[keyPath: sharedInfo.storageKeyPath].storage = newValue
        instance.notifyObserversAsync(for: sharedInfo.wrappedValueKeyPath)
      }
    }
  }
}

// We should also have equivalents of both subscripts for the projectedValue.

// P.S. What happens if you use a property wrapper with shared info on a
// non-instance property? Do you get an error, or does it just put the shared
// info alongside the regular property? In the latter case, what happens with
// the projected value?

// To close out, here are two kinds of Lazy we can do: one that's just like the
// example Doug showed at WWDC but more efficient...

@sharedInfoPropertyWrapper
struct Lazy<Value> {
  var compute: () -> Value

  init(wrappedValue: @autoclosure () -> Value) {
    self.compute = wrappedValue
  }

  func instantiate() -> Instance { return .init() }

  struct Instance {
    var value: Value? = nil

    subscript(wrappedValueWithSharedInfo sharedInfo: Lazy<Value>) -> Value {
      mutating get {
        if let value = self.value {
          return value
        }
        value = sharedInfo.compute()
        return value!
      }
      set {
        value = newValue
      }
    }
  }
}

class Calculator {
  @Lazy var regionalTipRates = Dictionary(contentsOf: …)
}

// ...and one that allows referencing 'self', sort of, at the cost of some
// goofy-looking syntax at the use site.

@sharedInfoPropertyWrapper
struct LazyFromSelf<Owner: AnyObject, Value> {
  var compute: (Owner) -> Value
  var storageKeyPath: ReferenceWritableKeyPath<Owner, Instance>

  init(
    _ compute: (Owner) -> Value,
    storageKeyPath: ReferenceWritableKeyPath<Owner, Instance> = #propertyWrapperStorageKeyPath
  ) {
    self.compute = compute
    self.storageKeyPath = storageKeyPath
  }

  func instantiate() -> Instance { return .init() }

  struct Instance {
    var value: Value? = nil

    static subscript(
      instance: Owner,
      sharedInfo: LazyFromSelf<Owner, Value>
    ) -> Value {
      get {
        if let value = instance[keyPath: sharedInfo.storageKeyPath].value {
          return value
        }
        let result = sharedInfo.compute(instance)
        instance[keyPath: sharedInfo.storageKeyPath].value = result
        return result
      }
      set {
        instance[keyPath: sharedInfo.storageKeyPath].value = newValue
      }
    }
  }
}

class WindowController {
  // Yeah, ick. We'd need some *additional* feature to make this better. I think
  // I've gone on long enough, though.
  @LazyFromSelf({ (self_: Self) in
    self_.loadWindow()
  }) var window: Window
}
10 Likes