简体   繁体   中英

How to make a Swift enum with associated values equatable

I have an enum of associated values which I would like to make equatable for testing purposes, but do not know how this pattern would work with an enum case with more than one argument.

For example, summarised below I know the syntax for making heading equatable. How would this work for options, which contains multiple values of different types?

enum ViewModel {
    case heading(String)
    case options(id: String, title: String, enabled: Bool)
}

func ==(lhs: ViewModel, rhs: ViewModel) -> Bool {
    switch (lhs, rhs) {
    case (let .heading(lhsString), let .heading(rhsString)):
        return lhsString == rhsString
    case options...
    default:
        return false
    }
}

I know Swift 4.1 can synthesize conformance for Equatable for us, but at present I am not able to update to this version.

SE-0185 Synthesizing Equatable and Hashable conformance has been implemented in Swift 4.1, so that it suffices do declare conformance to the protocol (if all members are Equatable ):

enum ViewModel: Equatable {
    case heading(String)
    case options(id: String, title: String, enabled: Bool)
}

For earlier Swift versions, a convenient way is to use that tuples can be compared with == .

You many also want to enclose the compatibility code in a Swift version check, so that the automatic synthesis is used once the project is updated to Swift 4.1:

enum ViewModel: Equatable {
    case heading(String)
    case options(id: String, title: String, enabled: Bool)
    
    #if swift(>=4.1)
    #else
    static func ==(lhs: ViewModel, rhs: ViewModel) -> Bool {
        switch (lhs, rhs) {
        case (let .heading(lhsString), let .heading(rhsString)):
            return lhsString == rhsString
        case (let .options(lhsId, lhsTitle, lhsEnabled), let .options(rhsId, rhsTitle, rhsEnabled)):
            return (lhsId, lhsTitle, lhsEnabled) == (rhsId, rhsTitle, rhsEnabled)
        default:
            return false
        }
    }
    #endif
}

You can add something like below, check this link for more information. Return statement for options depend on your needs.

#if swift(>=4.1)
#else
func ==(lhs: ViewModel, rhs: ViewModel) -> Bool {
    switch (lhs, rhs) {
    case (let .heading(lhsString), let .heading(rhsString)):
        return lhsString == rhsString

    case (let .options(id1, title1, enabled1),let .options(id2, title2, enabled2)):
        return id1 == id2 && title1 == title2 && enabled1 == enabled2
    default:
        return false
    }
}
#endif

Maybe not relevant for the OP but this might help others:

Remember that if you only want to compare an enum value against a fixed value , you can simply use pattern matching:

if case let ViewModel.heading(title) = enumValueToCompare {
  // Do something with title
}

If you care about the associated value, you can add some conditions on it:

if case let ViewModel.heading(title) = enumValueToCompare, title == "SomeTitle" {
  // Do something with title
}

Implementing Equatable on Enumerations with non-equatable associated types

TL;DR

To implement Equatable on an enumeration with non- Equatable associated values, you can leverage String(reflecting:) as the basis for checking equality. This can be applied via a convenience ReflectiveEquatable protocol (see below.)

Details:

While as mentioned in some of the other answers, Swift can apply Equatable to enums with cases having associated values provided those values are themselves Equatable .

For instance, in the below example, since String already conforms to Equatable , the compiler can synthesize equality for the Response enum by simply decorating it with the Equatable protocol...

enum Response: Equatable {
    case success
    case failed(String)
}

However, this one will not compile because the associated type Error is not itself Equatable , thus the compiler can't synthesize equality for us...

enum Response: Equatable {
    case success
    case failed(Error)
}

More frustratingly, you can't manually conform Error to Equatable as Error is a protocol, not a type, and you can only add conformance to actual types, not protocols. Without knowing the actual types Error is applied to, there's no way for you to check for equality.

Or is there?; ;)

Implement Equality via String(reflecting:)

The solution I propose is to leverage String(reflecting:) to implement equality. Reflection works recursively through all nested and associated types, resulting in a unique string that we ultimately use for the equality check.

This capability can be implemented in a reusable fashion via a custom ReflectiveEquatable protocol, defined as such...

// Conform this protocol to Equatable
protocol ReflectiveEquatable: Equatable {}

extension ReflectiveEquatable {

    var reflectedValue: String { String(reflecting: self) }

    // Explicitly implement the required `==` function
    // (The compiler will synthesize `!=` for us implicitly)
    static func ==(lhs: Self, rhs: Self) -> Bool {
        return lhs.reflectedValue == rhs.reflectedValue
    }
}

With the above in place, you can now conform the Response enum to ReflectiveEquatable , thus giving it Equatable implicitly, and it now compiles without issue:

// Make enum with non-`Equatable` associated values `Equatable`
enum Response: ReflectiveEquatable {
    case success
    case failed(Error)
}

You can demonstrate it's working as expected with the following test code:

// Define custom errors (also with associated types)
enum MyError: Error {
    case primary
    case secondary
    case other(String)
}

enum MyOtherError: Error {
    case primary
}

