skip to content

In Ruby, when do two Struct or Data instances compare equal with ==, and why can identical-looking values still differ?

level: middleimportance: should knowfreq 40%

answer

  1. same class, not same shape
  2. members compared pairwise with ==
  3. subclass instance is never equal
  4. 100 == 100.0 holds per member
  5. plain classes compare identity

basics

~20 s

Struct#== and Data#== return true only when both objects have exactly the same class and every member is == to its counterpart. Two classes with the same members, or a subclass and its parent, never compare equal.

solid answer

~40 s

The generated `==` first returns `true` for the same object, then `false` if the other object is not a Struct-family instance or its class is not **exactly** the receiver's class, and only then compares members pairwise with `==`. So `Money[100, "EUR"] == Money[100.0, "EUR"]` is `true`, because `100 == 100.0`. Two separately defined classes with identical members are never equal, a subclass instance never equals a parent-class instance, and a Hash with the same keys and values is not equal either. Because a Struct is mutable, its equality can change after a writer call. A plain class, by contrast, inherits `Object#==`, which compares identity, which is one of the main reasons to generate a class with Struct or Data at all.

code

ruby · 15 lines
ruby
Money = Data.define(:cents, :currency)
Price = Data.define(:cents, :currency)
class Tip < Money; end

Money[100, "EUR"] == Money[100.0, "EUR"]         # => true  (100 == 100.0)
Money[100, "EUR"] == Price[100, "EUR"]           # => false (different class)
Money[100, "EUR"] == Tip[100, "EUR"]             # => false (exact class only)
Money[100, "EUR"] == {cents: 100, currency: "EUR"} # => false

Fix = Struct.new(:lat, :lng)
a = Fix.new(1, 2)
b = Fix.new(1, 2)
a == b     # => true
b.lat = 5
a == b     # => false

go deeper

for a junior

Recall that Struct and Data instances with the same class and equal member values compare equal with ==, unlike plain objects.

for a middle

Explain the exact-class check, per-member == and why same-shaped classes or subclasses never compare equal.

for a senior

Spot equality bugs from comparing values of different generated classes, from Hash inputs and from Structs that change after being compared.

for a principal

Decide when value equality should be built in by generating Data classes and when identity equality of plain classes is the safer model.

## What the generated == checks Both `Struct.new` and `Data.define` classes inherit a **value-based `==`** from CRuby's `struct.c` (Data's `==` is the same C function as Struct's). It runs these steps in order: 1. If the other object **is the receiver**, return `true`. 2. If the other object is **not a Struct-family instance** at all (a Hash, an Array, a plain object), return `false`. 3. If the other object's class is **not exactly** the receiver's class, return `false`. 4. Otherwise compare the members **pairwise with `==`**, in member order, and return `true` only if every pair is equal. The comparison is guarded against self-referencing members, so a cycle does not recurse forever. That is the whole rule. It has three consequences that interviewers like to probe. ## Same shape is not the same class The Ruby documentation for `Data#==` makes the point directly: two classes defined with the same member names produce instances that are **never equal**, even with identical values. | Comparison | Result | Why | |---|---|---| | `Money[100, "EUR"] == Money[100, "EUR"]` | `true` | same class, members equal | | `Money[100, "EUR"] == Price[100, "EUR"]` | `false` | different classes, same shape | | `Money[100, "EUR"] == Tip[100, "EUR"]` where `class Tip < Money` | `false` | class must match exactly, not by `is_a?` | | `Money[100, "EUR"] == {cents: 100, currency: "EUR"}` | `false` | a Hash is not a Struct-family object | | `Money[100, "EUR"] == Money[100.0, "EUR"]` | `true` | `100 == 100.0` | The exact-class rule means equality is **symmetric**: `a == b` and `b == a` always agree, which a `kind_of?`-based check could not promise for a parent and child. ## Members are compared with == Because step 4 uses each member's own `==`, numeric members follow numeric equality: an Integer and a Float with the same value compare equal. That is usually what you want for money read from two sources, but it is also a reminder that `==` is **looser** than the `eql?` used for Hash keys, where `100` and `100.0` differ. Equality contracts for Hash keys are a separate subject; for `==`, remember that each member decides for itself. Nested members recurse naturally: a `Route` Data whose members are two `Coordinate` Data objects compares equal when both coordinates compare equal. ## Mutable Structs change their equality A Struct instance has writers, so the answer to `a == b` is only true **at the moment you ask**: - `a = Fix.new(1, 2)` and `b = Fix.new(1, 2)` compare equal. - After `b.lat = 5`, they no longer do. A Data instance is frozen, so once two Data values are equal they **stay** equal (as long as their members are themselves immutable). ## Why this beats a plain class A hand-written class inherits `Object#==`, which compares **identity**: two `Point.new(1, 2)` objects are different unless you write `==` yourself. Struct and Data give you correct value equality for free, and you should rarely override it: - Overriding `==` alone leaves the built-in `eql?` and `hash` untouched, so Hash lookups and `uniq` would disagree with `==`. - If two related classes must compare equal, question the design first; usually one class with an extra member is simpler than cross-class equality. ## Seeing why two values differ When a comparison unexpectedly returns `false`, three quick checks usually explain it: - Compare `a.class` and `b.class`; `inspect` prints the class name (`#<data Money ...>` vs `#<data Price ...>`), so a same-shape class stands out. - Compare `a.to_h` and `b.to_h` to find the member that differs. - Check for a Struct that was mutated between being stored and being compared. ## Checklist for code review - Comparing values of **different generated classes**? Expect `false`. - Comparing a subclass instance with its parent? Expect `false`. - Comparing against a Hash from JSON? Convert with `to_h` first or build a value object from the Hash. - Relying on equality of a Struct that other code can mutate? Consider `Data` instead.

  • Can a Data instance ever be == to a Struct instance with the same member names and values?
    No. Both are Struct-family objects internally, but the class check requires the other object's class to be exactly the receiver's class. A Data-generated class and a Struct-generated class are unrelated, so `==` returns false before any member is compared.
  • Why does the exact-class rule matter for symmetry?
    If a parent compared equal to any `is_a?` descendant with matching members, `parent == child` could be true while the child, which might add members or override `==`, says false. Requiring identical classes makes `a == b` and `b == a` always agree.

saying these in an interview costs you the question

  • Struct equality compares object identity, like a plain Object.
  • Two Data classes with the same members give equal instances for equal values.
  • A subclass instance equals its parent's instance when the members match.
  • Struct#== compares members with eql?, so 100 and 100.0 differ.
  • A Struct compares equal to a Hash with the same keys and values.