Numbers are the quintessential value objects. $1+1=2$ anywhere in the world, but numbers in computers work differently.
$1+1=2$ in u8
and u64
, but $1+1=0$ in u1
, for the same reason that $255+1=0$ in u8
. That is, number realizations in computers do not follow the same rules and identities of pure abstract numbers of pure abstract mathematics.
In source code, constants are special. Because constant folding can generate huge savings by successive code reductions, to the point of rendering entire functions, even entire programs, into a constant result that executes no code at all, it's very profitable to pour a lot of effort into these optimizations.
This is where abstract numbers and constants meet. As constants can be folded in successive rounds, this also can be done for numbers and number operations. But it is necessary to always keep in mind the above notion, that operations on number realizations generate different results than may be expected from abstract numbers.
An if ( 0.1 + 0.2 == 0.3 )
will probably fail in any computer that follows IEEE 734, because some of these numbers are not representable in binary floating point format. A decimal number that is not representable is aproximated into another number, and because of that, decimal identities do not hold anymore.
As you can imagine, this is bad for constant folding and branch eliminations, as exemplified on the if()
above, and will run against human intuition.
So there is an appeal to treat numeric constants differently than turning them into realized numbers immediately for operations. By treating these source numbers as ComptimeBigDecimal
, or even better, as ComptimeFraction
, it is possible to have intuitive, previsible and stable results for additions, subtractions and multiplications, and is also possible to minimize the imprecision accumulation of operations and to postnote imprecision of divisions to one last Fraction
into number realization format.
But as you noted, it is bad that number expressions generate different results in compile time and run time.
If a compiler treats number constants differently in compilation time, it also should expose this functionality as a library, and by doing so, be possible to replicate the results of compile time in run time.
But none of this will change the reality of existence, and even preference, of realized number types, with all the imperfections noted above. Because these realizated number formats are fast, sometimes hardware fast, and no simulated integer/decimal/fractional code can reach this performance.
Beyond these concerns, it's advantageous to treat source code numbers differently from numbers realized at runtime, even in the absence of number operations.
This is an example I recently encountered in Python:
>>> from fractions import Fraction
>>> Fraction(1.65)
Fraction(3715469692580659, 2251799813685248)
>>> Fraction("1.65")
Fraction(33, 20)
Why did $1.65$ get converted to such a weird fraction, even though it can be easily converted to $100/65$? Because the decimal constant 1.65
in the source code was first converted to a double
, and then this other number was precisely converted in a decimal fraction.
By simply delaying the source code number literal realization, it would be possible to code Fraction()
to accept a comptime_decimal_literal
, that in turn would result in both expressions above reaching an exact fraction of $1.65$, instead of the approximate fraction of $3715469692580659/2251799813685248$.