Currently in Swift 6.2, you cannot send an object out of an actor or Task if it has been bound to a property or captured. Also you cannot send a non-Sendable object from a synchronous context into an asynchronous context.
SE-0414 Region Based Isolation and SE-0430 Sending Parameters and Return Values mention a Disconnected type in the Future Directions sections. I would like to discuss a simple implementation that doesn't require adding it to the language at a low level.
For non-Copyable types, if you store a ~Copyable object in a property, you can never get it out of the property. You can replace it, but never retrieve it. In order to allow ~Copyable types to be stored in properties and later retrieved, Optional has been extended to match the copyability of its wrapped object, and a take() method consumes the Wrapped value and sets the Optional to nil.
The Disconnected type must suppress copyability always, and have a sending parameter/return on its init(_:) and take() methods. Otherwise it is the same as Optional.
public enum Disconnected<Wrapped: ~Copyable & ~Escapable>: ~Copyable, ~Escapable {
case some(Wrapped)
case none
}
extension Disconnected: ExpressibleByNilLiteral
where Wrapped: ~Copyable & ~Escapable {
/// Do not call this initializer directly. It is used by the compiler when you initialize with nil.
public init(nilLiteral: ()) { self = .none }
}
extension Disconnected: Sendable where Wrapped: ~Copyable & ~Escapable & Sendable {}
extension Disconnected: Escapable where Wrapped: ~Copyable & Escapable {
public init(_ value: consuming sending Wrapped) { self = .some( value ) }
public mutating func send() -> sending Wrapped? {
let result = consume self
self = .none
if case let .some(result) = result { return result }
else { return nil }
}
public mutating func trySend() throws(DisconnectedError) -> sending Wrapped {
let result = consume self
self = .none
if case let .some(result) = result { return result }
else { throw .empty }
}
}
public enum DisconnectedError: Error {case empty}
As long as you only use the init method to create this object, it will prevent data races, but allow you to move an object in and out of Actor or Task isolated regions.
class Nonsendable { var name: String = "nonsendable" }
actor A {
var i = Nonsendable()
// func test() { var d: Disconnected<Nonsendable> = .init(i) } // error: Sending 'self.i' risks causing data races. 'self'-isolated 'self.i' is passed as a 'sending' parameter; Uses in callee may race with later 'self'-isolated uses.
func useNS(_ ns: sending Nonsendable) { print("actor A used:", ns.name) }
}
actor Traveler {
var value: Disconnected<Nonsendable>
var otherValue: Disconnected<Nonsendable> = nil
init(_ value: sending Nonsendable) {
self.value = .init(value)
}
func trySend() throws(DisconnectedError) -> sending Nonsendable {
try self.value.trySend()
}
}
// A bad actor
extension Traveler {
/*
func doBadThings() {
otherValue = value // error: Noncopyable 'unknown' cannot be consumed when captured by an escaping closure or borrowed by a non-Escapable type
}
func uhOh() {
if case let .some(otherValue) = otherValue { // error: Noncopyable 'unknown' cannot be consumed when captured by an escaping closure or borrowed by a non-Escapable type
otherValue.name += "-uh oh!"
}
}
*/
}
However, this implementation allows one to initialize Disconnected like var d: Disconnected<Nonsendable> = .some(value)
which bypasses the sending parameter in the initializer. This allows data races.
If Disconnected is extended with copyability, then it allows data races if you copy one.
//extension Disconnected: Copyable where Wrapped: Copyable & ~Escapable {}
Similarly, if Optional is extended with this send()
method, it will allow data races.
actor Traveler2 {
var value: Nonsendable?
var otherValue: Nonsendable? = nil
init(_ value: sending Nonsendable) { self.value = .init(value) }
func trySend() throws(DisconnectedError) -> sending Nonsendable { try self.value.trySend() }
}
// ... same extension
func test() {
let a = A()
let x = Nonsendable()
let t = Traveler2(x)
Task {
await t.doBadThings()
do {
let sentX = try await t.trySend() // This is a second reference to x
Task { t.uhOh() } // Modifies the reference to x stored in otherValue
Task { a.useNS(sentX) } // Data race! This can read before or after x is modified.
} catch {
print(error)
}
}
}
If Disconnected could have an attribute which suppresses case initializers and requires the enum to use init methods, then I think this would satisfy the requirements of a 'Disconnected' type as described in the SE proposals.
Because the init(_:) and send() methods both have sending values, the type system ensures the Wrapped object is in a disconnected region at both input and output. Because Disconnected is a non-Copyable enum with only one stored value, we know that Disconnected cannot do anything internally that would break exclusivity rules (like copy Wrapped's internal state). Therefore well-typedness implies data race safety.
This would allow us to safely move non-Sendable objects from a synchronous context into an asynchronous context as well as to make data structures where the internal structure is protected by a region.
The attribute would:
- Make case initializers private
- either: synthesize an init method for each case (but allow you to override them) or require you to provide an init method corresponding to each case's associated type pattern
- Require that each case have a unique associated type pattern
- Require there is no more than one case without an associated type
The attribute could be called @opaqueEnum or @opaque. This makes sense because if you declare one that is ~Copyable, then it also can't be destructured externally, making it a type you cannot see inside of, neither at initialization, nor by destructuring.
In my opinion, it would be swifty to change the name of this type from Disconnected to Capability. This changes the focus to the way programmers use the type rather than the internal math.
I don't know how to make attributes, so I hope to discuss this with someone who does know.
Also I would appreciate if someone could point me to more thorough data race tests or an explanation of exactly what must be proved for this to provide safety.