fabián cañas

Swift Equatable Enums with Associated Values

2015-07-19

I'm looking for an easy and safe way to make an enum with associated values conform to Equatable.

Given an enum with arguments that are Equatable, (in this case just Strings, but heterogenous types should also work)

enum Constraint {
    case Ascending(String)
    case Descending(String)
    case Exists(String)
    case DoesNotExist(String)
}

I want two Constraint cases to be equal if and only if the cases match, and their associated values are equal.

extension Constraint :Equatable {}

Note that it's possible an enum simply isn't the right type for the job.1 But for the task that prompted this writing, it feels excellent to use the resultant interface, just not to write its implementation.

My first approach is to nest switch statements. However I find it unwieldly. Is there a better way?

func == (lhs: Constraint, rhs: Constraint) -> Bool {
    switch (lhs) {
    case .Ascending(let lKey):
        switch (rhs) {
        case .Ascending(let rKey):
            return lKey == rKey
        case .Descending:
            return false
        case .Exists:
            return false
        case .DoesNotExist:
            return false
        }
    case .Descending(let lKey):
        switch (rhs) {
        case .Ascending:
            return false
        case .Descending(let rKey):
            return lKey == rKey
        case .Exists:
            return false
        case .DoesNotExist:
            return false
        }
    case .Exists(let lKey):
        switch (rhs) {
        case .Ascending:
            return false
        case .Descending:
            return false
        case .Exists(let rKey):
            return lKey == rKey
        case .DoesNotExist:
            return false
        }
    case .DoesNotExist(let lKey):
        switch (rhs) {
        case .Ascending:
            return false
        case .Descending:
            return false
        case .Exists:
            return false
        case .DoesNotExist(let rKey):
            return lKey == rKey
        }
    }
}

This enum isn't minimal -- I could have shown off just two cases. It's also not nearly as large as I expect real uses to get. But it does help illustrate that the code will grow quadratically with the number of cases in the enum, and that gets large very quickly.

There is a more concise way to define equality for this enum, but I worry that because of its reliance on non-exhaustive switch statements that they may prove to be error-prone.

func == (lhs: Constraint, rhs: Constraint) -> Bool {
    switch (lhs, rhs) {
    case (.Ascending(let lKey), .Ascending(let rKey)):
        return lKey == rKey
    case (.Descending(let lKey), .Descending(let rKey)):
        return lKey == rKey
    case (.Exists(let lKey), .Exists(let rKey)):
        return lKey == rKey
    case (.DoesNotExist(let lKey), .DoesNotExist(let rKey)):
        return lKey == rKey
    default:
        return false
    }
}

We can also play a fun trick with switch statements in Swift by extracting the value comparison to a case constraint. So the case for .Ascending

case (.Ascending(let lKey), .Ascending(let rKey)):
    return lKey == rKey

becomes

case (.Ascending(let lKey), .Ascending(let rKey)) where lKey == rKey:
    return true

The overall function doesn't become much better, but I do feel there is some advantage or order to each case encapsulating a complete logical description. It feels less like a control flow structure and more like a list of satifying conditions. I like declarative code.

func == (lhs: Constraint, rhs: Constraint) -> Bool {
    switch (lhs, rhs) {
    case (.Ascending(let lKey), .Ascending(let rKey)) where lKey == rKey:
        return true
    case (.Descending(let lKey), .Descending(let rKey)) where lKey == rKey:
        return true
    case (.Exists(let lKey), .Exists(let rKey)) where lKey == rKey:
        return true
    case (.DoesNotExist(let lKey), .DoesNotExist(let rKey)) where lKey == rKey:
        return true
    default:
        return false
    }
}

But there is still a control structure in place, and I take issue with how it's being used. We're using a default case to return false for any case not explicitly handled. This becomes a problem when a new case gets added to the enum. The == function's implementation becomes incorrect, but we don't know it. In the original implementation with nested cases the compiler immediately tells us something is wrong if the enum is grown. We would be missing one case in the main switch, and one case in each of that switch's cases. Complicated but safe.

We can attempt to split the difference by reverting to nested switch statements, and use a default case on the inner switches, like so

func == (lhs: Constraint, rhs: Constraint) -> Bool {
    switch (lhs) {
    case .Ascending(let lKey):
        switch (rhs) {
        case .Ascending(let rKey):
            return lKey == rKey
        default:
            return false
        }
    case .Descending(let lKey):
        switch (rhs) {
        case .Descending(let rKey):
            return lKey == rKey
        default:
            return false
        }
    case .Exists(let lKey):
        switch (rhs) {
        case .Exists(let rKey):
            return lKey == rKey
        default:
            return false
        }
    case .DoesNotExist(let lKey):
        switch (rhs) {
        case .DoesNotExist(let rKey):
            return lKey == rKey
        default:
            return false
        }
    }
}

This has the benefit of becoming incorrect to the compiler if any new cases are added to Constraint, yet still only growing linearly with the number of cases. This is my current favorite solution for this particular code, but it still doesn't sit right with me. It works because we can carve out a small subset of combinations with one behavior, and treat all other combinations with another. I can't see a way to make enums interact more elegantly if more complex requirements were to arise.

The trouble stems from the fact that it's not trivial to unpack an enumeration's case from its argument. As far as I can tell, you can only do so in a switch statement. And an enum's case isn't especially easy to manipulate either. Enums with raw values help with the manipulation of enums, but only because raw values allow us to use a more flexible type as a proxy for the enum's case. Implementing equality for a simple enum with a raw value is trivial:

enum Suit : Int {
    case Clubs
    case Diamonds
    case Spades
    case Hearts
}

extension Suit :Equatable {}

func == (lhs: Suit, rhs: Suit) -> Bool {
    return lhs.rawValue == rhs.rawValue
}

But Swift doesn't allow an enum with a raw type to have cases with arguments. At this point, I'm kind of stuck. I'm not terribly happy with any solution so far. In other languages, a tight corner like this might lead me down a metaprogramming path. Swift is a bit limited on that front and I don't yet see a metaprogramming solution.

I think it's worth reflecting that it's Swift's own expressiveness and type safety that set me up to be dissatisfied. I've grown accustomed to writing code that leans heavily on the type system to ensure its correctness. And Swift tends to remain quite legible: brief without being inscrutable terse, and descriptive without Objective-C's famous verbosity. I like Swift and I've grown accustomed to it. I don't think I would even be thinking about how to make this code as safe as it possibly could be if I were writing it in Objective-C.


1: I'm experimenting with a value-typed Query construct to wrap Parse. I happen to feel that a query, subquery, divorced from any request or results is a good candidate for a value type over a reference type. And wanting to perform comparisons on queries plays no small part in that.


Update: Swift 2 now has this behavior by default