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¶
- Add
0.1ten times as a Float withinject(:+), then withsum, then as0.1rand asBigDecimal("0.1"); print each total and whether it== 1. Then compute19.99 * 3in all three types. Which of the results would you put on an invoice, and which line explains whysumandinjectdisagree? - 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 withInteger(), and use10 ** decimalsas the denominator — then check thatto_fraction("2.675") == 2675r/1000, that it is not2.675.to_r, and thatto_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.
See also¶
0.1 + 0.2 != 0.3, and2.5.roundis3— the Float this page escapes from: what it holds, how it rounds, andto_rversusrationalize7 / 2is3: integer division floors —7.quo(2), the division that answers in Rational- Numbers become text with
format,to_s(16)and%—to_s("F")besideformat("%.2f"), and the rounding each one does 1 + 1.5works because Integer asks Float tocoerce— the promotions of rows 1–5: who wins when a Rational meets a Floatinjectandeach_with_object— whysumandinject(:+)disagree on ten Floats, as the kata shows- Rational numbers ↗ and Decimal numbers ↗ — the Rust library, where both are crates and a decimal has no context
Timeis an instant,Dateis a calendar day — aTimeis a point on the timeline with an offset attached- Integers never overflow:
2 ** 200is just an Integer — Ruby has oneIntegerclass with no ceiling - There is more than one way, and one obvious way — Ruby's idioms