Operator Overloading
Ruby doesn’t have special syntax reserved for operators the way many languages do — +, -, ==, and even [] are just method calls with a friendly spelling. That means any class you write can define what + or <=> means for its own instances, and Ruby will happily dispatch to your method whenever someone writes obj + other. This is operator overloading: giving familiar operators new, type-specific meaning so your objects can be combined, compared, and indexed as naturally as built-in numbers, strings, and arrays.
Overview / How it works
In Ruby, everything is an object — 1.class returns Integer, nil.class returns NilClass, and even the result of an if expression is a value. Operators are no exception: an expression like a + b is parsed by Ruby and then rewritten internally as the method call a.+(b). The + “operator” is really just a method named + defined on a‘s class, looked up exactly the way any other method is looked up — through a‘s singleton class, then its class, then any included modules (most recently included first), then up the superclass chain to Object and BasicObject.
Because operators are ordinary methods, you overload one simply by defining a method with that operator’s name. Ruby reserves special method-name syntax for this: +, -, *, /, %, **, ==, <=>, <, >, <=, >=, [], []=, <<, >>, !, ===, and the unary +@ / -@ are all valid method names you can def. A handful of operators are not overloadable because Ruby handles them at the syntax level rather than as method dispatch: &&, ||, .. / ..., defined?, ::, and plain assignment =. Trying to def one of those raises a SyntaxError.
One especially useful pattern is the “spaceship” operator, <=>. Implement it once, include Comparable in your class, and Ruby derives <, >, <=, >=, ==, between?, and clamp automatically — you never write those methods by hand. It’s the same mixin idea behind Enumerable: define one primitive method and gain a family of related behavior for free.
A subtlety worth knowing: precedence and associativity (whether * binds tighter than +, whether ** is right-associative) are fixed by Ruby’s grammar and are the same for every class — you can change what an operator does, but never how tightly it binds. There’s also coerce: when a built-in numeric like Integer or Float is the left-hand operand and doesn’t know how to combine with your object (e.g. 3 + my_vector), Ruby’s numeric classes call my_vector.coerce(3) to ask your object to convert the pair into a compatible form. Without a coerce method, my_vector + 3 works fine (your + method handles it) but 3 + my_vector raises a TypeError.
Syntax
Overloading an operator is just defining a regular instance method whose name is the operator symbol:
class ClassName
def +(other)
# return a new object representing the sum
end
def -@
# unary minus, e.g. -obj
end
def [](key)
# obj[key]
end
def []=(key, value)
# obj[key] = value
end
end
| Operator | Method to define | Notes |
|---|---|---|
+ - * / % ** |
def +(other) etc. |
a + b is rewritten as a.+(b) |
== |
def ==(other) |
value equality; guard the argument’s type |
<=> |
def <=>(other) |
returns -1, 0, 1, or nil; pair with include Comparable |
[] []= |
def [](k) / def []=(k, v) |
indexing, like a collection |
<< |
def <<(item) |
append/shovel; should return self for chaining |
+@ -@ |
def +@ / def -@ |
unary +obj, -obj — no arguments |
! |
def ! |
!obj |
=== |
def ===(other) |
used implicitly by case/when |
Examples
Example 1: Arithmetic operators on a Vector2D
class Vector2D
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def +(other)
Vector2D.new(x + other.x, y + other.y)
end
def -(other)
Vector2D.new(x - other.x, y - other.y)
end
def *(scalar)
Vector2D.new(x * scalar, y * scalar)
end
def ==(other)
other.is_a?(Vector2D) && x == other.x && y == other.y
end
def to_s
"(#{x}, #{y})"
end
end
a = Vector2D.new(2, 3)
b = Vector2D.new(4, 1)
puts "a + b = #{a + b}"
puts "a - b = #{a - b}"
puts "a * 3 = #{a * 3}"
puts "a == Vector2D.new(2, 3): #{a == Vector2D.new(2, 3)}"
Output:
a + b = (6, 4)
a - b = (-2, 2)
a * 3 = (6, 9)
a == Vector2D.new(2, 3): true
Each binary operator returns a brand-new Vector2D rather than mutating a or b — exactly what you’d expect from + on numbers. to_s is called implicitly by string interpolation, so #{a + b} prints the readable (6, 4) form. The overloaded == checks both the class and the coordinates, so two separately-constructed vectors with the same x/y compare equal.
Example 2: Comparable objects with the spaceship operator
class Money
include Comparable
attr_reader :cents
def initialize(dollars)
@cents = (dollars * 100).round
end
def <=>(other)
cents <=> other.cents
end
def to_s
format("$%.2f", cents / 100.0)
end
end
prices = [Money.new(19.99), Money.new(5.50), Money.new(42.00)]
sorted = prices.sort
sorted.each { |m| puts m }
cheap = Money.new(5.50)
expensive = Money.new(42.00)
puts "cheap < expensive: #{cheap < expensive}"
puts "cheap.between?(Money.new(1), Money.new(10)): #{cheap.between?(Money.new(1), Money.new(10))}"
Output:
$5.50
$19.99
$42.00
cheap < expensive: true
cheap.between?(Money.new(1), Money.new(10)): true
Money defines only <=>, comparing the internal cents integers. Because it also includes Comparable, that single method is enough to make Array#sort work, and to make <, >, and between? available with no further code. Storing amounts as integer cents (rather than floats) avoids floating-point rounding surprises when comparing money.
Example 3: Collection-style operators — [], []=, <<, and unary -@
class Playlist
def initialize
@songs = []
end
def [](index)
@songs[index]
end
def []=(index, song)
@songs[index] = song
end
def <<(song)
@songs << song
self
end
def -@
Playlist.new.tap { |p| @songs.reverse_each { |s| p << s } }
end
def to_s
@songs.join(", ")
end
end
list = Playlist.new
list << "Intro" << "Verse" << "Chorus"
list[1] = "Bridge"
puts list
puts -list
Output:
Intro, Bridge, Chorus
Chorus, Bridge, Intro
Overloading << and returning self is what lets list << "Intro" << "Verse" << "Chorus" chain like the built-in Array#<< does. []= lets you assign by index just like a real array. The unary -@ method overloads the prefix minus, so -list builds and returns a brand-new, reversed Playlist without touching the original list.
How it works step by step
Take the expression a + b * c where a, b, and c are instances of a custom class. Ruby resolves this in two distinct phases:
- Parsing: Ruby’s grammar decides precedence purely syntactically —
*binds tighter than+for every class, always, no matter what methods you define. The expression is rewritten internally asa.+(b.*(c)): two nested method calls. - Dispatch (innermost first): Ruby evaluates
b.*(c). It looks up a method named*starting atb‘s singleton class, thenb‘s class, then any modules that class includes (most recently included first), then up the superclass chain towardObject. The first matching*method found wins — ordinary method lookup, nothing operator-specific about it. - Outer call: The result of
b.*(c)becomes the argument toa.+(...), which is looked up and dispatched the same way.
This is why overloaded operators respect inheritance and mixins exactly like any other method call: a subclass can override +, call super to reuse the parent class’s arithmetic and then adjust the result, and a module can supply one <=> that every including class shares via Comparable.
Common Mistakes
Mistake 1: Overloading == without eql? and hash
== is used for value comparisons in general code, but Hash and Set lookups use eql? and hash instead. If you only override ==, two “equal” objects can still fail to find each other as hash keys, because the default hash is based on each object’s identity:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(other)
other.is_a?(Point) && x == other.x && y == other.y
end
end
seen = {}
seen[Point.new(1, 2)] = "first"
puts seen[Point.new(1, 2)].inspect
Output:
nil
Even though Point.new(1, 2) == Point.new(1, 2) is true, the second lookup misses because the two instances have different default hash values. The fix is to also define eql? (used by Hash for key comparison) and hash (used to pick the bucket) so they agree with ==:
class Point
attr_reader :x, :y
def initialize(x, y)
@x = x
@y = y
end
def ==(other)
other.is_a?(Point) && x == other.x && y == other.y
end
alias eql? ==
def hash
[x, y].hash
end
end
seen = {}
seen[Point.new(1, 2)] = "first"
puts seen[Point.new(1, 2)].inspect
Output:
"first"
Mistake 2: Mutating self inside a binary operator
Callers assume a + b produces a new value, the same way 1 + 2 never changes 1. If your + mutates the receiver and returns self instead of building a new object, every reference to the original object silently changes too:
class Counter
attr_accessor :value
def initialize(value)
@value = value
end
def +(amount)
@value += amount
self
end
end
base = Counter.new(10)
a = base + 5
b = base + 3
puts a.value
puts b.value
puts a.equal?(b)
Output:
18
18
true
Because + mutated @value on the receiver and returned self, a and b both end up pointing at the very same object as base — a.equal?(b) is true, and both additions stacked onto one shared counter instead of producing two independent results. The fix is to return a new instance and leave the receiver untouched:
class Counter
attr_accessor :value
def initialize(value)
@value = value
end
def +(amount)
Counter.new(@value + amount)
end
end
base = Counter.new(10)
a = base + 5
b = base + 3
puts a.value
puts b.value
puts base.value
puts a.equal?(b)
Output:
15
13
10
false
Best Practices
- Only overload an operator when its meaning genuinely matches the reader’s intuition (
+for combining,<=>for ordering) — don’t repurpose+for something unrelated just because it reads nicely. - Prefer returning a new object over mutating the receiver in binary operators like
+,-, and*, matching how Ruby’s own numeric andStringoperators behave. - Always
return selffrom<<so chained calls likeobj << a << bkeep working. - If instances of your class will be used as
Hashkeys or stored in aSet, defineeql?andhashalongside==so lookups behave consistently. - Implement
<=>once andinclude Comparableinstead of hand-writing<,>,<=, and>=individually. - Guard the type of
otherinside==and<=>(e.g. withis_a?) rather than assuming it’s always the same class, and returnnilfrom<=>when the comparison doesn’t make sense. - Remember that
&&,||,../..., and plain=can never be overloaded — Ruby handles those at the parser level, not through method dispatch. - Give your class a meaningful
to_s(and ofteninspect) so values built from your overloaded operators print in a readable way.
Practice Exercises
- Write a
Fractionclass storingnumeratoranddenominator. Overload+,-,*,/, and==so thatFraction.new(1, 2) + Fraction.new(1, 3)produces a fraction equivalent to 5/6, and give it ato_slike"5/6". Hint: useInteger#gcdto reduce to lowest terms after each operation. - Write a
Temperatureclass that stores degrees Celsius. Implement<=>,include Comparable, and confirm that[Temperature.new(30), Temperature.new(-5), Temperature.new(10)].sortproduces the temperatures from coldest to hottest without writing<or>yourself. - Extend the
Playlistclass from Example 3 with a+method that concatenates two playlists into a new one (without mutating either original), and aneachmethod so you caninclude Enumerableand call.map/.selecton a playlist directly.
Summary
- Operators in Ruby are ordinary method calls;
a + bis reallya.+(b), dispatched through the normal ancestor chain. - You overload an operator by defining an instance method whose name is the operator symbol, such as
+,==,[], or<<. &&,||,../..., and=can never be overloaded — Ruby handles them at the syntax level.- Defining
<=>and includingComparablegives you<,>,<=,>=,==, andbetween?for free. [],[]=, and<<let your objects behave like collections; unary+@/-@overload prefix+obj/-obj.- Prefer returning new objects over mutating the receiver, and pair a custom
==witheql?andhashwhenever instances might be used asHashkeys.
