I have seen this pattern come up several times while enabling concurrency in a large codebase, and I'm not sure how to solve it: I want to constrain a data protocol to be used on the main actor, but the caller is free to instantiate the object elsewhere.
@MainActor
public protocol TransitionData { ... }
// nonisolated
func handleNetworkEvent() {
let data = CustomData(field1: "a", field2: someView)
appNavigator().transitionToScreen(with: data)
}
CustomData here is something private to this module, and conforms to the public TransitionData. And transitionToScreen will internally dispatch to the main thread (it is legacy code and can't be refactored).
CustomData itself does not need to be instantiated on the main thread, but the TransitionData conformance is requiring it. Making the protocol Sendable is overly restrictive and incorrect for this case.
I suppose this is what sending solves? But having the guarantees of main actor on the types themselves works better, especially in a large codebase, than depending on sending.
Is there any way to, or work being done to allow initializers on different actors than the one which applies for its use? It would seem something like an implicit sending on inits, where the compiler checks that all the init does is assign required variables, should work here.
Could you post a full self-contained code snippet that shows the error you're getting and that we can play with? I don't quite understand what exactly you want to do.
If you mark an initializer of a @MainActor type as nonisolated, it can be called from other isolation contexts and the initializer body still has access to the type's fields (until the instance is fully initialized, I think). For example, this works:
@MainActor
public protocol TransitionData {}
// Implicitly @MainActor
private struct CustomData: TransitionData {
var field1: String
var field2: Int
nonisolated init(field1: String, field2: Int) {
// nonisolated init can assign to fields
self.field1 = field1
self.field2 = field2
}
}
// nonisolated
func handleNetworkEvent() {
let data = CustomData(field1: "a", field2: 1)
}
yes thank you! But perhaps there is a language feature to be made where init is implicitly nonisolated as long as it's just assignment inside? The particulars of this codebase make updating all the custom data object a challenge.
And perhaps I might implore Apple to update some UIKit classes like UIView to allow initialization-only on a non-main actor, which would make concurrency far simpler to adopt.
Where handlePush might be called on any thread, and the pushHandler.openScreen internally dispatches to the main thread before opening the screen. Now if you try to adopt structured concurrency incrementally it would be simplest to allow Target to be instantiated on any actor to avoid a cascading refactor of main actor requirements or async updates.
In a situation like this, where you cannot (or do not want to) change the isolation of the enclosing function and you must keep it non-isolated, you have limited options.
In fact, the only thing that occurs to me is to move this code, possibly even just this one function via an extension, into another module that has less-restrictive checking turned on. Structural changes like this can be a real pain, but that is something that has worked for me in the past.
Can be handy tool as you work your way through a migration, and possibly worth a try!