Skip to content

1/3r and BigDecimal("0.1") are exact where Float is not

Level: 201 · anyone who has been bitten by 0.1 + 0.2

One line: Ruby ships two exact number types beside Float — Rational, with a literal suffix (1/3r, 0.1r), and BigDecimal, a default gem with decimal digits and a rounding mode — so 0.1 + 0.2 == 0.3 is true in both; Python's Fraction and Decimal are their twins, except that Python rounds a half to even by default and refuses to mix a Decimal with a float where Ruby converts.

A Rational is a pair of Integers, numerator over denominator, and every operation between Rationals is exact: 1/3r + 1/6r is (1/2) and (1/3r) * 3 is 1. The r suffix makes a literal — 0.1r is exactly one tenth, where Rational(0.1) is the Float's exact binary value — and Rational(1, 3), Rational("1/3") and 3.to_r are the other spellings. A BigDecimal holds decimal digits with an arbitrary precision, so BigDecimal("0.1") + BigDecimal("0.2") is BigDecimal("0.3") — the case Float gets wrong — and it carries the rounding modes money needs (ROUND_HALF_UP by default, :half_even on request); division takes a precision, because one third has no finite decimal form. Both classes are Numeric and Comparable, both drop to a Float on to_f or when a Float enters the arithmetic, and Complex completes the tower with 2i as a literal.

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

 1. 1/3r                                   -> (1/3)                  the r suffix makes a Rational literal; class Rational
 2. Rational(1, 3), Rational("1/3"), 3.to_r, 0.1r -> (1/3), (1/3), (3/1), (1/10) four spellings; 0.1r is exactly one tenth
 3. Rational(0.1)                          -> (3602879701896397/36028797018963968) from a Float you get the Float's exact value, not one tenth
 4. 1/3r + 1/6r                            -> (1/2)                  exact arithmetic; (1/3r) * 3 == 1 is true
 5. (1/3r).to_f                            -> 0.3333333333333333     a Float on request; 1/3r + 0.5 is 0.8333333333333333, a Float
 6. Rational(5, 2).round                   -> 3                      half away from zero, like Float; round(half: :even) is 2; Rational(1, 3).round(2) is (33/100)
 7. BigDecimal("0.1") + BigDecimal("0.2") == BigDecimal("0.3") -> true                   decimal digits, so there is no binary fraction to miss by
 8. (BigDecimal("0.1") + BigDecimal("0.2")).to_s -> "0.3e0"                to_s is scientific; to_s("F") is "0.3"
 9. BigDecimal("1234.5678").to_s("3F")     -> "1 234.567 8"          grouped by 3; to_f 1234.5678, to_i 1234, to_r (6172839/5000)
10. BigDecimal("2.675").round(2).to_s("F") -> "2.68"                 ROUND_HALF_UP on decimal digits by default; round(2, :half_even) gives 2.68, and 2.665 gives 2.66
11. BigDecimal("1").div(3, 10).to_s("F")   -> "0.3333333333"         division takes a precision; BigDecimal("1") / 3 * 3 == 1 is false, while 1/3r * 3 == 1 is true
12. BigDecimal("0.1") == 0.1               -> true                   and BigDecimal("0.3") == 0.1 + 0.2 is true: the Float is rounded to a short decimal first, not read at its exact binary value
13. BigDecimal("0.1") + 0.1                -> 0.2e0                  a Float operand is converted the same way; class BigDecimal
14. Complex(1, 2) * Complex(1, 2)          -> (-3+4i)                2i is a literal; Complex(3, 4).abs is 5.0; rectangular [1, 2]
15. Math.sqrt(-1)                          -> Math::DomainError: Numerical argument is out of domain - sqrt no Complex unless asked: Complex::I ** 2 is (-1+0i)
16. BigDecimal.ancestors.take(3)           -> [BigDecimal, Numeric, Comparable] Rational, BigDecimal and Complex are all Numeric; Rational.ancestors.take(3) is [Rational, Numeric, Comparable]

