Currently, we can infer @MainActor
on an entire type based on the presence of certain property wrappers within that type. TL;DR: I'm making a case that that's a bad idea, and we should reconsider it if possible.
When talking about global actor inference, SE-0316 (Global Actors) states:
Declarations that are not explicitly annotated with either a global actor or
nonisolated
can infer global actor isolation from several different places:[...]
A struct or class containing a wrapped instance property with a global actor-qualified wrappedValue infers actor isolation from that property wrapper:
@propertyWrapper struct UIUpdating<Wrapped> { @MainActor var wrappedValue: Wrapped } struct CounterView { // infers @MainActor from use of @UIUpdating @UIUpdating var intValue: Int = 0 }
I have found that this particular inference rule is surprising and nonobvious to most users. In practical usage, this often comes up when using SwiftUI. I frequently deal with developers who have trouble understanding the Swift Concurrency model, because it's not obvious to them when actor isolation applies. When something is inferred, it is not visible to the user, and that makes it harder to understand.
@MainActor func mainActorStuff() {}
// @MainActor -- inferred from presence of @StateObject var
struct MyView: View {
@StateObject private var model = Model()
var body: some View {
Text("Hello, \(model.name)").onAppear { updateUI() }
}
func updateUI() {
mainActorStuff()
}
}
The above view compiles just fine. But if we change @StateObject
to @State
โฆ
func updateUI() {
// error: Call to main actor-isolated global function
// 'mainActorStuff()' in a synchronous nonisolated context
mainActorStuff()
}
In my experience, it's not at all obvious to most developers that changing @StateObject
to @State
should cause some function that doesn't even touch that property to stop compiling. The same happens if we remove an unused @StateObject
property entirely.
In fact, we can also get errors if we add a new property:
@propertyWrapper
struct DBObject<Wrapped> {
@DatabaseActor var wrappedValue: Wrapped
}
struct MyView: View {
// ๐ This is new
@DBObject private var dbConnection = DBConnection()
@StateObject private var model = Model()
// ...
}
When we have two property wrappers that have wrappedValue
s isolated to two different actors, then the inference just silently stops, and you're back to having to declare it manually. The compiler doesn't tell you that it's doing this; it just silently infers (or doesn't infer) it. This feels a lot like Einstein's "spooky action at a distance", where changing one property declaration can cause another function to fail to compile, even if it doesn't use that property at all.
It's not clear to me why the inference based on property wrappers was proposed initially. I didn't find any discussion of it during the Global Actors review. I suspect it may have been to make it easier to interact with @ObservedObject
when SwiftUI first rolled out. But I'm not sure it actually makes it significantly easier; it only saves us from writing a single annotation, and the loss of that annotation causes its own confusion, as shown above. (See also this recent tweet and this recent blog post)
I'd propose that we should stop inferring actor isolation based on property wrappers, perhaps in Swift 6. That would definitely break some code right now, but as we ramp up the enforcement of actor isolation in upcoming Swift releases, the breakage would likely increase. In practice, the source breakage could be limited if SwiftUI.View
were annotated @MainActor
directly. (I recognize that SwiftUI isn't in the purview of Swift Evolution, though. But it does give Apple a way to mitigate the breakage for their users.)
Is there anything I'm missing that justifies the current inference behavior?