Deterministic math: a proposal for every computer language

One kind of number, exact decimal of any size, precision chosen per operation, and no float, int16 or int32 kinds of mess.

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:

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

Int32: wrap-around

Int64: wrap-around

Float and int: a value that is not a number

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

  1. 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.25 and 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.
  2. 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+2 is the text 12 and (1+2) is the number 3.
  3. 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, so 1/3 looks 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) gives 1 out 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.
  4. 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:

ExpressionResultWhat was asked for
(1/3)0.33333333the default in this build: 8 places worked, 8 shown
(1/3,15,15)0.33333333333333315 places worked, 15 shown
(2/3,15,2)0.6715 places worked, rounded to 2 for display
(2/3,2,2)0.662 places worked: cut toward zero, so no rounding up
(1/3*3)0.99999999left 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)1the same numbers in the other order: 3*1 is 3, and 3/3 is exactly 1
(1/3*3,8,7)1the same order, worked to 8 places and rounded to 7: the nines round up to exactly 1
(1/3*3,9,8)1worked one place deeper than it is shown: 0.999999999 rounds to 1 at 8 places
(1/3,20,19)0.3333333333333333333the proposed defaults: 20 places worked, 19 shown, deeper than any float and plainly not one
(1/3*3,20,19)1the proposed defaults applied to the troublesome product: exactly 1
(2.5,8,0)3rounded half away from zero
(-2.5,8,0)-3the same rule for a negative number
(1.0000000000000000000000001+1)2the operand is read to 8 places, so the tiny tail is cut off by rule
(1.0000000000000000000000001+1,30,30)2.0000000000000000000000001asked for 30 places: the tail is kept
(22/7,50,50)3.1428571428571428571428571428571428571428571428571450 places of a familiar fraction
(0.1+0.2)=0.3TRUE1 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.

ExpressionResultMeaning
(10 & 12)8bitwise and
(10 | 12)14bitwise or
(10 xor 12)6bitwise exclusive or
(~10)-11bitwise not: -x-1, with no word width to wrap in
(10 <- 2)40shift left
(-5 -> 1)-3shift right rounds toward minus infinity, as two's complement does
(power(2,70) | 1)1180591620717411303425well past 64 bits, and still exact
(power(2,128) - 1)340282366920938463463374607431768211455the 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

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.