@MainActor final class C: Equatable {
var value = [1:1]
static func == (lhs: C, rhs: C) -> Bool {
// Error: Main actor-isolated operator function '==' cannot be used to satisfy nonisolated requirement from protocol 'Equatable'
// 1. Add 'nonisolated' to '==' to make this operator function not isolated to the actor
// 2. Add '@preconcurrency' to the 'Equatable' conformance to defer isolation checking to run time
lhs.value == rhs.value
}
}
I guess option 2 is the way to go here? Why would I ever want using option 1? Is that for non global actors?
Option 1 is for when you don't need to access any isolated state inside the actor to implement ==. For example, if you defined == in terms of the object identity of the class rather than its data:
@MainActor final class C: Equatable {
nonisolated static func == (lhs: C, rhs: C) -> Bool {
lhs === rhs
}
}
#2 is the more dangerous option because it will crash at runtime if you ever invoke == while not on the main actor. And with Swift 6.2 there's an option #3 where you can use C: @MainActor Equatable and then you will only be allowed to invoke == on C if you have first proven you are in a main actor context.
(As an aside, it's not correct to implement == for a class with mutable state using its data. Object identity is pretty much the only correct implementation for == on classes.)
Could you give an example how that could happen? The class above is not sendable, so I assume it could only be created, used (and destroyed) on the main actor, is that not the case?
BTW, I'm getting the same error with a struct instead of a class.
It is sendable since all actors are implicitly sendable.
The problem arises when you pass the @MainActor value through a function that only requires Equatable. That loses the main actor isolation, and so then it will be possible to invoke == in a non-main actor context. Here is an example:
@MainActor final class C: @preconcurrency Equatable {
var value = 0
static func == (lhs: C, rhs: C) -> Bool {
lhs.value == rhs.value
}
}
@Test func foo() {
Task.detached {
let lhs = C()
bar(lhs)
}
}
func bar(_ value: some Equatable) {
_ = value == value // đź’Ą
}
Hmm. BTW, if I add an otherwise do nothing init() {} - that acts as a "marker" and gives the "desired" behaviour - can no longer create that class outside of MainActor.
That is also to be expected. The implicitly synthesized initializer is not isolated, but as soon as you add an explicit one, it is isolated to the @MainActor. That also does not change the results of the code I shared above. It will still lead to a crash because it is possible to erase the "main actor"-ness by passing the value through some Equatable.
But it's only possible to cheat if you do @preconcurrency Equatable, and so at that point it shouldn't be too surprising. If you do @MainActor Equatable, then you aren't able to cheat, and if you do regular Equatable, then it means your == was implemented without accessing any isolated state and so that is also ok.
I mean that I could merely send a non sendable... Simpler example:
protocol SomeProtocol {}
@MainActor struct S: SomeProtocol, CustomStringConvertible { // not sendable!
init() {} // to make this init isolated
public nonisolated var description: String {
dispatchPrecondition(condition: .onQueue(.main))
return ""
}
}
@MainActor func foo() {
let s = S()
let e: some SomeProtocol = s
Task.detached {
let this = e as! S
// job done, sent it
print(this) // đź’Ą
}
}
foo()
And further, because description is nonisolated, it can be called from any thread. There should be no expectation that it would be called on the main thread. The code is working as I would expect, including the crash.
Perhaps it was confusing when I shared this code snippet that implemented a nonisolated func ==:
@MainActor final class C: Equatable {
nonisolated static func == (lhs: C, rhs: C) -> Bool {
lhs === rhs
}
}
This function can be called from any thread, and that is ok because the object identity of a class/actor does not require isolation. It can be accessed from anywhere.
Say I want to construct a thing (class/struct, doesn't matter) that could only be created, used and destroyed on the main actor, and for that it would be impossible to sneak it somehow out of the main actor, e.g. using the trick the above example illustrates.
That is possible. That is what @MainActor does to a type, though you do need to explicitly use isolated deinit to have it destroyed on the main actor.
But your example doesn't sneak anything anywhere. You specifically implemented a nonisolated property, and so it is allowed to be called from any thread. And so it is not surprising that it was called on a non-main thread and then crashed. If you did not mark that property as nonisolated, then you wouldn't have been able to invoke it on a non-main thread, but at the cost of making your conformance @MainActor CustomStringConvertible.
I see. So while I could pass the value via that type cast hack I won't be able doing anything with it unless it has non isolated members. Neat, no loophole.
I think the easiest way to explain this is to illustrate that it can be used to break the promises of Set:
class Foo: Hashable {
var x: Int
init(x: Int) { self.x = x }
static func == (lhs: Foo, rhs: Foo) -> Bool { lhs.x == rhs.x }
func hash(into hasher: inout Hasher) { hasher.combine(x) }
}
let a = Foo(x: 1)
let b = Foo(x: 2)
let s: Set<Foo> = [a, b] // both are added, since they're not equal
b.x = 1 // set now contains { x: 1 } twice!
Global-actor-isolated types (@MainActor struct Foo {}) are always Sendable. This is because Swift knows that the information inside the type can only be accessed on a particular global actor. It’s ok to hold onto a value of that type inside any actor because that does not give you the ability to actually do anything with it synchronously. If you want a type that can only be created and used on the main actor, just not making that type Sendable is a great way to do that. It doesn’t guarantee that you are never, for example, transferring ownership of the value from the background to the main actor, but that is often something that is useful to do at the beginning of a value’s lifetime.
New in Swift 6.2, you can now alternatively mark your conformance to a protocol like Equatable as isolated to the main actor. This will cause the compiler to ensure your type’s conformance to Equatable is never used off the main actor even though the type is Sendable due to being actor isolated.
Sorry, was typing in the browser, of course I had that.
I am getting the error when "the protocol" is not marked Sendable (even if it is marked with MainActor)
oops, you are right... the struct was just "Sendable".
I'm a bit confused thought:
You can't have a non-main actor type conform to a main actor protocol.
I am not conforming the "S" itself to MainActor.... it's just one of it fields!