I’m trying to wrap my head around Swift 6’s concurrency model while migrating some code that uses a weak delegate, and I want to minimize changes. Suppose I have a delegate defined like this:
protocol ServiceDelegate: AnyObject {
func doSomething()
}
And a service interface:
protocol ServiceProvidable {
var delegate: ServiceDelegate? { get set }
func load()
}
The implementation looks something like this:
final class ServiceProvider: NSObject, ServiceProvidable, URLSessionDelegate {
weak var delegate: ServiceDelegate?
func load() {
delegate?.doSomething()
}
}
With Swift 6 enabled, I get the following error:
Stored property 'delegate' of 'Sendable'-conforming class 'ServiceProvider' is mutable
Possible Solutions
As I see it, I have a few options, but each comes with its own trade-offs:
Isolate ServiceProvidable to an actor (e.g., @MainActor): This approach would involve bigger refactoring for other code
Create a thread-safe wrapper around the delegate: I could implement a wrapper that ensures thread-safe access to the delegate and then mark the wrapper as @unchecked Sendable. Here’s what this could look like:
protocol ServiceDelegate: AnyObject {
func doSomething()
}
protocol SafeServiceDelegateProvidable: Sendable {
var delegate: ServiceDelegate? { get set }
}
protocol ServiceProvidable {
var safeDelegate: SafeServiceDelegateProvidable { get }
func load()
}
final class ServiceProvider: NSObject, ServiceProvidable, URLSessionDelegate {
let safeDelegate: SafeServiceDelegateProvidable = SafeServiceDelegateProvider()
func load() {
safeDelegate.delegate?.doSomething()
}
}
final class SafeServiceDelegateProvider: SafeServiceDelegateProvidable, @unchecked Sendable {
private var queue = DispatchQueue(label: "queue")
private weak var _delegate: ServiceDelegate?
var delegate: ServiceDelegate? {
get {
queue.sync { _delegate }
}
set {
queue.sync { _delegate = newValue }
}
}
}
However, there’s a complication: safeDelegate
needs to be updated (e.g. service.safeDelegate.delegate = self
) after initialization, and since it can’t be a let constant, changing it to a var results in error: Stored property 'safeDelegate' of 'Sendable'-conforming class 'ServiceProvider' is mutable
Question
What would be the easiest or most effective solution to this problem? Are there better ways to handle weak delegates in Swift 6, given the concurrency changes?
safeDelegate.delegate
can be set if the compiler can determine that safeDelegate
has reference semantics. You can constraint the protocol to AnyObject
:
protocol SafeServiceDelegateProvidable: AnyObject, Sendable { .. }
Or, you can change the type of safeDelegate
to the concrete class SafeServiceDelegateProvider
instead. I don't see how SafeServiceDelegateProvidable
is useful here.
protocol ServiceProvidable {
var safeDelegate: SafeServiceDelegateProvider { get }
func load()
}
final class ServiceProvider: NSObject, ServiceProvidable, URLSessionDelegate {
let safeDelegate = SafeServiceDelegateProvider()
func load() {
safeDelegate.delegate?.doSomething()
}
}
As Rob says in the comments, to actually make SafeServiceDelegateProvider
fulfil the requirements of Sendable
, ServiceDelegate
needs to be Sendable
too.
protocol ServiceDelegate: AnyObject, Sendable {
func doSomething()
}
Otherwise, SafeServiceDelegateProvider
is still not safe to send across threads. Consider:
class NotSendable: ServiceDelegate {
var someProperty = 0
func doSomething() {
someProperty += 1
}
}
@MainActor
class Foo {
let notSendable = NotSendable()
func doSomething() {
let provider = SafeServiceDelegateProvider()
provider.delegate = notSendable
Task.detached {
// here I sent 'provider' to some other thread
// doSomething is going to increment someProperty
// this is going to race with the line 'notSendable.someProperty += 1' just below
provider.delegate?.doSomething()
}
notSendable.someProperty += 1
}
}