Beware of specifying isolation requirements for whole protocols

Matt Massicotte has a well-written, brief introduction to isolation in Swift. But it mostly just enumerates the state of things, without offering much guidance. One pitfall in particular is important to call out, regarding isolated protocols.

@MainActor
protocol GloballyIsolatedProtocol {
    func method()
}

protocol PerMemberIsolatedProtocol {
    @MainActor
    func method()
}

These might seem pretty similar – you’d be forgiven for assuming it’s just a convenience to put @MainActor on the protocol overall rather than having to repeat it for every member of the protocol. Less error-prone, too.

But, you generally shouldn’t do that. They are not equivalent.

The first form is not merely saying that all the members of the protocol require a certain isolation, but that the type that conforms to the protocol must have that isolation. The whole type.

And you might think: …so what?

And indeed it seems like it’s fine:

@MainActor
func doStuff() {}

class ClassA: GloballyIsolatedProtocol {
    func method() {
        doStuff()
    }
}

class ClassB: PerMemberIsolatedProtocol {
    func method() {
        doStuff()
    }
}

// ✅ Everything is hunky-dory as far as the compiler is concerned.

🤔 Some folks might think it’s a bit dangerously magical that ClassA is secretly @MainActor now by simply conforming to the protocol, even though nothing in the class’s declaration actually says that – and likewise for method for ClassB. That’s largely a separate topic. But okay, it makes a kind of sense at least…

But try to use actors instead of classes, and see how it all falls apart:

actor ActorA: GloballyIsolatedProtocol { // ❌ Actor 'ActorA' cannot conform to global actor isolated protocol 'GloballyIsolatedProtocol'
    func method() {
        doStuff() // ❌ Call to main actor-isolated global function 'doStuff()' in a synchronous actor-isolated context
    }
}

actor ActorB: PerMemberIsolatedProtocol {
    func method() { // ❌ Actor-isolated instance method 'method()' cannot be used to satisfy main actor-isolated protocol requirement
        doStuff() // ❌ Call to main actor-isolated global function 'doStuff()' in a synchronous actor-isolated context
    }
}

All these error messages are correct, even if they may be surprising at first. You might even say: so what? That’s working as expected.

The problem is that there is nothing an actor can do to conform to GloballyIsolatedProtocol. It is an actor-hostile protocol. It is unusable by actors.

Whereas PerMemberIsolatedProtocol can be used by an actor:

actor ActorB: PerMemberIsolatedProtocol {
    @MainActor
    func method() {
        doStuff()
    }
}

Given that protocols are most often just used to express an interface requirement – e.g. for delegates, data sources, codability, etc – there is usually no reason why actors shouldn’t be allowed to conform to them. Even if the protocol does have some isolation requirements, there’s no technical reason an actor can’t abide by those if it’s given the chance.

This leads into a more complicated aspect of isolation in Swift, regarding how actors aren’t necessarily restricted to their own isolation domain. They can have methods and properties that are not isolated at all, or isolated to a different domain. In Swift 6 they’ll even be able to have methods which are isolated to different actors!

So don’t make the mistake of assuming actors can only ever be off in their own isolated worlds. And don’t needlessly exclude them from supporting your protocols.

Leave a Comment