Operator Overloading

Published by Programming Line in Ruby. Last updated .

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:

  1. 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 as a.+(b.*(c)): two nested method calls.
  2. Dispatch (innermost first): Ruby evaluates b.*(c). It looks up a method named * starting at b‘s singleton class, then b‘s class, then any modules that class includes (most recently included first), then up the superclass chain toward Object. The first matching * method found wins — ordinary method lookup, nothing operator-specific about it.
  3. Outer call: The result of b.*(c) becomes the argument to a.+(...), 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 and String operators behave.
  • Always return self from << so chained calls like obj << a << b keep working.
  • If instances of your class will be used as Hash keys or stored in a Set, define eql? and hash alongside == so lookups behave consistently.
  • Implement <=> once and include Comparable instead of hand-writing <, >, <=, and >= individually.
  • Guard the type of other inside == and <=> (e.g. with is_a?) rather than assuming it’s always the same class, and return nil from <=> 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 often inspect) so values built from your overloaded operators print in a readable way.

Practice Exercises

  1. Write a Fraction class storing numerator and denominator. Overload +, -, *, /, and == so that Fraction.new(1, 2) + Fraction.new(1, 3) produces a fraction equivalent to 5/6, and give it a to_s like "5/6". Hint: use Integer#gcd to reduce to lowest terms after each operation.
  2. Write a Temperature class that stores degrees Celsius. Implement <=>, include Comparable, and confirm that [Temperature.new(30), Temperature.new(-5), Temperature.new(10)].sort produces the temperatures from coldest to hottest without writing < or > yourself.
  3. Extend the Playlist class from Example 3 with a + method that concatenates two playlists into a new one (without mutating either original), and an each method so you can include Enumerable and call .map/.select on a playlist directly.

Summary

  • Operators in Ruby are ordinary method calls; a + b is really a.+(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 including Comparable gives you <, >, <=, >=, ==, and between? 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 == with eql? and hash whenever instances might be used as Hash keys.