How to make a Swift enum with associated values equatable
Asked Answered
W

6

68

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.

Wehner answered 12/7, 2018 at 7:52 Comment(0)
N
108

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
}
Nutation answered 12/7, 2018 at 8:28 Comment(7)
This is true only if all case have associated value. if for some reason not all case have associate values then you need to empilement the func == (l, r) -> bool method.Spadiceous
@PascaleBeaulac: No, I do not think that is true.Nutation
try it. I know I have such a case in code and using swift 5. I need to do as Mehrdad posted below to remove the error. Just adding Equatable does not work. You get the error that it is not conforming to Equatable and you need to add stubs. if all cases do have associated value like in ur example then yes it does works.Spadiceous
@PascaleBeaulac: Cannot reproduce. enum Foo : Equatable { case bar; case baz(String) } compiles without problems for me.Nutation
I get the error... As I said in my app I have 1 enum that goes like this enum Foo : Equatable { case bar; case baz; case boz(SSO); case piff } and if we don't add the stub we get the error and it does not compile. edit: SSO simple swift objectSpadiceous
@PascaleBeaulac: Does SSO conform to the Equatable protocol?Nutation
Yup, the SSO was not equatable. So it explains the error, and I do wish Xcode would put the error on the offending line. You got the thank from me and my team leader (who wrote the code in question ;) )Spadiceous
D
16

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
}
Dygall answered 26/4, 2021 at 3:49 Comment(0)
I
8

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
Ivett answered 12/7, 2018 at 8:7 Comment(2)
Needs to be static funcSchoonmaker
You can also compare via tuples... return (id1, title1, enabled1) == (id2, title2, enabled2). Also in newer Swift versions, 'switch' is an expression now so you can even omit the return keyword.Stannic
S
4

Implementing Equatable on Enumerations with non-equatable associated types

Summary

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.

Stannic answered 6/6, 2023 at 18:55 Comment(6)
Hiya Mark. We are in a bit of a pickle while the site is largely unmoderated. But I wonder if I could put it to you - and I do mean it constructively - that technical writing is a community preference here. Slang and chatroom material is probably best on Facebook.Rankins
I would make the opposite argument that TL;DR is not slang at all. It's a well-known idiom not just in the programming world, but in the general work world, used in documentation, emails, etc. Every position I've held in the past fifteen years has used it. Plus, it's also all over this site! Just search 'tl;dr' and you'll see what I mean since there are 49,637 results! But honestly, not worth debating over so I'm not going to get in a 'revert-war' with you. It can stay as you changed it.Stannic
Interesting, thanks Mark. I suppose the reason I don't like it is that it feels like it is intimating laziness - "too long, didn't [don't] read" feels like a cultural encouragement against careful engineering or against doing a job well. I appreciate Larry Wall might approve, but I am not sure he had indolence in mind!Rankins
One piece of advice that a coworker once told me is when you're reviewing something (questions here, PR changes, etc.) the goal should only be to make sure it is clear, and works as expected without errors. It should not be to make it look like you wrote it yourself. It's like tabs vs spaces. It's two-spaces vs four for indents. It's brackets on the same line or a new line. Unless there's been an official standard put in place, it's best not to let personal opinions enter the mix because it pulls focus from the issue, which is... does this work as stated? Just food for thought.Stannic
Good advice. I wonder if we are in some agreement, but we just draw the line at different points. A search here for "plz help" returns 13,848 results, but its popularity is not a sign that the community does not require technical writing, or that being needy with volunteers is acceptable. But I probably sit on the stricter side of editing policy here (and I acknowledge that, like much of Stack Overflow, it is not possible to codify every nuance).Rankins
Specifically in case of Error, any Error can be bridged to an instance of NSError It looks to be possible to use this instance for comparison and general error processing without any knowledge of its original typeLivraison
T
3

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
}
Thug answered 28/5, 2020 at 7:44 Comment(0)
S
0

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
Survive answered 26/5, 2023 at 5:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.