Equality, Hashing and Comparison

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Ruby 4.0 (4.0.7, checked on 2026-10-18). Ruby has not one but several kinds of "the same": whether two references point at the very same object (equal?), whether two objects hold the same value (==), whether two objects are interchangeable as Hash keys (eql? together with hash), whether a value belongs to a category (===), and how two values are ordered (<=>, usually with Comparable). Every one of them is an ordinary method that a class can define, and the core library calls a specific one of them from each operation: Array#include? uses ==, Array#uniq and every Hash use eql? and hash, case and grep use ===, sort uses <=>. Most equality bugs in Ruby programs come from defining one of these and forgetting that the others exist, so this page describes each one, the contract that connects them, which core operation calls which, and how to write your own value classes so that they behave in Hash, Set, uniq, case and sort.

It builds on Classes and Objects (defining methods and initialize), Modules and Mixins (Comparable is a module you include) and Operators and Precedence (operators are method calls). The numeric and collection types it compares are covered in Numbers, Strings, Arrays, Hashes, Sets and Ranges; value objects in Struct, Data and Value Objects; copies and frozen state in Object Lifecycle, Freezing and Copying; case/when in Control Expressions and case/in in Pattern Matching; coerce and the implicit conversions in Conversions and Coercion. For the equivalent topics in other languages, see Java (equals and hashCode), Python (__eq__, __hash__, __lt__), Kotlin, C#, Rust (PartialEq, Eq, Hash, PartialOrd, Ord) and Go (comparable types).

Every example was syntax-checked with ruby -wc, run with ruby -w on Ruby 4.0.7 and linted with RuboCop 1.92 using the configuration of the running shelf gem (double-quoted strings, NewCops: enable, Style/Documentation off). The output shown is the real output; p prints the inspect form and returns its argument, so p x # => y means that y is what was printed. Where an example deliberately does what a RuboCop cop dislikes (comparing a float with ==, comparing an object with itself), the file wraps those lines in a rubocop:disable and rubocop:enable pair that names the cop; the section on RuboCop cops explains each one. The examples use the shelf gem (a small library catalogue, see the shelf gem source) and are run with ruby -Ilib, so require "shelf" finds it.

The family of methods

Ruby draws the line between these questions in the object model itself. The table lists where each method comes from (ruby -e 'p Object.instance_method(:eql?).owner' prints Kernel; the reference documentation lists the same methods under Object) and what the default does.

Method Defined in Default behaviour Question it answers

equal?

BasicObject

Same object (same identity). Never override.

Are these two references the same object?

==

BasicObject

Same as equal?.

Do these two objects represent the same value?

!=

BasicObject

Calls == and negates the result.

Do they differ?

eql?

Kernel

Same as equal?.

Are these interchangeable as Hash keys (strict, no type conversion)?

hash

Kernel

Derived from the object identity.

Which bucket does this object belong to?

===

Kernel

Same as ==. Overridden by Module, Range, Regexp, Proc, Set, Symbol.

Does this value belong to this category (the case question)?

<=>

Kernel

0 if equal? or ==, otherwise nil.

Which comes first (-1, 0, 1) or are they unordered (nil)?

object_id

Kernel

A unique integer for the lifetime of the object.

A number identifying this object.

So a class that defines nothing compares by identity everywhere. The rest of this page is about the cases where that is not what you want, and the contracts you must keep when you change it.

# frozen_string_literal: true

# rubocop:disable Style/CaseEquality, Lint/FloatComparison

a = String.new("shelf")
b = String.new("shelf")

p a.equal?(b) # => false
p a == b # => true
p a.eql?(b) # => true
p a === b # => true
p a.hash == b.hash # => true
p 1 == 1.0 # => true
p 1.eql?(1.0) # => false
p 1.equal?(1) # => true
p 1.hash == 1.0.hash # => false
# rubocop:enable Style/CaseEquality, Lint/FloatComparison

Source: Object#eql?

The first block shows why there are several equalities: two distinct strings are == and eql? (same content, same hash) but not equal? (two objects), while 1 and 1.0 are == (numeric equality converts) but not eql? and have different hashes (a Hash treats them as different keys). The rubocop:disable lines at the top of that file name the cops that object to those comparisons on purpose.

Identity: equal? and object_id

equal? answers whether two references denote the same object. It is defined in BasicObject, it is the foundation that ==, eql? and hash fall back on, and a class should never override it: every caller (Ruby itself, other libraries, RuboCop) assumes that equal? means identity. object_id (and its alias __id__, which stays available on BasicObject for classes that undefine object_id) gives the identity as an integer.

# frozen_string_literal: true

# rubocop:disable Lint/IdentityComparison, Lint/BinaryOperatorWithIdenticalOperands

require "shelf"

book = Object.new
other = Object.new
p book.object_id == book.object_id # => true
p book.object_id == other.object_id # => false
p book.object_id.class # => Integer
p book.__id__ == book.object_id # => true
p book.dup.object_id == book.object_id # => false
p 1.object_id # => 3
p 2.object_id # => 5
p nil.object_id # => 4
p true.object_id # => 20
p false.object_id # => 0
p :shelf.object_id == :shelf.object_id # => true
p 1.5.object_id == 1.5.object_id # => true
p (2**70).object_id == (2**70).object_id # => false

# Under the frozen_string_literal magic comment equal literals are one object.
p "shelf".equal?("shelf") # => true
p String.new("shelf").equal?(String.new("shelf")) # => false
p "shelf".dup.equal?("shelf") # => false
p (+"shelf").equal?("shelf") # => false
p (-String.new("shelf")).equal?(-String.new("shelf")) # => true
p Shelf::Isbn.new("9780306406157").equal?(Shelf::Isbn.new("9780306406157")) # => false
# rubocop:enable Lint/IdentityComparison, Lint/BinaryOperatorWithIdenticalOperands

What the example shows, and what to remember:

  • Identity is per object, not per value: dup produces a new object with a new id, even for an equal copy.

  • Small integers, nil, true, false, symbols and (on 64-bit platforms) most floats are immediate values: there is only ever one object for a given value, so equal? is true whenever == is. Integers have object_id of 2 * n + 1 (here 1 is 3 and 2 is 5); the ids of nil, true and false are fixed small numbers, and the exact values are an implementation detail that has changed between releases. Never write code that depends on them. An integer beyond the immediate range (2**70) is a heap object, so two equal values are not equal?.

  • Since Ruby 2.7 an object_id is a monotonically increasing integer assigned the first time it is requested, not the address of the object. That makes it stable across garbage collection compaction but means it carries no memory information. It is unique among live objects, and Ruby does not reuse it for another object.

  • ObjectSpace._id2ref converts an id back into an object, but it is deprecated in 4.0 (it needs an internal table that costs memory and speed) and prints warning: ObjectSpace._id2ref is deprecated under -w. Use an ObjectSpace::WeakMap or ObjectSpace::WeakKeyMap when you need to look objects up by identity without keeping them alive, see Object Lifecycle, Freezing and Copying.

  • With # frozen_string_literal: true, equal string literals in a file are one shared frozen object, so "shelf".equal?("shelf") is true there; without the comment, and for String.new, dup and +@, every evaluation creates a fresh string. The unary -@ (-str) deduplicates a string into the interned frozen copy, which is why the -String.new("shelf") pair is equal?. Relying on this for correctness is a mistake: it is an optimisation.

  • Two value objects built from the same value are not the same object (Shelf::Isbn.new(...) twice). That is what == is for.

