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¶
- Write a
Celsiusclass with+,*,<=>andcoerceso thatc + 1,1 + c,c * 2,2.5 * c,c > 15and[Celsius.new(30), c, Celsius.new(-5)].sortall work (include Comparablegives you>,between?andclampfrom<=>), then show whatc + "hot"raises. - Change
Meters#coerceto return[self, numeric]and print2 - Meters.new(3)and2 * Meters.new(3): which answer changes, and why only that one? Then giveMetersato_intand check whether2 * 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¶
- Operators are methods —
1 + xis1.+(x): why the left operand decides, and which operators are not methods - Duck typing and
respond_to?—to_str,to_aryandto_proc: the implicit-conversion protocol in full 1/3randBigDecimal("0.1")are exact where Float is not — the classes rows 1 and 3 promote to, and what a Float does to a Rational1 == 1.0is true, but{1 => :a}[1.0]isnil— comparison across the same line:==converts,eql?does not- Common error messages —
String can't be coerced into Integerandno implicit conversion of Integer into Stringin the library's table of errors to_inever fails;Integer()does ↗ — the explicit conversions that fix rows 10 and 11, in the Ruby text library7 / 2is3: integer division floors — Ruby's/on two Integers is Python's//