BigDecimal
Exact decimal numbers: arithmetic that keeps every digit without losing precision, and rounding only when you ask for it.
A Float cannot hold 0.1. What it holds is
0.1000000000000000055511151231257827, which is close enough until you add
three of them and get 0.30000000000000004. The addition is not at fault;
base two has no exact 0.1 to add. A BigDecimal works in base ten, so it
does:
BigDecimal.fromString "0.1"
|> Maybe.map (\d -> BigDecimal.add d d |> BigDecimal.add d)
|> Maybe.map BigDecimal.toString
--> Just "0.3"
A value is a BigInt and a power of ten: unscaled * 10^-scale.
There is no fixed precision anywhere, so add, subBy and mul never round.
They widen. The only operation that can fail to terminate is division, and
division and roundTo are the only operations that round a value.
toStringWithPlacesUsing takes a
Rounding too, but only to write a value down; it changes
nothing.
One value, one representation
1.50 and 1.5 are the same number, and in this module they are also the
same value. Every BigDecimal goes through a constructor that strips trailing
zeroes, so == is numeric equality.
BigDecimal.fromString "1.50" == BigDecimal.fromString "1.5"
--> True
This is the opposite of java.math.BigDecimal, where the two are equal by
compareTo but distinct by equals. We differ because Gren's == is
structural and cannot be overridden. A type whose == disagrees with its
compare is a trap in the language's most-used operator, and no amount of
documentation removes it.
The cost is that the scale no longer records significance. A BigDecimal
does not remember that a price was quoted to the cent. That is a formatting
question, and you ask it at the edge:
BigDecimal.toStringWithPlaces 2 price --> "1.50"
A number of the form unscaled * 10^-scale.
The unscaled value is a BigInt and carries the sign, so there is no
size limit on the digits. The scale is an ordinary Int and may be negative:
100 is stored as 1 with a scale of -2, because that is what stripping
trailing zeroes leaves.
Every value is built through a constructor that does that stripping, which is
what makes == numeric equality. Zero is unscaled = 0, scale = 0 and nothing
else, so x |> subBy x == BigDecimal.zero is a reliable test.
Making them
Zero.
There is only one zero: a zero unscaled value is always given a scale of
zero, so 0.00 and 0 and 0e-40 are all this value.
It is also the divisor that makes every division in this module return
Nothing.
One.
The step, and the numerator of a reciprocal: one |> divByTo 20 HalfEven x.
An ordinary Int, which is a whole number and so has a scale of zero or
less. Above 2^53 an Int is already inexact, so this converts the double you
passed, not the number you meant.
Back to an Int, when it is a whole number and fits in one exactly.
There are two ways to get Nothing, and both are refusals rather than
roundings: there is a fractional part, or the whole part is past
Math.maxSafeInteger. Apply roundTo 0 first if you meant to
discard the fraction.
BigDecimal.fromString "2.5" |> Maybe.andThen BigDecimal.toInt
--> Nothing
A BigInt, exactly and always.
The whole part, truncated toward zero: -3.7 becomes -3, the same
direction BigInt.fromFloat and Down go.
BigDecimal.fromString "-3.7" |> Maybe.map BigDecimal.toBigInt
--> Just (BigInt.fromInt -3)
For any other direction, apply roundTo 0 with the rounding you
want first.
The exact value of a Float, which is almost never the number that
was typed to produce it. NaN and the infinities are Nothing. Everything
else converts, because every finite double is an integer over a power of two,
and a power of two is a terminating decimal.
BigDecimal.fromFloat 0.1 |> Maybe.map BigDecimal.toString
--> Just "0.1000000000000000055511151231257827021181583404541015625"
Those digits are not noise. That is the whole of the double, and this is the
function that shows it to you, the same job BigInt.fromFloat does for
1.0e30.
That is also why this is the wrong function for a number a person wrote.
fromString is the one for that:
BigDecimal.fromString "0.1" |> Maybe.map BigDecimal.toString
--> Just "0.1"
To a Float, rounding to the nearest one. Rounding is all a Float can
do, and it is the reason this module exists.
Past a double's range the answer is an infinity rather than Nothing, because
that is what the arithmetic would have given anyway. toString
is the direction that never loses a digit, and toInt is the
conversion that refuses.
Reading and writing
Read a decimal number: an optional sign, digits with an optional point
somewhere among them, and an optional e exponent.
BigDecimal.fromString "-0.001" |> Maybe.map BigDecimal.toString
--> Just "-0.001"
BigDecimal.fromString "1.5e3" |> Maybe.map BigDecimal.toString
--> Just "1500"
The e may be either case and the exponent may carry its own sign, so
1.5E-3 is 0.0015 and 1e+5 is 100000. The exponent is not bounded:
1e-9999999999 is accepted, and would take ten billion digits to write. For
text you did not write, fromStringWithin refuses that
before building it. toString never writes an exponent back
out; 1e-9 comes back as 0.000000001.
An underscore between two digits is ignored, as in BigInt.fromString, so
1_000.000_1 parses. One anywhere else, next to the point, the sign or the
e, makes the whole string Nothing. Unlike that function there are no
0x, 0b or 0o prefixes; a hexadecimal fraction is not something anyone
needs.
Anything else is Nothing rather than zero: no digits at all, a second
point, an e with nothing after it, a trailing unit:
BigDecimal.fromString "1.2.3" --> Nothing
BigDecimal.fromString "3 apples" --> Nothing
The point is always . and there is no grouping separator but _, as in a
Gren literal. A comma is not a digit, so 1,234.56 and 1.234,56 are both
Nothing. That is deliberate: 1,234 is a thousand in one country and a bit
over one in another, and no parser can tell from the text alone. Normalize a
localized string before it gets here, where the locale is known:
german text =
text
|> String.replace "." ""
|> String.replace "," "."
|> BigDecimal.fromString
-- "1.234,56" --> Just 1234.56
The same two replacements cover most of Europe. Grouping varies more than the
decimal separator does: French writes 1 234,56 with a space or a narrow
no-break space, Swiss German writes 1'234.56, and Indian English groups
unevenly as 12,34,567.89. Strip whatever the locale groups with, swap its
decimal separator for ., and this function does the rest.
This is the function for a number a person wrote down.
fromFloat tells you what a double is really holding, which is
a different question.
The number in full, with a leading - when negative and no exponent
however far the point has to travel.
BigDecimal.fromString "1e-9" |> Maybe.map BigDecimal.toString
--> Just "0.000000001"
There are no trailing zeroes, because the value has none to write:
toString of 1.50 is "1.5". toStringWithPlaces is
the one that pads.
This is the lossless direction. A BigDecimal survives a round trip through a
String and does not survive one through a Float, so if you are storing
these anywhere, store the string.
The number with exactly this many decimal places, padded with zeroes if it has fewer and rounded half-even if it has more.
BigDecimal.fromString "1.5"
|> Maybe.map (BigDecimal.toStringWithPlaces 2)
--> Just "1.50"
This is where significance lives. A BigDecimal does not remember that a
price was quoted to the cent (1.50 and 1.5 are one value), so the number
of places is something the formatter is told, by the code that knows what the
number is for.
Half-even because it is the rounding that does not drift, which is the right
default for a column of numbers and the wrong one for a receipt.
toStringWithPlacesUsing is the same function
with the rounding named.
A count of zero gives a whole number and no point, and a negative one rounds to tens or hundreds and writes the zeroes out.
toStringWithPlaces with the rounding said out
loud: toStringWithPlacesUsing places rounding value.
BigDecimal.fromString "2.345"
|> Maybe.map (BigDecimal.toStringWithPlacesUsing 2 BigDecimal.HalfUp)
--> Just "2.35"
BigDecimal.fromString "2.345"
|> Maybe.map (BigDecimal.toStringWithPlaces 2)
--> Just "2.34"
Both of those are correct, and they answer different questions. Half-even is the better default because ties fall both ways, so a long column of rounded numbers does not drift upwards; that is why it is the one the plain function uses, and why a report should keep it. But a price is not a column. Shops, invoices and most tax authorities round a half away from zero, and a total that disagrees with the arithmetic a customer did by hand is a support ticket whatever IEEE 754 has to say about it.
So HalfEven for a report, HalfUp for a receipt, and this is where you say
which. Nothing is rounded but the text: use roundTo if the
value itself should change, and note that a value rounded to places
already will come out the same through either function.
Reading text you did not write
fromString with a ceiling on how many digits the number
takes to write out, for text that comes from somewhere you do not control:
fromStringWithin limit text.
The exponent is the hazard. 1e-9999999999 is thirteen characters and a
perfectly small number, and it needs ten billion digits to hold. The plain
fromString will try. This one works out from the text alone how many
digits toString would have to write, and refuses before
building anything if that is more than the limit:
BigDecimal.fromStringWithin 10 "0.00123"
|> Result.map BigDecimal.toString
--> Ok "0.00123"
BigDecimal.fromStringWithin 10 "1e-9999999999"
--> Err (BigDecimal.TooLong { digits = 10000000000, limit = 10 })
BigDecimal.fromStringWithin 10 "1.2.3"
--> Err BigDecimal.NotANumber
The count is the digits of the written number on both sides of the point:
0.00123 is six, 1e3 is four, 1.5 is two, and zero is one. Leading and
trailing zeroes in the text are not counted, because the value does not keep
them. A limit below one refuses everything. Malformed text is NotANumber
whatever its length.
Why fromStringWithin refused.
NotANumber is everything the plain fromString returns
Nothing for. TooLong is a well-formed number that would take more digits
to write out than the limit allows, and it says how many.
Arithmetic
Add, exactly. The answer is carried at the finer of the two scales, which is the one that loses nothing.
No By suffix, unlike subBy, because addition is commutative, so
there is no subject for a pipeline to put last.
Add up an array, exactly.
BigDecimal.sum
[ BigDecimal.fromInt 1990 |> BigDecimal.movePointBy -2
, BigDecimal.fromInt 495 |> BigDecimal.movePointBy -2
]
|> BigDecimal.toString
--> "24.85"
A column of prices is the most common thing anyone does with these, and the
reason it is worth a function of its own is that nothing here rounds: the
answer does not depend on the order the values arrive in, which is exactly
what a Float cannot promise.
sum of an empty array is zero.
Subtract the first from the second: subBy b a is a - b.
a |> BigDecimal.subBy one
Multiply, exactly. Commutative, so no By.
A product of decimals always terminates: the scales add, and the digits are a
BigInt multiplication. Exactness costs nothing here.
Flip the sign. The scale is untouched, so no arithmetic happens here at any size.
negate zero is zero; there is no negative zero.
Drop the sign. Like negate this cannot fail and cannot
overflow, because a BigDecimal has no most negative value.
Raise to a power: powBy exponent base. A non-negative exponent is exact
however many digits it takes: the unscaled value goes through BigInt.powBy
and the scale is multiplied.
BigDecimal.fromString "1.05" |> Maybe.map (BigDecimal.powBy 10)
--> BigDecimal.fromString "1.62889462677744140625"
A negative exponent is a reciprocal, and a reciprocal usually does not
terminate. This gives the exact answer when there is one (powBy -3 (fromInt 2) is 0.125) and zero when there is not. That is the same shape
of answer BigInt.powBy gives a negative exponent, and it comes with the same
warning: if 3 ^ -1 should be 0.333... rather than nothing,
divByTo is the function that will say so.
Dividing
There are two, because a decimal division either terminates or it does not, and only the caller knows which outcome is acceptable.
Divide exactly, or not at all: divBy divisor dividend.
There are two reasons for Nothing, and the type does not distinguish them:
the divisor was zero, or the quotient does not terminate in base ten. The
second happens whenever the divisor keeps a prime factor other than two or
five after the common factors cancel, so isZero on the divisor is
how you tell the two apart when it matters.
-- 1 / 8 is 0.125, and stops
BigDecimal.one |> BigDecimal.divBy (BigDecimal.fromInt 8)
--> BigDecimal.fromString "0.125"
-- 1 / 3 does not
BigDecimal.one |> BigDecimal.divBy (BigDecimal.fromInt 3)
--> Nothing
Use this when a wrong answer is worse than no answer (splitting a total,
converting a unit), and divByTo when you have a number of places
in mind and are willing to say so.
Divide to a fixed number of decimal places, rounding the way you say:
divByTo places rounding divisor dividend.
This is the division that always has an answer, so the only Nothing is a
zero divisor.
BigDecimal.one
|> BigDecimal.divByTo 5 BigDecimal.HalfEven (BigDecimal.fromInt 3)
|> Maybe.map BigDecimal.toString
--> Just "0.33333"
places is decimal places, not significant digits, and it may be negative:
divByTo -3 HalfUp gives an answer to the nearest thousand. Trailing zeroes
still come off afterwards, so a result that lands on 0.25 is 0.25 and not
0.25000; use toStringWithPlaces if you need the
zeroes.
Splitting a total
Sometimes you need to divide a value into individual quantities that will sum up to the original value, always. You cannot round, you must share the remainder.
Split a total into parts that add back up to it:
allocate places parts total.
places says where the smallest unit is: 2 for a currency with cents, 0
for one without, and it may be negative to hand out whole hundreds.
BigDecimal.fromString "10.00"
|> Maybe.andThen (BigDecimal.allocate 2 3)
|> Maybe.map (Array.map BigDecimal.toString)
--> Just [ "3.34", "3.33", "3.33" ]
Compare what dividing gives you. divByTo 2 HalfUp makes every share 3.33,
and three times 3.33 is 9.99; the cent is gone and no rounding mode brings
it back. Here the parts differ by one unit at the most, the earliest ones take
the extra, and the sum is the total you passed in.
There are two refusals, and both are refusals rather than roundings:
partsis less than one. There is nowhere to put the money.totalis not a whole number of units atplaces.allocate 2will not split10.005, because a third of a cent is not something to hand anybody. Round first and own the rounding:allocate 2 3 (roundTo 2 HalfUp total).
A negative total splits away from zero the same way, so the parts of
negate total are the negated parts of total.
Split a total in proportion to a set of weights, and still have the parts
add back up to it: allocateBy places weights total.
This is the one a cart wants. An order-level discount comes off the lines, and it has to come off them exactly, or the lines no longer explain the total.
-- 10.00 off, shared out over three lines by what they cost
BigDecimal.fromString "10.00"
|> Maybe.andThen (BigDecimal.allocateBy 2 [ line1, line2, line3 ])
Weights are relative, so [ 1, 1, 2 ] and [ 25, 25, 50 ] split alike and
the scales they are written at do not matter. Each part is its exact share
rounded toward zero, and the units left over go to the parts whose discarded
fractions were biggest, earliest first. That is the largest-remainder method,
and it is what keeps the parts within one unit of each other's fair share.
BigDecimal.fromString "0.05"
|> Maybe.andThen
(BigDecimal.allocateBy 2
[ BigDecimal.one, BigDecimal.one, BigDecimal.one ]
)
|> Maybe.map (Array.map BigDecimal.toString)
--> Just [ "0.02", "0.02", "0.01" ]
Give every weight the same value and you get the split
allocate makes. Like allocate, this refuses a total that is
not a whole number of units at places. It also refuses weights that do not
describe shares of anything: an empty array, a negative weight, or weights
that add up to zero.
Rounding
What to do with a digit that will not fit.
Seven of them, in three groups. The first two say which way to go regardless of how close the value was:
Up: away from zero.2.4and2.6both become3.Down: toward zero, which is truncation.2.4and2.6both become2, and-2.6becomes-2.
The next two depend on the sign of the number rather than on how close it was:
Ceiling: toward positive infinity.-2.6becomes-2.Floor: toward negative infinity.-2.4becomes-3.
The last three all go to the nearer value and differ only on an exact tie:
HalfUp: ties away from zero.2.5becomes3. This is the rounding taught in school, and the one most people mean.HalfDown: ties toward zero.2.5becomes2.HalfEven: ties to the even neighbor.2.5becomes2and3.5becomes4. Ties fall both ways instead of always up, so a long column of rounded numbers does not drift. This is banker's rounding, and it is what IEEE 754 does by default.
Round to a number of decimal places: roundTo places rounding value.
BigDecimal.fromString "2.675"
|> Maybe.map (BigDecimal.roundTo 2 BigDecimal.HalfUp)
|> Maybe.map BigDecimal.toString
--> Just "2.68"
That is the example a Float gets wrong, and not because of the rounding:
the nearest double to 2.675 is slightly below it, so rounding that gives
2.67. Here the value is exactly 2.675 and the tie is a real tie.
Places may be negative, which rounds to tens or hundreds. Rounding to more
places than a number has does nothing at all. There are no trailing zeroes to
add, because a value never carries any;
toStringWithPlaces is where padding lives.
Comparing
How the first compares to the second, the way Basics.compare does.
Because trailing zeroes are stripped, compare a b == EQ and a == b agree
about everything, which is the property this module is arranged around.
Whether this is zero, which costs nothing at any size: normalization leaves exactly one zero, so this is a look at the unscaled value and nothing else.
Zero is not negative.
Whether there is nothing after the point.
The test is simply whether the scale is zero or less, and that only works
because of normalization: a value that ends in a zero has already had it
removed, so 1.50 arrives here as 1.5 and 2.00 as 2.
BigDecimal.fromString "2.00" |> Maybe.map BigDecimal.isInteger
--> Just True
The larger of the two, standing in for Basics.max.
The smaller of the two, standing in for Basics.min.
The representation
The power of ten the value is stored over: 1.5 has a scale of 1, and
100 has a scale of -2.
This is the canonical scale, not one you chose. Because trailing zeroes are stripped, it says how many decimal places the number actually needs, which is a fact about the value rather than about its history.
The digits, without the point: the BigInt that scale is the
exponent for. Together they are the whole of the representation.
BigDecimal.fromString "-1.25" |> Maybe.map BigDecimal.unscaled
--> Just (BigInt.fromInt -125)
Multiply by a power of ten by moving the point, not by multiplying: a positive count moves it right.
BigDecimal.fromString "1.5" |> Maybe.map (BigDecimal.movePointBy 2)
--> BigDecimal.fromString "150"
This costs nothing at any size, because only the scale changes.
A note on argument order
As in BigInt, wherever the order of the operands matters, the
subject comes last:
a |> BigDecimal.subBy one -- a - 1
a |> BigDecimal.divBy b -- a / b
compare is the exception, because it stands in for Basics.compare.