skip to content

A Ruby Hash keyed by Point objects stops finding an entry after that point's x is changed in place; why, and how do you prevent or repair it?

level: seniorimportance: should knowfreq 35%

answer

  1. slot chosen at insertion time
  2. new hash, old slot
  3. key? false, keys.include? true
  4. Hash#rehash rebuilds the index
  5. frozen or Data keys

basics

~20 s

The Hash placed the entry using the key's hash at insertion. Changing x changes that hash, so lookups search the wrong place and miss. Hash#rehash repairs the index; frozen keys, such as Data instances, prevent it.

solid answer

~40 s

When a key is stored, the `Hash` records it under the key's `hash` value at that moment. If the key object is later mutated, as in `point.x = 9` on a mutable `Point` whose `hash` depends on `x`, its hash changes but the entry stays where it was. A lookup computes the new hash, looks in the wrong place and misses: `h[point]` is `nil` and `h.key?(point)` is `false`, yet `h.keys.include?(point)` is `true` because `Array#include?` scans with `==`. `h.rehash` recomputes every key's position and fixes it (it raises `RuntimeError` if called during iteration). Prevention is better: make keys immutable (freeze them, or use `Data.define`, whose instances are frozen), avoid writers on key classes, or use `compare_by_identity` when identity is really what you want. `String` keys are safe because `Hash` stores a frozen copy.

code

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

home = Point.new(x: 1, y: 2)
home.frozen?        # => true

moved = home.with(x: 9)   # a new Point; home is unchanged
visits = {home => 3}
visits[home]        # => 3
visits[moved]       # => nil, a different key by design

go deeper

for a junior

Remember that mutating an object used as a Hash key can make the Hash lose track of it, and that String keys are protected by being copied and frozen.

for a middle

Explain that the position comes from hash at insertion, why key? is false while keys.include? is true, and how rehash repairs the index.

for a senior

Diagnose missing entries caused by mutated keys, prefer Data or frozen keys, watch for shallow freezing, and use compare_by_identity only for identity semantics.

for a principal

Make immutability of anything used as a key a design rule, and review caches and indexes for keys whose hash depends on mutable state.

## Why the entry disappears A `Hash` stores each entry in a position derived from the key's **`hash` value at insertion time**. It does not watch the key object afterwards. If the key is mutated in a way that changes its `hash`, the stored position is now wrong: ```ruby Point = Struct.new(:x, :y) # mutable: has x= and y= home = Point.new(1, 2) visits = {home => 3} home.x = 9 # hash value changes visits[home] # => nil visits.key?(home) # => false visits.keys.include?(home) # => true visits.rehash visits[home] # => 3 ``` What each line shows: - `visits[home]` computes the **new** hash, looks where that hash points, and finds nothing. - `key?` does the same lookup, so it is also false. - `keys.include?` builds an Array of keys and compares them with `==`, which ignores hashing, so it finds the very same object. The core documentation describes exactly this under "Modifying an Active Hash Key", using Array keys, and the same applies to `Set` and to anything else built on `eql?` and `hash`. ## Repairing: `Hash#rehash` `Hash#rehash` recomputes the position of every key from its current `hash` and returns the hash. Two cautions: 1. Calling it **while iterating** over the same hash raises `RuntimeError` (rehash during iteration). 2. If the mutation made two keys equal, rehashing merges them and one entry wins, so data can disappear. For a `Set`, the equivalent is `Set#reset`, which reindexes and deduplicates the elements after they were modified. Rehashing is a repair, not a design: it only helps if you know every place that mutates keys. ## Preventing it | Approach | How it helps | |---|---| | `Data.define(:x, :y)` | instances are frozen and have no setters; equality and hash are generated | | `freeze` in `initialize` | any later write raises `FrozenError` instead of corrupting the index | | No writer methods on key classes | removes the easy way to mutate a key | | `compare_by_identity` on the Hash | keys match by identity only, so field changes do not matter | | Keep a stable id as the key | store `point.id` or a frozen tuple instead of the live object | Be aware that freezing is **shallow**. A `Data` instance with an Array member is frozen, but the Array inside it is not; pushing into that Array changes the member's `hash` and therefore the key's. Keys should be built from values that are themselves immutable. ## Why String keys are safe `Hash#[]=` treats String keys specially: an unfrozen String is replaced by a **duplicated and frozen copy**, so later changes to your original string cannot affect the stored key. No other class gets this treatment; Arrays, Structs and your own classes are stored as the very object you passed. ## Diagnosing it in production The symptom is a lookup that returns `nil` or a default for a key you can see when you print the hash. When that happens: 1. Check whether the key's class has setters or mutable fields used by `hash`. 2. Confirm it on a copy: `h.dup.rehash[key]`. If the lookup succeeds after rehashing, a key's hash changed after insertion. 3. Look for code paths that modify objects after they were inserted, including helper methods that call `<<` on an Array member. ## Why interviewers ask it It is a senior question because the bug appears far from its cause and the Hash is not "broken" in any visible way. A strong candidate explains the mechanism, knows `rehash` exists and its caveats, and fixes the design with immutable keys rather than sprinkling `rehash` calls.

  • When is compare_by_identity the right fix rather than immutable keys?
    When the question is "this exact object" rather than "this value", such as caching per request object or tracking which objects were visited. After `h.compare_by_identity`, keys match only if they are the same object, so mutation cannot misplace them. It is wrong for value lookups: a freshly built `Point.new(1, 2)` will never find an entry stored under another equal point.
  • Can a frozen Data key still change its hash value?
    Yes, if a member is a mutable object. `Data` freezes the instance itself, not the objects it references, so an Array or Hash member can still be modified, changing that member's `hash` and therefore the key's. Build keys from immutable members, or freeze the members too.

saying these in an interview costs you the question

  • The Hash re-indexes a key automatically when the key object is modified.
  • If h[point] is nil after the change, the entry has been deleted.
  • Hash#rehash is safe to call inside h.each while fixing keys.
  • Freezing a Data key makes its hash value permanently stable, whatever its members are.
  • Array and Struct keys are copied and frozen by Hash, just like String keys.