The proposal in one paragraph
A programming language should have one kind of number: an exact decimal of any size. Arithmetic should be marked, so a reader can see where numbers are read. Every calculation should carry its own precision, chosen by the programmer rather than by the width of a register. Any step that cannot be done exactly should stop with a clear error instead of giving an approximation that looks like an answer. Under these rules the same expression gives the same bytes on every machine, every compiler and every day. That is what deterministic math means here, and the h language already works this way. This page proposes that every language should.
The mess we have accepted
Most languages ask the programmer to pick a number type before writing a single calculation: int8, int16, int32, int64, their unsigned twins, float, double, long double, and then a separate decimal or big-integer type bolted on for the cases where the others are visibly wrong. Each of these carries its own failures, and the failures are silent:
- Overflow wraps. Add one to the largest 32-bit integer and the result is a large negative number. Nothing stops; the wrong value flows on into the next calculation, the report, the invoice.
- Binary fractions cannot hold decimal ones. The decimal 0.1 has no finite binary form, so a float stores a neighbour of it. Add 0.1 and 0.2 and the answer is not 0.3. Money, percentages, measurements and tax rates are all written in decimal, and the hardware quietly stores something else.
- Precision belongs to the storage, not to the problem. A double has about 16 significant digits whether the task needs 2 or 40. Above 253 whole numbers stop being exact, and no warning is given.
- Special values pretend to be numbers. NaN, positive and negative infinity, and negative zero all type-check as numbers and all behave strangely in comparisons and sorts.
- Results depend on the machine. Rounding modes, extended-precision registers, fused multiply-add, compiler flags and vectorisation can all change the last bits of a result. The same source can print different numbers on two computers.
- Mixed types convert silently. An integer divided by an integer, an integer added to a float, a 16-bit value stored into an 8-bit field: each language has its own rules, and most of them lose information without saying so.
None of this is the programmer's fault. The type menu reflects the width of hardware registers in the 1970s and 1980s, and it has been carried into every language since out of habit. The register widths were an implementation detail then and they are an implementation detail now.
What the hardware model gets wrong, measured
Each example below shows the expression, the flawed result (what the kind of number named in the heading gives for it) and reality, the exact result, with the precision and rounding it was worked out with stated under the table. The reality row is worked out by this page, in h, each time it is served: nothing in it is typed in by hand.
Precision here means places after the decimal point, not digits in total. The whole-number part of a result is never limited, so 10000000000000000+1 is exact at the default 8 decimal places and would be the same at 30. A float's 16 significant digits cap the whole number and the fraction together; a decimal precision caps only the fraction, and only where the programmer says.
Float: binary fractions and lost digits
Expr 0.1+0.2Flawed 0.30000000000000004Reality 0.3worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr (0.1+0.7)*10Flawed 7.999999999999999Reality 8worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr 4.35*100Flawed 434.99999999999994Reality 435worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr 1.1*1.1Flawed 1.2100000000000002Reality 1.21worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr 10000000000000000+1Flawed 10000000000000000, the 1 is lostReality 10000000000000001worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr 9007199254740993+0Flawed 9007199254740992, beyond 2 to the 53rd whole numbers driftReality 9007199254740993worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr power(2,100)Flawed 1.2676506002282294e+30, an approximationReality 1267650600228229401496703205376a whole number worked out exactly: no decimal places, no rounding
Expr 12345678901234567890*98765432109876543210Flawed 1.2193263111263526e+39Reality 1219326311370217952237463801111263526900worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr 1/3Flawed 0.3333333333333333, a binary neighbour shown in decimalReality 0.33333333worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Expr 1/3Flawed 0.3333333333333333, however many places are asked forReality 0.333333333333333333333333333333worked to 30 decimal places, rounded to 30; the whole-number part is never limited
Int32: wrap-around
Expr 2147483647+1Flawed -2147483648, wrapped aroundReality 2147483648worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Int64: wrap-around
Expr 9223372036854775807+1Flawed -9223372036854775808, wrapped aroundReality 9223372036854775808worked to 8 decimal places, rounded to 8; the whole-number part is never limited
Float and int: a value that is not a number
Expr 1/0Flawed Infinity for a float, a crash for an intReality stops with a clear error naming the lineworked to 8 decimal places, rounded to 8; the whole-number part is never limited
The last example is described rather than worked out: dividing by zero in h stops the page with an error, which is the point. A language that gives Infinity has decided to keep running with a value that is not a number.
The four rules
- One kind of number. A number is a decimal written out in digits, with a sign and a point when it needs them, and with no limit on its size. There is nothing to declare and nothing to convert.
7,-0.25and a 300-digit whole number are the same kind of thing. There is no NaN, no infinity, no negative zero, no wrap-around: whatever cannot be represented exactly is an error, not a value. - Arithmetic is marked. Calculation happens inside parentheses and nowhere else. Outside them,
+joins text and-,*and/are not even allowed. A reader never has to wonder whether a line is doing arithmetic; if it is, the parentheses say so. In h,1+2is the text12and(1+2)is the number3. - Precision is chosen per calculation, by the programmer. Every parenthesised group carries two numbers: the working precision p, the number of decimal places every step is held to, and the display precision r, the number of places the result is rounded to. Any calculation can state both:
(expr, p, r). Cutting at p is toward zero at every step, and rounding at r is half away from zero. Both are stated, so the result is defined to the last digit. When they are not stated, the defaults should be generous, with the rounding one place shallower than the working precision: 20 places worked, 19 shown (24 and 23 would serve as well). Deliberately not 16: a default that prints the same digits a double prints invites the reader to assume the same behaviour, and the behaviour is different in every way that matters. Twenty places is more than any float gives, so1/3looks like what it is, a decimal held to a stated depth, and working one place deeper than the result is displayed lets the rounding repair the cut that a division leaves, so(1/3*3)gives1out of the box. That pairing is only a default: any expression may state any p and any r, including an r equal to p, and nothing in the language ties one to the other. The build serving this page, h 0.71.6, defaults to 8 and 8; this proposal asks that the next build of h adopt 20 and 19. - Mistakes stop loudly. Dividing by zero, raising zero to a negative power, shifting by a fraction, or feeding text that is not a number into arithmetic ends the program with a message that names the line. No value is invented to keep things going. A calculation either gives the exact answer under its stated precision, or it does not give one at all.
A number is what is written. A result is what the rules say. The machine's word size has nothing to do with either.
Precision belongs to the calculation
Because the precision travels with each expression, a programmer can give money two places, a survey calculation fifteen, and a cryptographic step several hundred, all in the same program and all exactly. Every row below is worked out live on this page:
| Expression | Result | What was asked for |
|---|---|---|
(1/3) | 0.33333333 | the default in this build: 8 places worked, 8 shown |
(1/3,15,15) | 0.333333333333333 | 15 places worked, 15 shown |
(2/3,15,2) | 0.67 | 15 places worked, rounded to 2 for display |
(2/3,2,2) | 0.66 | 2 places worked: cut toward zero, so no rounding up |
(1/3*3) | 0.99999999 | left to right: 1/3 is held to 8 places, 0.33333333, then multiplied by 3; this is the exact answer to that question |
(3*1/3) | 1 | the same numbers in the other order: 3*1 is 3, and 3/3 is exactly 1 |
(1/3*3,8,7) | 1 | the same order, worked to 8 places and rounded to 7: the nines round up to exactly 1 |
(1/3*3,9,8) | 1 | worked one place deeper than it is shown: 0.999999999 rounds to 1 at 8 places |
(1/3,20,19) | 0.3333333333333333333 | the proposed defaults: 20 places worked, 19 shown, deeper than any float and plainly not one |
(1/3*3,20,19) | 1 | the proposed defaults applied to the troublesome product: exactly 1 |
(2.5,8,0) | 3 | rounded half away from zero |
(-2.5,8,0) | -3 | the same rule for a negative number |
(1.0000000000000000000000001+1) | 2 | the operand is read to 8 places, so the tiny tail is cut off by rule |
(1.0000000000000000000000001+1,30,30) | 2.0000000000000000000000001 | asked for 30 places: the tail is kept |
(22/7,50,50) | 3.14285714285714285714285714285714285714285714285714 | 50 places of a familiar fraction |
(0.1+0.2)=0.3 | TRUE | 1 means true: the comparison that every float tutorial warns about just works |
Two of those rows deserve a word. A reader may object that one third times three is one, and that 0.999… going on forever is also one. Both are true, and neither is what (1/3*3) asks for. The expression is worked left to right: 1/3 is held to the stated 8 places, which gives 0.33333333, and that number times 3 is 0.99999999: eight nines and then nothing, not nines forever. It is the exact product of the step that was written, at the precision that was stated. A float makes the same kind of cut, in binary, and then rounds again on the way to the screen so that it prints 1; the loss is still there, only hidden. In h the loss is where the programmer put it, and the programmer can move it: write the division last, as (3*1/3), and the result is exactly 1.
Better still, the two precisions can be chosen so that the expected answer is guaranteed. Work to one place more than is shown, and the cut at the last working place is repaired by the rounding at the display place: (1/3*3,8,7) works to 8 places, gets 0.99999999, and rounds it to 7 places, which is exactly 1; (1/3*3,9,8) does the same one place deeper. This is a general rule, not a trick for one expression: a working precision a place or two beyond the display precision absorbs the cut that any division leaves, and because both numbers are written in the expression, the result is defined to the last digit and is the same on every machine. A float has no such dial; its cut happens at a binary place nobody chose, and the rounding that hides it happens in the print routine, out of the programmer's hands. Because this pairing is what a programmer expects without having to think about it, the proposal recommends it as the default, at a depth that is plainly not a float's: work to 20 places and show 19 (or 24 and 23), so an unadorned (1/3) is 0.3333333333333333333, nineteen threes, and an unadorned (1/3*3) is 1. The depth is chosen on purpose to be more than a double's 16 digits. A default that printed exactly what a float prints would carry the float's reputation with it, and a reader would expect its habits, the binary neighbours and the epsilon comparisons, when none of them apply. The one-place gap is a property of the default only. The language imposes no such rule on an expression: (x,8,8), (x,30,2) and (x,100,100) are all allowed, and the programmer who writes the two numbers decides their relation. The build serving this page defaults to 8 and 8, which is why the plain (1/3*3) row above shows the nines; the defaults of 20 and 19 are proposed for the next build of h.
Notice that the number of decimal places an operand happens to be written with never changes anything. 1.50 and 1.5 are the same number, and (1.50+0) is 1.5. The only definition of precision is the one the programmer wrote.
Whole numbers of any size, bit by bit
Bit operations do not need a word size either. Treat every whole number as if it had infinitely many sign bits, and and, or, exclusive or, not and the shifts are all defined exactly for any size of number, negative ones included. A number with a fraction is refused, because there is no honest answer for it.
| Expression | Result | Meaning |
|---|---|---|
(10 & 12) | 8 | bitwise and |
(10 | 12) | 14 | bitwise or |
(10 xor 12) | 6 | bitwise exclusive or |
(~10) | -11 | bitwise not: -x-1, with no word width to wrap in |
(10 <- 2) | 40 | shift left |
(-5 -> 1) | -3 | shift right rounds toward minus infinity, as two's complement does |
(power(2,70) | 1) | 1180591620717411303425 | well past 64 bits, and still exact |
(power(2,128) - 1) | 340282366920938463463374607431768211455 | the largest unsigned 128-bit value, as an ordinary number |
Objections, answered
Is it not slow?
Exact decimal arithmetic costs more per operation than a hardware multiply. It also costs far less than the engineering that goes into working around floats: the rounding helpers, the money libraries, the epsilon comparisons, the tests that assert on approximate values, and the production incidents when a sum came out a cent short. For the great majority of programs, arithmetic is nowhere near the bottleneck; text, storage and the network are. Where raw speed on huge arrays genuinely matters, that is a specialist library, chosen deliberately, and not the default behaviour of every number in every program.
Scientists need floats.
Scientists need results they can reproduce. A decimal with a precision chosen per calculation gives them that, and lets them state the precision in the paper rather than inherit it from the compiler. Floating point was a fine answer to the memory of 1985; it is not a law of nature.
What about 1/3? It never ends.
Then the programmer says how far to go, and the language goes exactly that far, the same way every time. (1/3,15,15) is defined to the last digit. A float's 0.3333333333333333 is also cut off; it is just cut off in binary, at a place nobody chose, and then printed as if it were decimal.
Why forbid arithmetic outside the parentheses?
Because the two most common things a program does with values are joining text and calculating, and a language that uses the same symbol for both has to guess which one was meant. Marking arithmetic removes the guess. It also removes the silent conversions: a value either enters a calculation, where it must be a number, or it does not.
Do fixed-width types not document intent?
A range is intent; a width is a storage detail. If a value must be between 0 and 255, the program should check that and say so when it is violated, which is exactly what a wrapping byte does not do. Databases, wire formats and hardware registers have widths, and the place to deal with those is at the edge where data is read and written, not in every expression in between.
What a language gains
- Determinism. The same source gives the same digits on every machine, under every compiler, forever. Tests can compare exact text.
- One mental model. There is nothing to learn about promotion, coercion, signedness or overflow, because none of it exists.
- Honest errors. A program fails where the problem is, with the line number, not three functions later with a NaN.
- Data that round-trips. A number read from a file, a form or a JSON body is kept as the digits that arrived and written back as the same digits.
- A smaller language. The whole of h arithmetic fits on this page. Compare that with the chapter on numeric types in any mainstream language manual.
The ask
If you design or maintain a language: make exact decimal the only number, mark arithmetic so it can be seen, put the precision in the programmer's hands at every expression, and refuse to invent a value when there is no exact one. Keep the machine-width types, if you must, as explicit foreign types at the edges where bytes are exchanged, never as the thing a programmer gets when they write 1.
This page is served by http.apps 0.72.4, and every number in its tables was calculated by the runtime at the moment you loaded it. Reload it on any machine and the digits will be the same.