// Direct check
print(Response.success == Response.success) // prints 'true'
print(Response.success != Response.success) // prints 'false'

// Same enum value, 'primary', but on different error types
// If we had instead used `String(describing:)` in the implementation,
// this would have matched giving us a false-positive.
print(Response.failed(MyError.primary) == Response.failed(MyError.primary)) // prints 'true'
print(Response.failed(MyError.primary) == Response.failed(MyOtherError.primary)) // prints 'false'

// Associated values of an enum which themselves also have associated values
print(Response.failed(MyError.other("A")) == Response.failed(MyError.other("A"))) // prints 'true'
print(Response.failed(MyError.other("A")) == Response.failed(MyError.other("B"))) // prints 'false'

Note: Adhering to ReflectiveEquatable does not make the associated types Equatable !
(...but they also don't have to be for this to work!)

In the above example, it's important to note you are only applying Equatable to the specific type you apply the ReflectiveEquatable protocol to. The associated types used within it do not pick it up as well.

This means in this example, Error still doesn't conform to Equatable , thus the code below will still fail to compile...

print(MyError.primary == MyError.primary) // Doesn't support equality so won't compile!

The reason this implementation still works is because as mentioned above, we're not relying on the associated values' conformance to Equatable . We're instead relying on how the specific enum's case appears under reflection, and since that results in a string which takes all associated values into consideration (by recursively reflecting on them as well), we get a pretty unique string, and that's what's ultimately checked.

For instance, this prints the reflected value of the enum case used in the last test above:

print(Response.failed(MyError.other("A")).reflectedValue)

And here's the resulting output:

main.Response.failed(main.MyError.other("A"))

Note: 'main' here is the module name containing this code.

Bonus: ReflectiveHashable

Using the same technique, you can implement Hashable based on reflection with the following ReflectiveHashable protocol...

// Conform this to both `Hashable` and `ReflectiveEquatable`
// (implicitly also conforming it to `Equatable`, a requirement of `Hashable`)
protocol ReflectiveHashable: Hashable, ReflectiveEquatable {}

// Implement the `hash` function.
extension ReflectiveHashable {
    
    func hash(into hasher: inout Hasher) {
        hasher.combine(reflectedValue)
    }
}

With that in place, if you now conform your enum to ReflectiveHashable , you get both Hashable and Equatable (via ReflectiveEquatable ) simply and easily...

// Make enum `Hashable` (and implicitly `Equatable`)
enum Response: ReflectiveHashable {
    case success
    case failed(Error)
}

Final Thoughts - Reflection? Really?!

While admittedly, reflection is not the most performant method compared to standard equality checks (and by several orders of magnitude at that), the question most people incorrectly ask is 'Shouldn't we avoid reflecting because it's so much slower?' The real question one should ask is 'Does it actually need to be faster?'

Consider the area where this may solve a problem for you. Is it while processing millions and millions of checks a second and where performance is critical, or is it more likely in response to a user-action? In other words, do you even notice that it's slower, or are you only looking at it from an academic standpoint?

The takeaway here being make sure you're not prematurely discounting the use of reflection if it solves a problem for you like the above. Don't optimize for something that doesn't actually move the needle. The best solution often isn't the fastest to run, but the fastest to be finished.

Elegant way to work with associated value ( even if the enum is indirect):

first you need to have the value property:

indirect enum MyEnum {
    var value: String? {
        return String(describing: self).components(separatedBy: "(").first
    }
    case greeting(text: String)
    case goodbye(bool: Bool)
    case hey
    case none
}

print(MyEnum.greeting(text: "Howdy").value)
// prints : greeting

now you can use the value to implement Equatable like this:

    indirect enum MyEnum: Equatable {
     static func == (lhs: MyEnum, rhs: MyEnum) -> Bool {
        lhs.value == rhs.value
     }
    
     var value: String? {
        return String(describing: self).components(separatedBy: "(").first
     }
     case greeting(text: String)
     case goodbye(bool: Bool)
     case hey
     case none
   }

In the original solution, there is a section of code that can be simplified if you want to compare only the enum cases without comparing their associated values, Here's the updated code:

   enum ViewModel: Equatable {
   case heading(String)
   case options(id: String, title: String, enabled: Bool)

   #if swift(>=4.1)
   #else
   static func == (lhs: ViewModel, rhs: ViewModel) -> Bool {
       switch (lhs, rhs) {
       case (.heading, .heading), (.options, .options):
           return true
       default:
           return false
        }
     }
    #endif
 }

   let model1 = ViewModel.options(id: "1", title: "hello", enabled: true)
   let model2 = ViewModel.options(id: "2", title: "hello", enabled: true)
   let model3 = ViewModel.options(id: "1", title: "hello", enabled: true)

   print(model1 == model2) // false
   print(model1 == model3) // true

The technical post webpages of this site follow the CC BY-SA 4.0 protocol. If you need to reprint, please indicate the site URL or the original address.Any question please contact:yoyou2525@163.com.

 
粤ICP备18138465号  © 2020-2024 STACKOOM.COM