Why does Sendable conformance require reference properties to be Sendable?

Please help me understand sendability and reference types and container objects:

final class Company {
    var name: String
    init(name: String) {
        self.name = name
    }
}

final class Employee: Sendable {
    let id: String
    let employer: Company // ERROR: Stored property 'employer' of 'Sendable'-conforming class 'Employee' has non-sendable type 'Company'
    
    init(id: String, employer: Company) {
        self.id = id
        self.employer = employer
    }
}

I feel like I should be able to pass Employee across isolation contexts as Sendable, because as a container object it is immutable and not going to change, even though it has a property Company that itself has a mutable var field. Company is a reference type and the let employer pointer address is immutable.

Mutating Company, a container of a container, is independent of mutating Employee. The top-level object container should be considered thread-safe even if the sub-container is not.

1 Like

If this were allowed you could effectively 'smuggle' the non-Sendable Company across isolation domains just by wrapping it in an Employee. Consider, what's the difference between:

@MainActor {
func f(_ company: Company) {
  Task.detached {
    company.name = "Data race"
  }
}

and

@MainActor {
func g(_ company: Company) {
  let e = Employee(id: "id", employer: company)
  Task.detached {
    e.employer.name = "Data race"
  }
}

?

1 Like
@MainActor {
func g(_ company: Company) {
  let e = Employee(id: "id", employer: company)
  Task.detached {
    e.employer.name = "Data race"
  }
}

in the second example, can the compiler also disallow this mutation because it's mutating a non-sendable property from a passed-in object container?

This isn't considered a formal 'mutation' of employer, because the notional 'value' of a class is its reference identity, not the data it contains. But regardless, we could split this out into a version which doesn't assign 'through' employer and it would still be a race:

@MainActor {
func g(_ company: Company) {
  let e = Employee(id: "id", employer: company)
  Task.detached {
    let c = e.employer
    c.name = "Data race"
  }
}
1 Like