4 Numbers — fixed-width integers and floating point
What to know first
Looking back
What happened to let x u8 300 . in chapter 3? How does C handle the same thing?
A. It was rejected with E-TYPE-WIDTH, because 300 is outside the u8 range 0 … 255. C silently truncates it to 44, so the value in the source and the actual value differ. This chapter looks at how the same principle applies when a computed result, not a literal, exceeds its width.
The need for this chapter, and its context
By the end of this chapter
The questions this chapter answers
- If
narrow_wrapdoes what C does, why isn’t wrapping the default? - Why can’t bitwise operations be written infix inside an
exprisland? - Isn’t writing
castevery time a chore?
4.1 Integer types name their width#
An integer type’s name is its sign and bit width. u means unsigned, i means signed.
| Type | Width | Range |
|---|---|---|
u8 · u16 · u32 · u64 | 8 · 16 · 32 · 64 | 0 … 2N−1 |
i8 · i16 · i32 · i64 | 8 · 16 · 32 · 64 | −2N−1 … 2N−1−1 |
usize · idiff | 16 or more — set by the implementation | by width (see “The size types” below) |
u128 · i128 | 128 — on 64-bit targets only | same pattern (see “128-bit integers” below) |
Table 4.1 — Integer types and their ranges
C’s int can have different sizes on different machines, but Lowent’s u32 is 32 bits everywhere. Negative numbers use two’s complement. usize is a different type from u64 even on a machine where they have the same width, and the two do not convert automatically — so that places mixing lengths with plain numbers show in the source. The one word that crosses is cast (see “The size types” below).
4.2 128-bit integers#
u128 · i128 exist on 64-bit targets only (x86_64 · arm64 · riscv64 · win64). On a 32-bit target the words are refused (E-INT128-TARGET). Use them to hold, as one value, a computation wider than 64 bits — such as the whole product of two 64-bit numbers.
fn top_half input a u64 . input b u64 . output u64 . do
let x u128 widen u128 a. . .
return cast u64 shr mul x. widen u128 b. . . 64 . . .
end .- Nothing widens into a 128-bit place automatically — write
widen u128 …. A value in a 64-bit slot has no upper half, so the widening is where it is decided whether to extend the sign or fill with zeros. - A literal wider than 64 bits (
340282366920938463463374607431768211455) fits only a 128-bit place. - Besides locals · inputs · results · the content of an
option, they stand as struct fields and as elements of slices · arrays · views · stacks. The byte layout is the C compiler’s 128-bit integer — size 16 · alignment 16 · little end first. As a vector (vec) element or across the C boundary they areE-INT128-PLACE. - A 128-bit number on the command line is read as written; one that does not fit the type (a negative
u128· 2128 or more) is refused. - If you only need the upper half of a 64-bit product,
bigint64.mul_hialso does it (it builds no 128-bit value). 128-bit integers run on the fast path as locals, arithmetic, inputs and results. An op that touches a 128-bit value inside a struct field or an array, slice, view or stack element, or useschk_*ornarrow_tryon a 128-bit value, goes down the tagged path (--why-slowsays why).
4.3 Widening is automatic, narrowing is written#
A conversion that loses no value is called widening. Passing a u8 where a u32 is expected keeps the value, so the compiler does it for you. Even with mixed signs, arithmetic is allowed if a value-preserving widening exists — every u8 fits in an i16.
examples/ch04/widths.low
module widths .
rem run: mix 200 -100
rem run: shrink 200
rem trap: shrink 300
rem run: shrink_sat 300
rem run: shrink_wrap 300
fn mix input a u8 . input b i16 . output i16 .
do
return add a. b. . .
end .
fn shrink input x u64 . output u8 .
do
return narrow u8 x. . .
end .
fn shrink_sat input x u64 . output u8 .
do
return narrow_sat u8 x. . .
end .
fn shrink_wrap input x u64 . output u8 .
do
return narrow_wrap u8 x. . .
end .
Output
$ lowentc --run mix widths.low 200 -100
mix(200, -100) = 100
$ lowentc --run shrink widths.low 200
shrink(200) = 200
$ lowentc --run shrink_sat widths.low 300
shrink_sat(300) = 255
$ lowentc --run shrink_wrap widths.low 300
shrink_wrap(300) = 44
$ lowentc --run shrink widths.low 300
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)
mix adds a u8 and an i16, and the result has the wider type, i16. To make the widening visible, write widen u64 x. .. An i32 and a u32 of the same width cannot be mixed, because neither fits entirely in the other.
examples/ch04/sign_bad.low
module sign_bad .
rem expect: E-TYPE-SIGN
fn total input a i32 . input b u32 . output i32 .
do
return add a. b. . .
end .
Output
$ lowentc --check sign_bad.low
sign_bad.low:6:0 E-TYPE-SIGN: sign mismatch: no value-preserving widening exists (widen both to a strictly wider signed type, or bitcast_sign)
A conversion that can lose a value is narrowing, and it must be written. shrink in the same file narrows a u64 to a u8 with narrow u8 x. .. 200 fits and comes out unchanged, but 300 does not, so execution stops (E-VM-CAST). If you do not want it to stop, choose the treatment you want by name. narrow_sat stops at the end value 255, and narrow_wrap wraps like C and gives 44. narrow_try gives none when the value does not fit.
Q. If narrow_wrap does what C does, why isn’t wrapping the default?
A. To leave in the source whether wrapping was intended or a mistake. If wrapping were the default, someone reading a place where narrow u8 x. . gave 44 could not tell whether that was the wanted value. With stopping as the default, an author who wanted wrapping wrote narrow_wrap. The name written is the record of the author’s intent.
4.4 Overflow stops#
Arithmetic happens at the declared width. If the result does not fit the width, execution stops (a trap).
examples/ch04/overflow.low
module overflow .
rem run: bump 254
rem trap: bump 255
rem run: bump_wrap 255
rem run: bump_sat 255
fn bump input a u8 . output u8 .
do
return add a. 1 . .
end .
fn bump_wrap input a u8 . output u8 .
do
return wrap_add a. 1 . .
end .
fn bump_sat input a u8 . output u8 .
do
return sat_add a. 1 . .
end .
Output
$ lowentc --run bump overflow.low 254
bump(254) = 255
$ lowentc --run bump_wrap overflow.low 255
bump_wrap(255) = 0
$ lowentc --run bump_sat overflow.low 255
bump_sat(255) = 255
$ lowentc --run bump overflow.low 255
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)
bump 254 fits as 255, but bump 255 would have to be 256, so the VM stops with E-VM-OVERFLOW. Native code stops at the same place with a non-zero exit code (the verification script checks that both stop). As with widening, you change the treatment by choosing a name.
| Treatment | Ops | On overflow |
|---|---|---|
| Stop (default) | add · sub · mul | Execution stops |
| Wrap | wrap_add · wrap_sub · wrap_mul | Wraps around within the width |
| Saturate | sat_add · sat_sub · sat_mul | Stops at the end value of the width |
| As a value | chk_add · chk_sub · chk_mul | Reports it as an option |
Table 4.2 — Choosing the treatment of overflow by name
The “as a value” family turns overflow into an event the program can handle.
examples/ch04/chk.low
module chk .
rem run: safe_bump 255
rem run: safe_bump 7
fn safe_bump input a u8 . output u64 .
do
let r option u8 chk_add a. 1 . .
guard is_some r. . else return 999 . .
return widen u64 some_value r. . . .
end .
Output
$ lowentc --run safe_bump chk.low 255
safe_bump(255) = 999
$ lowentc --run safe_bump chk.low 7
safe_bump(7) = 8
chk_add a. 1 . gives an option u8. On overflow it is none, and guard is_some r else … handles that case first. You will meet this shape again in chapter 11.
A common misconception. Checking for overflow makes code slow
4.5 Division and remainder#
Dividing an integer by zero stops. Signed division truncates towards zero, and the sign of the remainder mod follows the divisor.
examples/ch04/divide.low
module divide .
rem run: quot -7 2
rem run: modulo -7 2
rem trap: quot 1 0
rem run: safe_div 7 0
rem run: safe_div 7 2
fn quot input a i32 . input b i32 . output i32 .
do
return div a. b. . .
end .
fn modulo input a i32 . input b i32 . output i32 .
do
return mod a. b. . .
end .
fn safe_div input a u32 . input b u32 . output u32 .
do
let nz option nonzero u32 nonzero_of b. . .
guard is_some nz. . else return 0 . .
return div_nz a. some_value nz. . . .
end .
Output
$ lowentc --run quot divide.low -7 2
quot(-7, 2) = -3
$ lowentc --run modulo divide.low -7 2
modulo(-7, 2) = 1
$ lowentc --run safe_div divide.low 7 0
safe_div(7, 0) = 0
$ lowentc --run safe_div divide.low 7 2
safe_div(7, 2) = 3
$ lowentc --run quot divide.low 1 0
== ir diagnostics (1) ==
0:0 E-VM-DIV0: divide by zero
quot -7 2 is −3 and modulo -7 2 is 1. Because the remainder follows the divisor’s sign, mod h. n. . for a positive n is always at least 0 and less than n. That property is why the bounds check in idx slots. mod h. n. . . disappears when a hash table picks a slot, and it is proven in Coq (chapter 40).
The only case where signed division overflows is MIN / −1, and that stops too. In C it is undefined behaviour.
There is also a way to check once that a divisor is not zero and carry that fact around. nonzero_of b. . gives an option nonzero u32, and the value inside can be passed to div_nz. No zero check remains at the div_nz site. The check was not removed but moved to one place, with its result carried in the type.
4.6 Booleans are not numbers#
bool has two values, true and false. An integer in a condition is rejected.
examples/ch04/cond_bad.low
module cond_bad .
rem expect: E-TYPE-COND
fn nonzero_flag input n u32 . output u8 .
do
if n. do return 1 . end .
return 0 .
end .
Output
$ lowentc --check cond_bad.low
cond_bad.low:6:0 E-TYPE-COND: a condition must be bool, not a number — this language has no truthiness (D12: no implicit int↔bool). Say what the test is: `ne x 0`
C’s if (n) reads as “n is not zero”, but the reader has to guess from context whether it means “n exists” or “n is true”. Lowent makes you write what you are asking — if gt n 0. Conversely, a bool cannot be added like a number. and, or and not take only booleans, and and and or skip the right-hand side when the left-hand side already decides the answer.
4.7 Bitwise operations#
Bitwise operations treat a value as a sequence of bits. They use words instead of symbols.
examples/ch04/bits.low
module bits .
rem run: mask 12 10
rem run: flip 12
rem run: spin 129 1
rem run: s_shr -8 1
rem run: u_shr 4294967288 1
rem run: ones 7
rem 12 = 0000_1100 · 10 = 0000_1010
fn mask input a u8 . input b u8 . output u8 . do return bit_and a. b. . . end .
fn flip input a u8 . output u8 . do return bit_not a. . . end .
fn spin input a u8 . input n u8 . output u8 . do return rotl a. n. . . end .
fn s_shr input a i32 . input n u32 . output i32 . do return shr a. n. . . end .
fn u_shr input a u32 . input n u32 . output u32 . do return shr a. n. . . end .
fn ones input a u8 . output u8 . do return count_ones a. . . end .
Output
$ lowentc --run mask bits.low 12 10
mask(12, 10) = 8
$ lowentc --run flip bits.low 12
flip(12) = 243
$ lowentc --run spin bits.low 129 1
spin(129, 1) = 3
$ lowentc --run s_shr bits.low -8 1
s_shr(-8, 1) = -4
$ lowentc --run u_shr bits.low 4294967288 1
u_shr(4294967288, 1) = 2147483644
$ lowentc --run ones bits.low 7
ones(7) = 3
bit_and·bit_or·bit_xor·bit_not— bitwise logic.flip 12is 243 because the type isu8. The answer of a bitwise operation is always tied to the width of its type.shlandshrshift;rotlandrotrrotate.spin 129 1is 3 because the top 1 came back in at the bottom; withshlthat 1 would have been discarded.shrmeans different things depending on sign. −8 and 4294967288 are the same bit pattern in 32 bits, but the signed one shifts in the sign bit and becomes −4, while the unsigned one shifts in 0 and becomes 2147483644.count_ones·leading_zeros·trailing_zeroscount bits, andbyte_swapreverses byte order.clmul_lo·clmul_hiare carry-less multiplication. They are not core ops but builtin ops: writecall_builtin clmul_lo a. b. ., or callclmul128of the librarycrypto_hw(chapter 32). Carry-less multiplication is a product whose addition is xor, with no carries. Cryptography and checksums (GF(2^128), CRC) stand on it. The answer is 128 bits, so it comes back as two words. When the machine has the instruction the processor lowers to it (lowentc --hw pclmul, orauto), and when it does not, to the same computation in plain code — one meaning, two speeds.
Shifting by at least the width of the type stops. In C that is undefined behaviour. If you want the shift amount wrapped into the width, use wrap_shl and wrap_shr.
A shift count has an unsigned integer type. That is why n in s_shr is a u32 — written as i32 the program is refused (E-SHIFT-SIGNED), because a negative count has no single meaning. A rotation turns by the count modulo the width, so it takes any integer.
Q. Why can’t bitwise operations be written infix inside an expr island?
A. In C, a & b == c groups as a & (b == c). It is a trap even for long-time users, and languages disagree on bitwise precedence. Putting them in the island would add something to look up instead of making them easier to read. Written prefix, the parentheses state the answer and nothing can be misread.
4.8 Floating point#
f32 and f64 are the IEEE 754 binary 32- and 64-bit formats. Operations happen at the width of their operands, and integers and floating-point values do not convert to each other automatically. One operation is one rounding — the processor does not fuse a multiply and an add into one instruction (FMA). Fused, one rounding goes missing and 0.1 × 10 − 1 is no longer 0. A conversion is one rounding too — cast f32 rounds an f64 or an integer to the nearest f32, and a number too large for f32 becomes infinity (cast f32 <f64 1e300> is inf).
examples/ch04/floats.low
module floats .
rem run: third 1.0
rem run: hyp 3.0 4.0
use math .
fn third input x f64 . output f64 .
do
return div x. 3.0 . .
end .
fn hyp input a f64 . input b f64 . output f64 .
do
return math.sqrt add mul a. a. . mul b. b. . . . .
end .
Output
$ lowentc --run third floats.low 1.0
third(1.0) = 0.333333
$ lowentc --run hyp floats.low 3.0 4.0
hyp(3.0, 4.0) = 5.0
The VM shows floating-point results briefly (0.333333). Dividing a floating-point value by zero does not stop; it becomes infinity as IEEE 754 prescribes. Transcendental functions such as sqrt, sin and exp live in the standard library math (use math ., then math.sqrt x. .); they are floating-point only and available only on machines with an operating system. The exact last bit of their results is up to the machine and its maths library.
A common misconception. Floating-point values can be compared with eq too
math module provides that question (close) (chapter 32). Rounding, NaN and −0 are also properties the proofs of this language do not cover (chapter 50).f32 and the size types follow the same rules.
examples/ch04/sizes.low
module sizes .
rem run: advance 5 -8
rem run: third32 1.0
rem run: same_third 1.0 1.0
rem usize and idiff are for lengths, indexes and their differences; the implementation sets the width (16 bits or more), and they cross to other integers through cast only
fn advance input n usize . input k idiff . output idiff .
requires le n. 1000 . .
requires ge k. -1000 . .
requires le k. 1000 . .
do
return add cast idiff n. . k. . .
end .
rem f32 is a 32-bit float: half the memory, about seven significant digits
fn third32 input x f32 . output f32 . do
return div x. 3.0 . .
end .
rem even 1/3 is a different number in f32 and f64; widened and compared, they are not equal
fn same_third input x f32 . input y f64 . output bool . do
let a f32 div x. 3.0 . .
let b f64 div y. 3.0 . .
return eq widen f64 a. . b. . .
end .
Output
$ lowentc --run advance sizes.low 5 -8
advance(5, -8) = -3
$ lowentc --run third32 sizes.low 1.0
third32(1.0) = 0.333333
$ lowentc --run same_third sizes.low 1.0 1.0
same_third(1.0, 1.0) = 0
usizeandidiffare the unsigned and signed integers for lengths, indexes and their differences. They correspond to C’ssize_tandptrdiff_t(chapter 29). The implementation sets their width, so useu64andi64for fixed-width arithmetic. They move to another integer type throughcastonly, as incast idiff n. .— the next section gives the rule.f32takes half the memory and has about seven significant digits. Use it where quantity matters more than precision, as in large arrays or graphics.same_thirdcomputes the same 1/3 asf32and asf64, widens, and compares. The answer is false (0). Even though the VM shows both briefly as0.333333, they are different numbers.
4.9 The size types — usize and idiff#
Lengths, indexes and byte counts are written usize. The numbers that len, bitset_count, stack_count and capacity give are usize. idiff is its signed partner, and it has one use: the difference of two positions. Unlike the other integers, these two do not name their width.
4.9.1 The implementation sets the width#
usize and idiff are at least 16 bits wide, and at most as wide as the implementation’s widest integer. This compiler uses the address width of the target machine, which --target selects.
| Width | Largest usize | Range of idiff | Such a machine |
|---|---|---|---|
| 16 | 65 535 | −32 768 … 32 767 | a small microcontroller — word16 (translation only) |
| 32 | 4 294 967 295 | −2 147 483 648 … 2 147 483 647 | a 32-bit board such as cortex_m |
| 64 | 264−1 | −263 … 263−1 | x86_64 · arm64 · riscv64 |
Table 4.3 — The range of usize and idiff by width
The aim is source that translates on all three. The rules below therefore hold without asking the width.
4.9.2 They do not mix with other integers — the crossing is cast#
usize and idiff never convert automatically to or from any other integer type: not from u8 to usize, and not from usize to u64. They do not mix in arithmetic or comparison either — to add or compare a usize and a u64, move one of them first. The word that moves is cast, and only cast.
cast u64 n. .— ausizetou64.cast usize x. .— another integer tousize.cast idiff n. .— ausizetoidiff(and back the same way).
cast gives the value unchanged when it fits the target type and stops when it does not (E-VM-CAST). It never cuts silently.
widen and narrow cannot be used with usize or idiff. Those two words state a direction, and going from usize to u32 narrows on a 64-bit machine and widens on a 16-bit one. A conversion whose direction depends on the machine, written with a word of direction, is true on one machine only. cast asks no direction.
Number literals are written as they are — the 1 in add i. 1 . and the 0 in idx xs. 0 . take the type of their place.
examples/ch04/usize_cast.low
module usize_cast .
rem run: last [3,4,5]
rem run: bucket [1,2,3,4] 1000003
rem run: from_outside [9,9,9] 2
rem run: from_outside [9,9,9] 7
rem run: gap 3 10
rem run: packed [5,6,7,8] 3
rem carry lengths and indexes as usize all the way --- no conversion at all
fn last input xs slice u8 . output u8 .
requires gt len xs. . 0 . .
do
let n usize len xs. . .
return idx xs. sub n. 1 . . .
end .
rem where a length meets a number that is not one (a hash) --- compute on the wide side (u64), cast when it becomes an index
fn bucket input xs slice u8 . input h u64 . output u8 .
requires gt len xs. . 0 . .
do
let slot u64 mod h. cast u64 len xs. . . . .
return idx xs. cast usize slot. . . .
end .
rem take a number from outside with narrow_try, which does not stop --- none when it does not fit, and the program decides
fn from_outside input xs slice u8 . input want u64 . output u8 . do
let p option usize narrow_try usize want. . .
guard is_some p. . else return 255 . .
let i usize some_value p. . .
guard lt i. len xs. . . else return 254 . .
return idx xs. i. . .
end .
rem the difference of two positions is an idiff --- it holds half the range of usize, and cast guards that
fn gap input a usize . input b usize . output idiff . do
return sub cast idiff a. . cast idiff b. . . .
end .
rem an index kept in a narrow field (u8) --- write cast on the way in and on the way out
fn packed input xs slice u8 . input i usize . output u8 .
requires lt i. len xs. . . .
do
let small u8 cast u8 i. . .
return idx xs. cast usize small. . . .
end .
Output
$ lowentc --run last usize_cast.low [3,4,5]
last([3,4,5]) = 5
arg0 (written) = [3,4,5]
$ lowentc --run bucket usize_cast.low [1,2,3,4] 1000003
bucket([1,2,3,4], 1000003) = 4
arg0 (written) = [1,2,3,4]
$ lowentc --run from_outside usize_cast.low [9,9,9] 2
from_outside([9,9,9], 2) = 9
arg0 (written) = [9,9,9]
$ lowentc --run from_outside usize_cast.low [9,9,9] 7
from_outside([9,9,9], 7) = 254
arg0 (written) = [9,9,9]
$ lowentc --run gap usize_cast.low 3 10
gap(3, 10) = -7
$ lowentc --run packed usize_cast.low [5,6,7,8] 3
packed([5,6,7,8], 3) = 8
arg0 (written) = [5,6,7,8]
lasttakes the length asusizeand uses it asusize. There is no conversion. Carry lengths and indexes asusizefrom start to finish andcasthardly appears.bucketuses the remainder of a hash (u64) by the length as an index. Compute on the wide side: raise the length tou64, take the remainder, and move the result tousize. The remainder is smaller than the length, so thiscaststops on no machine. Moving the hash tousizefirst would run on a 64-bit machine and stop on a 32-bit one.from_outsidetakes a number from outside withnarrow_try usize. When it does not fit the answer isnone, and the program decides what to do.gapmoves two positions toidiffand then subtracts. Subtractingusizefromusizestops when the result would go below 0.packedkeeps an index in au8field and takes it out again.castis written once on the way in and once on the way out.
4.9.3 Moving without stopping#
When cast stops, the program ends. If the value came from outside the program — a header field in a file, a length received from the network, a number the user typed — its not fitting is no defect but input to refuse. For those places use one of the three words whose name picks what happens.
| Word | What it does |
|---|---|
cast usize x. . | stops — when not fitting is a defect |
narrow_try usize x. . | gives an option usize — none when it does not fit |
narrow_sat usize x. . | sticks to the largest value |
narrow_wrap usize x. . | keeps the low bits — only where wrapping is the meaning, as in a hash |
Table 4.4 — Moving to usize when the value does not fit
4.9.4 What to watch for at each width#
To keep one source standing at 16, 32 and 64 bits:
- Keep numbers that are not lengths in fixed-width types. A position in a file, a time, a hash and a protocol field are
u64oru32. A 32-bit machine still has files larger than 4 GiB, and 32 bits hold little more than four seconds of nanoseconds. Useusizeonly for lengths and positions in memory. - Where they meet, raise to the wide side and compute there. Raise the length, as in
add off. cast u64 len buf. . . .. Computing on the narrow side gives source that runs on large machines only. usizearithmetic overflows at its width.mul n. size_of rec . .has room on a 64-bit machine and stops past 65 535 on a 16-bit one. No wrong value comes out, but where sizes are multiplied it is better to take the overflow as a value withchk_mul(see “Overflow stops” above).- A large constant does not translate for a small machine.
100000in ausizeplace is refused withE-TYPE-WIDTHfor a 16-bit target. That is earlier than stopping at run time. A program that writes a buffer size as a constant stands only on machines that size fits. - A
usizefield of a struct has a different size on each machine: 8 bytes at 64 bits, 4 at 32. Do not putusizein a layout that goes to a file or onto the wire; writeu32oru64there and move the value when reading it in. idiffholds only half ofusize. On a 16-bit machine a position past 32 767 stops atcast idiff. When the distance between two positions is needed, another way is to subtract the smaller from the larger inusizeand carry the direction separately as abool.- Keeping an index in a narrow field sets a limit. With an index in a
u32field, thecast u32on the way in stops once there are more than 232 elements. Whoever chose the type chose that limit, so write it as arequirestoo. - Check for another target.
lowentc --target cortex_m --check prog.lowtranslates with a 32-bitusize. Large constants and layout differences show up there.
Counter-example. Mixing a usize with another integer as they are
examples/ch04/mistake_usizemix.low
module mistake_usizemix .
rem expect: E-TYPE-NOMINAL
rem ✘ adding a usize to a u64 as they are --- the width of the sum was never written
fn end_of input start usize . input more u64 . output u64 . do
return add start. cast usize more. . . .
end .
Output
$ lowentc --check mistake_usizemix.low
mistake_usizemix.low:6:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)
start is a position in memory and more is a u64. The source does not say at which width to add. Write add cast u64 start. . more. . to add on the wide side. When the result is used as a position again, bring it down with cast usize.
Counter-example. Moving a usize with widen
examples/ch04/mistake_usizewiden.low
module mistake_usizewiden .
rem expect: E-TYPE-NOMINAL
rem ✘ moving a usize with widen --- whether it widens depends on the machine. Write cast
fn as_u64 input n usize . output usize . do
return widen u64 n. . .
end .
Output
$ lowentc --check mistake_usizewiden.low
mistake_usizewiden.low:6:0 E-TYPE-NOMINAL: `usize` / `idiff` convert with `cast` only — their width is the implementation's (16 bits up to the widest integer), so whether this WIDENS or NARROWS depends on the target, and `widen` promises a direction. Write `cast <type> <value>`: it asks no direction and stops when the value does not fit
mistake_usizewiden.low:6:0 E-TYPE-NOMINAL: these are NOMINALLY DISTINCT types with the same representation — a `newtype` (or usize/idiff) is not interchangeable with its base or with another newtype. Convert explicitly with `cast <type> <value>` (RFC-0002 §6.3.1)
Looking at 64-bit machines only, usize to u64 seems always to widen, but the implementation sets the width. Write cast u64 n. ..
4.10 Explicit conversion is cast’s job#
Reading a value as another type happens only where the source says so. The one conversion the compiler makes by itself is widening; every other conversion is written by the author as a word. At the centre is cast <type> <value>. In this language, explicit conversion is what cast is for.
| Word | What it covers | When the value does not fit |
|---|---|---|
widen | widening that keeps the value — the compiler also does it by itself | cannot happen |
narrow | a smaller width within the same kind | stops |
cast | every other conversion — changing the sign · between integers and floating point · between usize/idiff and other integers · a bool to a number · between an enum and a number | stops |
Table 4.5 — The three words that write a conversion
examples/ch04/cast_kinds.low
module cast_kinds .
rem run: sign 200
rem run: to_float 7
rem run: to_int 7.9
rem run: bool_num 9
rem run: level_num 2
rem trap: sign 40000
rem change the sign --- read a u32 value as i16. It stops when the value does not fit
fn sign input n u32 . output i16 . do
return cast i16 n. . .
end .
rem cross kinds --- an integer to a floating-point number
fn to_float input n u32 . output f64 . do
return cast f64 n. . .
end .
rem a floating-point number to an integer --- the fraction is dropped toward 0
fn to_int input x f64 . output i64 . do
return cast i64 x. . .
end .
rem a bool to a number --- true is 1, false is 0
fn bool_num input n u64 . output u8 . do
return cast u8 gt n. 5 . . .
end .
enum level do
low .
mid .
high .
end .
fn pick input k u8 . output level . do
if eq k. 0 . do return low. . end .
if eq k. 1 . do return mid. . end .
return high. .
end .
rem an enum to a number --- the variant's number (the first is 1)
fn level_num input k u8 . output u8 . do
return cast u8 pick k. . . .
end .
Output
$ lowentc --run sign cast_kinds.low 200
sign(200) = 200
$ lowentc --run to_float cast_kinds.low 7
to_float(7) = 7.0
$ lowentc --run to_int cast_kinds.low 7.9
to_int(7.9) = 7
$ lowentc --run bool_num cast_kinds.low 9
bool_num(9) = 1
$ lowentc --run level_num cast_kinds.low 2
level_num(2) = 3
$ lowentc --run sign cast_kinds.low 40000
== ir diagnostics (1) ==
0:0 E-VM-CAST: value does not fit the target width (use narrow_wrap / narrow_sat / narrow_try)
signreads au32as ani16. 200 fits and comes out unchanged; 40000 does not fit ini16, so it stops.castis not a sign that “anything goes” but a sign that “I know the value may change here”. It never truncates quietly.to_floatandto_intcross kinds. Converting a floating-point number to an integer drops the fraction toward 0 — 7.9 becomes 7.bool_numturns a bool into a number: true is 1, false is 0. There is nocastthe other way, from a number to a bool; write what you are asking, as inne n. 0 ..level_numreads the number of an enum value. The first variant is 1, sohighis 3. The other way,cast level n. .gives the variant numbered n and stops if there is none. An enum value is not a number, so without thiscastit does not go into a number place (chapter 10).
A conversion that could be read more than one way never happens by itself: the same bits could be read as two different numbers, the conversion widens on one machine and narrows on another, or the value changes kind. Types and conversions added to the language later follow the same rule — if it is ambiguous, it is written cast.
Q. Isn’t writing cast every time a chore?
A. It costs some typing. In return a reader can find every place a value may change by eye. Search the program for cast and you have seen every place a type changes. One place where the compiler changes a type silently — like C’s integer promotions or mixed signs — and that promise is broken.
4.11 Common mistakes#
Many number mistakes pass compilation and only show up while running. Where other languages would quietly produce a wrong value, Lowent stops — and the place it stops is the place to fix.
Counter-example. Finding the middle by adding first, then halving
examples/ch04/mistake_midpoint.low
module mistake_midpoint .
rem trap: mid 200 100
fn mid input a u8 . input b u8 . output u8 .
do
rem ✘ adding first gives 300, which does not fit in u8
return div add a. b. . 2 . .
end .
Output
$ lowentc --run mid mistake_midpoint.low 200 100
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)
200 + 100 is 300, and u8 only goes up to 255, so it overflows before the division. This is the very bug that lurked for decades in (lo + hi) / 2 inside binary searches. C would quietly give 22, half of the wrapped value 44. The fix is to compute at a wider width.
examples/ch04/midpoint_fixed.low
module midpoint_fixed .
rem run: mid 200 100
rem run: mid 255 255
fn mid input a u8 . input b u8 . output u8 .
do
rem add one width up --- u16 holds up to 510
let both u16 add widen u16 a. . widen u16 b. . . .
rem half of it fits in u8 again; we know that, and narrow checks it
return narrow u8 div both. 2 . . .
end .
Output
$ lowentc --run mid midpoint_fixed.low 200 100
mid(200, 100) = 150
$ lowentc --run mid midpoint_fixed.low 255 255
mid(255, 255) = 255
Widen to u16, add, divide, then narrow u8 back down. The middle always fits in u8, so the narrowing never stops — and if the computation is ever changed by mistake, it stops right there and tells you. (When lo <= hi is guaranteed, add lo. div sub hi. lo. . 2 . . does not overflow either.)
Counter-example. Subtracting a larger number from an unsigned one
examples/ch04/mistake_usub.low
module mistake_usub .
rem trap: gap 3 5
fn gap input a u64 . input b u64 . output u64 .
do
rem ✘ unsigned numbers cannot go below 0 --- 3 − 5 is not negative, it stops
return sub a. b. . .
end .
Output
$ lowentc --run gap mistake_usub.low 3 5
== ir diagnostics (1) ==
0:0 E-VM-OVERFLOW: integer overflow at the declared width (use wrap_*/sat_*, or prove the range)
u64 has nothing below 0. 3 − 5 is not −2 but an overflow, so it stops (C would give 18446744073709551614). If you want the size of the difference, subtract the smaller from the larger — if ge a. b. . do return sub a. b. . . end . return sub b. a. . . — and if a negative result is meaningful, compute in i64 from the start.
Counter-example. Giving a loop counter too narrow a type
examples/ch04/mistake_narrowloop.low
module mistake_narrowloop .
rem expect: E-TYPE-WIDTH
fn tally output u64 .
do
rem ✘ a u8 loop counter has nothing after 255 --- the 256th `add i 1` stops
var i u8 0 .
var n u64 0 .
while lt i. 256 . do
set n. add n. 1 . .
set i. add i. 1 . .
end .
return n. .
end .
Output
$ lowentc --check mistake_narrowloop.low
mistake_narrowloop.low:9:0 E-TYPE-WIDTH: this comparison holds a value that the other side's type cannot hold, so the answer is the same for every input — the comparison decides nothing. A name declared `u8` is at most 255, `u16` at most 65535, `i8` at most 127; a literal beyond that is out of range exactly as it would be in an assignment, where the tool has always refused it. Widen the name's type, or compare against a value the type can reach. (This is how a loop written `while lt i 256 .` over a `u8` counter never ends)
mistake_narrowloop.low:9:0 E-TYPE-WIDTH: value does not fit the declared type (a literal out of range, or a narrowing — use narrow / narrow_wrap / narrow_sat / round_to)
A u8 can never reach 256, so lt i. 256 . is always true. In C, i would wrap from 255 back to 0 and the loop would never end. Lowent refuses the comparison at translation time with E-TYPE-WIDTH: comparing against a value the type cannot hold gives the same answer for every input, so it decides nothing. Give a loop counter a type wider than the largest value it counts to (u64).
Counter-example. Doing float arithmetic with an integer literal
examples/ch04/mistake_floatint.low
module mistake_floatint .
rem expect: E-TYPE-MIX
fn half input x f64 . output f64 .
do
rem ✘ divides an f64 by the integer literal 2 --- write 2.0 where a float is expected
return div x. 2 . .
end .
Output
$ lowentc --check mistake_floatint.low
mistake_floatint.low:7:0 E-TYPE-MIX: a floating-point value and an INTEGER literal are mixed in one operation. Floating point and integers do not convert implicitly (§6.2.5), so write the literal as a float (`2.0`) — or convert the other side. It used to pass `--check` and stop at run time (`E-VM-TYPE: arithmetic needs ints`)
Integers and floats never convert into each other automatically. To divide an f64, write the literal as a float too: 2.0. Mixing them is rejected at translation time with E-TYPE-MIX; it used to stop while running with E-VM-TYPE.
The largest u64 goes in as it is when passed as a --run argument.
examples/ch04/runmax.low
module runmax .
rem run: echo 18446744073709551615
rem test
rem the upper half of u64 goes in as a `--run` argument as it is (since 1.20.0)
fn echo input n u64 . output u64 .
do
return n. .
end .
rem boundary values can also be written as literals inside a test
test largest_u64
do
let m u64 18446744073709551615 .
expect gt m. 9223372036854775807 . .
expect eq div m. 2 . 9223372036854775807 . .
end .
Output
$ lowentc --run echo runmax.low 18446744073709551615
echo(18446744073709551615) = 18446744073709551615
$ lowentc --test runmax.low
[PASS] largest_u64
== tests: 1 run, 1 passed, 0 FAILED ==
Until 2026-09-16 this number was quietly cut down to 9223372036854775807 — a different number, and both back ends did it, so comparing them did not reveal it — and after that, up to 1.19.0, it was refused at the boundary (E-VM-CONTRACT). Since 1.20.0 the boundary takes 0 to 2^64 − 1 without a sign; -1 and 2^64 are still refused. Boundary values can also be written as literals inside the program, as largest_u64 does.
A common misconception. f32 and f64 hold the same number
examples/ch04/mistake_f32literal.low
module mistake_f32literal .
rem run: stored
rem run: computed
rem ✘ an f32 holding a literal as is; in this edition it is not rounded to 32 bits, so it compares equal to the f64 0.1
fn stored output bool . do
let a f32 0.1 .
let b f64 0.1 .
return eq widen f64 a. . b. . .
end .
rem an f32 that went through one operation (0.1 + 0) is rounded to 32 bits and compares unequal
fn computed output bool . do
let a f32 0.1 .
let c f32 add a. 0.0 . .
let b f64 0.1 .
return eq widen f64 c. . b. . .
end .
Output
$ lowentc --run stored mistake_f32literal.low
stored() = 0
$ lowentc --run computed mistake_f32literal.low
computed() = 0
Both stored and computed are false. The 0.1 held in an f32 place is rounded to 32 bits, so widening it still does not equal the f64 0.1. Until 2026-09-16 a literal (or an argument passed with --run) was not rounded, and stored was true — the same type held values of different precision depending on where they came from. Choose the width deliberately, and write widen or narrow when comparing two widths.
A common misconception. div 7 2 . is 3.5
Integer division keeps only the quotient and drops the fraction.
examples/ch04/intdiv.low
module intdiv .
rem run: avg2 3 4
rem run: avg_f 3.0 4.0
fn avg2 input a u64 . input b u64 . output u64 .
do
rem integer division drops the fraction --- 7 / 2 is 3
return div add a. b. . 2 . .
end .
fn avg_f input a f64 . input b f64 . output f64 .
do
rem if you need the fraction, compute in floating point from the start
return div add a. b. . 2.0 . .
end .
Output
$ lowentc --run avg2 intdiv.low 3 4
avg2(3, 4) = 3
$ lowentc --run avg_f intdiv.low 3.0 4.0
avg_f(3.0, 4.0) = 3.5
avg2 3 4 is 3. If you need the fraction, take the inputs as floats and divide by 2.0. If you need rounding, write it — add div d. 2 . before dividing, for example — because the language never rounds for you.
The floating-point remainder gets its own name, math.fmod, separate from the integer mod.
examples/ch04/floatmod.low
module floatmod .
rem run: rest
rem run: whole_rest 7 2
use math .
rem the floating-point remainder is `fmod` --- a different name from the integer `mod`
fn rest output f64 .
do
return math.fmod 7.5 2.0 . .
end .
fn whole_rest input a u64 . input b u64 . output u64 .
requires gt b. 0 . .
do
return mod a. b. . .
end .
Output
$ lowentc --run rest floatmod.low
rest() = 1.5
$ lowentc --run whole_rest floatmod.low 7 2
whole_rest(7, 2) = 1
math.fmod 7.5 2.0 . is 1.5 and mod 7 2 . is 1. Not loading two kinds onto one name is the same reason as for div — what is computed shows in the name.
4.12 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
u8 … u64 · i8 … i64 | integers with the width in the name | sizes never change from machine to machine |
f32 · f64 | IEEE 754 floating point | never mixed with integers — the literal is 2.0 too |
widen u64 x. . | widening (loses nothing) | happens automatically, but can be written to make it visible |
narrow u8 x. . | narrowing — stops if it does not fit | so values never change silently |
narrow_sat · narrow_wrap · narrow_try | clamp to the end value · wrap · as an option | pick the outcome by name |
cast i16 n. . | every other conversion — stops when the value does not fit | so every place a value changes shows in the source |
add · sub · mul | arithmetic that stops on overflow | overflow is a bug by default |
wrap_add · sat_add · chk_add | wrap · saturate · option | the intended outcome stays in the source |
div · mod | quotient (towards zero) · remainder (sign of the divisor) | division by zero and MIN / −1 stop |
nonzero_of b. . · div_nz | check “not zero” once and carry it in the type | moves the check to one place |
bit_and · bit_or · bit_xor · bit_not | bitwise logic | no symbol-precedence traps |
shl · shr · rotl · rotr | shift · rotate | shifting by the width or more stops |
call_builtin clmul_lo · call_builtin clmul_hi | carry-less multiply (builtin ops) — two words out | the machine instruction when there is one, the computation when there is not |
call_builtin mul_hi (bigint64.mul_hi) | the upper 64 bits of a u64 product (builtin op) — the lower half is wrap_mul | one multiply where the C compiler has a 128-bit integer, four 32-bit products where it does not |
count_ones · leading_zeros · trailing_zeros · byte_swap | count bits · reverse bytes | common jobs get one name |
true · false · and · or · not | booleans and their logic | numbers are never used as conditions |
usize · idiff · f32 | integers for lengths and indexes (width set by the implementation) · 32-bit float | writing lengths and positions · when quantity matters more than precision |
cast usize x · cast u64 n | moves between usize · idiff and another integer | the same meaning whatever the machine’s width |
Table 4.6 — Number syntax — shape · meaning · why it looks this way
Recap
narrow. Overflow, division by zero, out-of-range narrowing and shifting by the width or more all stop, and other treatments (wrap, saturate, as a value) are chosen by names like wrap_, sat_, chk_ and narrow_try. The sign of mod follows the divisor. Booleans are not numbers, bitwise operations are written as words, and width and sign decide their meaning.