swiftactor

How do I write a custom == implementation on an actor isolated class?


I am trying to add actor isolate on a class, but it implements custom Hashable (thus Equality).

Is it possible? Here's a simplified example:

actor SomeFoo: Equatable {
    var someValue: Double = 0
    
    public static func ==(lhs: SomeFoo, rhs: SomeFoo) -> Bool {
        fabs(lhs.someValue - rhs.someValue) < 0.0001
    }

}

which yields:

In my searching I see that you can do it for a self-declared actor (conform to Actor in the protocol, which seems to work if you have an immutable state).

But in this case, I need Equatable to conform, and I need a custom implementation of equality (==) based upon mutable state, due to the nature of the state in the real code I'm trying to get going.


Solution

  • Generally, if you are trying to make an actor conform to Equatable in a way other than ===, you're likely misusing actors or Equatable. In your specific example, this should be a value type (a struct). The concept of equality of actors, particularly relying on mutable state, is extremely problematic and spawns a bevy of race conditions. Between the time that you test == and the time you use that fact, either actor may have changed such that they are no longer equal. Almost anything you would want to do with this should be done another way.

    Before continuing, make sure that you want Equatable. In particular, make sure that your types comply with the substitutability semantics of Equatable:

    Equality implies substitutability—any two instances that compare equally can be used interchangeably in any code that depends on their values. To maintain substitutability, the == operator should take into account all visible aspects of an Equatable type. Exposing nonvalue aspects of Equatable types other than class identity is discouraged, and any that are exposed should be explicitly pointed out in documentation.

    This is often a reason that it makes little sense for an actor to be Equatable. Can you really, in all cases, substitute one of these actors for the other, when their someValue isn't even quite the same value?

    With all that in mind, it's still worth discussing how to do this as an exercise, even if it is almost certainly not useful.

    The first way is to make the actor immutable. If someValue were let rather than var, then it would all work fine.

    Of course if someValue were let, this probably wouldn't be an actor, so let's move on to the hard case. Conforming to Equatable for two mutable actors.

    To do this, you must take snapshots of the actor. This is the pattern I generally use:

    import os
    
    actor SomeFoo: Equatable {
        struct State {
            var someValue: Double = 0
        }
    
        private let state = OSAllocatedUnfairLock(initialState: State())
    
        nonisolated var snapshot: State {
            get { state.withLock { $0 } }
            set { state.withLock { $0 = newValue } }
        }
    
        public static func ==(lhs: SomeFoo, rhs: SomeFoo) -> Bool {
            fabs(lhs.snapshot.someValue - rhs.snapshot.someValue) < 0.0001
        }
    }
    

    You will be very tempted to create a nonisolated var someValue computed getter. Be very, very careful of creating that. If there are multiple mutable properties (and there usually are), this creates race conditions where multiple fetches may be out of sync. For example, imagine you wrote this:

    // This is a very dangerous Actor.
    actor SomeFoo {
        struct State {
            var firstName: String = ""
            var lastName: String = ""
        }
        
        private let state = OSAllocatedUnfairLock(initialState: State())
        
        nonisolated var snapshot: State {
            get { state.withLock { $0 } }
            set { state.withLock { $0 = newValue } }
        }
        
        // These are terrible ideas; don't do this.
        nonisolated var firstName: String {
            get { snapshot.firstName }
            set { snapshot.firstName = newValue }
        }
        nonisolated var lastName: String {
            get { snapshot.lastName }
            set { snapshot.lastName = newValue }
        }
    }
    

    This is what everyone thinks they want, but it is begging to create race conditions. Imagine something is reading:

    if obj.firstName == "John" && obj.lastName == "Doe" { ... }
    

    While at the same time something else is writing:

    obj.firstName = "John"
    obj.lastName = "Smith"
    

    These can interleave and create a race condition, which is exactly what you were using the actor to avoid. When designing your actor, you need to think very carefully about what values are atomic.

    And mutable reference types generally shouldn't be Equatable.