Reading the output

Rows 1–6 are Rational. Rows 1 and 2 are the spellings; row 3 is the trap: Rational(0.1) does not give one tenth but the fraction the Float actually holds, because the Float was rounded before Rational ever saw it — 0.1r and Rational("0.1") never go through a Float and are exact. Rows 4 and 5 are arithmetic: exact among Rationals, and a Float the moment a Float joins in (1/3r + 0.5 is a Float). Row 6 is rounding: a Rational rounds like a Float, half away from zero, with half: :even on request, and round(2) gives a Rational back.

Rows 7–13 are BigDecimal. Row 7 is the headline, 0.1 + 0.2 == 0.3. Rows 8 and 9 are its two to_s forms: the default is scientific notation — 0.3e0, which surprises everyone once — and to_s("F") is positional, with an optional group size ("3F") that inserts a space every three digits. Row 10 is rounding on decimal digits with a mode: 2.675 rounds to 2.68 under the default ROUND_HALF_UP and also under :half_even (7 is odd), while 2.665 goes to 2.66 under :half_even. Row 11 is division: div(3, 10) asks for ten digits, and a plain / stops at some precision too, so BigDecimal("1") / 3 * 3 is not 1 — the Rational is. Rows 12 and 13 are how a BigDecimal meets a Float: BigDecimal("0.1") == 0.1 is true and BigDecimal("0.1") + 0.1 is 0.2e0, and BigDecimal("0.3") == 0.1 + 0.2 is true as well, so the Float is rounded to a short decimal — about 16 significant digits (Not machine-checked here.) — before it is compared or added, rather than read at its exact binary value. Rows 14–16 are Complex and the family tree: 2i is a literal, abs is the modulus, Math.sqrt(-1) raises Math::DomainError rather than answer in Complex, and all three classes sit under Numeric and Comparable.

Compared with Python

Fraction is Rational without a literal: Fraction(1, 3), Fraction("1/3") and Fraction("0.1") are exact, and Fraction(0.1) is the same binary value as Rational(0.1). The arithmetic rows agree, including the drop to float when one joins in; the rounding row does not, because round(Fraction(5, 2)) is 2 — half to even, like every round in Python. Decimal is BigDecimal with a context: the precision (28 digits by default) and the rounding mode (ROUND_HALF_EVEN) are process-wide state, set through getcontext() or scoped with localcontext(), rather than arguments to each call — so Decimal(1) / 3 needs no precision argument, and quantize(Decimal("0.01")) is the rounding call. Its default str is positional where BigDecimal's is scientific, and f"{d:,}" groups digits with commas where to_s("3F") uses spaces. The sharpest difference is rows 12 and 13: Decimal("0.1") == 0.1 is False, because Python converts the float exactly, to 0.1000000000000000055511151231257827…, and Decimal("0.1") + 0.1 is a TypeError — Python refuses to mix the two at all, where Ruby quietly converts. complex and cmath match Complex, math.sqrt(-1) raises like Math.sqrt(-1), and the numbers ABCs stand in for Numeric, with the wrinkle that Decimal registers as a numbers.Number but not as a numbers.Real.

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

 1. Fraction(1, 3)                         -> Fraction(1, 3)         no literal; str() is 1/3
 2. Fraction('1/3'), Fraction(3), Fraction('0.1') -> 1/3, 3, 1/10           the string forms are exact; Fraction('0.1') is one tenth
 3. Fraction(0.1)                          -> 3602879701896397/36028797018963968 from a float you get the float's exact value, not one tenth
 4. Fraction(1, 3) + Fraction(1, 6)        -> 1/2                    exact arithmetic; Fraction(1, 3) * 3 == 1 is True
 5. float(Fraction(1, 3))                  -> 0.3333333333333333     a float on request; Fraction(1, 3) + 0.5 is 0.8333333333333333, a float
 6. round(Fraction(5, 2))                  -> 2                      half to even, like float; round(Fraction(1, 3), 2) is 33/100, a Fraction
 7. Decimal('0.1') + Decimal('0.2') == Decimal('0.3') -> True                   decimal digits, so there is no binary fraction to miss by
 8. str(Decimal('0.1') + Decimal('0.2'))   -> '0.3'                  str is positional; format(..., 'e') is '3e-1'
 9. f"{Decimal('1234.5678'):,}"            -> '1,234.5678'           grouped by 3; float 1234.5678, int 1234, as_integer_ratio (6172839, 5000)
