In adopting Swift Concurrency, there are cases where a @unchecked Sendable
wrapper type is the best/only solution.
For me, this is mostly about interfacing with legacy Objective-C APIs where I do not expect Swift Concurrency support in the near future to be available. sending
and other compiler hints help in many cases, but in practice there are still situations where a non-sendable value must cross isolation boundaries.
The following is a wrapper type that provides such an escape hatch while still offering some runtime checks and an API that encourages sound handling of operations that the compiler cannot check:
/// A wrapper type to make non-sendable values sendable by providing constraint access.
final class Immutable<Value>: @unchecked Sendable {
private var value: Value
/// An internal status to enforce some of the access constraints at runtime.
///
/// - A non-negative status indicates the concurrent immutable access count.
/// - A value of `-1` indicates that the value has been consumed and this wrapper may no longer be accessed.
private let accessStatus = Mutex(0)
init(_ value: Value) {
self.value = value
}
/// Provides immutable access to the wrapped value.
///
/// The wrapped value is passed to `body` where it may not be mutated.
/// Multiple concurrent immutable accesses are permitted.
///
/// > Important: The fact that `body` does not mutate the value is neither checked at compile time nor at runtime; failing to fulfill this constraint is a serious programming error.
///
/// A precondition verifies that the value has not been consumed.
///
/// - Parameter body: If the closure has a return value, that value is also used as the return value of the `withUncheckedImmutableAccess(_:)` method.
/// Any errors thrown by the closure are rethrown.
func withUncheckedImmutableAccess<E, T>(
_ body: (Value) throws(E) -> T
) throws(E) -> T {
self.accessStatus.withLock {
precondition($0 >= 0, "Cannot acquire immutable access of consumed value.")
$0 += 1
}
defer {
self.accessStatus.withLock { $0 -= 1 }
}
return try body(self.value)
}
/// Returns the wrapped value and prohibits any further access of the value using the wrapper.
///
/// A precondition verifies that the value is currently not accessed and has not been consumed.
func consume() -> Value {
self.accessStatus.withLock {
precondition($0 > -1, "Cannot consume value that has already been consumed.")
precondition($0 == 0, "Cannot consume value: access count \($0) is non-zero.")
$0 = -1
}
return self.value
}
}
(I am using Mutex
from github.com/swhitty/swift-mutex as OSAllocatedUnfairLock
or Synchronization.Mutex
are not available on all of my deployment targets.)
The idea is to pretend that a value is immutable and thus make it Sendable
.
This immutability constraint cannot be upheld by the compiler, and it is possible that some other code still has a reference to the value (for example, when it is an NSObject
) and mutates it from outside the wrapper. Or that a user of the wrapper misuses the API and performs mutations inside withUncheckedImmutableAccess { … }
.
Still, I think this wrapper improves clarity at site of use and some runtime checking, which is better than a simple @unchecked Sendable
wrapper providing public access to its wrapped property.
Example usage:
let size = storage.someObject.withUncheckedImmutableAccess { $0.size }
let someObject = storage.someObject.consume()
// after `consume()`, these will stop program execution:
let size2 = storage.someObject.withUncheckedImmutableAccess { $0.size }
let someObject2 = storage.someObject.consume()
In the example above the Immutable<T>
is a property for simplicity, but in my usage I mostly need this wrapper for return values. consume()
makes it clear I do not expect further use of the wrapper. withUncheckedImmutableAccess { … }
makes it clear that no mutation should happen to the value within the given closure.
A possible extension would be to allow mutation, but only when the access count is 0:
extension Immutable {
/// Provides mutable access to the wrapped value.
///
/// The wrapped value is passed to `body` where it may be mutated.
/// Mutation is allowed as `body` is guaranteed to have exclusive access to the value.
/// In effect, this operation is the same as consuming the value, mutating it outside of this wrapper, and wrapping it again.
///
/// A precondition verifies that the value is currently not accessed and has not been consumed.
///
/// - Parameter body: If the closure has a return value, that value is also used as the return value of the `withExclusiveAccess(_:)` method.
/// Any errors thrown by the closure are rethrown.
func withExclusiveAccess<E, T>(
_ body: (inout Value) throws(E) -> T
) throws(E) -> T {
self.accessStatus.withLock {
precondition($0 == 0, "Cannot acquire exclusive access: access status \($0) is non-zero.")
$0 = -2
}
defer {
self.accessStatus.withLock { $0 = 0 }
}
return try body(&self.value)
}
}
I would be interested to hear if anyone has ideas of how to further improve this type.