Swift Regret: Weak Vars in Structs

Part of the Swift Regrets series.

Swift lets you mark vars in structs as weak, just like in classes. Currently this is the only way to have a collection of weak values without having one class instance per weak ref. But you have to manually filter such a collection; it doesn’t auto-shrink when a reference goes away. Still, sometimes that’s what you want.

This wasn’t on my original list, but Jens Ayton brought it up because, like lazy vars, weak vars make a containing struct act weirdly. And it makes my regret/delight tick-tock line up better. So here we go:

var obj: Optional = SomeObject()
let weakWrapper = Wrapper(obj)
print(weakWrapper.object == nil) // false
obj = nil
print(weakWrapper.object == nil) // true

There you go. The struct is declared with let, but its member changes anyway! Value semantics advocates HATE him!

*cough* Unlike lazy, you can’t build weak references out of other existing features. A safe weak reference fundamentally needs to do some bookkeeping when initialized and destroyed. That means that if weak refs in structs weren’t allowed, you couldn’t build your own weak collection without directly talking to the runtime, whether you’re making a compacting or non-compacting one. You really want the compiler to insert the correct operations for you. We still don’t have compacting collections in the stdlib, though, so… (They’re not that easy to design or implement, but still.)

Anyway, weak refs in structs have copy/destroy overhead like strong refs, but the desired semantics make the “value” change even when “immutable”. It’d be clearer, if not necessarily better, if WeakRef were a wrapper type with a computed property referent. Then it’d be obvious why the value can change. But it’d be a lot more annoying to use. Still, maybe it’s a useful mental model—think of it like a property wrapper.

All that’s why most people don’t like weak in structs, but I actually have a different reason. Swift weak references work a lot like Swift unowned references, with dedicated retain and release operations and a check on load to see if the object is alive. However, Objective-C had weak references first, and they’re very different. An Objective-C weak reference registers itself with the runtime, which then (essentially) adds an extra step to the target object’s deinitialization: “zero out this other address over here”.

That doesn’t play well with Swift structs, which can be freely copied around. Not only is the struct now forced to keep at least one field in memory rather than a register, but moving the struct, destructively, is no longer a memcpy. Every other primitive type in Swift can be moved “bitwise” / by memcpy / “trivially” in the C++ sense. Even Swift-native weak references. But AnyObject can be an Objective-C class, or a descendent thereof, and so we’re forced to consider Objective-C on Apple platforms…which means the language everywhere allows for customized, “non-trivial” moves. In practice this is mostly a code size impact, and not a huge one, but unspecialized generic code is probably a little bit slower than it otherwise needs to be because of this.

Now, this might turn out to be a good thing for C++ interop. C++ allows types to have non-trivial moves, and without that support those types would otherwise not be allowed in Swift. (Though C++ moves are non-destructive; Swift move is C++ move-then-destroy.) But I don’t know if that was worth the extra implementation complexity, even if it meant C++ interop was forever limited in Swift, or required additional special annotations for certain types.

Still, there are valid uses for weak references in structs, even if they do add implementation complexity and a bit of weirdness. If we hadn’t allowed them we would have needed to provide workarounds. And of course it’s the backwards compatibility requirement with Objective-C that got us here. So, *shrug*.