Do not compare ids with == to test identity: a.object_id == b.object_id allocates ids for both objects and says less clearly what you mean than a.equal?(b). RuboCop’s Lint/IdentityComparison reports it.

Value equality:

== is the operator Ruby programmers use all day. Its default is identity (inherited from BasicObject), and any class that represents a value overrides it. The core classes already do: String compares bytes and encodings compatibly, Array and Hash compare element by element, Integer, Float, Rational and Complex compare numerically across types, Range compares both ends and the exclusion flag, Time compares the instant (not the zone), Struct and Data compare their members, Set compares membership, Exception compares class, message and backtrace.

A well-formed == is an equivalence relation. The properties a caller may rely on:

  • Reflexive: x == x is true (with the one numeric exception of NaN, see below).

  • Symmetric: a == b and b == a agree.

  • Transitive: a == b and b == c imply a == c.

  • Consistent: the answer does not change unless an object is mutated.

  • Total: x == nil and x == other_kind_of_object return false; they never raise, and they never call a method that may be missing on the argument.

!= is never defined by hand: BasicObject#!= calls == and negates the result, so overriding == is enough and a != b follows. Overriding != separately is almost always a bug, and !(a == b) is what the language does already. Kernel#=~ was removed in Ruby 3.2: nil =~ /x/ still works because NilClass and the classes that match (String, Symbol, Regexp) define it, but Object.new !~ /x/ now raises NoMethodError.

Writing == for your own class

The conventional shape has four parts: a type test, a comparison of the same attributes that hash uses, a total answer for non-matching arguments, and the eql? alias with a matching hash.

# frozen_string_literal: true

# rubocop:disable Style/OneClassPerFile

require "shelf"

# A symmetric-looking == written with is_a? is not symmetric once a subclass adds state.
class Copy
  attr_reader :isbn

  def initialize(isbn) = @isbn = isbn

  def ==(other) = other.is_a?(Copy) && isbn == other.isbn
end

# A reference-only copy that also carries a room.
class ReferenceCopy < Copy
  attr_reader :room

  def initialize(isbn, room)
    super(isbn)
    @room = room
  end

  def ==(other) = other.is_a?(ReferenceCopy) && isbn == other.isbn && room == other.room
end

isbn = Shelf::Isbn.new("9780306406157")
plain = Copy.new(isbn)
reference = ReferenceCopy.new(isbn, "R1")
p plain == reference # => true
p reference == plain # => false

# The conventional fix: only objects of exactly the same class are equal.
class StrictCopy
  attr_reader :isbn

  def initialize(isbn) = @isbn = isbn

  def ==(other) = other.instance_of?(self.class) && isbn == other.isbn
  alias eql? ==

  def hash = [self.class, isbn].hash
end

strict = StrictCopy.new(isbn)
p strict == StrictCopy.new(isbn) # => true
p StrictCopy.new(isbn) == plain # => false
nothing = nil
p StrictCopy.new(isbn) == nothing # => false
p StrictCopy.new(isbn).eql?(StrictCopy.new(isbn)) # => true
p strict.hash == StrictCopy.new(isbn).hash # => true
p strict != StrictCopy.new(isbn) # => false
# rubocop:enable Style/OneClassPerFile

Source: Object#eql?

The first two classes show the classic trap of an == built on is_a?: when a subclass adds state, the base class accepts the subclass (plain == reference is true), but the subclass, whose == compares the extra field and needs a ReferenceCopy on the right, rejects the base class (reference == plain is false). That is not symmetric, so include?, index and delete answer differently depending on which side the argument is on. The usual fix is other.instance_of?(self.class): only objects of exactly the same class are equal, which is symmetric and transitive by construction. The cost is that a subclass of a value class is never equal to its parent, which is normally what you want for value objects (and one reason why the style guides tell you not to subclass them, see Struct, Data and Value Objects). RuboCop’s Style/ClassEqualityComparison steers you away from other.class == self.class towards instance_of?.

Guidelines for the body of ==:

  • Compare exactly the attributes that define the value, and the same ones in eql? and hash. Cache or derived values (a memoised total, a timestamp of last access) are not part of the value.

  • Do not coerce or convert the argument in == or in eql?. Convert only in coerce and <=> of numeric-like classes, see Conversions and Coercion. The core String#== is the one well-known exception that looks at to_str: if the argument is not a String but responds to to_str, it returns other == self.

  • Compare by == the attributes whose own class defines value equality. Comparing an Isbn to a String is a smell, not a feature.

  • Do not mutate in ==; keep it cheap. It is called inside include?, index, delete and Comparable.

  • Prefer a Struct or Data when all you need is "equal when all members are equal": they define ==, eql?, hash and inspect correctly, see Struct and Data.

Equality across the core types

# frozen_string_literal: true

# rubocop:disable Lint/BinaryOperatorWithIdenticalOperands, Style/YodaCondition

p [1, 2] == [1, 2] # => true
p [1, 2] == [1.0, 2.0] # => true
p [1, 2].eql?([1.0, 2.0]) # => false
p [1, [2]] == [1, [2]] # => true
p (1..3) == (1..3) # => true
p (1..3) == (1...4) # => false
p (1..3) == (1.0..3.0) # => true
p (1..3).eql?(1.0..3.0) # => false
p (1..3).hash == (1..3).hash # => true
p Set[1, 2] == Set[2, 1] # => true
p Set[1, 2].eql?(Set[2, 1]) # => true
p Set[Set[1]].include?(Set[1]) # => true
p :a == "a" # => false
p :a.to_proc == :a.to_proc # => true
name = String.new("a")
p name.to_sym.equal?(:a) # => true
p "abc" == "abc".b # => true
p "é" == "é".encode("ISO-8859-1") # => false
p "é".bytes == "é".encode("ISO-8859-1").bytes # => false
p "e\u0301" == "é" # => false
p "e\u0301".unicode_normalize == "é" # => true
p "Dune".casecmp?("DUNE") # => true
p "Dune".casecmp("DUNE") # => 0
p "Dune" <=> "DUNE" # => 1
p "a" < "b" # => true
p "Z" < "a" # => true
p nil == false # => false
p nil.to_a == [] # => true
p Object.new == Object.new # => false
p Class == Class # => true
p String == "x".class # => true
p "x".instance_of?(String) # => true
p "x".is_a?(Comparable) # => true
p "x".is_a?(Object) # => true
# rubocop:enable Lint/BinaryOperatorWithIdenticalOperands, Style/YodaCondition

