swiftproperty-wrapper

How to compose swift property wrappers?


I have recently been experimenting with swift property wrappers and wondered if there was any way of composing them together in order to achieve a more modular architecture. E.g:

@WrapperOne @WrapperTwo var foo: T

Looking through the documentation yielded nothing. The only reference to how to do this is on this GitHub page (https://github.com/apple/swift-evolution/blob/master/proposals/0258-property-wrappers.md) (quotes below) which seems to say it is possible. Other articles have said that they are difficult to compose but have not explained how to go about doing this. However, I can't make heads or tails of it and would appreciate it if someone could show me some example code on how to implement this (see bottom of post).

When multiple property wrappers are provided for a given property, the wrappers are composed together to get both effects. For example, consider the composition of DelayedMutable and Copying:

@DelayedMutable @Copying var path: UIBezierPath

Here, we have a property for which we can delay initialization until later. When we do set a value, it will be copied via NSCopying's copy method. Composition is implemented by nesting later wrapper types inside earlier wrapper types, where the innermost nested type is the original property's type. For the example above, the backing storage will be of type DelayedMutable<Copying<UIBezierPath>> and the synthesized getter/setter for path will look through both levels of .wrappedValue:

private var _path: DelayedMutable<Copying<UIBezierPath>> = .init()
var path: UIBezierPath {
    get { return _path.wrappedValue.wrappedValue }
    set { _path.wrappedValue.wrappedValue = newValue }
}

Note that this design means that property wrapper composition is not commutative, because the order of the attributes affects how the nesting is performed: @DelayedMutable @Copying var path1: UIBezierPath // _path1 has type DelayedMutable> @Copying @DelayedMutable var path2: UIBezierPath // error: _path2 has ill-formed type Copying> In this case, the type checker prevents the second ordering, because DelayedMutable does not conform to the NSCopying protocol. This won't always be the case: some semantically-bad compositions won't necessarily by caught by the type system. Alternatives to this approach to composition are presented in "Alternatives considered."

Ideally, I would like to implement something like the following:

@propertyWrapper
struct Doubled {
    var number: Int
    var wrappedValue: Int {
        get { (value * 2) }
        set { value = Int(newValue / 2) }
    }
    init(wrappedValue: Int) {
        self.number = wrappedValue
    }
}

and

@propertyWrapper
struct Tripled {
    var number: Int
    var wrappedValue: Int {
        get { (value * 3) }
        set { value = Int(newValue / 3) }
    }
    init(wrappedValue: Int) {
        self.number = wrappedValue
    }
}

so that this could be achieved:

@Tripled @Doubled var number = 5

I understand that this example is a somewhat silly reason to implement property wrapper composition but this is merely for simplicity's sake when learning a new feature.

Any help would be greatly appreciated.


Solution

  • As of Swift 5.2, nested property wrappers have become a lot more stable, but they're still a bit difficult to work with. I've written an article here about it, but the trick is that since the outer wrapper's wrappedValue is the type of the inner wrapper, and the inner wrapper's wrappedValue is the direct property type, you have to make the wrapper operate on both types.

    The basic idea I've followed is to create a protocol which the wrappers operate on. You can then have other wrappers conform to the protocol as well, in order to enable nesting.

    For example, in the case of Doubled:

    protocol Doublable {
        func doubling() -> Self
        func halving() -> Self
    }
    
    @propertyWrapper
    struct Doubled<T: Doublable> {
        var number: T
        var wrappedValue: T {
            get { number.doubling() }
            set { number = newValue.halving() }
        }
        init(wrappedValue: T) {
            self.number = wrappedValue
        }
    }
    
    extension Int: Doublable {
        func doubling() -> Int {
            return self * 2
        }
    
        func halving() -> Int {
            return Int(self / 2)
        }
    }
    
    extension Doubled: Doublable {
        func doubling() -> Self {
            return Doubled(wrappedValue: self.wrappedValue)
        }
    
        func halving() -> Self {
            return Doubled(wrappedValue: self.wrappedValue)
        }
    }
    
    struct Test {
        @Doubled @Doubled var value: Int = 10
    }
    
    var test = Test()
    print(test.value) // prints 40
    

    You could do the same thing for Tripled, with a Tripleable protocol, and so on.

    However, I should note that instead of nesting @Tripled @Doubled, it might be better to create another wrapper like @Multiple(6) instead: then you won't have to deal with any protocols, but you'll get the same effect.