pith has four numeric types. three of them are exact and one of them is not, and knowing which is which is most of this page.
| type | holds | exact | cost |
|---|---|---|---|
Int |
whole numbers, 64 bits, two's complement | until it overflows | a machine register |
Float |
ieee-754 binary double | no | a machine register |
std.bigint.BigInt |
whole numbers, any size | yes | a heap value; one machine word of magnitude under 10^18 |
std.decimal.Decimal |
decimal fractions, any size | yes | a heap value wrapping a BigInt |
the short version: Int for counts, indices, ids, and bytes. Float for
measurements, ratios, and anything headed for a chart. Decimal for money, tax,
invoice lines, and anything a person will later reconcile against a statement.
BigInt when a whole number outgrows 64 bits.
a Float stores a binary fraction. 0.1 is not a binary fraction, so a Float
cannot hold it — it holds the nearest one it can, which is slightly off. the
error is invisible in one value and accumulates across many:
mut total := 0.0
mut count := 0
while count < 10:
total = total + 0.1
count = count + 1
print("{total}") # 0.9999999999999999
the same loop over Decimal gives 1.00, because a decimal fraction is what it
stores.
the second failure is size. a Float has 53 bits of mantissa, so past 2^53 it
stops being able to name consecutive integers. a ledger balance of
12345678901234567.89 — comfortably inside a NUMERIC(20,2) column — comes back
from a Float as 12345678901234568. the cents are gone and the integer part is
off by one, and nothing anywhere reports an error.
neither failure announces itself. that is what makes it worth a type rather than a warning in a comment.
examples/money.pith runs both versions side by side.
a Decimal is an arbitrary-precision integer and a scale: the value is
unscaled / 10^scale. this is java's BigDecimal, and it is also exactly what
sql NUMERIC(p, s) means, which is why a database column round-trips through it
without losing a digit.
import std.decimal as decimal
price := decimal.parse("19.99")!
quantity := decimal.from_int(3)
subtotal := decimal.mul(price, quantity) # 59.97
tax := decimal.mul(subtotal, decimal.parse("0.0825")!)
total := decimal.add(subtotal, decimal.rescale(tax, 2, decimal.half_even())!)
print(decimal.to_string(total)) # 64.92
1.10 parses to an unscaled 110 at scale 2 and prints back as 1.10, not 1.1.
that is deliberate: a NUMERIC(10,2) column stores the trailing zero and prints
it, so keeping it is what makes the round-trip byte-identical.
comparison ignores the scale — 1.0 and 1.00 are equal — so the scale never
changes an answer, only how the value reads. strip_trailing_zeros drops it when
the shorter form is what you want.
addition and subtraction take the wider of the two scales, so no digit of either
operand is dropped. multiplication takes the sum of the two scales, because that
is what an exact product needs: 1.05 * 1.05 is 1.1025 at scale 4. rescale it
when you want it back at scale 2, and say how it should round.
on purpose. java's new BigDecimal(0.1) gives
0.1000000000000000055511151231257827021181583404541015625, which is the honest
answer and almost never the wanted one. the alternative — taking the decimal a
Float was printed as and treating it as exact — hides the error rather than
removing it. neither belongs behind a function that looks safe.
if you genuinely hold a Float and want a Decimal, format it and parse the
text, so the lossy step is the one you wrote:
approximate := decimal.parse(fmt_float(measurement, 4))!
to_float goes the other way and is documented as lossy, because printing and
plotting are real needs.
division is the only operation here that can be inexact — 1/3 has no decimal
form — so div takes the scale and the rounding mode at the call site. there is
no default, because a default is how a fraction of a cent goes missing:
share := decimal.div(total, decimal.from_int(3), 2, decimal.half_even())!
the seven modes are down (truncate toward zero), up (away from zero), floor
(toward negative infinity), ceiling (toward positive infinity), half_up,
half_down, and half_even.
half_even is the one to reach for with money. it sends an exact midpoint to
whichever neighbour ends in an even digit, so midpoints go up and down about
equally often and a long run of transactions does not drift. half_up — the
rounding taught in school — pushes every midpoint away from zero. rounding
0.125, 0.135, 0.145 and 0.155 to cents sums to 0.58 under half_up and
to 0.56 under half_even; the exact total is 0.56.
when you expect a division to come out even, say so:
half := decimal.div_exact(amount, decimal.from_int(2))!
div_exact computes the quotient exactly and fails when it does not terminate,
so an assumption that stops holding surfaces as an error rather than as a quiet
rounding. it keeps at least as many places as the dividend had beyond the
divisor, so 10.00 / 2 is 5.00 rather than 5, and no more than it needs, so
1.00 / 8 is 0.125.
dividing 10.00 three ways and rounding each share gives 3.33 three times,
which is 9.99. allocate hands the leftover minor units out one at a time so
the parts sum back to exactly the total:
shares := decimal.allocate(decimal.parse("10.00")!, [1, 1, 1])!
# 3.34, 3.33, 3.33
the weights are integer proportions, so [3, 7] splits 30/70.
BigInt is the arbitrary-precision integer Decimal is built on, and it stands
alone for whole numbers that outgrow 64 bits.
import std.bigint as bigint
value := bigint.parse("170141183460469231731687303715884105727")!
print(bigint.to_string(bigint.mul(value, value)))
add, sub and mul are exact. divmod truncates toward zero and gives the
remainder the sign of the dividend, matching pith's own / and % on Int, and
fails on a zero divisor rather than returning a fabricated value.
a magnitude of at most 18 decimal digits lives in a machine Int inside the
value, with no list and no loop, so ordinary-sized arithmetic does not pay for
the general path.
two things it deliberately does not do:
- no bitwise or shift operations. the limb base is 10^9 rather than a power
of two, which makes decimal conversion and scaling by a power of ten linear —
the workload this type exists for — at the cost of making bit twiddling
expensive.
std.bitsoperates onIntand is the right tool for that. - not for cryptography. nothing here is constant-time and there is no modular
exponentiation. key material and signatures belong in
std.crypto, which is backed by a reviewed implementation.
a NUMERIC (postgres) or DECIMAL (mysql) column decodes to
std.sql.Value.Numeric, carrying a Decimal. read it with row.decimal(name):
import std.db.postgres as db
import std.decimal as decimal
handle := db.open("postgres://me:pw@127.0.0.1:5432/app")!
mut balance := decimal.zero()
for row in handle.query("select amount from ledger", [])!:
balance = decimal.add(balance, row.decimal("amount"))
print(decimal.to_string(balance))
float4 and float8 still decode to Value.Real, because those genuinely are
floats. reading a numeric column with row.float(name) still works and still
rounds — the coercion is there so code written before Numeric existed keeps
compiling, not because it is a good idea.
bind a decimal back by its text, which is exact:
handle.exec("insert into ledger (amount) values ($1)", [decimal.to_string(amount)])!
a value the driver cannot parse as a number — postgres sends NaN, Infinity
and -Infinity for a numeric — decodes to Value.Text holding the original
string. it is never turned into a zero.
see docs/db.md for the rest of the database surface.
Decimal caps its scale at 16383, which is postgres's own ceiling on a numeric's
display scale, and parse refuses an exponent that would expand past a million
digits rather than letting untrusted input turn into an allocation.
bigint.ten_pow and bigint.pow refuse a result past 1.8 million digits for the
same reason. these are types for money and measurements, not for computing a
constant to a million places.