This actually got me thinking… I have a similar pattern for my own CoW types… but I'm seeing a (potential) missing piece here WRT Sendable
.
Suppose you start with your example and then try to make the CoW Sendable
. Here is the error:
public struct MyCopyOnWriteStruct : Sendable {
public init() {}
public var value: Int {
get {
storage.value
}
set {
copyStorageIfNecessary()
storage.value = newValue
}
}
private var storage = Storage()
// `- error: stored property 'storage' of 'Sendable'-conforming struct 'MyCopyOnWriteStruct' has non-sendable type 'Storage'
private mutating func copyStorageIfNecessary() {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy()
}
}
}
fileprivate final class Storage {
// `- note: class 'Storage' does not conform to the 'Sendable' protocol
init() {}
var value = 0
func copy() -> Storage {
let copy = Storage()
copy.value = value
return copy
}
}
private var storage = Storage()
private mutating func copyStorageIfNecessary() {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy()
}
}
}
fileprivate final class Storage {
init() {}
var value = 0
func copy() -> Storage {
let copy = Storage()
copy.value = value
return copy
}
}
Ok… what if we mark Storage
as Sendable
?
fileprivate final class Storage : Sendable {
init() {}
var value = 0
// `- error: stored property 'value' of 'Sendable'-conforming class 'Storage' is mutable
func copy() -> Storage {
let copy = Storage()
copy.value = value
return copy
}
}
Almost there… one more step:
fileprivate final class Storage : Sendable {
init() {}
nonisolated(unsafe) var value = 0
func copy() -> Storage {
let copy = Storage()
copy.value = value
return copy
}
}
Ok… no errors. All good? What if we go on vacation and a new engineer comes here to add a new value to our CoW (trying to follow the same pattern we just did)?
final public class Item {
var value: Int = 0
}
public struct MyCopyOnWriteStruct : Sendable {
public init() {}
public var value: Int {
get { storage.value }
set {
copyStorageIfNecessary()
storage.value = newValue
}
}
public var item: Item {
get { storage.item }
set {
copyStorageIfNecessary()
storage.item = newValue
}
}
private var storage = Storage()
private mutating func copyStorageIfNecessary() {
if !isKnownUniquelyReferenced(&storage) {
storage = storage.copy()
}
}
}
fileprivate final class Storage : Sendable {
init() {}
nonisolated(unsafe) var value = 0
nonisolated(unsafe) var item = Item()
func copy() -> Storage {
let copy = Storage()
copy.value = value
return copy
}
}
Hmm… no errors… what if we put these same properties in a type that is not Cow?
public struct MyStruct : Sendable {
public var value = 0
public var item = Item()
// `- error: stored property 'item' of 'Sendable'-conforming struct 'MyStruct' has non-sendable type 'Item'
}
Ahh… there it is. By marking our Storage
instance variables as nonisolated(unsafe)
… we opt-out of strict concurrency checking and enable our Storage
to be Sendable
. But this is a big hammer… we would actually prefer something with a little more control behind it.
- We want our instance variable to be
unsafe
WRT our own access (so we opt out of Strict Concurrency checking).
- We want our instance variable to be
safe
WRT the underlying Sendable
conformance of the type of the instance variable itself (so we do not opt out of Strict Concurrency checking).
Which gives us a dilemma… do we make this type compatible with Sendable
(and let the engineer manage the risk of losing Strict Concurrency errors when passing in a type that is not Sendable
)?
As of right now… I'm not sure we can get one without the other. It feels like we are missing one additional dimension of nonisolated
specificity. Hmm… any ideas about how else to work around this in 5.10?