Exact numbers and rounding
formula-cpp provides an exact rational number, formula::Rational, and a
rounding layer built on top of it. This page explains why that exists, how to
construct and use it, and where its limits are.
Why not double
Binary floating point cannot represent most decimal fractions exactly, and it
cannot accumulate them exactly either. Summing 0.1 ten times in double:
double sum = 0.0;
for (int i = 0; i < 10; ++i)
sum += 0.1;
// sum is 0.99999999999999988898, not 1.0
That is not a rare edge case; it is what binary floating point does with
decimal input in general. A quantity such as 450 millilitres, stored as
0,45 litres in a double and converted back, is not reliably 450 again --
the round trip is lossy because 0,45 is not exactly representable in base 2.
Rational makes that round trip exact:
Rational const volumeInMillilitres = *Rational::from_decimal(45, 1); // 450/1
Rational const volumeInLitres = volumeInMillilitres / Rational { 1000 }; // 9/20
Rational const roundTripped = volumeInLitres * Rational { 1000 };
// roundTripped == volumeInMillilitres, exactly
The second reason is more fundamental than accumulated error: rounding rules
in measurement and reporting methods are specified behaviour, not
formatting. "Round the result to two decimal places, ties away from zero" is
part of the method that produced the number, not a choice made when printing
it. A number type that cannot represent decimal values exactly cannot apply
such a rule faithfully -- it is already carrying error before the rounding
rule is even applied. Rational is exact so that rounding, when it happens,
is the only place precision is deliberately given up.
Constructing a Rational
| You want | Write | You get |
|---|---|---|
| an integer | Rational { 7 } |
7/1 |
| a fraction | Rational { 3, 4 } |
3/4 |
| an exact decimal | Rational::from_decimal(45, -2) |
9/20 |
| a whole number of tens | Rational::from_decimal(45, 1) |
450/1 |
the exact value of a double |
Rational::from_double_exact(0.45) |
a power-of-two denominator |
a measured double on a known scale |
rational_from_double(0.45, DecimalPlaces { 2 }, mode) |
9/20 |
Spelled out, as runnable code:
Rational const a { 7 }; // 7/1
Rational const b { 3, 4 }; // 3/4
Rational const c = *Rational::from_decimal(45, -2); // 9/20
Rational const d = *Rational::from_decimal(45, 1); // 450/1
Rational const e = *Rational::from_double_exact(0.45); // a power-of-two denominator
Rational const f =
*formula::rational_from_double(0.45, DecimalPlaces { 2 }, RoundingMode::HalfAwayFromZero); // 9/20
from_decimal, from_double_exact and rational_from_double all return
std::expected<Rational, ArithmeticError> because the conversion can fail --
from_decimal's scale factor can overflow, and from_double_exact can be
asked for a NaN or an infinity. * unwraps a value known to be present; use
the checked_ layer described below when the input is not already known-good.
Rational r = 0.45; does not compile. Rational has a constructor
template for floating-point types whose body is a static_assert that always
fires, with a message pointing at from_decimal, from_double_exact and
rational_from_double. This is deliberate: a double is a binary fraction,
so silently converting one to a Rational would make 0.45 mean
8106479329266893 / 2^54, not 9/20 -- exactly the confusion this type exists to
prevent.
Exact or nothing
checked_ means exactly one thing in this library: the function returns
std::expected<T, ArithmeticError> and never throws. Every checked_
function that has a natural infallible-looking spelling also has a throwing
counterpart -- but not every operation has both forms, and this section says
which.
formula::checked_add, checked_sub, checked_mul, checked_div,
checked_pow, checked_negate and checked_abs return
std::expected<Rational, ArithmeticError>:
std::expected<Rational, ArithmeticError> const result = formula::checked_add(a, b);
if (!result)
{
// result.error() is an ArithmeticError; formula::describe(result.error())
// gives a lowercase noun phrase such as "overflow in exact arithmetic"
std::string_view const message = formula::describe(result.error());
}
Their throwing counterparts -- operator+, operator- (binary and unary),
operator*, operator/, their += family, pow and abs -- are thin
wrappers that throw formula::ArithmeticException on failure:
Rational const sum = a + b; // throws formula::ArithmeticException on overflow
Three operations are checked-only, deliberately without a throwing form.
checked_reciprocal has no throwing counterpart because operator/ already
covers the throwing case: dividing into Rational { 1 } is a reciprocal.
checked_decimal_exponent (rounding.hpp) and rational_from_double are
checked-only because their failure is not the rare case a throw is meant
for -- a zero value or an out-of-range double is an ordinary input a
caller should expect to handle, not an exceptional one.
Because a throw is not a core constant expression, a failure that happens
inside a constexpr context is not a runtime exception at all -- it is a
compile error. Code that divides by a constexpr zero, or overflows a
constexpr computation, fails to compile rather than failing at run time.
Nothing in this library saturates and nothing truncates silently. An operation either produces the exact result or reports why it could not; there is no near-miss value standing in for a result that does not exist.
Rounding
formula::RoundingMode has seven values. The four non-half modes are
unconditional -- they ignore how close the value is and always move the same
way. The three half modes move to the nearer result and differ only on an
exact tie. Worked on 7/4 = 1,75, -7/4 = -1,75, 3/2 = 1,5 and
5/2 = 2,5:
| Mode | Meaning | 1,75 | -1,75 | 1,5 | 2,5 |
|---|---|---|---|---|---|
HalfAwayFromZero |
nearest, ties away from zero | 2 | -2 | 2 | 3 |
HalfTowardZero |
nearest, ties toward zero | 2 | -2 | 1 | 2 |
HalfEven |
nearest, ties to even | 2 | -2 | 2 | 2 |
Ceiling |
toward positive infinity | 2 | -1 | 2 | 3 |
Floor |
toward negative infinity | 1 | -2 | 1 | 2 |
TowardZero |
toward zero, plain truncation | 1 | -1 | 1 | 2 |
AwayFromZero |
always away from zero | 2 | -2 | 2 | 3 |
Rational::Int const nearest = formula::round_to_int(Rational { 7, 4 }, RoundingMode::HalfAwayFromZero); // 2
round_to_int returns the integer itself; round_to_integer returns it as a
Rational for use in further exact arithmetic.
Beyond rounding to a whole number, three forms round to a place:
// Decimal places: 45,67 rounded to one decimal place is 45,7, i.e. 457/10.
Rational const value = *Rational::from_decimal(4567, -2);
Rational const toOneDecimal = formula::round(value, DecimalPlaces { 1 }, RoundingMode::HalfAwayFromZero);
// Significant digits: the same 45,67 rounded to two significant digits is 46 -- a
// different operation from decimal places, and the two can and do disagree.
Rational const toTwoSignificant = formula::round(value, SignificantDigits { 2 }, RoundingMode::HalfAwayFromZero);
// Rounding to an arbitrary step, the primitive the two forms above are built on.
Rational const snapped = formula::round_to_multiple(Rational { 7 }, Rational { 5 }, RoundingMode::HalfAwayFromZero); // 5
Rounding is part of the calculation
Because rounding is an operation over exact values rather than a formatting step, the order in which it is applied is part of the method and changes the result. Rounding an intermediate value before using it produces a different answer from rounding only at the end -- both are correct for their respective methods, and a library that rounds only on output cannot express the difference:
Rational const mean = Rational { 302, 3 }; // 100,666...
Rational const roundedFirst =
formula::round(mean, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero) * Rational { 2 }; // 202
Rational const roundedLast =
formula::round(mean * Rational { 2 }, DecimalPlaces { 0 }, RoundingMode::HalfAwayFromZero); // 201
// roundedFirst != roundedLast
roundedFirst rounds the mean to a whole number first and doubles the
rounded value; roundedLast doubles first and rounds only the final result.
Both are legitimate methods, and which one a specification calls for changes
the answer.
Limits
Rational's numerator and denominator are std::int64_t. DecimalPlaces
and the decimal-place form of round are limited to ±18 places, because the
scale factor 10^places must itself fit in Int; an out-of-range
DecimalPlaces reports Overflow, while an out-of-range SignificantDigits
(fewer than 1) reports DomainError -- both mean "argument outside the
domain of the operation", but a caller switching on the code should expect
either one. That ±18 ceiling is rarely the one actually hit, though.
Rounding to N decimal places scales the value by 10^N. Common factors of
two cancel against the denominator first, so what must fit in Int is
|numerator| * (10^N / gcd(10^N, denominator))
which for a power-of-two denominator is |numerator| * 5^N. The limit is set
by the numerator's magnitude, not by the denominator and not by the size of
the value:
numerator (over 2^54) |
max decimal places |
|---|---|
1 |
18 |
10^9 |
18 |
10^12 |
15 |
8106479329266893 (a double's mantissa) |
4 |
Holding the numerator at 53 bits and varying the denominator from 2^10 to
2^62 leaves the answer at 4 places throughout; 1 / 2^60 rounds correctly at
all 18.
This is why from_decimal and rational_from_double behave so differently for
the same nominal value. from_decimal(45, -2) is 9/20 -- numerator 9, so all
18 places work. The same 0,45 as a double is exactly
8106479329266893 / 2^54: a double's mantissa is always about 53 bits
whatever its exponent, so any value from from_double_exact or
rational_from_double caps out at 4 decimal places, large or small alike.
Asking for more reports Overflow, never a wrong number.
from_double_exact additionally refuses a double whose exact value would need
a denominator above 2^63. Measured, that rules out a full-mantissa value below
2^-10 (about 0,00098): 0.0009765625 converts, 0.0001 is refused outright,
before rounding is even reached.
For an exact decimal, prefer from_decimal: its numerator is whatever you
passed -- usually a handful of significant digits -- so the limit above does not
bite. Its denominator need not be small at all: from_decimal(1, -18) is
1/10^18, and it still rounds correctly at every decimal place from 0 to 18.
That is the clearest demonstration that the denominator is not what constrains
rounding to decimals.
Rounding to a negative number of places -- to whole tens, hundreds, thousands
-- scales the other way: the step is an integer, so it multiplies the
denominator rather than the numerator, and there the denominator is what
constrains you. 1/10^18 is refused at every negative place for exactly that
reason, while 1/3 handles them all.
Reach for rational_from_double only when the input is a genuinely measured
double, and only at modest decimal precision.
Overflow is always reported, never absorbed -- with one nuance worth knowing.
checked_add and checked_sub can report overflow for a result that would,
once reduced, actually fit: if both operands' numerators are near 2^63 and
their denominators share a large common factor, the intermediate numerator
sum can exceed int64_t even though the reduced answer is representable --
for example IntMax/3037000500 + IntMax/3037000500, whose exact value
IntMax/1518500250 fits easily. Multiplication does not have this problem,
because it cross-reduces before multiplying. Measured over 473,984 operand
pairs against 128-bit ground truth: no wrong values were ever produced, and
no false overflows occurred at all for numerators below roughly 10^6, which
covers realistic use. The failure direction is always the safe one -- a
reported error, never a wrong number.