Numbers

Three numeric types, each for a different job.

typewhat it isuse for
int64-bit signed, checkedcounting, indexing
floatIEEE-754 doublescientific, approximate
decimalexact base-10, ~28 digitsmoney

int overflow is an error

9223372036854775807 + 1     # runtime error, not a wraparound

Silent wraparound is a bug that surfaces far from its cause. Ecko refuses at the operation.

float literals take an exponent

let tolerance = 2.5e-9
let avogadro = 6.022e23
let big = 1e3               # 1000.0, a float - not the integer 1000

e or E, with an optional sign, and at least one digit after it. That last part is what keeps e usable as a variable: 2 * e is arithmetic, because there are no digits following.

An exponent always produces a float, whatever the mantissa looked like. It is also the only practical way to reach the ends of the range - the largest float is about 1.8e308, and nobody writes that as digits.

ecko fmt leaves the spelling you chose alone, the same way it leaves strings alone. Expanding 6.022e23 into twenty-four digits would be the identical value and a far worse line.

float equality is exact IEEE

0.1 + 0.2 == 0.3            # false
approx(0.1 + 0.2, 0.3)      # true
approx(a, b, 0.01)          # explicit epsilon

== on floats does what IEEE says, which is the honest answer. approx has a default epsilon of 1e-9.

NaN does not equal itself, which is part of that same honesty:

import std.math

n = math.sqrt(-1.0)
n == n                      # false
contains([n], n)            # false - equality is asked here too
math.isnan(n)               # true - this is the question to ask

It catches people out in every language that has floats, so it is worth saying plainly: a NaN inside a list can never be found by searching for it, and a NaN compared against anything, itself included, is false. math.isnan is the only reliable test.

decimal for money

price = 19.99m
qty = 3
subtotal = price * qty  # 59.97 exactly

tax_rate = decimal("0.0825")
total = subtotal + subtotal * tax_rate

print(0.1m + 0.2m)  # 0.3
print(0.1 + 0.2)  # 0.30000000000000004

The rules, chosen so money behaves:

  • decimal mixes with int and the result is decimal. 5m == 5 is true.
  • int, round, floor and ceil accept a decimal and return an int, exactly - nothing routes through a float on the way. int truncates toward zero; round takes halves away from zero, not the half-to-even a statistics library would use, because money does not work that way. float(aDecimal) stays rejected, for the same reason decimal(aFloat) is.
  • round(x, places, mode?) rounds to places and keeps the type. A decimal comes back a decimal at exactly that scale, so round(12.345m, 2) is 12.35m and round(12.3m, 2) is 12.30m. Halves go away from zero unless you pick a mode: half_up (the default), half_even, half_down, up, down, ceiling or floor. round(2.345m, 2, "half_even") is 2.34m. A float rounds the same way but in binary, so round money as a decimal. fmt.fixed renders a number to a set number of places as text.
  • decimal with float is a hard error. A binary float has already lost precision; letting it into an exact calculation would silently corrupt the result. Convert explicitly if you truly mean to.
  • decimal(aFloat) is rejected. Pass a string - decimal("0.1") - to state the exact value you want. decimal(x) does accept an int or a numeric string. It is lenient about the string, as int is: surrounding whitespace is trimmed and exponent notation is read (decimal("1e5") is 100000). For money typed in by a person, where "1e5" is more likely a mistake than an amount, parse with the money package, which is strict.
  • +, -, * preserve scale, so cents are never dropped: 19.99m + 0.01m is 20.00.
  • / and % return the exact quotient normalized: decimal(10) / 4 is 2.5.
  • Overflow and divide-by-zero raise, as with int.

/ divides, // floors

7 / 2  # 3.5 - always a float for two ints
6 / 3  # 2.0
7 // 2  # 3 - floor division, an int for two ints
(0 - 7) // 2  # -4 - floor, not truncation
7.5 // 2  # 3.0

/ never drops the fraction, so an average or a percentile cannot quietly come out as a whole number. Use // when you want the whole number of times one value fits in another - a page count, a midpoint index, a day number. It rounds toward negative infinity, as in Python, and % is its remainder, with the sign of the divisor: -7 % 2 is 1, so a == (a // b) * b + a % b always holds and a weekday or a minute-of-hour computed with % is right for a negative time too. % mixes a float and an int the way the other operators do. A decimal stays a decimal under both: decimal(10) / 4 is 2.5 and decimal(10) // 4 is 2.

A / where you meant // does not always fail: x = x / 2 makes x a whole Float, and if it only feeds more arithmetic the answer stays right while every step after it runs in floating point. ecko check flags the clear case, a / inside an x % 2 == 0 branch, and the case that always fails: a / as a range bound or a count, such as take(xs, len(xs) / 2).

JSON

JSON has no exact-decimal type, so a decimal is written as a plain number in its exact text (12.50m is 12.50). By default decoding gives back a float; decode with json.decode(text, decimal: true) and every non-integer number comes back as an exact decimal instead, so a money field survives the round trip. See std.json.

Maths

std.math has the constants and functions: pi, e, sqrt, sin, log, floor, clamp, gcd, isclose and the rest.