Source: Array#==

Points worth remembering:

  • Array#== compares element by element with == and Array#eql? with eql?, so [1, 2] == [1.0, 2.0] while [1, 2].eql?([1.0, 2.0]) is false. Range#== and Range#eql? behave the same way on their end points.

  • Hash#== ignores insertion order (see the example in the hash section below) and compares values with ==. A Set is equal to another set with the same members, whatever their order; and a Set can itself be a member of a Set because it defines hash and eql?.

  • A Symbol is never equal to a String with the same text, and equal? holds for symbols made from equal strings (name.to_sym.equal?(:a)), because there is one object per symbol. Do not rely on equality of the procs returned by Symbol#to_proc: it holds today but is not documented.

  • String#== compares content and encoding compatibility: a UTF-8 string and an ISO-8859-1 string with different bytes are not equal, while ASCII-only strings in compatible encodings compare equal even if one is binary ("abc".b). Visually identical Unicode text is not necessarily equal: "é" (an e plus a combining accent) and the precomposed "é" differ until you call unicode_normalize. See Encodings and Unicode and Strings.

  • String#casecmp and casecmp? do ASCII-only and Unicode case folding respectively; == and <=> on strings are case sensitive and compare bytes, so uppercase letters sort before lowercase ones ("Z" < "a").

  • Class equality is plain identity: String == "x".class is true, and instance_of? and is_a? are different questions (exact class versus class-or-subclass-or-module, a kind_of? alias exists for is_a?).

Methods, procs, exceptions and times

# frozen_string_literal: true

# rubocop:disable Lint/BinaryOperatorWithIdenticalOperands

require "shelf"

p Integer <=> Numeric # => -1
p Numeric <=> Integer # => 1
p Integer <=> String # => nil
p Integer < Comparable # => true
p Integer <= Integer # => true
p Shelf::Book.ancestors.include?(Comparable) # => false

p RuntimeError.new("late") == RuntimeError.new("late") # => true
p RuntimeError.new("late") == StandardError.new("late") # => false
p RuntimeError.new("late").eql?(RuntimeError.new("late")) # => false

title = String.new("Dune")
p title.method(:upcase) == title.method(:upcase) # => true
p title.method(:upcase).eql?(title.method(:upcase)) # => true
p title.method(:upcase).hash == title.method(:upcase).hash # => true
p title.method(:upcase) == title.dup.method(:upcase) # => false
double = ->(n) { n * 2 }
p double == double # => true
p double == double.dup # => true
p double == ->(n) { n * 2 } # => false

p Time.at(0) == Time.at(0, in: "UTC") # => true
p Time.at(0).eql?(Time.at(0, in: "+09:00")) # => true
p Time.at(0, 500, :millisecond) == Time.at(0.5) # => true
p Time.at(0).equal?(Time.at(0)) # => false
p Time.at(1) <=> Time.at(0.5) # => 1
# rubocop:enable Lint/BinaryOperatorWithIdenticalOperands

Source: Method#==

  • Module#<=>, <, <=, > and >= order classes and modules by inheritance (Integer <=> Numeric is -1, and unrelated classes give nil); Module does not include Comparable, it defines these itself.

  • Exception#== is true for two exceptions of the same class with equal messages and backtraces; eql? stays identity.

  • Two Method objects are equal when they have the same receiver object and the same definition. A different (even equal) receiver gives false, as the dup of the string does.

  • Since Ruby 3.0, Proc#== and Proc#eql? are defined: a proc is equal to its own dup, but two procs created from separate literals with identical bodies are not equal.

  • Time#== and Time#eql? compare the instant, including sub-second precision, not the time zone offset: the same instant in UTC and in +09:00 is == and eql?. Compare times by ==/<=>, not by their formatted strings.

Hash keys: eql? and hash

A Hash decides whether two keys are the same by two checks: their hash values must be identical, and then the keys must be eql?. Set, Array#uniq, Array#|, Array#&, Array#-, Array#intersect?, Enumerable#tally, Enumerable#group_by and Hash itself all build on that pair. The documented contract:

  • If a.eql?(b) then a.hash == b.hash. The reverse need not hold (collisions are allowed), but a good hash spreads values over the integers.

  • eql? is at least as strict as ==: objects that are eql? are ==. The other direction does not hold (1 == 1.0 but 1.eql?(1.0) is false), and the core library keeps it that way on purpose so that {1 => :a}[1.0] is nil.

  • hash returns an Integer, is stable for as long as the object is not mutated in a way eql? can see, and may change from one run of the program to the next: Ruby seeds its hash function randomly per process. Never persist a hash, compare it across processes or use it as a stable identifier; use a digest (Digest::SHA256, see Standard Library Tour) for that.

  • The default Kernel#hash and Kernel#eql? are based on identity, so every object is a distinct key until you define both.

# frozen_string_literal: true

require "shelf"

# A physical copy: the same copy means the same ISBN and the same shelf mark.
class Copy
  attr_reader :isbn, :mark

  def initialize(isbn, mark)
    @isbn = isbn
    @mark = mark
  end

  def ==(other)
    other.instance_of?(self.class) && isbn == other.isbn && mark == other.mark
  end
  alias eql? ==

  def hash = [self.class, isbn, mark].hash
end

# The same class with == only: the hash contract is broken.
LooseCopy = Class.new do
  attr_reader :isbn, :mark

  def initialize(isbn, mark)
    @isbn = isbn
    @mark = mark
  end

  def ==(other)
    other.instance_of?(self.class) && isbn == other.isbn && mark == other.mark
  end
end

isbn = Shelf::Isbn.new("978-0-306-40615-7")
c1 = Copy.new(isbn, "A-12")
c2 = Copy.new(isbn, "A-12")
p c1.equal?(c2) # => false
p c1 == c2 # => true
p c1 != c2 # => false
p({ c1 => :on_loan }[c2]) # => :on_loan
p [c1, c2].uniq.size # => 1
p Set[c1, c2].size # => 1

l1 = LooseCopy.new(isbn, "A-12")
l2 = LooseCopy.new(isbn, "A-12")
p l1 == l2 # => true
p [l1].include?(l2) # => true
p({ l1 => :on_loan }[l2]) # => nil
p [l1, l2].uniq.size # => 2

Copy follows the contract: == and eql? agree (the alias), and hash is computed from the same fields that == compares, so a Hash lookup, uniq and a Set all treat two copies of the same ISBN and mark as one. LooseCopy defines only ==: include? finds the equal copy (it calls ==), but the Hash lookup does not (it calls hash first, which is still identity-based), and uniq keeps both. No error or warning is raised; the symptom is a lookup that silently misses, which is the reason to always define ==, eql? and hash together.

