swiftasync-awaituikitstructured-concurrency

Main actor-isolated static property 'text' cannot be mutated from a non-isolated context; this is an error in Swift 6


I'm facing an issue while trying to modify a static property within a subclass of UIViewController from another class, specifically inside an asynchronous function. Additionally, I'm encountering the error

Main actor-isolated static property 'text' cannot be mutated from a non-isolated context; this is an error in Swift 6

My code:

class Primary: UIViewController {
    static var text = ""

}

class Secondary {
    func someAction() async {
        Primary.text = "12" // Getting warning in this line!
    }
}

Anyhow by using DispatchQueue.main.async will silence this warning.

My question is that why static property in UIViewController is considered as an actor isolated property? If it is isolated, then why can't we use await (just curious) to access that property?


Solution

  • You said:

    My question is that why static property in UIViewController is considered as an actor isolated property?

    If you look at the UIViewController documentation, it tells us that the whole class is isolated to the main actor, defined as follows:

    @MainActor
    class UIViewController : UIResponder
    

    Because UIViewController is isolated to the main actor, all subclasses (and any subclasses’ properties, methods, etc.) are also isolated to the main actor.

    If it is isolated, then why can’t we use await (just curious) to access that property?

    The error message is a little misleading. Yes, part of the problem is that you are trying to perform some action in a different asynchronous context (which begs for await). But the deeper problem is that you are trying to reach inside and directly update an actor-isolated property, at all. The actor is responsible for handling any mutation of its properties. External types should not be able to mutate its properties directly.

    The whole idea of using actors is that the actor should facilitate all mutation of its own properties. This is done not only to prevent data races, but also to eliminate race conditions, more generally. See WWDC 2021’s video Protect mutable state with Swift actors.

    But you can provide a method to mutate this property:

    class Primary: UIViewController {
        static var text = ""
    
        static func updateText(_ string: String) {
            text = string
        }
    }
    
    class Secondary {
        func someAction() async {
            Primary.updateText("12")         // Error: Expression is 'async' but is not marked with 'await'
        }
    }
    

    Now that we are updating that property correctly, you get the appropriate error about needing to await this update. Thus, the corrected implementation would be:

    class Secondary {
        func someAction() async {
            await Primary.updateText("12")   // OK
        }
    }
    

    As discussed below, I think the whole idea of mutable static properties is suspect, so I would not advise the above. But I am just trying to illustrate how, in general, one might theoretically resolve the compiler error you described.


    A few unrelated observations:

    1. It is very curious to have static mutable properties. If the static was introduced merely to provide a convenient way of allowing Secondary to mutate some property of Primary, I would strongly advise against that pattern.

      A static should be used only where you really need all instances of Primary have some shared state. That is the only purpose of static. The problem with the mutable static properties is that dependencies between types are hidden, leading to code that is exceedingly hard to debug. It is akin to the vibrant debate about why globals are “evil”. (Lol.) Dependencies are hidden and the mutating state is hard to control/debug.

      In short, mutable statics are exceedingly unusual for a view controllers, and is generally code-smell.

    2. The idea of one type reaching in and mutating a property of another type is antithetical to the goal of keeping types loosely coupled. E.g., Secondary should provide general-purpose mechanisms to allow its properties to be observed, but make no assumptions about Primary. Primary should merely instantiate Secondary and observe its properties.

    E.g., with ObservableObject, you might use Combine to observe changes:

    import UIKit
    import Combine
    
    class Primary: UIViewController {
        private let secondary = Secondary()
    
        @IBOutlet weak var textField: UITextField!
        
        private var cancellables: Set<AnyCancellable> = []
    
        override func viewDidLoad() {
            super.viewDidLoad()
    
            textField.text = "-1"
            
            secondary
                .objectWillChange
                .receive(on: DispatchQueue.main)
                .sink { [weak self] object in
                    self?.textField.text = self?.secondary.text
                }
                .store(in: &cancellables)
        }
    
        @IBAction func didTapButton(_ sender: Any) {
            Task { await secondary.someAction() }
        }
    }
    
    @MainActor
    class Secondary: ObservableObject, Sendable {
        @Published var text = ""
    
        func someAction() async {
            text = "42"
        }
    }
    

    This way, Secondary is no longer tightly coupled with Primary. In the above, Primary merely observes changes in Secondary.

    There are many different patterns (observation, closures, delegates) that achieve the same goal. But ideally, we want our types loosely coupled. Primary should avail itself of Secondary’s interface, but Secondary should not make any assumptions about Primary.