The overall, proposal makes sense, but I think too much of the behavior is implicit. I think by taking advantage of the consume
keyword everything could be made more explicit and potentially easier for the compiler to check and easier for humans to read.
The proposal is already using similar semantics as a “move”. Let me illustrate with your own examples modified:
Here’s the motivating example:
// Not Sendable
class Client {
init(name: String, initialBalance: Double) { ... }
}
actor ClientStore {
var clients: [Client] = []
static let shared = ClientStore()
func addClient(_ c: Client) {
clients.append(c)
}
}
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(client) // Error! 'Client' is non-`Sendable`!
}
Instead of this pattern working as-is, I propose the following modification be required to make this example work:
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(consume client)
}
This has the same result as your proposal, but the fact that the reference has been transferred over to ClientStore
is explicit with the consume
keyword. Further, the compiler will already ensure that the reference cannot by used within this scope any longer. The reference has “moved”.
func openNewAccount(name: String, initialBalance: Double) async {
let client = Client(name: name, initialBalance: initialBalance)
await ClientStore.shared.addClient(consume client)
client.logToAuditStream() // Error! `client` has moved and cannot be used.
}
The consume
keyword can keep solving these problems. For example, the rule about references that can be sent across isolation domains must contain properties that are either Sendable
(the current requirement) or consume
d.
let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)
await ClientStore.shared.addClient(consume john) // OK
await ClientStore.shared.addClient(consume joanna) // OK
Here, the two references are independent and can be consume
d and sent to the actor independently.
However, for the “friend” case:
let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)
john.friend = joanna // (1)
await ClientStore.shared.addClient(consume john) // Error: `john.friend` must consume!
Can only work when you do this:
class Client {
// …
private var _friend: Client?;
var friend: Client? {
// All properties must consume if we want to consume `Client` itself
// for sending across domains.
set { (newFriend: consume Client) in
self._friend = newFriend
}
}
}
let john = Client(name: "John", initialBalance: 0)
let joanna = Client(name: "Joanna", initialBalance: 0)
john.friend = consume joanna // OK!
await ClientStore.shared.addClient(consume john) // OK!
await ClientStore.shared.addClient(consume joanna) // ERROR! `joanna` no longer exists.
I like this modification as it leans on functionality that has already been added to Swift (consume
) and doesn’t introduce a brand new concept to understand, but it works pretty much the same as your original proposal.
Finally, let me provide one additional example for when client
is not created in the local scope:
func openNewAccount(client: Client) async {
await ClientStore.shared.addClient(consume client) // Error! 'Client' is not owned
}
can be fixed by doing this:
func openNewAccount(client: consume Client) async {
await ClientStore.shared.addClient(consume client)
// OK! if Client properties are all sendable or `consume`ing types.
}
Update:
My proposed solution cannot work with function arguments like in last example. This is because of how consume
works for class
types.
This is from the consume
keyword examples:
func useX(_ x: SomeClassType) -> () {}
func f() {
let x = ...
useX(x)
let other = x
_ = consume x
useX(consume other)
useX(other) // error: 'other' used after being consumed
useX(x) // error: 'x' used after being consumed
}
Sadly, consume
doesn’t not ensure you have a unique reference to a class type.
Sadly, this raises the question of whether any non-Sendable class types can considered safe to send across isloation domains.
What if the init
ializer itself, stores a reference every time a new instance is constructed.
// Not Sendable
class Client {
static var instances: [Client] = []
init(name: String, initialBalance: Double) {
self.name = name;
self.balance = initialBalance;
Client.instances.append(self); // Uh Oh!
// This makes this class unsafe to ever transfer across isolation domains.
}
}