Implementing hash:

  • Build it from the same attributes as eql? by hashing an Array of them, as [self.class, isbn, mark].hash does. Array#hash combines the element hashes in an order-sensitive way and is well distributed.

  • Do not combine hashes with ^ (xor) or + by hand: xor is commutative ([a, b] and [b, a] collide), cancels equal values and gives weak distribution. Including self.class keeps different classes with equal fields apart.

  • Every attribute used in hash must be an object whose own hash is consistent with its eql?. Floats, strings, symbols, arrays, Data and Struct all are; a plain object without its own hash contributes its identity.

  • Cache the value only for immutable objects (a frozen or Data instance); a cached hash that goes stale after mutation is a bug.

Mutating a key

# frozen_string_literal: true

# rubocop:disable Lint/BinaryOperatorWithIdenticalOperands

require "shelf"

key = String.new("title")
index = { key => 1 }
p index.keys.first.equal?(key) # => false
p index.keys.first.frozen? # => true
key << "s"
p index # => {"title" => 1}
p index["title"] # => 1

p({ 1 => :integer }[1.0]) # => nil
p({ 1 => :integer }.compare_by_identity[1]) # => :integer
p({ 0.0 => :zero }[-0.0]) # => :zero
nan = Float::NAN
p({ nan => :found }[nan]) # => :found
p({ nan => :found }[0.0 / 0.0]) # => nil
p [nan].include?(nan) # => true
p nan == nan # => false
p [1, 1.0, 1r].uniq # => [1, 1.0, (1/1)]
p Set[1, 1.0].size # => 2
p [1, 1.0, 1r].map(&:hash).uniq.size # => 3

tags = %w[ruby rust go]
by_identity = {}.compare_by_identity
by_identity[String.new("ruby")] = 1
p by_identity[String.new("ruby")] # => nil
p by_identity.compare_by_identity? # => true
p by_identity.keys # => ["ruby"]

shelf = [1, 2]
lookup = { shelf => :a }
shelf << 3
p lookup[[1, 2, 3]] # => nil
p lookup.rehash[[1, 2, 3]] # => :a

p({ a: 1, b: 2 } == { b: 2, a: 1 }) # => true
p({ a: 1, b: 2 }.to_a == { b: 2, a: 1 }.to_a) # => false
p({ a: 1 }.hash == { a: 1 }.hash) # => true
p [1, 2].hash == [1, 2].hash # => true
p [1, 2].hash == [2, 1].hash # => false
p tags.hash == %w[ruby rust go].hash # => true
p 1.hash.class # => Integer
# rubocop:enable Lint/BinaryOperatorWithIdenticalOperands

Source: Hash#rehash

  • A String key is duplicated and frozen on insertion (the first key is not equal? to the original, and it is frozen?), so appending to the original string leaves the Hash untouched. This protection applies to strings only.

  • An Array (or any mutable object) used as a key is stored in the bucket for its hash at insertion time. After shelf << 3 the array has a different hash, so the lookup misses (nil) even though the key is in the Hash; rehash rebuilds the index and repairs it. Prefer immutable keys: symbols, integers, frozen strings, Data instances, frozen arrays.

  • Hash#compare_by_identity (and Set#compare_by_identity) replaces eql?/hash with equal? and the object id, so two equal strings are distinct keys. It exists for caches and graphs keyed by object, and compare_by_identity? reports the mode. RuboCop’s Lint/HashCompareByIdentity flags the older idiom of using object_id as a Hash key.

  • Numeric keys follow eql?: 1 and 1.0 are distinct keys and 1 and 1r too; 0.0 and -0.0 are one key (they are eql?) even though they are different objects; NaN, which is not == to itself, can be found by the very same object (identity is checked before eql?) but not by another NaN.

  • Hash#== and Hash#hash ignore insertion order, while to_a keeps it; for an Array, [1, 2].hash and [2, 1].hash differ.

Which operation calls which method

# frozen_string_literal: true

# Equal by == but not by eql?/hash: Float 1.0 against Integer 1.
one = 1
uno = 1.0
list = [one]
p list.include?(uno) # => true
p list.index(uno) # => 0
p list.count(uno) # => 1
p(list.delete(uno)) # => 1
p [[one, :a]].assoc(uno) # => [1, :a]
p [one].eql?([uno]) # => false
p [one] == [uno] # => true
p [one] - [uno] # => [1]
p [one] | [uno] # => [1, 1.0]
p [one] & [uno] # => []
p [one, uno].uniq # => [1, 1.0]
p [one, uno].tally # => {1 => 1, 1.0 => 1}
p [one, uno].group_by(&:itself) # => {1 => [1], 1.0 => [1.0]}
p [one, 2].intersect?([uno, 3]) # => false
p [one, uno].to_set.size # => 2
p({ one => :a }.key?(uno)) # => false
p({ one => :a }.value?(:a)) # => true
p({ a: one }.value?(uno)) # => true
p({ a: one }.key(uno)) # => :a
p [uno, one].uniq(&:to_i) # => [1.0]

compared = Object.new
def compared.==(_other) = true
p compared <=> Object.new # => 0
p compared.equal?(compared) # => true
p compared != Object.new # => false

Source: Array#uniq

With 1 and 1.0 (equal by ==, different by eql?) the example separates the two families in the core library:

Operation Calls

==, !=

==

Array#include?, index, rindex, count(obj), delete, assoc (on the first element of nested arrays), Array#==, Hash#== (values), Hash#value?, Hash#key(value), Enumerable#include?

==

Hash keys, Hash#key?, Set, Array#uniq, |, &, -, union, difference, intersection, intersect?, tally, group_by

hash and eql?

case/when, grep, grep_v, all?(pattern), any?(pattern), none?(pattern), one?(pattern), rescue clauses, case/in value patterns

===

sort, min, max, minmax, sort_by (on the keys), max_by, min_by, Array#<=>, the Comparable methods, Range#cover?

<=>

Note that two equal-looking collections can answer differently depending on the operation: a list of integers include? the float 1.0 and yet [1] - [1.0] leaves the 1 in. Choose the method that matches the question, and keep numeric types consistent when you use them as keys.

Floating point, Rational and Complex

# frozen_string_literal: true

# rubocop:disable Lint/FloatComparison, Lint/BinaryOperatorWithIdenticalOperands, Style/YodaCondition

