skip to content

In Ruby, how do you make Point objects work as Hash keys and deduplicate with Array#uniq, and why must eql? and hash be overridden together?

level: middleimportance: must knowfreq 66%

answer

  1. hash picks the bucket
  2. eql? confirms the match
  3. equal eql? means equal hash
  4. [self.class, x, y].hash
  5. alias eql? ==

basics

~20 s

Override == for value comparison, alias eql? to it, and define hash as [self.class, x, y].hash. Hash and uniq first group objects by hash, then confirm with eql?, so overriding only one leaves equal points as separate keys.

solid answer

~40 s

A `Hash` finds a key in two steps: it calls `hash` to choose where to look, then calls `eql?` on the candidates found there. `Array#uniq` and `Set` work the same way. By default both methods reflect identity, so two `Point.new(1, 2)` objects are different keys. To fix it, define `==` to compare class and coordinates, write `alias eql? ==`, and define `def hash = [self.class, x, y].hash`, the pattern the core docs recommend. The contract is one-way: if `a.eql?(b)`, then `a.hash == b.hash` must hold. Override only `eql?` and equal points usually land in different places, so `eql?` is usually not consulted; override only `hash` and they meet but `eql?` still says no. `Data.define(:x, :y)` generates all three.

code

ruby · 8 lines
ruby
Point = Data.define(:x, :y)

visits = Hash.new(0)
visits[Point.new(x: 1, y: 2)] += 1
visits[Point[1, 2]] += 1
visits  # => {#<data Point x=1, y=2> => 2}

[Point[1, 2], Point[1, 2], Point[3, 4]].uniq.size  # => 2

go deeper

for a junior

Know that a class needs eql? and hash, not just ==, before its objects work as Hash keys, and write the three-method Point from memory.

for a middle

Explain the two-step lookup, the one-way contract between eql? and hash, and why [self.class, x, y].hash is the recommended implementation.

for a senior

Catch the alias eql? == trap with mixed Integer and Float fields, and prefer Data.define so the equality contract is generated rather than hand-maintained.

for a principal

Decide how value objects are defined across a codebase so hash-key equality stays consistent, and review hand-written eql? and hash as carefully as serialisation code.

## How a Hash decides two keys are the same A Ruby `Hash` does not compare a new key with every stored key. It uses two methods of the key object: 1. **`hash`** returns an Integer. The Hash uses it to decide where the entry lives, so only keys with matching hash values are candidates. 2. **`eql?`** is then called on those candidates to confirm a real match. `Array#uniq` builds a hash table internally, and `Set` stores its elements the same way, so both inherit exactly this behaviour. The core docs state the contract: for any two objects where `eql?` returns true, `hash` must return the same value. The reverse is not required: different objects may share a hash value, and `eql?` sorts them out. ## The default: every object is its own key On a class that overrides nothing, `eql?` and `hash` are based on identity. Two points with the same coordinates are different keys: ```ruby class Point attr_reader :x, :y def initialize(x, y) @x, @y = x, y end end {Point.new(1, 2) => :home}[Point.new(1, 2)] # => nil [Point.new(1, 2), Point.new(1, 2)].uniq.size # => 2 ``` ## The fix: override the trio ```ruby class Point attr_reader :x, :y def initialize(x, y) @x, @y = x, y end def ==(other) other.class == self.class && x == other.x && y == other.y end alias eql? == def hash = [self.class, x, y].hash end {Point.new(1, 2) => :home}[Point.new(1, 2)] # => :home [Point.new(1, 2), Point.new(1, 2)].uniq.size # => 1 ``` Notes on each piece: - **`==`** checks the class first so a `Point` never equals an unrelated object that happens to respond to `x` and `y`. - **`alias eql? ==`** makes hash-key equality follow value equality, which is what the core docs describe as the usual tradition. - **`hash`** delegates to `Array#hash` over the class and the fields that `==` compares. Including `self.class` keeps a `Point` and, say, a `Size` holding the same numbers from sharing a hash value. ## What goes wrong when only one is overridden | Overridden | Result for two equal points | |---|---| | only `==` | `==` is true, but Hash, `uniq` and `Set` still treat them as different | | only `eql?` | hash values normally differ, so `eql?` is usually never asked; results are unreliable | | only `hash` | they are compared, but identity-based `eql?` says no; still two keys | | `eql?` and `hash` together | one key, one element after `uniq` | The "only `eql?`" row is the nasty one: it can appear to work in a quick test and fail with other data, because it depends on hash values that were never designed to match. ## Letting Ruby write it: Data and Struct `Data.define(:x, :y)` (and `Struct.new(:x, :y)`) generate `==`, `eql?` and `hash` from the members. One subtlety: their `eql?` compares members with `eql?`, not `==`, so `Point[1, 2] == Point[1.0, 2]` is true while `eql?` is false. That keeps the contract intact: the two are not `eql?`, so nothing requires their hash values to match. The hand-written `alias eql? ==` above does not have that property, which matters if coordinates can arrive as mixed Integer and Float. ## Checklist for a hand-written value class 1. Decide which fields define the value; use exactly those in `==`, `eql?` and `hash`. 2. Check the class in `==` so unrelated objects are never equal. 3. Make `eql?` at least as strict as the fields' own `eql?` if Integer and Float values can both appear. 4. Build `hash` from `[self.class, *fields].hash` rather than inventing arithmetic. 5. Keep those fields from changing while the object is a key; mutable keys are a separate failure. ## Why interviewers ask it It tests whether a candidate knows how hash-based collections actually find things. The strong answer names the two-step lookup, writes the three methods correctly, explains why a lone `==` override does nothing for `Hash`, and knows that `Data.define` gives the whole contract for free.

  • With alias eql? ==, is Point.new(1, 2).eql?(Point.new(1.0, 2)) true, and why is that a problem?
    Yes, because `==` compares coordinates with `==` and `1 == 1.0`. But `[Point, 1, 2].hash` and `[Point, 1.0, 2].hash` differ, so `eql?` is true while the hashes differ, breaking the contract; a Hash may treat them as separate keys. Write `eql?` to compare fields with `eql?`, as `Data` and `Struct` do, or normalise coordinates on construction.
  • Why should hash include self.class rather than only the coordinates?
    Without it, a `Point(1, 2)` and, say, a `Size(1, 2)` would hash identically. That is legal but causes needless collisions, and it hints that the class check in `==` was forgotten. `[self.class, x, y].hash` is the form the core documentation recommends.

A Hash is a mailroom of pigeonholes: hash is the pigeonhole number written on the envelope, and eql? is the clerk checking the name inside that one pigeonhole. Two letters for the same person but different pigeonhole numbers never meet.

saying these in an interview costs you the question

  • Overriding == is enough for Hash keys and Array#uniq to treat equal points as one.
  • hash should return object_id so every Point gets a unique value.
  • If two objects have the same hash, Hash treats them as the same key.
  • Array#uniq compares every pair of elements with ==.