The issue is that the data types being accessed being Sendable
isn't sufficient to ensure the type is being accessed correctly in aggregate. You can implement plenty of unsafe behavior using purely Sendable
types.
A toy example:
Let's say that I'm working with extremely large datatypes in my app — struct
s which have to represent lots of data, and are expensive to compute and pass around. Instead of incurring large copies, I may decide to write a ThreadLocalDataStorage
type which stores the data, and allows me to access individual fields on those structs via thread-local ID and keypath:
struct ThreadLocalDataStorage {
// Every thread gets its own pool of IDs.
// IDs from one pool are not safe to use from another thread.
nonisolated static func get<T>(_ property: KeyPath<MyDataType, T>, forId: Int) -> T
}
To make access a little nicer, I might write a small DataProxy
struct which makes those fields easier to access:
@dynamicMemberLookup
struct DataProxy {
let id: Int
nonisolated subscript<T>(dynamicMember keyPath: KeyPath<MyDataType, T>) -> T
ThreadLocalDataStorage.get(keyPath, forId: id)
}
}
extension ThreadLocalDataStorage {
nonisolated static func insert(_ data: MyDataType) -> DataProxy
}
Now, even though DataProxy
contains only Sendable
data, and ThreadLocalDataStorage
is safe to access from any thread, the Int
identifiers only identify data accessible to the thread that inserted the data. As such, I don't want DataProxy
to be Sendable
, because that might cause the data to be accessed from the wrong thread:
/// DataProxy is only safe to access on the thread it was created on!!
@available(*, unavailable)
extension DataProxy: Sendable {}
Later, I see that I need to interface MyDataType
with an API requiring IdRequirement
, and I don't notice IdRequirement: Sendable
, so I write
extension DataProxy: IdRequirement {
func provideId() -> String {
// MyDataType has an `id` field itself.
self[\.id]
}
}
If the compiler doesn't prevent this access, because all of the data types are Sendable
and functions are nonisolated
... Then I'm in for a fun surprise if I pass a DataProxy
as an IdRequirement
to somewhere that then shuffles it across threads.
At a higher level, again: protocol conformances in Swift are intended to mean something at the type level. Either a whole type is Sendable
, or it isn't, because of what it means to be Sendable
; it doesn't make sense to say "if I look only at the parts of a type which are Sendable
then it's Sendable
" any more than it would to say "if I look only at the parts of a type which are Comparable
then it's Comparable
":
// Not Comparable because the operation wouldn't make sense.
struct WebPage {
let url: URL
let lastUpdated: Date
let contents: Data
// ...
}
protocol URLProvider: Comparable {
func getURL() -> URL
}
// Conforming only because I can provide a URL
extension WebPage: URLProvider {
func getURL() -> URL { url }
}
let googleDotCom: WebPage = /* ... */
let amazonDotCom: WebPage = /* ... */
// Now this is possible based on URL?
if googleDotCom < amazonDotCom { ... }
The semantics of a protocol need to apply to the whole type, as defined by the author.