p 0.1 + 0.2 # => 0.30000000000000004
p 0.1 + 0.2 == 0.3 # => false
p((0.1 + 0.2 - 0.3).abs < Float::EPSILON) # => true
p Float::EPSILON # => 2.220446049250313e-16
p((1_000_000.1 + 0.2 - 1_000_000.3).abs < Float::EPSILON) # => false
p 0.1r + 0.2r == 0.3r # => true
p 0.5 == 1/2r # => true
p 0.1 == 1/10r # => true
p 0.1.to_r == 1/10r # => false
p 0.1.rationalize == 1/10r # => true
p 1/3r == Rational(2, 6) # => true
p 1.eql?(1r) # => false
p 1 == 1r # => true
p 1 == Complex(1, 0) # => true
p (2**53) + 1 == ((2**53) + 1).to_f # => false
p(((2**53) + 1) <=> ((2**53) + 1).to_f) # => 1
p 0.0 == -0.0 # => true
p 0.0.eql?(-0.0) # => true
p 0.0.equal?(-0.0) # => false
p Float::NAN == Float::NAN # => false
p Float::NAN.eql?(Float::NAN) # => false
p Float::NAN.nan? # => true
p Float::INFINITY == Float::INFINITY # => true
p Float::INFINITY <=> Float::INFINITY # => 0
p 3.7.round == 4 # => true
p 12.5.round(half: :even) # => 12
p 1.0.hash == 1.hash # => false
p 1.0.eql?(1.0) # => true
# rubocop:enable Lint/FloatComparison, Lint/BinaryOperatorWithIdenticalOperands, Style/YodaCondition

Source: Float::EPSILON

  • 0.1 + 0.2 == 0.3 is false in every language with IEEE 754 doubles, and Ruby is no different. Compare with a tolerance ((a - b).abs < epsilon), choose the tolerance from the magnitudes involved (Float::EPSILON is only right near 1.0: the million-sized example fails with it), or use exact arithmetic. RuboCop’s Lint/FloatComparison flags == and != on float literals and recommends exactly that. Tests use assert_in_delta or assert_in_epsilon in Minitest, see Testing with Minitest.

  • Exact decimal values belong in Rational (0.1r + 0.2r == 0.3r is true) or in the bigdecimal bundled gem, see Numbers. Mixed comparison converts: 0.1 == 1/10r is true because the Rational is compared as a Float, although the exact value of the float (0.1.to_r) is not 1/10r; rationalize finds the simplest rational within the float’s precision.

  • An Integer and a Float compare exactly in Ruby: (2**53) + 1 is not equal to its own to_f (the float rounds down), <=> says the integer is greater, and no precision is lost by an implicit float conversion. 1 == 1r, 1 == Complex(1, 0) and 1 == 1.0 are all true, while eql? is false for each pair of different classes.

  • NaN is the one value that is not equal to itself: Float::NAN == Float::NAN and eql? are false; test for it with nan?. NaN <=> x is nil, so it breaks sort and min; Float::INFINITY == Float::INFINITY is true.

  • 0.0 == -0.0 and 0.0.eql?(-0.0) are true although equal? says they are different objects; a Hash treats them as one key.

  • Rounding is not equality: 12.5.round(half: :even) is 12, and 3.7.round == 4 compares integers. Round money with Rational or integers of cents before comparing.

Hash behaviour of numbers follows eql?: 1.0.hash == 1.hash is false, which is the reason a Hash can hold both keys. Convert at the boundary (Integer(x), Float(x)) so that one kind of number is used as the key.

Struct and Data

Struct and Data define ==, eql?, hash and inspect from their members, and are the shortest correct way to write a value class. Data (Ruby 3.2) is immutable, so it is safe as a Hash key; a Struct is mutable, so using it as a key inherits all the problems of the previous section.

# frozen_string_literal: true

# rubocop:disable Lint/BinaryOperatorWithIdenticalOperands

require "shelf"

isbn = Shelf::Isbn.new("9780306406157")
herbert = Shelf::Author.new("Frank Herbert")
dune = Shelf::Book.new(isbn:, title: "Dune", author: herbert)
same = Shelf::Book.new(isbn: Shelf::Isbn.new("978-0-306-40615-7"), title: "Dune", author: herbert)

p dune.equal?(same) # => false
p dune == same # => true
p dune.eql?(same) # => true
p dune.hash == same.hash # => true
p dune.frozen? # => true
p dune == Shelf::Book.new(isbn:, title: "Dune", author: Shelf::Author.new("F. Herbert")) # => false

Pair = Data.define(:left, :right)
p Pair.new(1, 2) == Pair.new(1.0, 2) # => true
p Pair.new(1, 2).eql?(Pair.new(1.0, 2)) # => false

Point = Struct.new(:x, :y)
Cell = Struct.new(:x, :y)
p Point.new(1, 2) == Point.new(1, 2) # => true
p Point.new(1, 2) == Cell.new(1, 2) # => false
p Point.new(1, 2) == Point.new(1.0, 2) # => true
p Point.new(1, 2).eql?(Point.new(1.0, 2)) # => false

pt = Point.new(1, 2)
seen = { pt => :here }
pt.x = 9
p seen[pt] # => nil
p seen.key?(Point.new(1, 2)) # => false
p seen.rehash[pt] # => :here
# rubocop:enable Lint/BinaryOperatorWithIdenticalOperands

Source: Data#eql?

  • Shelf::Book, a Data, is equal to another Book with equal members: == compares each member with ==, eql? and hash with eql? and hash. Two Data classes or two Struct classes with the same member names are never equal to each other; the class has to match.

  • Members of different numeric classes show the gap again: Pair.new(1, 2) == Pair.new(1.0, 2) is true, the same pair with eql? is false, and so they are different Hash keys.

  • Data instances are frozen (dune.frozen? is true), so a key cannot change; a Struct can, as the last lines show: after pt.x = 9 the lookup misses until rehash. If a Struct must be a key, freeze it.

  • Both define the comparison on the members in declaration order only; neither defines <=> or includes Comparable. To sort them, give an explicit key (sort_by { [it.year, it.title] }, using the it parameter from 3.4), or include Comparable on a Data class as in the next section.

  • Overriding == in a Data or Struct is possible but starts to fight the generated eql? and hash. Do it only together with the other two, and prefer a custom class for non-trivial equality.

Older code (and one popular book) writes class Money < Struct.new(:amount) or < Data.define(...), which creates an extra anonymous superclass; assign instead (Money = Data.define(:amount) do ... end) as Shelf::Book does. Passing keyword_init: true is no longer needed to allow keyword construction of a Struct (3.2); see Struct, Data and Value Objects.

Case equality: ===

=== is the "does this value belong to this category" operator. It is what case/when calls: when expr evaluates expr === value, with the when operand as the receiver. Kernel#=== is ==, so for plain values when behaves like an equality test, but many classes override it to mean membership.

# frozen_string_literal: true

# rubocop:disable Style/CaseEquality, Lint/BinaryOperatorWithIdenticalOperands, Style/NilComparison

require "shelf"

