Skip to content

Operators are methods, except the ones that are not

Level: 201 · a reader who has written __add__ in Python

One line: +, -@, [], []=, <=>, << and even ! are ordinary methods you define with def, and 1 + 2 is 1.+(2) — but &&, ||, =, ?:, .., and, or and not are syntax and cannot be defined or looked up.

When Ruby sees a + b it sends the message + to a with b as the argument; the operator table is just spelling rules for method names. So a class defines + with def +(other), unary minus as def -@, indexing as def [] and def []=, and gets the whole family of comparisons by defining <=> and including Comparable. Even ! is a method (BasicObject#!), which is why !obj and not obj can be overridden. The exceptions are the operators whose meaning depends on not evaluating an operand or on the parser: short-circuit &&/|| (and their word forms), assignment =, the conditional ?:, and ranges ... Those are not messages; 1.method(:"&&") is a NameError and def && does not parse. One more thing to know from Python: a reflected operation (2 * vec) does not look for a __rmul__; Ruby's Integer#* asks the argument for coerce instead.

Verified output of operators_are_methods_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. an operator is a call:         1 + 2 -> 3, 1.+(2) -> 3, 1.send(:+, 2) -> 3, 1.method(:+).owner -> Integer
2. def +(other):                  v + w -> Vec(4, 6), v.+(w) -> Vec(4, 6)
3. def -@ (unary minus):          -v -> Vec(-1, -2)
4. def [] and def []=:            v[0] -> 1; v[1] = 9 evaluates to 9, not the body's :ignored; v is Vec(1, 9)
5. def <=> plus Comparable:       v < w true, v == Vec.new(1, 2) true, v != w true, [w, v].min -> Vec(1, 2), v.clamp(w, w) -> Vec(3, 4)
6. def <<:                        v << 10 -> Vec(11, 12)
7. def ! is a method:             !v -> "bang called on Vec(1, 2)", (not v) -> "bang called on Vec(1, 2)", v.! -> "bang called on Vec(1, 2)"
   every object has one:          1.method(:!).owner -> BasicObject, !1 -> false
8. def * but no coerce:           v * 2 -> Vec(2, 4); 2 * v -> TypeError: Vec can't be coerced into Integer
9. && is not a method:            1.method(:"&&") -> NameError: undefined method '&&' for class 'Integer'
   def &&(other); end            -> SyntaxError
10. Integer responds to:          + - * / % ** == != < <= > >= <=> << >> & | ^ ~ ! -@ +@ [] ===
    syntax, not methods:          && || .. and or not =

Reading the output

Row 1 is the claim at its plainest: 1 + 2, 1.+(2) and 1.send(:+, 2) are one call, owned by Integer. Rows 2, 3 and 6 are Vec's own +, -@ and <<. Row 4 is [] and []= — with a detail from the setters lesson: v[1] = 9 evaluates to 9 no matter that the body returned :ignored. Row 5 is the payoff of <=>: one definition plus include Comparable gives <, ==, !=, min, clamp and the rest. Row 7 shows ! really is a method: !v, not v and the explicit v.! all run our definition and return a String, which no Python not could do.

Row 8 is the reflected case: v * 2 works because Vec#* exists, but 2 * v fails with TypeError: Vec can't be coerced into Integer — Integer#* does not know about Vec and asks it for coerce, which the numbers chapter covers. Row 9 is the boundary: && is not a method name — looking it up is a NameError, defining it is a SyntaxError. Row 10 sorts a fixed list of operator spellings by 1.respond_to?: everything from + to === is a method on Integer; && || .. and or not = are not.

Compared with Python

Python's model is the same one with different spelling: an operator is a call to a "dunder" hook — __add__, __neg__, __getitem__, __setitem__, __eq__, __lt__, __lshift__ — and row 1 shows 1 + 2 as (1).__add__(2). The differences are in the corners. Row 4: v[1] = 9 is a statement in Python, so __setitem__'s return value simply vanishes; Ruby's assignment is an expression that evaluates to the right-hand side. Row 5: there is no <=>; __eq__ and __lt__ plus functools.total_ordering give the family, and without __lt__ a < is a TypeError. Row 7: not v calls __bool__, but can only ever produce a bool — Ruby's ! is a real method that returned a String. Row 8 is the reflected operation Ruby lacks: 2 * v finds __rmul__. Rows 9 and 10 draw the same boundary as Ruby: and, or, not and = are keywords and syntax with no hook, while & has __and__.

Verified output of operators_are_methods_py.py — regenerated by tools/run_examples.py, never hand-typed.

1. an operator is a call:         1 + 2 -> 3 , (1).__add__(2) -> 3 , int.__add__(1, 2) -> 3 , type((1).__add__).__name__ -> method-wrapper
2. def __add__:                   v + w -> Vec(4, 6) , v.__add__(w) -> Vec(4, 6)
3. def __neg__:                   -v -> Vec(-1, -2)
4. __getitem__ and __setitem__:   v[0] -> 1 ; v[1] = 9 is a statement with no value (the 'ignored' return is discarded); v is Vec(1, 9)
5. __eq__ and __lt__ (+ total_ordering): v < w True , v == Vec(1, 2) True , v != w True , min([w, v]) -> Vec(1, 2) , v > w False
   without __lt__:               NoLt() < NoLt() -> TypeError
6. def __lshift__:                v << 10 -> Vec(11, 12)
7. not calls __bool__:            not v -> True (__bool__ ran 1 time and could only return a bool)
8. __mul__ and __rmul__:          v * 2 -> Vec(2, 4) ; 2 * v -> Vec(2, 4) (the reflected hook answers)
9. and is not a hook:             b & b -> bitwise & called ; b and 'second' -> 'second' (__and__ not called)
   keywords, not methods:         and True , or True , not True
10. int has a hook for:           + - * / % ** == != < <= > >= << >> & | ^ ~ -@ +@
    no hook (syntax, or not Python): <=> ! [] === && || .. and or not =

Try it

  1. Write a Money class holding cents, with +, unary -, <=> and to_s, and include Comparable. Print a sum, a negation, a sorted array, max, <, ==, !=, between? and clamp — and print which module owns the < you never defined.
  2. Add [] to Money so that m[:dollars] and m[:cents] split the amount, then []= so that m[:cents] = 5 changes it. Print what m[:cents] = 5 evaluates to when the body of []= returns :nope. Finally try def &&(other) inside the class and read the SyntaxError.
Solution to 1

operators_are_methods_kata_rb.rb in full — pasted here by tools/run_examples.py from the file CI runs.

# Kata: a Money class whose +, unary -, and <=> are methods, with Comparable
# supplying <, ==, between? and clamp from the one <=> definition.
class Money
  include Comparable
  attr_reader :cents

  def initialize(cents) = @cents = cents
  def +(other) = Money.new(cents + other.cents)
  def -@ = Money.new(-cents)
  def <=>(other) = cents <=> other.cents
  def to_s = format("$%.2f", cents / 100.0)
  def inspect = to_s
end

a, b, c = Money.new(500), Money.new(1250), Money.new(99)
puts "1. a + b                    -> #{a + b}"
puts "2. -a                       -> #{-a}"
puts "3. [b, a, c].sort           -> #{[b, a, c].sort.inspect}"
puts "4. [b, a, c].max            -> #{[b, a, c].max}"
puts "5. a < b, a == Money.new(500), a != b -> #{a < b}, #{a == Money.new(500)}, #{a != b}"
puts "6. c.between?(a, b)         -> #{c.between?(a, b)}; c.clamp(a, b) -> #{c.clamp(a, b)}"
puts "7. Money.instance_method(:<).owner -> #{Money.instance_method(:<).owner} (Comparable wrote < from <=>)"

Verified output of operators_are_methods_kata_rb.rb — regenerated by tools/run_examples.py, never hand-typed.

1. a + b                    -> $17.50
2. -a                       -> $-5.00
3. [b, a, c].sort           -> [$0.99, $5.00, $12.50]
4. [b, a, c].max            -> $12.50
5. a < b, a == Money.new(500), a != b -> true, true, true
6. c.between?(a, b)         -> false; c.clamp(a, b) -> $5.00
7. Money.instance_method(:<).owner -> Comparable (Comparable wrote < from <=>)

See also