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 |
|---|---|---|---|
|
|
Same object (same identity). Never override. |
Are these two references the same object? |
|
|
Same as |
Do these two objects represent the same value? |
|
|
Calls |
Do they differ? |
|
|
Same as |
Are these interchangeable as Hash keys (strict, no type conversion)? |
|
|
Derived from the object identity. |
Which bucket does this object belong to? |
|
|
Same as |
Does this value belong to this category (the |
|
|
|
Which comes first ( |
|
|
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
Source: Object#object_id
What the example shows, and what to remember:
-
Identity is per object, not per value:
dupproduces 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, soequal?is true whenever==is. Integers haveobject_idof2 * n + 1(here1is3and2is5); the ids ofnil,trueandfalseare 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 notequal?. -
Since Ruby 2.7 an
object_idis 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._id2refconverts an id back into an object, but it is deprecated in 4.0 (it needs an internal table that costs memory and speed) and printswarning: ObjectSpace._id2ref is deprecatedunder-w. Use anObjectSpace::WeakMaporObjectSpace::WeakKeyMapwhen 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")istruethere; without the comment, and forString.new,dupand+@, 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 isequal?. 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 == xistrue(with the one numeric exception ofNaN, see below). -
Symmetric:
a == bandb == aagree. -
Transitive:
a == bandb == cimplya == c. -
Consistent: the answer does not change unless an object is mutated.
-
Total:
x == nilandx == other_kind_of_objectreturnfalse; 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?andhash. 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 ineql?. Convert only incoerceand<=>of numeric-like classes, see Conversions and Coercion. The coreString#==is the one well-known exception that looks atto_str: if the argument is not aStringbut responds toto_str, it returnsother == self. -
Compare by
==the attributes whose own class defines value equality. Comparing anIsbnto aStringis a smell, not a feature. -
Do not mutate in
==; keep it cheap. It is called insideinclude?,index,deleteandComparable. -
Prefer a
StructorDatawhen all you need is "equal when all members are equal": they define==,eql?,hashandinspectcorrectly, 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==andArray#eql?witheql?, so[1, 2] == [1.0, 2.0]while[1, 2].eql?([1.0, 2.0])isfalse.Range#==andRange#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==. ASetis equal to another set with the same members, whatever their order; and aSetcan itself be a member of aSetbecause it defineshashandeql?. -
A
Symbolis never equal to aStringwith the same text, andequal?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 bySymbol#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:"é"(aneplus a combining accent) and the precomposed"é"differ until you callunicode_normalize. See Encodings and Unicode and Strings. -
String#casecmpandcasecmp?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".classistrue, andinstance_of?andis_a?are different questions (exact class versus class-or-subclass-or-module, akind_of?alias exists foris_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 <=> Numericis-1, and unrelated classes givenil);Moduledoes not includeComparable, it defines these itself. -
Exception#==is true for two exceptions of the same class with equal messages and backtraces;eql?stays identity. -
Two
Methodobjects are equal when they have the same receiver object and the same definition. A different (even equal) receiver givesfalse, as thedupof the string does. -
Since Ruby 3.0,
Proc#==andProc#eql?are defined: a proc is equal to its owndup, but two procs created from separate literals with identical bodies are not equal. -
Time#==andTime#eql?compare the instant, including sub-second precision, not the time zone offset: the same instant in UTC and in+09:00is==andeql?. 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)thena.hash == b.hash. The reverse need not hold (collisions are allowed), but a goodhashspreads values over the integers. -
eql?is at least as strict as==: objects that areeql?are==. The other direction does not hold (1 == 1.0but1.eql?(1.0)isfalse), and the core library keeps it that way on purpose so that{1 => :a}[1.0]isnil. -
hashreturns anInteger, is stable for as long as the object is not mutated in a wayeql?can see, and may change from one run of the program to the next: Ruby seeds its hash function randomly per process. Never persist ahash, 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#hashandKernel#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
Source: Hash: Hash Key Equivalence
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].hashdoes.Array#hashcombines 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. Includingself.classkeeps different classes with equal fields apart. -
Every attribute used in
hashmust be an object whose ownhashis consistent with itseql?. Floats, strings, symbols, arrays,DataandStructall are; a plain object without its ownhashcontributes its identity. -
Cache the value only for immutable objects (a frozen or
Datainstance); 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
Stringkey is duplicated and frozen on insertion (the first key is notequal?to the original, and it isfrozen?), 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 << 3the array has a differenthash, so the lookup misses (nil) even though the key is in the Hash;rehashrebuilds the index and repairs it. Prefer immutable keys: symbols, integers, frozen strings,Datainstances, frozen arrays. -
Hash#compare_by_identity(andSet#compare_by_identity) replaceseql?/hashwithequal?and the object id, so two equal strings are distinct keys. It exists for caches and graphs keyed by object, andcompare_by_identity?reports the mode. RuboCop’sLint/HashCompareByIdentityflags the older idiom of usingobject_idas a Hash key. -
Numeric keys follow
eql?:1and1.0are distinct keys and1and1rtoo;0.0and-0.0are one key (they areeql?) even though they are different objects;NaN, which is not==to itself, can be found by the very same object (identity is checked beforeeql?) but not by another NaN. -
Hash#==andHash#hashignore insertion order, whileto_akeeps it; for an Array,[1, 2].hashand[2, 1].hashdiffer.
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 |
|---|---|
|
|
|
|
Hash keys, |
|
|
|
|
|
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.3isfalsein 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::EPSILONis only right near1.0: the million-sized example fails with it), or use exact arithmetic. RuboCop’sLint/FloatComparisonflags==and!=on float literals and recommends exactly that. Tests useassert_in_deltaorassert_in_epsilonin Minitest, see Testing with Minitest. -
Exact decimal values belong in
Rational(0.1r + 0.2r == 0.3ristrue) or in thebigdecimalbundled gem, see Numbers. Mixed comparison converts:0.1 == 1/10ristruebecause the Rational is compared as a Float, although the exact value of the float (0.1.to_r) is not1/10r;rationalizefinds the simplest rational within the float’s precision. -
An
Integerand aFloatcompare exactly in Ruby:(2**53) + 1is not equal to its ownto_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)and1 == 1.0are all true, whileeql?isfalsefor each pair of different classes. -
NaNis the one value that is not equal to itself:Float::NAN == Float::NANandeql?arefalse; test for it withnan?.NaN <=> xisnil, so it breakssortandmin;Float::INFINITY == Float::INFINITYistrue. -
0.0 == -0.0and0.0.eql?(-0.0)aretruealthoughequal?says they are different objects; a Hash treats them as one key. -
Rounding is not equality:
12.5.round(half: :even)is12, and3.7.round == 4compares integers. Round money withRationalor 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, aData, is equal to anotherBookwith equal members:==compares each member with==,eql?andhashwitheql?andhash. TwoDataclasses or twoStructclasses 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)istrue, the same pair witheql?isfalse, and so they are different Hash keys. -
Datainstances are frozen (dune.frozen?istrue), so a key cannot change; aStructcan, as the last lines show: afterpt.x = 9the lookup misses untilrehash. If aStructmust be a key,freezeit. -
Both define the comparison on the members in declaration order only; neither defines
<=>or includesComparable. To sort them, give an explicit key (sort_by { [it.year, it.title] }, using theitparameter from 3.4), or includeComparableon aDataclass as in the next section. -
Overriding
==in aDataorStructis possible but starts to fight the generatedeql?andhash. 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 |
|---|---|
|
|
|
|
|
matches a |
|
calls the proc with the value, so a lambda works as a predicate in |
|
|
|
|
|
|
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#grepandgrep_v(with a block to transform the matches), and the predicate formsall?,any?,none?,one?with a pattern argument:[3, 5, 8].any?(4..6)is clearer than a block.count(obj),include?anddeletetake a value and use==, not===. -
Value patterns in
case/inandpattern in valueexpressions 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#<⇒
-
nilmeans "not comparable", and it is a real result:"a" <=> 1,Float::NAN <=> 1andObject.new <=> Object.newall givenil.Array#<=>compares element by element and then by length ([1, 2] <=> [1]is1), and returnsnilas soon as two elements cannot be compared ([1, "a"] <=> [1, 2]). -
Operations that need an order (
sort,min,max,minmax,sort_by) raiseArgumentErrorwithcomparison of X with Y failedwhen they meet anil. Mixed arrays of integers and strings, ornil, are the common trigger; the message names the class of the receiver and the class (ornil, or the inspected value) of the other operand. -
sortuses<=>by default and a block can replace it (sort { |x, y| y <=> x }for descending, thoughsort.reverseorsort_bywith a negated key is faster).sort_bycomputes 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 (
Duneandduneundersort_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_byandminmaxuse 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
<=>ofEditionreturnsnilfor anything that is not anEdition. That is the protocol:Comparable#==then returnsfalse(no exception), while<,<=,>and>=raiseArgumentErrorwithcomparison of Edition with 1965 failed. Returningnilis better than raising from<=>: since Ruby 2.3Comparable#==no longer rescues exceptions raised inside<=>, so a raising<=>makes==raise too. -
Comparableis only as good as the<=>it is given: the result must be consistent (antisymmetric, transitive) and, if you also define==,eql?andhash, must return0exactly 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’sStyle/ComparableBetweensuggests it over a chain of comparisons, andStyle/ComparableClampsuggestsclampover[[x, min].max, max].min. -
clampis 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
Source: Comparable#clamp
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?,hashand<=>consistent:a.eql?(b)implies equal hashes anda == b, anda == bimplies(a <=> b).zero?. For a value object whose fields are all compared, the converse holds too. -
Do not include
Comparableto 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 aSetoruniqimplemented 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 withsort_byand 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 (
Editionabove) can derive both from the same array of attributes, which keeps them consistent by construction. -
Comparableprovides noeql?and nohash. If the class is a Hash key, define them (or use aData, 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 |
|---|---|
|
|
|
|
|
|
|
|
direct calls of |
|
|
|
|
|
|
|
a manual |
|
a manual |
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:
-
DataandStruct. Older examples subclass the result ofStruct.neworData.defineand passkeyword_init: true. Assign the class instead (Isbn = Data.define(:digits) do ... end); keyword construction works without the option since 3.2, andDatasupplies equality,eql?,hashand a readableinspectfor free. -
Comparableas a value-object recipe. Writinginclude Comparableplus a<=>over a few fields to get an equal value object conflates equality with ordering (see above): the resulting==changes with the ordering fields, andeql?andhashare missing. UseDatafor equality and addComparableonly 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) becameundefined method 'x' for nil(3.3 and 3.4), and comparison failures readcomparison of Integer with String failed. comparison failures readcomparison of Integer with String failed. -
Version history. Pattern matching (which uses
===) was experimental in 2.7 and stable in 3.0;Symbol#to_procdates to 1.8.7; a numbered parameter_1was extended by theitparameter in 3.4. -
DateTimeandOpenStructare not recommended for value objects;TimeandDatareplace them. -
Singletons and memoisation. A class-level
@instance ||= newis 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 withouteql?andhash: lookups,uniqandSetsilently ignore the equality. -
eql?andhashdefined on mutable attributes, then used as Hash keys: freeze the object or rebuild the index withrehash. -
==usingis_a?in a class hierarchy that adds state: not symmetric; useinstance_of?. -
==that raises for an unexpected argument (other.isbnonnil): check the type first. -
Comparablewithout<=>returningnilfor foreign types:<raisescomparison of ... failedat an awkward place. -
Comparableused to get==: see above. -
Comparing floats with
==: use a tolerance orRational. -
NaN: not equal to anything, breakssort; guard withnan?. -
1and1.0as separate keys;1 == 1.0yet[1] - [1.0]does not remove anything. -
Strings: encoding and normalisation differences make visually equal text unequal.
-
Comparing
object_idvalues, relying on their numeric value, or usingObjectSpace._id2ref. -
Persisting or comparing
hashvalues across runs; they are randomised per process. -
Sorting with
sort_byreturningnil(a missing field):ArgumentError; use a default orto_s. -
Overriding
equal?or!=.
Version notes
| Version | Change |
|---|---|
2.3 |
|
2.4 |
|
2.7 |
|
3.0 |
|
3.2 |
|
3.3 |
|
3.4 |
|
4.0 |
|
4.1 (upcoming) |
|
References
-
Object, BasicObject and Kernel
-
Object#eql?, Object#hash, Object#===, Object#<⇒ and BasicObject#equal?
-
Comparable, including clamp and between?
-
Hash Key Equivalence, Hash#compare_by_identity and Hash#rehash
-
Release notes: Ruby 4.0