p Integer === 3 # => true
p Integer === 3.0 # => false
p Numeric === 3.0 # => true
p Comparable === 3 # => true
p Object === nil # => true
p String === String # => false
p Class === String # => true
p (1..10) === 5 # => true
p (1..10) === 5.5 # => true
p (1..10) === "5" # => false
p (..0) === -3 # => true
p (1..10).include?(5.5) # => true
p (Time.at(0)..Time.at(100)) === Time.at(50) # => true
p(/ru/ === "ruby") # => true
p(/ru/ === :ruby) # => true
p(/ru/ === 42) # => false
p :ruby === :ruby # => true
p "ruby" === "ruby" # => true
p ->(n) { n > 3 } === 5 # => true
p :even?.to_proc === 4 # => true
p Set[1, 2] === 2 # => true
p nil === nil # => true
p Object.new === Object.new # => false
p Shelf::Isbn === Shelf::Isbn.new("9780306406157") # => true
p %w[ruby rust perl go].grep(/ru/) # => ["ruby", "rust"]
p %w[ruby rust perl go].grep_v(/ru/) # => ["perl", "go"]
p [1, "a", :b, 2.5, nil].grep(Numeric) # => [1, 2.5]
p [1, "a", :b, 2.5, nil].grep(Numeric, &:to_s) # => ["1", "2.5"]
p [3, 5, 8].all?(Integer) # => true
p [3, 5, 8].any?(4..6) # => true
p [3, 5, 8].none?(9..) # => true
p [3, 5, 8].count(5) # => 1
p %w[a b a].one?("b") # => true
# rubocop:enable Style/CaseEquality, Lint/BinaryOperatorWithIdenticalOperands, Style/NilComparison

Source: Module#===

Receiver receiver === value means

Module / Class

value.is_a?(receiver); so Integer === 3, Comparable === 3 and Object === nil are true, and String === String is false (a class is a Class, not a String)

Range

cover?: (1..10) === 5.5 is true while (1..10) === "5" is false; endless and beginless ranges work ((..0) === -3), and so do ranges of Time

Regexp

matches a String or Symbol (setting $~); other objects give false

Proc

calls the proc with the value, so a lambda works as a predicate in when

Set

include?

Symbol, String

==

Object, NilClass, numbers and everything else

==

Three practical uses, all of which call ===:

  • case/when, where several operands can be listed and a splatted array expands to many (when *SMALL).

  • Enumerable#grep and grep_v (with a block to transform the matches), and the predicate forms all?, any?, none?, one? with a pattern argument: [3, 5, 8].any?(4..6) is clearer than a block. count(obj), include? and delete take a value and use ==, not ===.

  • Value patterns in case/in and pattern in value expressions use === as well, see Pattern Matching.

A class of your own can join in by defining ===; the receiver is the pattern, so the method is a singleton or instance method of the matcher, never of the value being tested.

# frozen_string_literal: true

# rubocop:disable Style/OneClassPerFile

require "shelf"

# A user-defined matcher: === is the only method case/when calls.
class Prefix
  def initialize(prefix) = @prefix = prefix

  def ===(isbn) = isbn.to_s.start_with?(@prefix)
end

# Errors worth retrying, matched by message instead of by class.
module Transient
  def self.===(error) = error.message.include?("timeout")
end

def classify(value) # rubocop:disable Metrics/CyclomaticComplexity, Metrics/MethodLength
  case value
  when Shelf::Isbn then "isbn #{value}"
  when Prefix.new("978") then "bookland prefix"
  when 1..9, 100.. then "small or huge"
  when Integer, Float then "number"
  when /\A\d+\z/ then "digits"
  when :a, :b then "letter symbol"
  when nil then "nothing"
  when ->(v) { v.respond_to?(:each) } then "collection"
  else "unknown"
  end
end

p classify(Shelf::Isbn.new("9780306406157")) # => "isbn 9780306406157"
p classify("9781234567890") # => "bookland prefix"
p classify("42") # => "digits"
p classify(5) # => "small or huge"
p classify(50) # => "number"
p classify(500) # => "small or huge"
p classify(20.5) # => "number"
p classify(:b) # => "letter symbol"
p classify(nil) # => "nothing"
p classify([1]) # => "collection"
p classify(Object.new) # => "unknown"

SMALL = [1, 2, 3].freeze
def small?(value)
  case value
  when *SMALL then true
  else false
  end
end
p small?(2) # => true
p small?(9) # => false

attempts = 0
begin
  attempts += 1
  raise IOError, "timeout while reading" if attempts < 3

  p attempts # => 3
rescue Transient
  retry
end

# case/in pattern matching also calls === for value patterns.
result = case 7
         in Integer | Float => n if n > 5 then "big #{n}"
         in Integer then "small"
         end
p result # => "big 7"
pair = [1, 2]
shape = case pair
        in [Integer, Integer] then :pair
        else :other
        end
p shape # => :pair
# rubocop:enable Style/OneClassPerFile

