Search code examples
swifthashable

Hashable struct with interchangeable properties?


I need to make a custom struct conform to Hashable so that I can use it as a Dictionary key type. The challenge though, is that two of the struct's properties are interchangeable for the purpose of identifying a unique instance.

Here's a simplified example to illustrate the problem:

struct MultiplicationQuestion {
    let leftOperand: Int
    let rightOperand: Int
    var answer: Int { return leftOperand * rightOperand }
}

The two important properties for identifying a unique MultiplicationQuestion are leftOperand and rightOperand, but it doesn't matter what order they are in, because '1 x 2' is essentially the same question as '2 x 1'. (For reasons I won't go into here, they need to be kept as separate properties.)

I tried defining Hashable conformance as follows, knowing that there is a conflict between equality as I've defined it for == and what the built-in Hasher is going to do:

extension MultiplicationQuestion: Hashable {
    static func == (lhs: MultiplicationQuestion, rhs: MultiplicationQuestion) -> Bool {
        return (lhs.leftOperand == rhs.leftOperand && lhs.rightOperand == rhs.rightOperand) || (lhs.leftOperand == rhs.rightOperand && lhs.rightOperand == rhs.leftOperand)
    }
    func hash(into hasher: inout Hasher) {
        hasher.combine(leftOperand)
        hasher.combine(rightOperand)
    }
}

I tested this by creating two Sets of questions and performing various operations on them:

var oneTimesTables = Set<MultiplicationQuestion>()
var twoTimesTables = Set<MultiplicationQuestion>()
for i in 1...5 {
    oneTimesTables.insert( MultiplicationQuestion(leftOperand: 1, rightOperand: i) )
    twoTimesTables.insert( MultiplicationQuestion(leftOperand: 2, rightOperand: i) )
}

let commonQuestions = oneTimesTables.intersection(twoTimesTables)
let allQuestions = oneTimesTables.union(twoTimesTables)

The hoped-for result (wishful thinking) is that commonQuestions contains one question (1 x 2), while allQuestions contains nine questions, having removed the duplicate.

The actual result however is unpredictable. If I run the playground multiple times, I get different results. Most of the time, commonQuestions.count is 0, but sometimes it is 1. And most of the time, allQuestions.count is 10, but sometimes it is 9. (I'm not sure what I was expecting, but this inconsistency was certainly a surprise!)

How can I make the hash(into:) method generate the same hash for two instances where the properties are the same but reversed?


Solution

  • This is how Hasher works

    https://developer.apple.com/documentation/swift/hasher

    However, the underlying hash algorithm is designed to exhibit avalanche effects: slight changes to the seed or the input byte sequence will typically produce drastic changes in the generated hash value.

    So the problem here in hash(into:) func

    Since the sequence matters combine operation is not commutative. You should find some other function to be a hash for this struct. In your case the best option is

        func hash(into hasher: inout Hasher) {
            hasher.combine(leftOperand & rightOperand)
        }
    

    As @Martin R pointed out to have less collisions it's better to use ^

        func hash(into hasher: inout Hasher) {
            hasher.combine(leftOperand ^ rightOperand)
        }