Add shared storage to property wrappers

I'm mixed on this idea.

I can definitely see the value of having shared storage between property wrappers to reduce allocation size.

However…

As a user of the @Clamped property wrapper, why should it be my responsibility to know how its storing information? The storage mechanism for a property wrapper is an implementation detail. If I'm using this, I don't care how or where the range is stored. I care that if I try to set bar = 15, the value gets clamped to 14. Anything more than that is burdening me with unnecessary ceremony that gets in the way of me writing my app (or whatever it is I'm creating).

So, is there a way we can provide property wrapper authors with shared storage, without having to force it on clients?

19 Likes

I agree. I want to spell this:

struct Hud {
  @Clamped(to: 0...14) var bar = 5
  @Clamped(to: 1...9) var foo = 1
}
3 Likes

I didn’t read the whole post (sorry!) but I realize extensions share a similar problem.

Would it be possible for this solution to allow for something like this?

extension UIImage {
// Find a cached thumbnail or create one if necessary. 
// Cache would be stored using ‘shared’ storage
var cachedThumbail : UIImage { … }
}

I also feel that suggested approach is a poor-man’s approach in the absence of the generic value parameters.

Could you please provide some examples of values which are valid for shared storage, but not valid for generic value parameters?

Swift is already able to generate generic types in runtime. I think it should be possible to generate generic types with arbitrary runtime value as a parameter:

protocol P {}
// Only Hashable types are allowed!
struct K: Hashable { … }
struct S<let n: [K]>: P {}
func makeS() -> P {
    let k = arc4random() & 1 == 0 ? [] : [.foo, .bar]
    return S<k>()
}
1 Like

One example is implementing @Lazy, which is typically initialized with a closure to compute the value upon first use of the wrapped value getter. Each instance of lazy needs to store this closure, even though the closure will never change across instances for a given application of @Lazy.

2 Likes

Conceptually this should also work, subject to availability of Equality of functions. One of the workarounds discussed in that thread was to treat closure literal as a syntax sugar for compiler generated struct in certain contexts. With this workaround in mind, it could look like this:

struct Lazy<Value, Thunk: Function0, let thunk: @autoclosure Thunk> where Thunk.ResultType == Value {
    var _value: Value?
    var wrappedValue: Value {
        get {
            if let v = _value { return v }
            let v = thunk()
           _value = v
        }
       set {
           _value = newValue
       }
   }
}

func makeInitialValue(arg: Int) -> Bla { ... }

struct Usage {
    @Lazy<Bla, _, makeInitialValue(arg: 42)> var prop: Bla
}

The compiler needs to be able to evaluate generic arguments at compile-time. There are plenty of use cases for @Lazy that do not use compile-time constant expressions.

Note that non-zero cost solution can be implemented today, without any new compiler features:

private class Box< Value: Hashable> {
    let value: Value
}

// TODO: weak reference set
private var boxes: [AnyHashable: AnyObject] = [:]

private func getBox<Value: Hashable>(value: Value) -> Box<Value> {
    if let b = boxes[value] { return b as! Box<Value> }
    let b = Box<Value>(value: value)
    boxes[value] = b
   return b   
}

@propertyWrapper
struct Shared<Value: Hashable> {
    private box: Box<Value>
    init(value: Value) {
        self.box = getBox(value: value)
    }
    var wrappedValue {
        get { box.value }
        set { box = getBox(value: newValue)
    }
}

extension ClosedRange {
  func clamp(_ value: Bound) -> Bound {}
}

@propertyWrapper
struct Clamped<Value: Comparable> {
  @Shared var range: ClosedRange<Value>
  private var value: Value
  
  init(wrappedValue: Value, range: RangeStorage<Value>) {
    self.value = range.clamp(wrappedValue)
    self.range = range
  }
  
  var wrappedValue: Value {
    get { value }
    set {
      value = range.clamp(newValue) 
    }
  }
}  

@shared is being proposed for property wrappers so extensions are out of scope.

The goal of this feature is exactly to optimize wrapper storage, not to come up with a way to implement a non-zero cost alternative.

Thanks for the input! Other folks (@hisekaldma and @benrimmington) have also pointed that this feature would be desirable as implementation detail. There were great suggestions in the thread that would help get the feature closer to this direction.


One of the main questions I had before this thread was about the "role" of the storage type. I will take these feedbacks in and iterate on the solution! :slightly_smiling_face:

3 Likes

Oh, I was actually able to dig up @Joe_Groff‘s old tweet regarding this idea:

I have some remarks and questions:

  1. I am not sure that the problem this pitch is trying to solve warrants adding an extra feature for it. And I definitely don't think that solving this problem warrants adding three new pieces of syntax (@shared before let, @shared before initializer arguments and someProperty$shared).

  2. I also think that users of a property wrapper should not have to be aware if it uses shared storage or not.

  3. Would the current form of this proposal allow for the creation of an @Lazy property wrapper with shared storage where the closure passed to the property wrapper captures other properties of the class? For example:

class TimesTwo {
    var input: Double
    @Lazy({ return 2 * input }) var output: Double
    ...
}
  1. To me, the @shared let looks a lot like the @State in SwiftUI. Let me compare:
SwiftUI Property wrappers
@State @share
View structs that share the same place in the UI also share the same state. Property wrapper instances that share the same use site in code also the same share state.

Maybe I miss the point, but couldn't the same usage pattern be used in this case (I am using @State here for the lack of a better name):

@propertyWrapper
struct Clamped<Value: Comparable> {
  @State var storage: RangeStorage<Value>
  private var value: Value
  
  init(wrappedValue: Value, range: ClosedRange<Value>) {
    _storage = State(initialValue: RangeStorage(range))
    value = storage.clamp(wrappedValue)
  }
  
  var wrappedValue: Value {
    get { value }
    set {
      value = storage.clamp(newValue) 
    }
  }
}

I think I would find it massively problematic if property wrappers had a capability that you couldn't achieve with a "regular" type wrapper, beyond the @/$ syntax features of property wrappers. (Honestly maybe they already do have capabilities that you can't achieve with a type wrapper, I haven't been keeping up with all of the property wrapper related proposals).

In other words, if this feature is useful for property wrappers, then surely it's useful for a generic type wrapper too that doesn't have the syntactic features of property wrappers. That should not be relegated to a future direction.

This example ought to be just as valid as the motivating example:

struct Clamped<Value: Comparable> {
  shared let storage: RangeStorage<Value>
  
  var wrappedValue: Value { ... }
  
  // a plain struct, with properties that will be shared across instances.
  struct RangeStorage<Value: Comparable> { ... }
}

struct Hud {
  var value = Clamped(storage: RangeStorage(0...14), wrappedValue: 5)
}
1 Like

This looks exciting, thanks for your efforts!

I agree with some of the comments made and am concerned that the proposed syntax would push implementation details (i.e. knowledge of what is/isn't shared) to consumers. The syntax provided in the document you linked looks pretty simple by comparison and doesn't require any new spelling. Is there a reason the proposal has moved away from that? (forgive me if you have already explained.. I did read most of the comments but I may have missed it)

Thanks again, I am quite eager to see what I can over-engineer when I can finally mix property wrappers with 'instance' properties with things like Codable :joy:

3 Likes

Could the OP (or anyone, really) comment on why this pitch is for property wrappers rather than types in general, as @Syre suggests?

What special need do property wrappers have that isn't shared by (say) structs that have a property that essentially parameterizes them?

As an example use-case of a slightly different kind, I sometimes add a constant let instance property to structs even though the declaration could be static, just because it's easier and more compact to reference the value using instance syntax. In some cases, I might want to do the same with a let instance property that isn't always initialized to the same value.

Aside from (say) interacting with C code that needs a specific struct layout, is there any reason why such let properties shouldn't routinely be optimized away into shared/cache storage?

3 Likes

Part of the motivation of property wrappers is to be able to implement declaration modifiers like lazy in libraries instead of in the compiler. This isn't possible to do with the same performance characteristics of the implementation in the compiler without this feature. Property wrappers also have an obvious per-declaration syntax that normal types don't have (perhaps yet), which is via arguments in the property wrapper attribute that is attached to a declaration.

If you have use cases for per-declaration shared storage as a general feature, please surface them! We're still figuring out the shape of the design, how general it should be, whether this approach is better than the subscript alternative, etc; that's the purpose of this thread :slight_smile:

6 Likes

Right, I think the central question for people who want to be able to apply this to a broader set of types than just property wrappers is to define the scope of sharing.

The basic concept behind this feature is that some values can be divided into "value-specific" components that need to be stored inline in every value and "shared" components that can meaningfully be shared across different values. For that to work, the implementation has to be able to statically identify during initialization that specific parts of the value should be used to initialize shared storage instead of value-specific storage, it has to determine what exactly they're shared among, and it has to be able to reconstitute the combined value when the value is accessed (perhaps by either building a composite value or by passing the shared components as extra arguments).

With property wrappers, there's a property wrapper type, there's a declaration of value storage, and there are no uses of the value stored in that storage that don't understand that they're using that specific declaration. That creates a simple answer to all our problems: the shared storage is what the property wrapper says is shared, it's shared for all instances of that specific declaration, and uses of the declaration can use their knowledge of the declaration to reconstitute the value.

With an arbitrary value of some type, some key parts of that don't exist. I can certainly have the type tell me what parts of it are shared. For a specific expression that constructs a value, maybe I can pull out the shared parts and use them to initialize shared storage which will be common for all values built by that expression. But some use of that value which just knows the type isn't going to be able to know the shared storage I have in mind; I'll have to pass a reference to that shared storage along as part of the value somehow. That's already a significant compromise; in terms of the code-rewrite I described above, there's no way to use the Instance type, just the Composite.

And the ability to do these supposedly more general things comes at significant cost. For example, if I can construct one of these values with totally dynamic arguments, then the implementation will need to separately allocate the storage for the shared parts of the value. That implies that either the shared storage leaks permanently for such dynamically-constructed values or that the composite type has to be able to garbage-collect it, and therefore do all the bookkeeping/reference-counting which that implies, which is not necessary for the property-wrapper use case. Also, if I can propagate a value of the composite type around, and I can e.g. assign that over another value of the composite type, then maybe I can change the shared storage associated with a particular declaration. That directly undermines the storage optimization intended for the property-wrapper use case, because an overwritten property wrapper would no longer use the same shared storage as would normally be determined by the declaration. It also breaks invariants in the likely scenario that the exact shared storage for a particular declaration (e.g. an integer range or a callback function) is semantically important to its operation. (This is why semantically the composite type needs to be an unmovable type, as I suggested in my code-rewrite rule.)

7 Likes

I appreciate the explanation here, though admittedly I think most of it has gone over my head.

I guess I'm struggling to identify exactly what it is about property wrappers that makes the statement above true for property wrappers, but untrue for regular types (especially because, at least as I understand it, the non-syntactic behavior of every property wrapper is currently fully expressible as a regular type, and every property wrapper can be used in their desugared form currently).

Considering the example from the detailed design section, but modified slightly:

struct Hud {
  @Clamped(RangeStorage(0...14)) var bar = 5
  var foo = Clamped(wrappedValue: 5, @shared: RangeStorage(1...9))
}

// desugars to

struct Hud {
  static let bar$shared = RangeStorage(0...14)
  static let foo$shared = RangeStorage(1...9)
  
  var bar = Clamped(wrappedValue: 5, @shared: bar$shared)
  var foo = Clamped(wrappedValue: 1, @shared: foo$shared)
}

Is that "desugars to" comment still correct for both bar and foo here?

If the "normal" syntax declaration of foo still desugars here, why wouldn't foo still desugar if Clamped was not a property wrapper?

If the "normal" syntax declaration of foo does not desugar here, is there something specifically about bar being declared using property wrapper syntax that enabled the desugaring?

A normal value can come from anywhere: it can be stored in a property, yes, but it can also be returned from a function, passed as an argument to a function, directly constructed by a call to an initializer, etc. By design, when you're using the value, the only extra information you have besides the value itself is its static type. So yeah, maybe all the values constructed by a particular initializer call have some data in common that could be shared between them; but when you're using that value later, you don't know what initializer call was originally used to construct it, so the only way you can get at that shared data is if it's passed along with the value itself. We could maybe optimize the representation so that it passes the shared data along more efficiently, but we can't completely elide it.

In contrast, we know exactly how a property wrapper is constructed, and we know when we're using it as an "independent" value, and the wrapper itself isn't normally passed around separately (so we could potentially forbid that for a particular wrapper if it was advantageous). So we do have an opportunity to split off the shared data and not store it in the value that's stored inline in the property / local frame / whatever. And that gets property wrappers much closer to parity with built-in modifiers like lazy.

Property wrappers also have an opportunity to construct a different, self-contained value to be passed around externally via the projected value. I think it's reasonable to to forbid passing the optimized wrapper storage around, and suggest using projected value for this purpose instead.

2 Likes

I do think there's a way to extend the idea of shared storage to types more broadly, but I'm concerned that it has sufficiently different trade-offs that it might compromise the feature's usefulness for property wrappers.

If you think of shared storage as extending from a particular construction site (e.g. MyType(x: 1, y: 2, z: 3)), and we know that MyType uses shared storage, then we could validate and construct the shared storage from the shared arguments to that specific initializer-call expression in basically the same way that I described it above for property wrappers:

struct MyType {
  @shared var x: Int, y: Int
  var z: Int
  init(@shared x: Int, @shared y: Int, z: Int) { ... }
  @shared init(x: Int, y: Int) { ... }
}

MyType(x: 1, y: 2, z: 3)
// rewritten to
static let anonymous_shared = MyType.Shared(x: 1, y: 2)
MyType(&anonymous_shared, z: 3)

MyType is then rewritten to carry a pointer to a MyType.Shared, so that you get useful sharing between all the values constructed by a specific expression, but the value can still be used in a normal, first-class way. This doesn't completely eliminate the overhead associated with passing around the shared data, but it may significantly lower it if the shared data is large or expensive to copy. It would be very invasive for programmers to make this sort of change manually.

The thing is, I don't know how to efficiently combine that with what we want to do with property wrappers. The natural representation for MyType is something like the following; notice how all the non-shared properties are stored inline in the struct:

struct MyType {
  struct Shared {
    var x: Int, y: Int
  }
  var shared: UnsafePointer<Shared>
  var z: Int
}

But for property wrappers, we really want to be able to store all the non-shared properties somewhere by themselves and then reconstitute a value of MyType from a reference to that without having to copy any of the non-shared data. For MyType, which just has a single Int of non-shared storage, it wouldn't be a big overhead to copy the Int into the reconstituted value (and then copy it back if we needed the MyType for a potentially-mutating operation), but for an arbitrary type that could be a big deal. This gets into the conversation that @Joe_Groff and I had above about in-place access; he's absolutely right that if we need to copy the non-shared storage back and forth, we may have a hard time eliminating those costs. So for property wrappers we really want a representation more like what I had in my earlier posts:

struct MyType {
  struct Shared {
    var x: Int, y: Int
  }
  struct Instance {
    var z: Int
  }
  var shared: UnsafePointer<Shared>
  var instance: UnsafeMutablePointer<Instance>
}

or at least something like the following, which would allow values to be passed around in a first-class way, but also allow efficient reconstitution as long as they weren't:

struct MyType {
  struct Shared {
    var x: Int, y: Int
  }
  struct Instance {
    var z: Int
  }
  enum InstanceStorage {
    case inline(Instance)
    case separate(UnsafeMutablePointer<Instance>)
  }
  var shared: UnsafePointer<Shared>
  var instance: InstanceStorage // when the value is copied, this always transitions to the inline case
}

But this seems like a substantial burden to impose on the property wrapper use case.

4 Likes