Property wrapper semantics

I'm not sure if this is a bug or just an implementation quirk or a misunderstanding, but if a property wrapper type is a class instead of a struct, it seems as if the abstraction... leaks... somehow.

I'm not really sure how to explain this, so here's some code:

@propertyWrapper
class Wrapper<ValueType> {
	init(wrappedValue initialValue: ValueType) {
		wrappedValue = initialValue
	}
	
	var wrappedValue: ValueType
}

struct TestA {
	@Wrapper var num = 0
}

struct TestB {
	@Wrapper var test = TestA()
}

func doSomething(with x: Int) {
	let t = TestB()
	var p = t.test	// Variable 'p' was never mutated; consider changing to 'let' constant
	p.num += x
}

The var p = t.test line has a comment after it that is the warning that Swift is showing me in Xcode - but only if Wrapper is a class. If I change it to a struct, then there's no warning. Likewise, if I redefine TestA to this (removing the wrapper), the warning will also go away - even if Wrapper remains a class:

struct TestA {
	var num = 0
}

Now I realize that, on some level, this might maybe make sense because the wrapper itself is a reference type, but it is not at all what I would expect when actually looking at the code as it is used in the doSomething function while noting that TestA is a struct. It seems to me that it should still behave like a structure here and that changing the value of p.num should essentially have the same effect as something like this:

p = TestA(num: p.num + x)

It seems like p should have been considered mutated and thus the warning never should have been emitted - but that is clearly not what is happening in this case.

There is an easy explanation for this. If your wrapper was a struct Swift will force you to use a var in order to allow a mutation, but since your wrapper is a class you can just use a constant. This works because for property wrappers the compiler will infer and synthesize the property accessors. The setter is just inferred as nonmutating set as you cannot have mutating accessors on classes.

@Wrapper var num = 0

//
private var _num = Wrapper(wrappedValue: 0)
var num: Int {
  get { _num.wrappedValue }
  nonmutating set { _num.wrappedValue = newValue }
}

The proposal points out why this would happen.

When compiled,

@Wrapper var num = 0

translates to

private var _num: Wrapper<Int> = Wrapper<Int>(wrappedValue: 0)
var num: Int {
  get { return _num.wrappedValue }
  set { _num.wrappedValue = newValue }
}

So your call translates to

p._num.wrappedValue += x

This means the property of TestA being changed is a member of the Wrapper Class, not an Int. So p is never being mutated because _num is a reference to a Wrapper, not a struct value.

The semantics of this are confusing and non-intuitive in this kind of case. It's possible some better warnings would be helpful, but the explanation requires getting into the weeds of propertyWrapper so I don't have a good idea of what would really be helpful.

If you would write the translation like you wrote manually the compiler would 'require' var as the setter is mutating regardless the wrapper type. The real trick here is that compiler is smart enough to infer nonmutating set because Wrapper in our case is a class. The translations for property wrappers are all predictable, there is no magic here. ;)

For example State struct in SwiftUI is explicitly designed that way but it‘s kept as a value type.

Thanks for the replies - it all makes sense and seems it's not a bug or anything, but it is still a very surprising behavior to encounter IMO!

I'm not sure there's any way to avoid a surprise like this, though. It would be potentially useful if the Swift diagnostics could somehow be aware that a reference-typed property wrapper was in the mix and note that it changes the semantics in a case like this - but perhaps that's too esoteric/specific of a thing to weave into the system (I have no idea how any of that stuff works).

I should note, too, that I got pretty far with my wrapper before this ever even came up. All of my simple test cases managed to somehow avoid even triggering the diagnostic until I tried to integrate the wrapper I was working on into a larger codebase.

A rule I try to follow is: don't hold reference types in a value type unless they are CoW. That would include property wrappers. In other words, if the property wrapper is a reference type, only use it in reference types.

I'd be interested to see what someone more knowledgable about this has to say, but that rule works well for me.

2 Likes