10. Decimal('2.675').quantize(Decimal('0.01')) -> 2.68                   ROUND_HALF_EVEN on decimal digits by default; ROUND_HALF_UP gives 2.68, and 2.665 gives 2.66
11. Decimal(1) / 3, with prec 10           -> 0.3333333333           division uses the context's precision (28 by default); Decimal(1) / 3 * 3 == 1 is False, while Fraction(1, 3) * 3 == 1 is True
12. Decimal('0.1') == 0.1                  -> False                  a float is compared exactly: Decimal(0.1) is 0.1000000000000000055511151231257827021181583404541015625
13. Decimal('0.1') + 0.1                   -> TypeError              no implicit mixing with float; Decimal('0.1') + Fraction(1, 10) is TypeError too
14. complex(1, 2) * complex(1, 2)          -> (-3+4j)                2j is a literal; abs(complex(3, 4)) is 5.0; (real, imag) (1.0, 2.0)
15. math.sqrt(-1)                          -> ValueError             no complex unless asked: cmath.sqrt(-1) is 1j; 1j ** 2 is (-1+0j)
16. isinstance(Fraction(1, 3), numbers.Rational) -> True                   the numbers ABCs; isinstance(Decimal('1'), numbers.Real) is False, numbers.Number True

Try it

  1. Add 0.1 ten times as a Float with inject(:+), then with sum, then as 0.1r and as BigDecimal("0.1"); print each total and whether it == 1. Then compute 19.99 * 3 in all three types. Which of the results would you put on an invoice, and which line explains why sum and inject disagree?
  2. Write to_fraction(s) that turns a decimal string such as "2.675" into an exact Rational without going through a Float — split on the dot, build the numerator with Integer(), and use 10 ** decimals as the denominator — then check that to_fraction("2.675") == 2675r/1000, that it is not 2.675.to_r, and that to_fraction("0.1") + to_fraction("0.2") == to_fraction("0.3").
Solution to 1

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

# Kata: add 0.1 ten times as a Float, a Rational and a BigDecimal, then
# multiply a price by three — and see which results equal what a calculator
# says.
require "bigdecimal"

float_sum    = Array.new(10, 0.1).inject(:+)
rational_sum = Array.new(10, 0.1r).inject(:+)
decimal_sum  = Array.new(10, BigDecimal("0.1")).inject(:+)
puts "1. ten Floats with inject(:+): #{float_sum}   == 1 is #{float_sum == 1}"
puts "2. ten Floats with sum:        #{Array.new(10, 0.1).sum}   == 1 is #{Array.new(10, 0.1).sum == 1} (sum compensates; inject does not)"
puts "3. ten Rationals:              #{rational_sum.inspect}   == 1 is #{rational_sum == 1}"
puts "4. ten BigDecimals:            #{decimal_sum.to_s('F')}   == 1 is #{decimal_sum == 1}"
puts "5. 19.99 * 3: Float #{19.99 * 3}, Rational #{(19.99r * 3).inspect}, BigDecimal #{(BigDecimal('19.99') * 3).to_s('F')}"

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

1. ten Floats with inject(:+): 0.9999999999999999   == 1 is false
2. ten Floats with sum:        1.0   == 1 is true (sum compensates; inject does not)
3. ten Rationals:              (1/1)   == 1 is true
4. ten BigDecimals:            1.0   == 1 is true
5. 19.99 * 3: Float 59.97, Rational (5997/100), BigDecimal 59.97

See also