swiftswift-concurrency

can't implement UIView `description` because it isn't MainActor


Let the code speak for itself:

final class Piece: UIView {
    var picName: String
    var column: Int
    var row: Int

    override var description: String {
        return "picname: \(picName); column: \(column); row: \(row)" // error
    }

    init(picName: String, column: Int, row: Int) {
        self.picName = picName
        self.column = column
        self.row = row
        super.init(frame: .zero)
    }

    required init?(coder aDecoder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
}

This code, which has worked since Swift 1, is now illegal in Swift 6, evidently because description, believe it or not, is not main actor-isolated:

Main actor-isolated property 'picName' can not be referenced from a nonisolated context (and so on)

How am I supposed to work around this?


EDIT: One workaround is to declare picName, column, and row as constants, with let instead of var. But that doesn't necessarily do me any good, and in any case I still don't understand why that would make a difference, so this discovery just makes the mystery deeper.


Solution

  • Swift thinks this is unsafe because description can be accessed from outside the main actor. It is a non-isolated requirement of the CustomStringConvertible protocol, so implementations of it must be non-isolated too.

    For example, something like this is possible:

    let view = Piece(picName: "", column: 0, row: 0)
    Task.detached {
        print(view.description)
    }
    view.picName = "Something else"
    

    While the detached task is running, the access of picName in view.description races with the write to picName outside the task.


    That said, it is very unlikely in practice that you would be accessing the description of a UIView outside of the main actor. Even the default implementation of UIView.description accesses its frame and layer properties. Both of these accesses could cause data races if the access happens outside the main actor.

    Option 1: I would just assume that accesses to description are always on the main actor.

    nonisolated override var description: String {
        MainActor.assumeIsolated {
            return "picname: \(picName); column: \(column); row: \(row)"
        }
    }
    

    This will crash if description is accessed from some other actor/no actor at all.

    Option 2: make the properties you want to access nonisolated(unsafe).

    nonisolated(unsafe) var picName: String
    nonisolated(unsafe) var column: Int
    nonisolated(unsafe) var row: Int
    

    This is basically "accepting" the data races that could happen.

    Option 3: make the class nonisolated, and explicitly add @MainActor to the members of the class that needs it.

    nonisolated final class Piece: UIView
    

    This is the "safest" option, but you might need to add a lot more @MainActors to things in the class. Also, you won't be able to send Piece across isolation boundaries, but you are unlikely to do that with a UIView anyway.