Swift Regret: mutating Protocol Methods vs. Classes
Swift regret #3: mutating protocol methods vs. classes
— Jordan Rose (@UINT_MIN) August 11, 2021
`mutating` in Swift is essentially sugar for `inout`, which in Swift formally means copy-in/copy-out but often optimizes to an in-place mutation.
Part of the Swift Regrets series.
mutating
in Swift is essentially sugar for inout
, which in Swift formally means copy-in/copy-out but often optimizes to an in-place mutation. For value types this makes a lot of sense, but not so much for classes. Classes aren’t arbitrarily copyable, after all; the thing you pass around and “copy” is the reference. So you can’t make a class method mutating
.
Enter protocols. Protocols can have mutating
requirements. For example:
protocol Sortable {
mutating func sortInPlace()
}
func isSorted<List: Sortable & Equatable>(_ list: List) -> Bool {
var copy = list
copy.sortInPlace()
return list == copy
}
Not a great implementation, but you get the idea. This works for, say, Array (which is copy-on-write), but would fall down on a mutable class like NSMutableArray. var copy = list
doesn’t make a copy at all, and the original NSMutableArray gets mutated!
With protocol extensions (added in Swift 2, if I recall correctly) this got even worse:
protocol Default {
init()
}
extension Default {
mutating func reset() {
self = Self()
}
}
Again, this works fine with Array: you copy the existing array in (cheap, because the storage is shared and copy-on-write), and copy the new array out. With an NSMutableArray, you’d copy the reference in, and get a new reference out. Unlike sort()
, we haven’t modified the shared NSMutableArray, so other references won’t be reset. That’s different from any other method you call on a class. (See SR-142.)
So, uh. When a class conforms to a protocol, mutating
requirements can result in shared mutation where it wasn’t expected, and mutating
extension methods can result in non-shared mutation where it wasn’t expected. And extension methods are default implementations, so…
At the time, Dave Abrahams had the idea that classes should not be allowed to conform to protocols with mutating requirements. At the very least, he brought up the problem and hoped we could think more on it. And this is Dave, who tolerates the existence of classes at best. ;-) But that doesn’t solve the extension method part, and, well, really this is about reference semantics, not classes specifically. (Consider using isSorted
with UnsafeMutableBufferPointer.)
So we didn’t come up with an answer, and I still don’t have one, but it still bugs me.
P.S. After tweeting this thread, I had multiple people point out that this would be a good use for “AnyValue”, the opposite of AnyObject that would only apply to structs and enums. I’ve historically been against AnyValue as a generic constraint because it doesn’t account for structs with reference semantics, but it would be a really good marker for functions that copy self
using plain assignment, or mutating
extension methods that need to re-assign self
. So I’ve finally come around on that idea.