Skip to content

1 + 1.5 works because Integer asks Float to coerce

Level: 201 · anyone writing a class that should mix with numbers

One line: When Integer#+ meets an operand it does not know, it calls that object's coerce and adds the pair it gets back — which is how 1 + 1.5, 1 + 1/2r and 2 * Meters.new(3) all work, and why 1 + "1" and "1" + 1 fail with two different TypeErrors; Python does the same job with __radd__ and NotImplemented.

An operator in Ruby is a method on its left operand, so 1 + x is 1.+(x) and Integer#+ decides what happens. For an Integer, Float, Rational or Complex argument it knows the answer and promotes to the wider class. For anything else it asks the argument: x.coerce(1) must return a two-element array [a, b] of objects that can be combined, and Ruby then computes a + b. Float#coerce(1) returns [1.0, 2.5] — both Floats — and that is the whole of 1 + 1.5. A class of yours gets the same treatment: define coerce and 2 * Meters.new(3) works, with the operands in the order coerce returns them. A String defines no coerce, so 1 + "1" raises TypeError: String can't be coerced into Integer; "1" + 1 fails on the other side, inside String#+, with no implicit conversion of Integer into String — and that message names the other protocol, the implicit conversions to_str and to_int, which core methods call on an argument that must be a String or an Integer. Arithmetic never uses those; it uses coerce.

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

 1. 1 + Rational(1, 2)           -> (3/2)                        the Integer is promoted to the wider class: Rational
 2. 1 + 1.5                      -> 2.5                          Float
 3. Rational(1, 2) + 0.5         -> 1.0                          Rational meets Float: Float wins, and exactness is gone
 4. 1 + 2i                       -> (1+2i)                       Complex; every pairing has a wider class to go to
 5. 1.coerce(2.5)                -> [2.5, 1.0]                   what Integer#+ asks of a Float: both as Floats, the argument first
 6. 2.5.coerce(1)                -> [1.0, 2.5]                   Float#coerce makes the Integer a Float
 7. m * 2                        -> #<Meters 6>                  our own *, with a Numeric argument
    Meters#coerce(2) called on #<Meters 3>
 8. 2 * m                        -> #<Meters 6>                  Integer#* did not know Meters, so it called m.coerce(2) and multiplied the pair
    Meters#coerce(2) called on #<Meters 3>
 9. 2 - m                        -> #<Meters -1>                 coerce returned [Meters(2), m], so the operands kept their order
10. 1 + "1"                      -> TypeError: String can't be coerced into Integer a String has no coerce
11. "1" + 1                      -> TypeError: no implicit conversion of Integer into String and String#+ wants a String: the other message
12. Integer("1") + 1             -> 2                            convert explicitly; "1" + 1.to_s is "11"
13. "a" + Name.new               -> "aBob"                       to_str is the implicit conversion to String
14. [10, 20, 30][Index.new]      -> 20                           to_int is the implicit conversion to Integer
15. 1 + Index.new                -> TypeError: Index can't be coerced into Integer arithmetic asks for coerce, never for to_int
16. Integer.ancestors.take(3)    -> [Integer, Numeric, Comparable] every number is a Numeric and Comparable; 1.is_a?(Numeric) is true

Reading the output

Rows 1–4 are the built-in promotions: an Integer meeting a Rational, a Float or a Complex becomes the wider class, and a Rational meeting a Float becomes a Float — exactness is lost the moment a Float enters. Rows 5 and 6 show the protocol itself: 1.coerce(2.5) is [2.5, 1.0] and 2.5.coerce(1) is [1.0, 2.5], the pair that Integer#+ and Float#+ actually add, with both members made Float.

Rows 7–9 are our own class. m * 2 needs nothing special: Meters#* takes a number. 2 * m is the interesting direction — Integer#* has never heard of Meters, so it calls m.coerce(2) (the indented line, printed from inside coerce), receives [Meters(2), m], and multiplies those. Because coerce puts the number first and self second, 2 - m comes out as Meters(2) - m, which is -1: the right answer, with the operands in the order the reader wrote them. A coerce that returned [self, numeric] would silently compute m - 2 instead — the second exercise.

Rows 10–12 are the two refusals. 1 + "1" is Integer's message, because the String had no coerce; "1" + 1 is String's, which wanted an implicit conversion. Both are fixed by converting explicitly: Integer("1"), to_i, to_s. Rows 13–15 are that other protocol: an object with to_str is accepted wherever a String is required ("a" + Name.new), one with to_int wherever an Integer index or count is required ([10, 20, 30][Index.new]), and neither is consulted by arithmetic — 1 + Index.new still asks for coerce. Row 16 is the family tree: every built-in number is a Numeric, and therefore Comparable.

Compared with Python

