denary 1.2.0
denary: ^1.2.0 copied to clipboard
Decimal numbers with a fixed point and no loss of precision. Two independent families - one on BigInt with no bounds on magnitude, one on int for speed.
Denary #
A package for decimal numbers with a fixed point and no loss of precision.
Table of contents #
-
3.1. What packages are already in place?
3.2. What's it supposed to be?
3.3. Package performance
3.4. decimal vs denary
-
4.2. Performance
4.3.
Decimaloptimization
Getting started #
dart pub add denary
import 'package:denary/denary.dart';
final price = Decimal.parse('19.99');
final total = price * Decimal(3);
print(total); // 59.97
print(Decimal.parse('0.1') + Decimal.parse('0.2') == Decimal.parse('0.3')); // true
// Not every division has a decimal answer, so the package makes you say which
// answer you want. Only the last of these throws over that — though all four
// throw on a zero divisor, which is a question rather than an answer.
print(total.divideOrNull(Decimal(7))); // null
print(total.divide(Decimal(7), scaleOnInfinitePrecision: 2)); // 8.57
print(total.divideWithRemainder(Decimal(2))); // 29 remainder 1.97
print(total / Decimal(3)); // 19.99
// A value is kept, the way it was written is not: trailing zeros say nothing
// about a number, so printing gives them back only when asked.
print(Decimal.parse('4.50')); // 4.5
print(Decimal.parse('4.50').toStringAsFixed(2)); // 4.50
There are two number types, and they never mix on their own:
Decimalkeeps the value in aBigInt. Nothing overflows, ever.ShortDecimalkeeps it in anint. Several times faster and smaller, and past the edge of int64 it wraps around silently, exactly asintdoes.
Either one can be imported without the other:
import 'package:denary/decimal.dart'; // the BigInt family
import 'package:denary/short_decimal.dart'; // the int64 family
Which to take, and what the second one costs, is in
Decimal vs ShortDecimal. The rest of this
README is why the package exists and what it is measured to cost.
Money #
Print the digits you mean. A value is kept, the way it was written is not:
print(Decimal.parse('4.50')); // 4.5
print(Decimal.parse('4.50').toStringAsFixed(2)); // 4.50
Round the way your rules round. round sends a half away from zero,
roundToEven sends it to the even neighbour — the rule accounting asks for, so
that a column of halves does not lean one way. roundAwayFromZero moves the
last digit out on any remainder at all. floor, ceil and truncate take a
number of digits as well.
print(Decimal.parse('2.5').round()); // 3
print(Decimal.parse('2.5').roundToEven()); // 2
print(Decimal.parse('2.01').roundAwayFromZero(1)); // 2.1
Divide in the direction you need. divide rounds to the closest; where the
direction matters, take the exact ratio of the division and round that:
final bill = Decimal.parse('23.99');
print(bill.divide(Decimal(3), scaleOnInfinitePrecision: 2)); // 8
print(bill.divideToFraction(Decimal(3)).floor(2)); // 7.99
Split a sum without losing a cent. Work in the smallest unit, divide as integers, and keep the remainder where you can see it:
final cents = (bill << 2) ~/ Decimal(3);
final share = Decimal.fromBigInt(cents, shiftRight: 2);
print(share); // 7.99
print(bill - share * Decimal(3)); // 0.02
~/ answers with a BigInt — integer division answers with an integer
everywhere in Dart — while % answers with a Decimal. divideWithRemainder
hands over both at once.
Coming from JSON with numeric prices. A double has lost what it lost
before this package sees it, so there is no constructor taking one. What you
almost always want is the shortest decimal that produces the same double,
and that is what its toString already is:
const fromApi = 19.99;
print(Decimal.parse(fromApi.toString())); // 19.99
Where the exact binary value is what you are after instead, ask for the digits:
Decimal.parse(fromApi.toStringAsFixed(20)) is 19.98999999999999843681.
Why? #
As of February 2025, there are several packages on pub.dev that work with decimals.
What packages are already in place? #
decimal
A wonderful package that works correctly with decimals. It exists since 2014 and is constantly updated. In one of the latest updates (3.2.0), performance has been significantly improved. Before that, speed was the weak point of this package. This was one of the reasons why denary appeared, since I started writing it before 3.2.0. However, I would have written it anyway. More about it below.
fixed
The package has difficulty dividing. You can't just do a 1 / 8 operation and get the expected 0.125:
final a = Fixed.fromInt(1, decimalDigits: 0); // 1
final b = Fixed.fromInt(8, decimalDigits: 0); // 8
print('$a / $b = ${a / b}'); // 1 / 8 = 0
final c = Fixed.fromInt(10, decimalDigits: 1); // 1.0
final d = Fixed.fromInt(80, decimalDigits: 1); // 8.0
print('$c / $d = ${c / d}'); // 1.0 / 8.0 = 0.1
final e = Fixed.fromInt(100, decimalDigits: 2); // 1.00
final f = Fixed.fromInt(800, decimalDigits: 2); // 8.00
print('$e / $f = ${e / f}'); // 1.00 / 8.00 = 0.13
The result depends on the scale of the numerator and denominator: the division keeps the larger of the two and rounds the answer to it. An eighth has an exact decimal form, and it is rounded away all the same, without a word. You have to know the answer's scale in advance and ask for it:
final a = Fixed.fromInt(1, decimalDigits: 0).copyWith(decimalDigits: 3); // 1.000
final b = Fixed.fromInt(8, decimalDigits: 0); // 8
print('$a / $b = ${a / b}'); // 1.000 / 8 = 0.125
The division itself is exact: fixed divides in BigInt, rounding half away
from zero. Checked on 6.1.1 and 6.2.0 — a number squared and divided back comes
out to the digit:
final a = Fixed.parse('111111111111111111');
final b = a * a;
print(b); // 12345679012345678987654320987654321
print(b / a); // 111111111111111111
What remains is the first point, and it is the reason this package exists: the
division rounds to a scale nobody asked for. Here Decimal(1) / Decimal(8) is
0.125 whatever the two sides were built from, and a division that has no
finite decimal form says so instead of quietly rounding.
decimal_type
This package also uses to divide double, but unlike
fixed it doesn't know some corner case:
var a = Decimal(BigInt.parse('644385861467633436300000'), decimalPrecision: 0);
var b = Decimal.fromInt(123);
print('$a / $b = ${a / b}'); // FormatException: Could not parse BigInt 238909442826288e+21
First the result of division is calculated as double, then it is converted to
a string using double.toStringAsFixed, and the string is then converted to
BigInt. But double.toStringAsFixed does not always return an "asFixed"
result. When the error limit is reached, the method switches to
double.toStringAsExponential. The author did not notice this feature. But we
found out faster what is hiding under the hood.
big_decimal
This one seems to have been ported over from Java:
A bugless implementation of BigDecimal in Dart based on Java's BigDecimal.
But it doesn't just divide 1 by 8 in it:
final a = BigDecimal.one;
final b = BigDecimal.parse('8');
print('$a / $b = ${a.divide(b)}'); // Exception: Rounding necessary
Numbers can be divided by specifying the rounding mode. But that's not what we wanted to do.
final a = BigDecimal.one;
final b = BigDecimal.parse('8');
print('$a / $b = ${a.divide(b, roundingMode: RoundingMode.FLOOR)}'); // 1 / 8 = 0
Or by changing the scale of the numerator:
final a = BigDecimal.parse('1.000');
final b = BigDecimal.parse('8');
print('$a / $b = ${a.divide(b)}'); // 1.000 / 8 = 0.125
If we don't guess the scale, we get an error.
That's how "a bugless implementation of BigDecimal" works.
precise_decimal
This one is younger than the rest — it came out in April 2026, when this
package was already written — and it is the one with nothing to report. It
divides 1 by 8 and gets 0.125 without being told a scale. It answers null
from tryDivideExact when the quotient does not terminate, and throws from
divideExact with a message that says why. It puts 0.5 above 0.49 in
compareTo. It carries 1 / 256 / 256 / … to the last digit. Every trap the
four above fall into, it walks past.
In the bench it is the only competitor that supports all 35 tests and answers
none of them wrongly — to-double-wide included, the row where
decimal and
big_decimal each miss the nearest
double on seven values out of twenty. On add-dirty-int it is faster than
this package, and that is the only row where anyone is: its coefficient stays
an int for as long as one fits, so small values never leave int64 arithmetic.
The visible difference between us is a matter of taste rather than of
correctness. It keeps the scale a number was parsed with, so parse('1.500')
prints as 1.500, while here the value is brought to its canonical form and
prints as 1.5; both packages call those two numbers equal in ==,
compareTo and hashCode. What it has and this package does not is the rest
of General Decimal Arithmetic — DecimalContext with decimal32/64/128,
conditions and traps. What this package has and it does not is a second family,
on int.
What's it supposed to be? #
Three packages out of five did not satisfy me because of bugs in calculations,
incomplete functionality (division) or use of double under the hood.
The decimal,
precise_decimal and
denary
does not have the above division problems. No need to calculate scale
yourself, and no double under the hood.
Numbers are read from strings — exponential notation included — and written back without losing anything on the way:
print(Decimal.parse('1.5e21')); // 1500000000000000000000
print(Decimal.parse('-0.000001')); // -0.000001
print(Decimal.parse('19.99').toStringAsFixed(4)); // 19.9900
decimal returns the result as Rational
(rational), since not every division
result can be represented by a decimal. But it can be easily converted
to Decimal:
final a = Decimal.one;
final b = Decimal.fromInt(256);
print('$a / $b = ${a / b}'); // 1 / 256 = 1/256
print('$a / $b = ${(a / b).toDecimal()}'); // 1 / 256 = 0.00390625
If the result cannot be represented as a decimal, i.e. the number has an
infinite number of decimal places (has infinite precision), an exception will
be thrown. But if you pass scaleOnInfinitePrecision to toDecimal to limit
the precision, the number will be converted to decimal with loss of precision
and no exception will be thrown.
final a = Decimal.one;
final b = Decimal.fromInt(3);
print('$a / $b = ${a / b}'); // 1 / 3 = 1/3
print('$a / $b = ${(a / b).toDecimal(scaleOnInfinitePrecision: 6)}'); // 1 / 3 = 0.333333
denary does the opposite and returns the result immediately:
final a = Decimal.one;
final b = Decimal(256);
print('$a / $b = ${a / b}'); // 1 / 256 = 0.00390625
I wanted a package that works with decimals to return the result as a decimal by default. But not every division has a decimal answer — one third has none — so the package makes you say which answer you want. Four ways, and only the last of them fails over a quotient nobody can write down:
final a = Decimal.one;
final b = Decimal(3);
print(a.isDivisibleBy(b)); // false — ask before dividing
print(a.divideOrNull(b)); // null — divide, and be told
print(a.divide(b, scaleOnInfinitePrecision: 6)); // 0.333333 — round on the spot
print(a / b); // throws DecimalDivideException
divideOrNull is the one to reach for by default: it asks the same question as
catching the exception and is several times faster at it — about five times in
Decimal, and more than a hundred in ShortDecimal, where the division costs
nanoseconds while a throw still costs about a microsecond. operator / is the
fast form for the case where the division is known to be exact in advance: a
value scaled by a power of ten, or a divisor that is a factor of the dividend.
Splitting money by a number of parts is not such a case: a third of 23.99 has
no finite decimal form, and Decimal.parse('23.99') / Decimal(3) throws. A
money split goes to the smallest unit first, and then nothing is lost:
Decimal.parse('23.99') << 2 is 2399 cents, ~/ Decimal(3) gives each part
799 of them, and % says which two are left over.
When it does throw, the exception is not a dead end: it carries every other answer the division had.
final a = Decimal.one;
final b = Decimal(3);
try {
print('$a / $b = ${a / b}');
} on DecimalDivideException catch (e) {
print('${e.dividend} / ${e.divisor} = ${e.fraction}'); // 1 / 3 = 1/3
print('${e.dividend} / ${e.divisor} = ${e.quotientWithRemainder}'); // 1 / 3 = 0 remainder 1
print('${e.dividend} / ${e.divisor} = ${e.round(6)}'); // 1 / 3 = 0.333333
}
It is possible to avoid exceptions by using one of the methods:
divideToDouble, divideToFraction, divideWithRemainder.
This way I tried to avoid different interpretations. If you need the result as
double, say so explicitly:
print('$a / $b = ${a.divideToDouble(b)}'); // 0.3333333333333333
print('$a / $b = ${a.divideToFraction(b)}'); // 1/3
print('$a / $b = ${a.divideWithRemainder(b)}'); // 0 remainder 1
The approach implemented in decimal is convenient because it allows to perform a number of actions, the intermediate results of which cannot be represented as a decimal, but the final result is still expected to be a decimal. For example: 1 / 3 * 9:
final rational = Decimal.fromInt(1) / Decimal.fromInt(3) * Decimal.fromInt(9).toRational();
final decimal = rational.toDecimal(); // 3
A package that works only with decimals will not be able to solve such an example so elegantly. Or you will have to resort to rounding and lose precision:
1 / 3 = 0.333
0.333 * 9 = 2.997
But you can use additional solutions for working with fractions, such as the fraction, or the already mentioned rational.
denary has its own
Fraction class, which provides basic functions for working with fraction.
final a = Fraction(BigInt.from(1), BigInt.from(2));
final b = Fraction(BigInt.from(1), BigInt.from(3));
final f1 = a * b;
final f2 = a / b;
final f3 = a + b;
final f4 = a - b;
print('($a) * ($b) = $f1 -> ${f1.round(6)}'); // (1/2) * (1/3) = 1/6 -> 0.166667
print('($a) / ($b) = $f2 -> ${f2.toDecimal()}'); // (1/2) / (1/3) = 3/2 -> 1.5
print('($a) + ($b) = $f3 -> ${f3.round(6)}'); // (1/2) + (1/3) = 5/6 -> 0.833333
print('($a) - ($b) = $f4 -> ${f4.round(6)}'); // (1/2) - (1/3) = 1/6 -> 0.166667
Package performance #
The numbers below come from the bench in example/ — what it does
and why is in example/README.md.
The short of it:
- every answer is checked before it is timed, so a wrong answer is never reported as a fast one;
- every benchmark is measured five times, and the whole sweep is run twice
(
--passes=2); the table shows the best of the two medians, because unrelated load on the machine can only ever make a benchmark look slower; - the result of every measured cycle goes into a sink the optimizer is not allowed to drop;
- a package that has no such operation shows
—, not an error.
One row is one pass over a list of values, and the number is what that pass
cost in microseconds. ★ marks the winner and everyone within 10 % of it,
▼Nx says how many times slower than the winner. ShortDecimal stands outside
that reckoning and carries a mark of its own: ★★ where it is faster than
every package in the comparison — so the rows where that mark is missing are
exactly the rows where int64 buys nothing. The absolute values mean nothing on
their own; another machine will give different ones. The ratios are the point.
Running the tests:
dart compile exe example/bin/benchmark.dart
example/bin/benchmark.exe all --passes=2
Dart: 3.13.0 (stable) on "macos_arm64"
OS: Version 26.5.2 (Build 25F84)
CPUs: 14
Mode: AOT (dart compile exe)
Runs: 5 (median of the series)
Deps: decimal 3.2.6, decimal_type 0.0.3, fixed 6.1.1, big_decimal 0.7.0,
precise_decimal 0.0.1
The two rightmost columns are this package: Decimal on BigInt and
ShortDecimal on int. ShortDecimal stands outside the comparison — int64 is
not the same job as BigInt, and it is in the table to show what that
difference buys. The bench also runs
big_double, which is left out here: it is
a floating-point type, and on most of these rows its answer is not the exact
one.
| decimal | decimal_type | fixed | big_decimal | precise_decimal | Decimal | ShortDecimal | |
|---|---|---|---|---|---|---|---|
| add-big-int | 1.567 µs | (▼2x) 2.651 µs | 1.941 µs | 1.856 µs | 1.441 µs | ★ 0.996 µs | — |
| add-int | 0.581 µs | (▼2x) 0.979 µs | (▼2x) 0.727 µs | 0.697 µs | 0.559 µs | ★ 0.357 µs | ★★ 0.141 µs |
| add-dirty-big-int | 0.778 µs | (▼2x) 1.288 µs | 0.966 µs | 0.925 µs | 0.720 µs | ★ 0.504 µs | — |
| add-dirty-int | (▼2x) 0.346 µs | (▼3x) 0.563 µs | (▼2x) 0.435 µs | (▼2x) 0.418 µs | ★ 0.167 µs | 0.223 µs | ★★ 0.087 µs |
| multiply-large-big-int | ★ 0.117 µs | ★ 0.107 µs | 0.145 µs | ★ 0.110 µs | (▼2x) 0.291 µs | ★ 0.115 µs | — |
| multiply-large-int | ★ 0.101 µs | ★ 0.094 µs | 0.130 µs | ★ 0.096 µs | (▼2x) 0.240 µs | ★ 0.099 µs | ★★ 0.047 µs |
| multiply-small-big-int | ★ 0.116 µs | ★ 0.108 µs | (▼46x) 5.040 µs | ★ 0.110 µs | (▼2x) 0.290 µs | ★ 0.115 µs | — |
| multiply-small-int | ★ 0.098 µs | ★ 0.092 µs | (▼35x) 3.231 µs | ★ 0.091 µs | 0.106 µs | ★ 0.095 µs | ★★ 0.047 µs |
| multiply-dirty-big-int | ★ 0.091 µs | ★ 0.086 µs | (▼31x) 2.733 µs | ★ 0.087 µs | (▼2x) 0.253 µs | ★ 0.090 µs | — |
| multiply-dirty-int | 0.038 µs | ★ 0.034 µs | (▼28x) 0.979 µs | ★ 0.035 µs | 0.046 µs | ★ 0.037 µs | ★★ 0.017 µs |
| divide-large-big-int | (▼5x) 8.990 µs | ERROR | 2.081 µs | 1.936 µs | 2.022 µs | ★ 1.675 µs | — |
| divide-large-int | (▼5x) 7.690 µs | ERROR | 1.738 µs | 1.638 µs | 1.719 µs | ★ 1.444 µs | ★★ 0.053 µs |
| divide-small-big-int | (▼110x) 589.243 µs | ERROR | ERROR | ERROR | (▼2x) 11.506 µs | ★ 5.314 µs | — |
| divide-small-int | (▼51x) 136.201 µs | ERROR | ERROR | ERROR | (▼2x) 6.928 µs | ★ 2.635 µs | ★★ 0.118 µs |
| divide-dirty-big-int | (▼343x) 449.916 µs | ERROR | (▼2x) 3.293 µs | 1.822 µs | (▼32x) 42.967 µs | ★ 1.309 µs | — |
| divide-dirty-int | (▼64x) 36.797 µs | ERROR | (▼2x) 1.238 µs | 0.795 µs | (▼10x) 5.867 µs | ★ 0.574 µs | ★★ 0.023 µs |
| divide-large-and-view-big-int | (▼5x) 9.059 µs | ERROR | 2.189 µs | 1.937 µs | 2.107 µs | ★ 1.675 µs | — |
| divide-large-and-view-int | (▼5x) 7.817 µs | ERROR | 1.856 µs | 1.653 µs | 1.807 µs | ★ 1.445 µs | ★★ 0.056 µs |
| divide-small-and-view-big-int | (▼91x) 595.201 µs | ERROR | ERROR | ERROR | 11.697 µs | ★ 6.506 µs | — |
| divide-small-and-view-int | (▼43x) 138.236 µs | ERROR | ERROR | ERROR | (▼2x) 7.115 µs | ★ 3.182 µs | ★★ 0.317 µs |
| raw-view-big-int | 22.705 µs | (▼2x) 48.778 µs | (▼2x) 53.323 µs | ★ 19.714 µs | 31.444 µs | ★ 18.464 µs | — |
| raw-view-int | 10.303 µs | (▼3x) 23.894 µs | (▼3x) 24.063 µs | 8.069 µs | (▼2x) 19.398 µs | ★ 6.888 µs | ★★ 1.584 µs |
| raw-view-zeros-big-int | (▼6x) 111.538 µs | (▼7x) 137.132 µs | (▼3x) 62.897 µs | ★ 18.886 µs | 31.330 µs | ★ 18.121 µs | — |
| raw-view-zeros-int | (▼6x) 48.973 µs | (▼6x) 47.820 µs | (▼4x) 30.115 µs | 8.172 µs | (▼2x) 20.465 µs | ★ 7.068 µs | ★★ 1.108 µs |
| repeat-view-big-int | (▼622x) 18.049 µs | (▼1697x) 49.241 µs | (▼1828x) 53.035 µs | (▼668x) 19.372 µs | (▼67x) 1.949 µs | ★ 0.029 µs | — |
| repeat-view-int | (▼227x) 6.362 µs | (▼852x) 23.863 µs | (▼851x) 23.839 µs | (▼274x) 7.695 µs | (▼59x) 1.653 µs | ★ 0.028 µs | 1.546 µs |
| repeat-view-zeros-big-int | (▼43x) 1.253 µs | (▼5121x) 148.533 µs | (▼2159x) 62.628 µs | (▼637x) 18.484 µs | (▼68x) 1.991 µs | ★ 0.029 µs | — |
| repeat-view-zeros-int | (▼35x) 1.031 µs | (▼1779x) 51.617 µs | (▼1026x) 29.757 µs | (▼268x) 7.781 µs | (▼59x) 1.736 µs | ★ 0.029 µs | 0.927 µs |
| parse | (▼12x) 14.092 µs | (▼12x) 14.269 µs | (▼76x) 85.361 µs | (▼9x) 10.812 µs | (▼10x) 11.638 µs | ★ 1.120 µs | ★★ 0.819 µs |
| compare | 0.633 µs | (▼7x) 2.537 µs | ERROR | (▼3x) 1.183 µs | 0.635 µs | ★ 0.334 µs | ★★ 0.157 µs |
| round | 3.913 µs | — | 4.354 µs | 4.257 µs | 5.349 µs | ★ 3.487 µs | ★★ 0.206 µs |
| to-double | (▼2x) 1.844 µs | (▼30x) 19.876 µs | — | 0.744 µs | (▼20x) 13.024 µs | ★ 0.650 µs | ★★ 0.092 µs |
| to-double-wide | ERROR | (▼14x) 34.358 µs | — | ERROR | (▼8x) 20.006 µs | ★ 2.306 µs | — |
| to-string-as-fixed | (▼2x) 15.063 µs | — | — | — | 7.406 µs | ★ 5.664 µs | ★★ 1.846 µs |
| unrepresentable-divide | (▼32x) 124.302 µs | — | — | 4.780 µs | 4.255 µs | ★ 3.789 µs | ★★ 0.388 µs |
ERROR is not a crash. It means the package answered and the answer was wrong.
fixed and
decimal_type return 0 for
1 / 256 / 256 / …, which is what a fixed scale does to a small number, and
fixed puts 0.5 below 0.49 in compare, because its compareTo looks at the
stored integers without first bringing the scales together.
big_decimal is the honest one in that
column: it refuses with Rounding necessary instead of answering wrongly — but
a refusal is not a result either, and it shows as ERROR all the same.
to-double-wide is the one row where decimal
shows ERROR, and it deserves to be spelled out. Its values carry more
significant digits than a double holds, so every one of them has to be
rounded and the only question is whether it lands on the nearest one. The set
was generated blind — not by hunting for values where somebody fails — and the
expected answers were computed outside Dart by exact conversion. Of the twenty,
decimal misses the nearest double on seven and big_decimal on seven as well;
both divide one double by another, which rounds twice. Over 100 000 random
values the two packages disagree on 33 815 of them, and on 300 sampled
disagreements checked against exact arithmetic, this package was right every
time and decimal never.
That row is also where the fastest answers are the wrong ones:
big_decimal finishes it in 0.8 µs and decimal in 2.4, against 2.3 here.
Where a division is exact, the gap is not about BigInt against int but about
what the algorithm can see. divide-dirty divides a product back by its own
factors — every step exact, nothing about the numbers saying so in advance —
and decimal spends 343 times longer on it
than this package.
unrepresentable-divide rounds a quotient that has no finite decimal form, and
this package takes the row ahead of everyone in the comparison, with
ShortDecimal ten times ahead of Decimal on top of that. divide returns the
exact answer whenever the division does have a finite form, however many digits
that takes, and it pays nothing to find out: when the divisor shares no prime
factor with ten, a non-zero remainder from the rounding is itself the proof that
no finite form exists, so the question is never asked separately.
Description of benchmarks
add
Adding numbers:
10000000000000000000 + 1000000000000000000 + 100000000000000000 + 10000000000000000 + 1000000000000000 + 100000000000000 + 10000000000000 + 1000000000000 + 100000000000 + 10000000000 + 1000000000 + 100000000 + 10000000 + 1000000 + 100000 + 10000 + 1000 + 100 + 10 + 1 + 0.1 + 0.01 + 0.001 + 0.0001 + 0.00001 + 0.000001 + 0.0000001 + 0.00000001 + 0.000000001 + 0.0000000001 + 0.00000000001 + 0.000000000001 + 0.0000000000001 + 0.00000000000001 + 0.000000000000001 + 0.0000000000000001 + 0.00000000000000001 + 0.000000000000000001 + 0.0000000000000000001 + 0.00000000000000000001 = 11111111111111111111.11111111111111111111
A very simple operation. But note that in the case of decimal, it is much more complicated than multiplication.
multiply-large
Multiplication of large numbers:
123456789 * 123456789 * 123456789 * 123456789 * 123456789 * 123456789 * 123456789 * 123456789 * 123456789 * 123456789 = 822526259147102579504761143661535547764137892295514168093701699676416207799736601
A simple operation for decimal. It is impossible to make a mistake in it. There is no simpler operation.
multiply-small
Multiplication of small numbers:
0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 * 0.0123456789 = 0.0000000000000000000822526259147102579504761143661535547764137892295514168093701699676416207799736601
A simple operation, but not all packages are ready to handle numbers that have more than 20 decimal places.
divide-large
Division of large numbers:
822526259147102579504761143661535547764137892295514168093701699676416207799736601 / 123456789 / 123456789 / 123456789 / 123456789 / 123456789 / 123456789 / 123456789 / 123456789 / 123456789 / 123456789 = 1
Division is not the strongest point of most packages. Even integers! Even the result of which is also an integer!
divide-small
Division of small numbers:
1 / 256 / 256 / 256 / 256 / 256 / 256 / 256 / 256 / 256 = 0.000000000000000000000211758236813575084767080625169910490512847900390625
It's a difficult task. It's easy to stumble over. decimal
solves it, but at what cost! Some packages use the double trick and stumble
over it. And some don't even try.
divide-large-and-view and divide-small-and-view
Division of numbers and converting the result in a readable format.
Packages can use intermediate results in their work, which speed up the speed of operations, but do not have a decimal form understandable to the user. (This is what decimal did until version 3.2.0). Therefore, the divide-large and divide-small test, where only division is performed, may be far from real life. This tests perform the same operation as divide-large and divide-small, but additionally convert the result of the operation (only the operation, not each step in this operation) into a readable form.
I'll be honest, it took me a long time to find a solution that satisfied me in terms of performance.
raw-view
Convert newly created numbers into a readable format:
- 123456789012345678901234567890123456789
- 1234567890123456789012345678901234567.89
- 12345678901234567890123456789012345.6789
- 123456789012345678901234567890123.456789
- 1234567890123456789012345678901.23456789
- 12345678901234567890123456789.0123456789
- 123456789012345678901234567.890123456789
- 1234567890123456789012345.67890123456789
- 12345678901234567890123.4567890123456789
- 123456789012345678901.234567890123456789
- 1234567890123456789.01234567890123456789
- 12345678901234567.8901234567890123456789
- 123456789012345.678901234567890123456789
- 1234567890123.45678901234567890123456789
- 12345678901.2345678901234567890123456789
- 123456789.012345678901234567890123456789
- 1234567.89012345678901234567890123456789
- 12345.6789012345678901234567890123456789
- 123.456789012345678901234567890123456789
- 1.23456789012345678901234567890123456789
This is usually a resource-intensive task, as the package does not have time to do any optimizations with the number.
The values above are one cycle of it. The set is a pool a hundred cycles deep, walked in order, so every cycle converts values no cycle before it converted. A package is free to remember what it printed — that is what repeat-view measures — but this row may not be answered from that memory, and a fresh object is not enough to prevent it: a table keyed by the value rather than by the object answers a new object just the same. A pool larger than any cache in the comparison is what keeps the row a first conversion for every package alike.
raw-view-zeros
Convert newly created numbers with lots of leading and trailing zeros into a readable format:
- 100000000000000000000000000000000000000
- 10000000000000000000000000000000000
- 1000000000000000000000000000000
- 100000000000000000000000000
- 10000000000000000000000
- 1000000000000000000
- 100000000000000
- 10000000000
- 1000000
- 100
- 0.01
- 0.000001
- 0.0000000001
- 0.00000000000001
- 0.000000000000000001
- 0.0000000000000000000001
- 0.00000000000000000000000001
- 0.000000000000000000000000000001
- 0.0000000000000000000000000000000001
- 0.00000000000000000000000000000000000001
Converting such numbers is technically quite different from converting numbers without zeros in raw-view. Each of the tests (raw-view and raw-vew-zeros) separately can give a wrong idea of performance, so they should be considered only together.
repeat-view
Converting the same numbers to a readable format a second time, and every time after that.
Printing one and the same value over and over is what a screen does, and a package is free to remember what it printed last time. raw-view measures the first conversion, this one measures all the later ones, and the two are only meaningful together: a package that is quick here and slow there has a cache, not a faster algorithm.
Decimal keeps the printed form; ShortDecimal does not, and cannot:
vm:deeply-immutable admits only final non-late fields, so a cache filled on
first use has nowhere to live. decimal_type,
fixed and big_decimal
keep nothing either.
repeat-view-zeros
The same, on numbers with a large number of leading or trailing zeros.
add-dirty, multiply-dirty, divide-dirty
The same three operations on values with nothing round about them: no trailing
zeros to strip, no common factors to cancel, no digit repeated. Every other set
here is built out of powers of ten or out of one factor repeated, which is the
best case for stripping zeros, for gcd and for the fast path of division.
Money does not look like that.
divide-dirty divides a product back by its own factors. Every division in it is
exact, but nothing about the numbers says so in advance — only gcd can see it.
parse
Reading twenty money-like numbers out of strings. Every package is given the same strings.
The row is not quite like for like, and the difference is a design decision on
this side: our parse stops at the digits and the scale, and leaves the
canonical form to whoever asks for it, while the other packages normalise the
value on the spot. Read the row together with raw-view and repeat-view, which
is where the work our parse did not do comes back.
compare
Comparing neighbours of the same magnitude but of different scales, so that the comparison cannot be settled by the exponent and has to bring the two numbers to a common scale first.
fixed 6.1.1 shows ERROR here: its
compareTo compares the stored integers without aligning the scales, so it puts
0.5 below 0.49. The bench checks every answer before timing it, and a wrong
answer is never reported as a fast one.
round
Rounding to two digits, halves away from zero.
to-double
Converting to the nearest double.
to-double-wide
The same conversion on numbers that carry more significant digits than a
double has room for — which is the only case where rounding to the nearest
double is a question at all. to-double above uses money-sized values, where
every package agrees.
The twenty values were generated with a fixed seed and no filtering, and their
expected answers come from exact decimal-to-binary conversion done outside
Dart. Magnitudes stay between 1e-4 and 1e16, where Dart prints a double
the same way the generator does, so the comparison is byte-for-byte.
to-string-as-fixed
Writing a number out with exactly two digits after the point — the operation that formats money for a screen.
unrepresentable-divide
Dividing by three: not one of the results has a finite decimal form, so every
one of them has to be rounded to ten digits. This is the price of the total
forms of division — divide(other, scaleOnInfinitePrecision: 10) here,
toDecimal(scaleOnInfinitePrecision: 10) in decimal,
divide(..., scale: 10) in big_decimal.
The packages that cannot do it at all show —.
decimal vs denary #
The last thing I want to do is compete with the author of decimal, especially when I see how long this package has been around and how well supported it is. I don't think I have anything overtly new to offer in the usual approach to decimal. Even using different approaches under the hood, the end result will be on the outside, not the inside. And it's pretty much the same feature set with pretty much the same performance.
But actually the decision to write my own
denary was not
only influenced by the poor (at the time) performance of
decimal. There was another reason. For my
task I needed a lightweight decimal, which needed a regular int instead of
BigInt to store values under the hood. My values fit even in int32. These are
the results of training: geoposition, distance, altitude gain, pace, heart
rate, cadence, power. As an old generation programmer, it's morally hard for me
to waste resources in places where it's not necessary. Especially I expect
a large amount of data and calculations with them. And I was surprised to find
no ready-made solution on pub.dev.
So, Decimal was not originally the main purpose of the package. The main goal
was ShortDecimal. Decimal was just a natural evolution of the package.
Decimal vs ShortDecimal #
Two families, three ways to import them:
// both, and the bridge between them
import 'package:denary/denary.dart';
// only the BigInt family
import 'package:denary/decimal.dart';
// only the int64 family
import 'package:denary/short_decimal.dart';
Take Decimal when the magnitudes are not known in advance: it has no bound
and nothing overflows. Take ShortDecimal when they are known to be small and
the speed is the point — it is several times faster and smaller, and its
overflow is silent. The bridge between them lives only in the umbrella import,
because it is the only thing that needs both.
ShortDecimal carries @pragma('vm:deeply-immutable'), and Decimal never
will — that annotation rejects a BigInt field outright. What it grants is
that the VM may hand another isolate the very same instance instead of a copy:
send one across a port and it comes back identical, where an ordinary class
of two int fields comes back copied. What it costs is every lazily filled
field, because a deeply immutable class may hold only final non-late ones —
which is why the printed form is remembered in Decimal and not here.
Both families answer to the same names. Two of those names are operators, and operators are easy to read the wrong way round, so each has a word for it:
| Operator | The same thing, spelled out |
|---|---|
value >> n |
value.movePointLeft(n) — divides by 10^n |
value << n |
value.movePointRight(n) — multiplies by 10^n |
>> moves the point left, and that is exactly what trips readers up.
ShortDecimal limitations #
ShortDecimal has the same functions as Decimal, but the values are stored
in int with all the consequences. On the one hand, it is high performance,
but on the other hand it is a possibility of uncontrolled overflow of a value,
which will not happen in case of using BigInt. You can write code that will
control overflow, but it will make the algorithms much more complicated and
slow. The additing is too simple to be burdened with additional checks. Each
such check will increase the operation's execution time by times.
Therefore, ShortDecimal should only be used with the possibility of overflow
in mind:
print(ShortDecimal(9223372036854775807) + ShortDecimal.one); // -9223372036854775808
The ShortDecimal capability bounds are int bounds. In native platforms and
wasm it is int64, in js environment accuracy is promised only up to int53.
For int64, significant digits (base in package terms), i.e. the value without leading and trailing zeros, must not be out of the range [-9223372036854775808..9223372036854775807].
final a = ShortDecimal(9223372036854775807) >> 40; // ok
final b = ShortDecimal(9223372036854775807) << 23; // ok
print(a); // 0.0000000000000000000009223372036854775807
print(b); // 922337203685477580700000000000000000000000
The number itself 922337203685477580700000000000000000000000 goes well beyond
int. But its base (without trailing zeros) fits into int64.
But you also have to work with that number in the same scale:
// 922337203685477580700000000000000000000000
// - 100000000000000000000000
// = 922337203685477580600000000000000000000000
print(b - (ShortDecimal(1) << 23)); // 922337203685477580600000000000000000000000 <- ok
// 922337203685477580700000000000000000000000
// - 1
// = 922337203685477580699999999999999999999999
print(b - ShortDecimal(1)); // -200376420520689665 <- overflow
Such constraints impose on ShortDecimal the need to constantly optimize the
value resulting from operations on it, in order to keep the ability to stay
within the int boundaries longer. Decimal does not need such optimization.
For example, multiplying two numbers: 1.2 * 5. Under the hood, everything is
stored in an integer variable (base) and a parameter indicating where the
decimal point is located (usually called scale). 1.2 would be stored as
(base: 12, scale: 1) and 5 as (base: 5, scale: 0).
final a = Decimal.parse('1.2'); // kept as base 12, scale 1
final b = Decimal.parse('5'); // kept as base 5, scale 0
final c = ShortDecimal.parse('1.2'); // kept as base 12, scale 1
final d = ShortDecimal.parse('5'); // kept as base 5, scale 0
Multiplication of such numbers is quite a simple operation: the bases are
multiplied and the scales are added. The result will be: (base: 60, scale: 1).
This is 6. And Decimal doesn't need to reduce it to (base: 6, scale: 0). But
for ShortDecimal it is vital.
final r1 = a * b;
print(r1); // 6, kept as base 60, scale 1
final r2 = c * d;
print(r2); // 6, kept as base 6, scale 0
Decimal, of course, could after each operation bring the value to normal,
i.e. to (base: 6, scale: 0), but this is additional time, which in most cases
is unnecessary. And where BigInt is used, there is no practical need for
this: there is not too much difference between (base: 6, scale: 0) and
(base: 60000000000, scale: 10). But in the case of int we can reach overflow
very quickly. For example, it is enough to multiply 1.0 by 1.0, i.e.
(base: 10, scale: 1) by (base: 10, scale: 1), only 18 times to go beyond the
int boundary. Even though it's only 1!
var a = Decimal.parse('1.0');
for (var i = 0; i < 18; i++) {
a *= Decimal.parse('1.0');
}
print(a); // 1, kept as base 10000000000000000000, scale 19
final i = 10000000000000000000; // The integer literal 10000000000000000000 can't be represented in 64 bits.
That's why you should pack the value after each operation to stay within int
boundaries longer. But you should not worry about performance. In the case of
int it will be much faster than BigInt without packing.
var a = ShortDecimal.parse('1.0');
for (var i = 0; i < 18; i++) {
a *= ShortDecimal.parse('1.0');
}
print(a); // 1, kept as base 1, scale 0
Performance #
The same bench, this package against
decimal and
precise_decimal, on the sets that
fit into an int so that both families can be shown at once. The first column
is the package most projects already have; the second is the quickest of the
competitors on most rows — twelve of the twenty — though not on multiplication,
round, to-double, raw-view or repeat-view-zeros, where it is the slower
of the two, nor on compare, where the two come out level:
| decimal | precise_decimal | Decimal | ShortDecimal | |
|---|---|---|---|---|
| add-int | (▼4x) 0.581 µs | (▼3x) 0.559 µs | (▼2x) 0.357 µs | ★ 0.141 µs |
| add-dirty-int | (▼3x) 0.346 µs | 0.167 µs | (▼2x) 0.223 µs | ★ 0.087 µs |
| multiply-large-int | (▼2x) 0.101 µs | (▼5x) 0.240 µs | (▼2x) 0.099 µs | ★ 0.047 µs |
| multiply-small-int | (▼2x) 0.098 µs | (▼2x) 0.106 µs | (▼2x) 0.095 µs | ★ 0.047 µs |
| multiply-dirty-int | (▼2x) 0.038 µs | (▼2x) 0.046 µs | (▼2x) 0.037 µs | ★ 0.017 µs |
| divide-large-int | (▼145x) 7.690 µs | (▼32x) 1.719 µs | (▼27x) 1.444 µs | ★ 0.053 µs |
| divide-small-int | (▼1154x) 136.201 µs | (▼58x) 6.928 µs | (▼22x) 2.635 µs | ★ 0.118 µs |
| divide-dirty-int | (▼1599x) 36.797 µs | (▼255x) 5.867 µs | (▼24x) 0.574 µs | ★ 0.023 µs |
| divide-large-and-view-int | (▼139x) 7.817 µs | (▼32x) 1.807 µs | (▼25x) 1.445 µs | ★ 0.056 µs |
| divide-small-and-view-int | (▼436x) 138.236 µs | (▼22x) 7.115 µs | (▼10x) 3.182 µs | ★ 0.317 µs |
| raw-view-int | (▼6x) 10.303 µs | (▼12x) 19.398 µs | (▼4x) 6.888 µs | ★ 1.584 µs |
| raw-view-zeros-int | (▼44x) 48.973 µs | (▼18x) 20.465 µs | (▼6x) 7.068 µs | ★ 1.108 µs |
| repeat-view-int | (▼227x) 6.362 µs | (▼59x) 1.653 µs | ★ 0.028 µs | (▼55x) 1.546 µs |
| repeat-view-zeros-int | (▼35x) 1.031 µs | (▼59x) 1.736 µs | ★ 0.029 µs | (▼31x) 0.927 µs |
| parse | (▼17x) 14.092 µs | (▼14x) 11.638 µs | 1.120 µs | ★ 0.819 µs |
| compare | (▼4x) 0.633 µs | (▼4x) 0.635 µs | (▼2x) 0.334 µs | ★ 0.157 µs |
| round | (▼18x) 3.913 µs | (▼25x) 5.349 µs | (▼16x) 3.487 µs | ★ 0.206 µs |
| to-double | (▼20x) 1.844 µs | (▼141x) 13.024 µs | (▼7x) 0.650 µs | ★ 0.092 µs |
| to-string-as-fixed | (▼8x) 15.063 µs | (▼4x) 7.406 µs | (▼3x) 5.664 µs | ★ 1.846 µs |
| unrepresentable-divide | (▼320x) 124.302 µs | (▼10x) 4.255 µs | (▼9x) 3.789 µs | ★ 0.388 µs |
For a description of the tests, see Package performance.
Decimal and ShortDecimal run the same algorithms, so the distance between
the two right-hand columns is the distance between BigInt and int: about
twice on arithmetic, twenty-odd on division.
One row is about something else. In repeat-view Decimal is fifty-odd times
ahead because it keeps the string it printed last time, and ShortDecimal may
not keep one at all — that is what vm:deeply-immutable costs, and instances
the VM can share between isolates are what it buys.
If your application does a handful of decimal operations, none of this matters
and Decimal from any of these packages will do. If it does a great many of
them, or if memory is tight, ShortDecimal is several times cheaper — as long
as you keep its limitations in mind.
Decimal optimization #
Some of what a decimal can be asked — how many digits it has after the point,
what its unscaled value is, whether it equals another decimal — has an answer
only in the canonical form: the base with its trailing zeros taken off and the
scale moved to match. Decimal does not hold every value that way, because
getting there costs BigInt arithmetic and most values are never asked those
questions. It packs a value the first time something needs the canonical form,
and keeps the result on hand.
There has to be a way to ask for it by hand, for when the algorithm does not do
it itself, and that is normalized():
final packed = value.normalized();
It answers with the value in its canonical form, and calling it again on the result costs nothing — the canonical form is its own. It answers rather than changes: a method that mutates what it is called on has no business in a value type.
The user does not need to know about packing and scaling, nor about base and
scale. What the user may legitimately want is the number taken apart, and
that is what unscaledValue and exponent are for — both read the canonical
form, so equal decimals answer equally whatever produced them.
What packing does to printing
Nothing, most of the time. This is a correction: earlier versions of this
section showed packing making toString fifty times faster, and those numbers
were measured before toString began keeping the string it had produced.
Printing one and the same value over and over is free either way now — the
first call prints, the rest read the kept string. Ten million toString()
calls on a single number, AOT, on the machine the tables above were measured
on, with the result going into a sink the optimizer may not drop:
| ten million prints of one value | |
|---|---|
| as it comes | 0.02 s |
after normalized() |
0.02 s |
Where the difference shows is values that are each printed once, and it points the other way:
var v = Decimal(1000000000000000000) >> 18; // = 1, and not in canonical form
for (var i = 0; i < 10000000; i++) {
final next = i.isEven ? -v : v; // a new number every time round
sink += next.toString().length; // and 'normalized()' before it, in the second run
}
| ten million values, each printed once | |
|---|---|
| as it comes | 1.36 s |
normalized() before each print |
5.38 s |
Packing a number costs several times more than printing it. toString takes
the trailing zeros off the string it is building anyway, and that is cheaper
than taking them off a BigInt and allocating a decimal to hold the result.
This is why normalization is not built into toString: everyone who prints a
number once and drops it would be paying for a canonical form nobody asked for.
And where the value is already canonical, there is nothing to pay and nothing to gain — 1.58 s against 1.61 s on the same ten million.
So: normalized() is for the canonical form itself — for unscaledValue, for
exponent, for a decimal that will be compared or used as a map key many times
over. It is not a way to print faster; the kept string is that.
ShortDecimal needs none of this: it normalises the value in every operation,
and its normalized() answers with the receiver itself.