Arbitrary-precision decimal arithmetic for Standard ML.
sml-bigdecimal provides a Decimal structure: a Java-BigDecimal-style
decimal type built on the Basis Library's arbitrary-precision integers
(IntInf). A value is a coefficient times a power of ten, so arithmetic is
exact and overflow-free, the scale (number of decimal places) is preserved,
and any digit-discarding operation takes an explicit rounding mode.
The Standard ML Basis Library has no decimal type, so this fills a real gap. The library is pure Standard ML (Basis-only) and portable; it is tested on MLton and Poly/ML.
real is binary floating-point: 0.1 + 0.2 is not 0.3, and you cannot ask
for "exactly two decimal places, rounded half-even." That makes real wrong
for money and anything human-facing. Decimal is exact in base ten and keeps
its scale, so 0.1 + 0.2 is 0.3 and 2.50 stays 2.50.
val price = valOf (Decimal.fromString "19.99")
val tax = Decimal.setScale Decimal.HALF_EVEN 2
(Decimal.* (price, valOf (Decimal.fromString "0.0825")))
val () = print (Decimal.toString tax ^ "\n") (* "1.65" *)A value represents coeff * 10^(-scale):
| Value | coeff | scale |
|---|---|---|
3.14 |
314 |
2 |
100 |
100 |
0 |
-0.001 |
-1 |
3 |
The scale is meaningful and user-controlled (unlike a rational, the trailing
zeros in 2.50 are kept). Sign lives in the coefficient; scale is always >= 0.
+and-promote to the larger of the two input scales (2.5 + 1.00 = 3.50).*adds the input scales (1.5 * 2.0 = 3.00).divandsetScaletake an explicit target scale and rounding mode, so precision is never discarded silently.
signature DECIMAL =
sig
type decimal
type t = decimal
datatype rounding = HALF_EVEN | HALF_UP | FLOOR | CEILING | TRUNCATE
val zero : t
val one : t
val fromInt : int -> t
val fromIntInf : IntInf.int -> t
val fromString : string -> t option (* "3.14", "-0.001", "100", "1e3", ".5", "2.5E-2" *)
val scale : t -> int
val coeff : t -> IntInf.int
val setScale : rounding -> int -> t -> t (* raises General.Domain if scale < 0 *)
val ~ : t -> t
val + : t * t -> t (* result scale = max of input scales *)
val - : t * t -> t (* result scale = max of input scales *)
val * : t * t -> t (* result scale = sum of input scales *)
val div : rounding -> int -> t * t -> t (* explicit result scale; raises General.Div on zero *)
val roundTo : rounding -> int -> t -> t (* round to n fractional digits *)
val round : rounding -> t -> t (* round to an integer (scale 0) *)
val floor : t -> t (* to integer, toward negative infinity *)
val ceil : t -> t (* to integer, toward positive infinity *)
val truncate : t -> t (* to integer, toward zero *)
val pow : t * int -> t (* integer power; exponent >= 0 *)
val sqrt : int -> t -> t (* square root to n fractional digits (Newton) *)
val compare : t * t -> order
val equal : t * t -> bool (* by value: 2.5 = 2.50 *)
val toReal : t -> real (* approximation *)
val toString : t -> string (* "3.14", "-0.001", "100" *)
end- Rounding modes.
HALF_EVEN(banker's rounding, the money default),HALF_UP,FLOOR(toward negative infinity),CEILING(toward positive infinity), andTRUNCATE(toward zero). - Equality is by value.
2.5and2.50areequalandcompareEQUAL, even though their representations differ. toRealis an approximation. Decimals are exact, butrealis binary floating-point, sotoRealreturns the nearestreal, not an exact result.- Error handling.
divby zero raisesGeneral.Div; a negative target scale (indivorsetScale) raisesGeneral.Domain.fromStringreturnsNONEon malformed input. fromStringgrammar. Optional sign (-,~, or+), an integer part, an optional.with a fractional part (.5is allowed;3.is not), and an optionale/Eexponent. The coefficient is arbitrary-precision (IntInf), so a huge mantissa is exact; the exponent is bounded to the portable signed-32-bit range and yieldsNONEbeyond it, identically on MLton and Poly/ML, rather than raisingOverflowunder MLton's fixed-widthint.
Beyond setScale, the library offers named rounding helpers, integer powers,
and a decimal square root. Everything is computed over the exact IntInf
coefficient — the square root uses Newton's method on the scaled integer, never
machine real — so results are deterministic and identical across compilers.
Decimal.round HALF_EVEN (d "2.5") (* 2 (banker's rounding) *)
Decimal.round HALF_UP (d "2.5") (* 3 *)
Decimal.roundTo HALF_UP 2 (d "1.2345") (* 1.23 *)
Decimal.floor (d "2.9") (* 2 *)
Decimal.ceil (d "2.1") (* 3 *)
Decimal.truncate (d "-2.9") (* -2 *)
Decimal.pow (d "1.1", 2) (* 1.21 *)
Decimal.sqrt 10 (d "2") (* 1.4142135623 *)round/roundTo.roundTo mode nrounds to exactlynfractional digits (a named alias ofsetScale);round moderounds to an integer (scale 0). Both reuse the existingroundingmodes.floor/ceil/truncate. Convenience rounding to an integer toward negative infinity, positive infinity, and zero respectively.pow (x, e). Integer power;pow (x, 0) = oneand the result scale isscale x * e, so the value is exact. A negative exponent raisesGeneral.Domain(usedivwith an explicit scale for reciprocals).sqrt n x. Square root truncated tonfractional digits via Newton's method on the scaled coefficient, soresult^2 <= x. A negative operand or negativenraisesGeneral.Domain.
This library follows the conventions of the
smlpkg package manager. There is no
registry or account to sign up for -- packages are referenced directly by
their git URL. In your own project's directory:
smlpkg add github.com/sjqtentacles/sml-bigdecimal
smlpkg syncThis downloads the library into
lib/github.com/sjqtentacles/sml-bigdecimal/. Reference its .mlb from your
own .mlb with a relative path.
$(SML_LIB)/basis/basis.mlb
lib/github.com/sjqtentacles/sml-bigdecimal/decimal.mlb
your-code.sml
use "lib/github.com/sjqtentacles/sml-bigdecimal/decimal.sig";
use "lib/github.com/sjqtentacles/sml-bigdecimal/decimal.sml";The test suite is a dependency-free assertion runner (pure Standard ML) that exits non-zero if any assertion fails.
make test # build + run the suite under MLton
make test-poly # run the suite under Poly/ML
make all-tests # run under both
make example # build + run the demo
make cleanBuilt test-first (TDD): the signature and full suite were written first against a stub, confirmed to compile and fail (red), then implemented to green.
examples/demo.sml does scale-preserving arithmetic on a
couple of fixed decimals, plus rounded division, an integer power, a Newton
square root, and two rounding modes. Everything is computed over exact IntInf
coefficients and printed with Decimal.toString, so the output is identical on
every run and on both compilers. Run it with:
$ make example
a = 3.14, b = 2.50
a + b = 5.64
a - b = 0.64
a * b = 7.8500
a / b (HALF_EVEN, 4dp) = 1.2560
1.1 ^ 2 = 1.21
sqrt 2 (10 dp) = 1.4142135623
round HALF_EVEN 2.5 = 2
round HALF_UP 2.5 = 3
For exact fractions with no rounding at all (e.g. 1/3 held exactly), see the
companion library sml-rational.
Rational is for loss-free exact arithmetic; Decimal is for scale-controlled,
human-readable decimal values with explicit rounding.
sml.pkg smlpkg manifest (package name + requires)
lib/github.com/sjqtentacles/sml-bigdecimal/
decimal.sig the DECIMAL signature (the contract)
decimal.sml structure Decimal :> DECIMAL
decimal.mlb MLton basis file for consumers
test/test.sml assertion-based test suite
test/test.mlb MLton basis file for the tests
Makefile build + test (MLton and Poly/ML)
.github/workflows/ci.yml CI on both compilers
MIT. See LICENSE.