Python runs the same negotiation under different names. int.__add__(1, 2.5) does not raise — it returns the sentinel NotImplemented, and the interpreter then tries the reflected method on the other operand, float.__radd__(2.5, 1), which answers 3.5. A class joins in by defining __rmul__, __radd__ and __rsub__ (rows 7–9): 2 * Meters(3) reaches Meters.__rmul__(2), and __rsub__ receives the left operand as its argument, so keeping the order right is the method's own job rather than a matter of which pair coerce returns. When both sides return NotImplemented, Python raises TypeError (rows 10 and 11); the twin prints the type only, because CPython rewords the messages between releases. The implicit protocols differ more: Python has __index__ for an integer index or count (row 14) but nothing like to_str — "a" + Name() is a TypeError, and an f-string calls __str__ instead (row 13). And there is no Numeric base class: int's MRO is just object, and the numbers ABCs (Number, Real, Rational, Integral) are how you ask whether something is a number.

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

 1. 1 + Fraction(1, 2)           -> Fraction(3, 2)               the int is promoted to the wider type: Fraction
 2. 1 + 1.5                      -> 2.5                          float
 3. Fraction(1, 2) + 0.5         -> 1.0                          Fraction meets float: float wins, and exactness is gone
 4. 1 + 2j                       -> (1+2j)                       complex; every pairing has a wider type to go to
 5. (1).__add__(2.5)             -> NotImplemented               int declines a float: NotImplemented, not an error
 6. (2.5).__radd__(1)            -> 3.5                          so Python asks the float's reflected method, and that answers
 7. m * 2                        -> Meters(6)                    our own __mul__, with a number
    Meters.__rmul__(2) called on Meters(3)
 8. 2 * m                        -> Meters(6)                    int.__mul__(2, m) returned NotImplemented, so Meters.__rmul__(2) ran
    Meters.__rsub__(2) called on Meters(3)
 9. 2 - m                        -> Meters(-1)                   __rsub__ gets the left operand as its argument and keeps the order itself
10. 1 + "1"                      -> TypeError                    str has no __radd__ for an int
11. "1" + 1                      -> TypeError                    and str.__add__ wants a str
12. int("1") + 1                 -> 2                            convert explicitly; "1" + str(1) is '11'
13. "a" + Name()                 -> TypeError                    no implicit conversion to str; f"a{Name()}" is 'aBob', via __str__
14. [10, 20, 30][Index()]        -> 20                           __index__ is the implicit conversion to an int
15. 1 + Index()                  -> TypeError                    arithmetic asks for __add__ and __radd__, never for __index__
16. [c.__name__ for c in int.__mro__] -> ['int', 'object']            no Numeric class; the numbers ABCs instead: isinstance(1, numbers.Number) is True

Try it

  1. Write a Celsius class with +, *, <=> and coerce so that c + 1, 1 + c, c * 2, 2.5 * c, c > 15 and [Celsius.new(30), c, Celsius.new(-5)].sort all work (include Comparable gives you >, between? and clamp from <=>), then show what c + "hot" raises.
  2. Change Meters#coerce to return [self, numeric] and print 2 - Meters.new(3) and 2 * Meters.new(3): which answer changes, and why only that one? Then give Meters a to_int and check whether 2 * Meters.new(3) or [1, 2, 3][Meters.new(1)] starts using it.
Solution to 1

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

# Kata: a Celsius class that mixes with plain numbers on either side —
# c + 1, 1 + c, c * 2, 2.5 * c — and compares and sorts, because coerce
# and <=> are defined and Comparable is included.

class Celsius
  include Comparable
  attr_reader :degrees

  def initialize(degrees) = @degrees = degrees
  def +(other) = Celsius.new(degrees + Celsius.degrees_of(other))
  def *(other) = Celsius.new(degrees * Celsius.degrees_of(other))
  def <=>(other) = degrees <=> Celsius.degrees_of(other)
  def coerce(number) = [Celsius.new(number), self]
  def self.degrees_of(x) = x.is_a?(Celsius) ? x.degrees : x
  def to_s = "#{degrees} C"
  def inspect = "#<Celsius #{degrees}>"
end

c = Celsius.new(20)
puts "1. c + 1    -> #{c + 1}"
puts "2. 1 + c    -> #{1 + c}       (Integer#+ called c.coerce(1))"
puts "3. c * 2    -> #{c * 2}"
puts "4. 2.5 * c  -> #{2.5 * c}     (Float#* called c.coerce(2.5))"
puts "5. c > 15   -> #{c > 15}, c.between?(10, 30) -> #{c.between?(10, 30)}   (Comparable, built on <=>)"
puts "6. sort     -> #{[Celsius.new(30), c, Celsius.new(-5)].sort.inspect}"
puts "7. max      -> #{[c, Celsius.new(30)].max}, c.clamp(0, 10) -> #{c.clamp(0, 10)}"
begin
  c + "hot"
rescue TypeError => e
  puts "8. c + \"hot\" -> #{e.class}: #{e.message}"
end

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

1. c + 1    -> 21 C
2. 1 + c    -> 21 C       (Integer#+ called c.coerce(1))
3. c * 2    -> 40 C
4. 2.5 * c  -> 50.0 C     (Float#* called c.coerce(2.5))
5. c > 15   -> true, c.between?(10, 30) -> true   (Comparable, built on <=>)
6. sort     -> [#<Celsius -5>, #<Celsius 20>, #<Celsius 30>]
7. max      -> 30 C, c.clamp(0, 10) -> 10
8. c + "hot" -> TypeError: String can't be coerced into Integer

See also