The case tries the clauses in order and stops at the first match: a Shelf::Isbn (matched by Module#===), a string carrying the Bookland prefix (a user-defined matcher), integers 1 to 9 or 100 and up (two ranges in one when), numbers, digit strings (Regexp#===), two symbols, nil, anything that responds to each (a lambda) and an else. The order matters: 50 reaches "number" because no range matches it, and 20.5 is a Float, so it is a number as well.

rescue also calls === on each listed class or module, which is why a module with a singleton === (Transient above) can act as a classification of errors. The operand of rescue must still be a class or module. case without a subject (case followed directly by when condition) evaluates each condition for truthiness and does not call ===.

Do not call === directly in application code; RuboCop’s Style/CaseEquality reports it, and is_a?, cover?, match? or include? say what is meant. The page’s examples call it directly on purpose.

Ordering: <=> and Comparable

a <=> b returns -1, 0 or 1 when a is less than, equal to or greater than b, and nil when the two cannot be ordered. Ruby calls it the spaceship operator. Object#<=> returns 0 for the same or == objects and nil otherwise, so a class has to define the method itself to be orderable, usually by delegating to the same attributes it uses for ==.

# frozen_string_literal: true

# rubocop:disable Lint/BinaryOperatorWithIdenticalOperands

require "shelf"

p 1 <=> 2 # => -1
p "b" <=> "a" # => 1
p "a" <=> 1 # => nil
p [1, 2] <=> [1, 3] # => -1
p [1, 2] <=> [1] # => 1
p [1, "a"] <=> [1, 2] # => nil
p :a <=> :b # => -1
p nil <=> nil # => 0
p Object.new <=> Object.new # => nil
p Time.at(0) <=> Time.at(1) # => -1
p Float::NAN <=> 1 # => nil
p (1 <=> Float::NAN).nil? # => true
p true <=> false # => nil

book = Object.new
p book <=> book # => 0

begin
  [3, "a", 1].sort
rescue ArgumentError => e
  p e.message # => "comparison of Integer with String failed"
end

begin
  [3, nil].max
rescue ArgumentError, NoMethodError => e
  p e.message # => "comparison of Integer with nil failed"
end

titles = %w[Dune Emma dune Persuasion]
p titles.sort # => ["Dune", "Emma", "Persuasion", "dune"]
p titles.sort_by(&:downcase) # => ["Dune", "dune", "Emma", "Persuasion"]
p(titles.sort { |x, y| y <=> x }) # => ["dune", "Persuasion", "Emma", "Dune"]
p(titles.sort_by { |t| [-t.length, t] }) # => ["Persuasion", "Dune", "Emma", "dune"]
p titles.minmax # => ["Dune", "dune"]
p titles.min(2) # => ["Dune", "Emma"]
p titles.max_by(&:length) # => "Persuasion"
p titles.sort_by(&:size).reverse # => ["Persuasion", "dune", "Emma", "Dune"]
# rubocop:enable Lint/BinaryOperatorWithIdenticalOperands

Source: Object#<⇒

  • nil means "not comparable", and it is a real result: "a" <=> 1, Float::NAN <=> 1 and Object.new <=> Object.new all give nil. Array#<=> compares element by element and then by length ([1, 2] <=> [1] is 1), and returns nil as soon as two elements cannot be compared ([1, "a"] <=> [1, 2]).

  • Operations that need an order (sort, min, max, minmax, sort_by) raise ArgumentError with comparison of X with Y failed when they meet a nil. Mixed arrays of integers and strings, or nil, are the common trigger; the message names the class of the receiver and the class (or nil, or the inspected value) of the other operand.

  • sort uses <=> by default and a block can replace it (sort { |x, y| y <=> x } for descending, though sort.reverse or sort_by with a negated key is faster). sort_by computes a key once per element and orders by the keys, so it is the right choice for expensive keys; an Array key sorts by several criteria ([-t.length, t]: length descending, then alphabetical).

  • The sort is not stable: elements that compare equal (Dune and dune under sort_by(&:downcase)) may come out in any order. Add a tie breaker to the key when the order matters.

  • min(2), max(n), min_by, max_by and minmax use the same orderings. String#<=> compares bytes, so uppercase sorts before lowercase and non-ASCII text sorts by encoding rather than by language; for locale-aware ordering use a collation library.

The Comparable mixin

Comparable turns a single <=> method into <, <=, >, >=, ==, between? and clamp. Include it and define <=>; do nothing else. The core classes that include it are String, Symbol, Integer, Float, Rational, Complex, Time and File::Stat. Array, Hash, Range, Set, Struct and Data do not include it; Set and Hash instead define their own subset comparisons (below).

# frozen_string_literal: true

# Ordering through Comparable, derived from the same fields as equality.
Edition = Data.define(:year, :printing) do
  include Comparable

  def <=>(other)
    return unless other.is_a?(Edition)

    [year, printing] <=> [other.year, other.printing]
  end
end

first = Edition.new(1965, 1)
reprint = Edition.new(1965, 2)
newer = Edition.new(1990, 1)

p first < reprint # => true
p reprint <= newer # => true
p first == Edition.new(1965, 1) # => true
p first.between?(first, newer) # => true
p([newer, first, reprint].sort.map { |e| [e.year, e.printing] }) # => [[1965, 1], [1965, 2], [1990, 1]]
p [newer, first, reprint].min # => #<data Edition year=1965, printing=1>
p [newer, first, reprint].max_by(&:printing) # => #<data Edition year=1965, printing=2>
p reprint.clamp(first, first) # => #<data Edition year=1965, printing=1>
p first == 1965 # => false
p first <=> 1965 # => nil

begin
  first < 1965
rescue ArgumentError => e
  p e.message # => "comparison of Edition with 1965 failed"
end

Source: Comparable

  • The <=> of Edition returns nil for anything that is not an Edition. That is the protocol: Comparable#== then returns false (no exception), while <, <=, > and >= raise ArgumentError with comparison of Edition with 1965 failed. Returning nil is better than raising from <=>: since Ruby 2.3 Comparable#== no longer rescues exceptions raised inside <=>, so a raising <=> makes == raise too.

  • Comparable is only as good as the <=> it is given: the result must be consistent (antisymmetric, transitive) and, if you also define ==, eql? and hash, must return 0 exactly for the objects those consider equal. Comparing arrays of attributes ([year, printing] <=> [other.year, other.printing]) is the idiomatic way.

  • between?(min, max) is inclusive on both ends. RuboCop’s Style/ComparableBetween suggests it over a chain of comparisons, and Style/ComparableClamp suggests clamp over [[x, min].max, max].min.

  • clamp is covered below.

clamp

Comparable#clamp(min, max) returns the receiver restricted to the interval: min if lower, max if higher, the receiver otherwise. With a range argument (2.7) an endless or beginless range leaves one side open. An exclusive range, and min > max, raise ArgumentError.

# frozen_string_literal: true

require "shelf"

p Shelf.config.loan_days # => 14
p Shelf.config.loan_days.clamp(1, 7) # => 7
p 30.clamp(1, 28) # => 28
p 0.clamp(1, 28) # => 1
p 30.clamp(..28) # => 28
p 5.clamp(7..) # => 7
p 1.5.clamp(1, 2) # => 1.5
p "m".clamp("a", "f") # => "f"

begin
  5.clamp(1...3)
rescue ArgumentError => e
  p e.message # => "cannot clamp with an exclusive range"
end

begin
  5.clamp(9, 1)
rescue ArgumentError => e
  p e.message # => "min argument must be less than or equal to max argument"
end

clamp works for any Comparable, not only numbers: "m".clamp("a", "f") is "f". It returns the boundary object itself, not a copy, and returns the receiver unchanged when it is already in range (1.5.clamp(1, 2) is 1.5, a Float inside an integer interval). The result keeps the class of whichever operand it returns, so 5.clamp(7..) is the Integer 7. Ruby 4.1 (due on 2026-12-25) is planned to add Range#clamp, which is a different operation: it returns a new Range whose begin and end are clamped to the given bounds (Feature #22175). To clamp a single value, Comparable#clamp stays the method to use.

Partial orders

<=> may return nil for incomparable values, and some core types use that. Set and Hash order by inclusion, so two unrelated collections have no order:

# frozen_string_literal: true

# Some orderings are partial: two values can be unrelated, and <=> says so with nil.
fiction = Set["Dune", "Emma"]
classics = Set["Emma"]
travel = Set["Walden"]

p classics <=> fiction # => -1
p fiction <=> classics # => 1
p fiction <=> Set["Emma", "Dune"] # => 0
p classics <=> travel # => nil
p classics < fiction # => true
p classics < travel # => false
p classics > travel # => false
p classics.subset?(fiction) # => true
p({ title: "Dune" } <= { title: "Dune", year: 1965 }) # => true
p Set.ancestors.include?(Comparable) # => false
p [fiction, classics, travel].map(&:size) # => [2, 1, 1]
begin
  [fiction, classics, travel].sort
rescue ArgumentError => e
  p e.message # => "comparison of Set with Set failed"
end

Source: Set

Set#<=> returns -1 for a proper subset, 1 for a proper superset, 0 for equal sets and nil for unrelated sets. < and > are the proper subset and superset tests, and for unrelated sets both are false, which is not the same as "equal": classics < travel and classics > travel are both false while classics <=> travel is nil. Sorting such objects fails with the usual comparison of Set with Set failed. When you design an ordering for a domain object, decide explicitly whether it is a total order (anything can be compared: use Comparable) or a partial one (use named predicates such as subset?).

Equality versus ordering

An ordering and an equality are two separate definitions that happen to share the answer "these two are the same". The mistakes below are all the same mistake: letting one of them stand in for the other.

# frozen_string_literal: true

require "shelf"

# Comparable#== comes from <=>, and it wins over the Data equality.
Title = Data.define(:text) do
  include Comparable

  def <=>(other) = text.casecmp(other.text)
end

p Title.ancestors.first(3) # => [Title, Comparable, Data]
a = Title.new("Dune")
b = Title.new("DUNE")
p a == b # => true
p a.eql?(b) # => false
p a.hash == b.hash # => false
p [a, b].uniq.size # => 2
p Set[a, b].size # => 2

Source: Comparable#==

Title is a Data whose <=> ignores case, and it includes Comparable. Because the module is included into the subclass, it sits before Data in the ancestors ([Title, Comparable, Data]), so Comparable#== wins over Data#==: Dune and DUNE are ==. But Data#eql? and Data#hash are untouched, so they are different Hash keys, uniq keeps both and a Set holds both. One class now has two notions of "equal" that disagree.

Rules that avoid it:

  • Keep ==, eql?, hash and <=> consistent: a.eql?(b) implies equal hashes and a == b, and a == b implies (a <=> b).zero?. For a value object whose fields are all compared, the converse holds too.

  • Do not include Comparable to get ==. It replaces a structural == with a comparison of whatever fields <=> looks at: if <=> compares only a title, two books with the same title and different authors are ==, sorted next to each other and silently merged by a Set or uniq implemented on ==.

  • When you want a different ordering than the equality (case-insensitive sort, order by date, order by popularity), do not put it into <=>. Sort with sort_by and a key (books.sort_by { [_1.author.name, _1.title] }) or give the comparison a name; keep <=> for the natural order.

  • A value object that is both equal-by-fields and ordered (Edition above) can derive both from the same array of attributes, which keeps them consistent by construction.

  • Comparable provides no eql? and no hash. If the class is a Hash key, define them (or use a Data, which already has them from its members).

  • Comparable#== compares by <=> after an identity check; define == explicitly if <=> is expensive or if incomparable operands should be handled specially.

RuboCop cops for equality and comparison

Cop What it reports

Lint/FloatComparison

== and != on floats.

Lint/IdentityComparison

a.object_id == b.object_id; use a.equal?(b).

Lint/BinaryOperatorWithIdenticalOperands

x == x, x <=> x and similar; usually a typo for two different variables.

Lint/HashCompareByIdentity

object_id used as a Hash key; use compare_by_identity.

Style/CaseEquality

direct calls of ===.

Style/ClassEqualityComparison

x.class == Foo; use instance_of?.

Style/YodaCondition

0 == x; write x == 0.

Style/NilComparison

x == nil; use x.nil?.

Style/ComparableBetween

a manual x >= min && x <= max.

Style/ComparableClamp

a manual [[x, min].max, max].min.

Where the older books are outdated

Two widely read Ruby books (a refactoring and design book and a book on modern Ruby idioms) both touch this area, and some of their advice no longer reflects Ruby 4.0:

  • Data and Struct. Older examples subclass the result of Struct.new or Data.define and pass keyword_init: true. Assign the class instead (Isbn = Data.define(:digits) do ... end); keyword construction works without the option since 3.2, and Data supplies equality, eql?, hash and a readable inspect for free.

  • Comparable as a value-object recipe. Writing include Comparable plus a <=> over a few fields to get an equal value object conflates equality with ordering (see above): the resulting == changes with the ordering fields, and eql? and hash are missing. Use Data for equality and add Comparable only for an ordering that agrees with it.

  • Hash#inspect. Output such as {:name=>"Ico"} is the pre-3.4 format; Ruby 3.4 and later print {name: "Ico"} and {"title" => 1} with spaces around =>.

  • Error messages. The old form (... for nil:NilClass, with a backtick opening the method name) became undefined method 'x' for nil (3.3 and 3.4), and comparison failures read comparison of Integer with String failed. comparison failures read comparison of Integer with String failed.

  • Version history. Pattern matching (which uses ===) was experimental in 2.7 and stable in 3.0; Symbol#to_proc dates to 1.8.7; a numbered parameter _1 was extended by the it parameter in 3.4.

  • DateTime and OpenStruct are not recommended for value objects; Time and Data replace them.

  • Singletons and memoisation. A class-level @instance ||= new is not thread safe; this is unrelated to equality but is often combined with == overrides in examples, see Singleton Classes and Class-Level Behavior.

Pitfalls checklist

  • == defined without eql? and hash: lookups, uniq and Set silently ignore the equality.

  • eql? and hash defined on mutable attributes, then used as Hash keys: freeze the object or rebuild the index with rehash.

  • == using is_a? in a class hierarchy that adds state: not symmetric; use instance_of?.

  • == that raises for an unexpected argument (other.isbn on nil): check the type first.

  • Comparable without <=> returning nil for foreign types: < raises comparison of ... failed at an awkward place.

  • Comparable used to get ==: see above.

  • Comparing floats with ==: use a tolerance or Rational.

  • NaN: not equal to anything, breaks sort; guard with nan?.

  • 1 and 1.0 as separate keys; 1 == 1.0 yet [1] - [1.0] does not remove anything.

  • Strings: encoding and normalisation differences make visually equal text unequal.

  • Comparing object_id values, relying on their numeric value, or using ObjectSpace._id2ref.

  • Persisting or comparing hash values across runs; they are randomised per process.

  • Sorting with sort_by returning nil (a missing field): ArgumentError; use a default or to_s.

  • Overriding equal? or !=.

Version notes

Version Change

2.3

Comparable#== no longer rescues exceptions raised by <=>.

2.4

Comparable#clamp(min, max).

2.7

Comparable#clamp(range). object_id becomes a monotonically increasing integer. Pattern matching (experimental).

3.0

Proc#== and Proc#eql? defined. Set#<=>. Pattern matching stable.

3.2

Data is added. Struct.new classes accept keyword arguments without keyword_init: true. Kernel#=~ removed. Set autoloaded.

3.3

NoMethodError messages use for an instance of ClassName.

3.4

Hash#inspect prints {name: "Ico"} and {"k" => v}; error messages use ' instead of a backtick. The it block parameter.

4.0

Set is a core class. ObjectSpace._id2ref is deprecated. object_id and hash of Class and Module are faster.

4.1 (upcoming)

Range#clamp is planned; check the release notes after 2026-12-25.