Status: Superseded by ADR-285 — its D1-D3 are D5-D7, D4 is D12, D5 is D29, D5.5 is D31, D6 is D8, D7 is D13 there Related: ADR-044 (every abort points at the Nikaia line — this record is its occasion and not its reason), ADR-021 D5 and ADR-037 D4 (the cache key, which D6 adds nothing to because this is not a switch), ADR-002 D1 (the generated Cargo project D6 writes into), ADR-033 D8 (the precedent this decision deliberately does not follow: a semantic default that cannot be switched off).
The specification says nothing about what an arithmetic overflow does. That is
a gap and not a contradiction, which is what makes this the smallest version of
the change: Part III A.2 already has the category, and overflow was never written
into it. Its words are “Errors indicating an inconsistent program state (Index
Out of Bounds, Division by Zero, explicit panic())”. An overflow is the same
kind of thing as a division by zero, and the neighbouring case was decided long
ago.
What the gap cost in the meantime is measurable. The generated Cargo project names
one profile, dev, so a user’s program aborts on an i32 overflow; every
benchmark in this repository runs --release, where it wraps. So the
measurements were taken on wrapping arithmetic and the shipped program aborts —
two different programs, and nobody chose that.
The same program computes the same thing under either build. Part I 1.2’s rule — what you choose when you build is how, never what — then holds for arithmetic too, and a benchmark measures what is shipped.
The whole of the change is one row: Part III A.2’s list of unrecoverable errors gains the overflow.
let h = h.wrapping_mul(31).wrapping_add(c) // a hash, wrapping on purpose
let volume = level.saturating_add(increase) // stops at the maximum
let n = a + b // aborts on overflow
No new operator. Swift’s &+ is out because & is borrowing in this language and
a &+ b collides with &a to the eye. And if saturating does not earn a symbol
then neither does wrapping: both are rare and both are meant to be visible.
The names are Rust’s, one for one, so the emitted code stays idiomatic and a reader comparing the two languages has nothing to learn.
wrapping_add, wrapping_sub, wrapping_mul, wrapping_div, wrapping_neg,
wrapping_abs, wrapping_shl, wrapping_shr, and the same with saturating_
wherever Rust has them.
Three are easy to leave out, and leaving them out makes apparently harmless code abort:
wrapping_neg and wrapping_abs — negating the smallest value of a signed
type overflows, because there is no matching positive one. So -x and x.abs()
can abort.wrapping_div — the smallest value divided by minus one overflows for the
same reason.A 64-bit number put into a 32-bit one may not fit. Same rule: abort, and whoever wants truncation says so. Otherwise the question is answered at the front door and let back in at the back.
D6’s mechanism cannot carry this, and that is not a flaw in either. Rust’s
as is defined to truncate: there is no check to switch on, because the
language below has no failure case there. Measured — 5000000000 as i32 prints
705032704 under -C overflow-checks=on and under -C debug-assertions=on
alike. So nothing about a conversion goes through the project file, and this
record carries two mechanisms rather than one that appears to cover both.
So a narrowing conversion is not emitted as a truncation. It is emitted as a checked conversion whose failure is unwrapped, and an unwrapped failure aborts.
The objection that refused helpers for arithmetic does not reach here, for two reasons the arithmetic case does not have. A conversion is rare and the programmer wrote it down on purpose — it is not in every loop. And the checked form is what a Rust programmer would write at that spot anyway, so somebody reading the emitted code finds nothing foreign.
The built-in form covers one half of it, measured rather than assumed.
i32::try_from(x) exists for an integer and hands back a failure value.
i32::try_from(f64) does not exist — the trait bound i32: TryFrom<f64> is
not satisfied — so a conversion out of a floating-point number needs its own test
for the range and for “not a number”, which is where a short helper in std
earns the place the arithmetic case denied it.
The message needs nothing of its own. The abort names the generated line, and ADR-044’s table translates it to the Nikaia line — the same answer as for every other abort.
let x: i32 = 3000000000 is decidable where it is written. It costs nothing to
say so and it saves an abort nobody needs.
let q = 1 / n where n is a constant zero is decidable in the same place by
the same machinery, and it was refused in the same way: rustc’s
unconditional_panic, saying “attempt to divide 1_i32 by zero” about the
generated file. D5’s sentence is “decidable where it is written”, and this is;
the only reason it is not already covered is that D5 was written about a literal
and a literal is not the only constant.
This does not make a division by zero a compile-time question. Part III A.2 keeps it where it is — unrecoverable, at run time, naming the Nikaia line (ADR-044) — and that is what every division by something this compiler cannot evaluate still does. What changes is the one case where the answer is already on the page: a divisor the compiler can prove is zero is said here, and the fold that proves it says nothing about any other.
NK1118, and the fold’s polarity is the whole safeguard: a divisor that does not
fold is a divisor nothing is claimed about (Part III, C.4).
A checked helper per operation would put the meaning in the code rather than in a
setting, and it was considered for exactly that reason. It is refused: the
arithmetic is identical either way, but anyone who opens the generated Rust during
a benchmark comparison and finds helper calls where + should be will not believe
the numbers. For a language that argues from readable Rust, that is a loss with
nothing bought.
So the emitted code says a + b, and the project file carries the check. This
reaches arithmetic and nothing else: a conversion has no such switch to be carried
by, which is D4’s second mechanism.
The check applies to the generated program and not to foreign libraries, and that is not a detail. A hash function in a Rust dependency wraps on purpose and would abort with the check on. So it takes the general setting for the shipping build and an exception for every foreign package — two blocks, and Nikaia writes that file on every build anyway.
Measured, because both halves had to be true at once: with
overflow-checks = true for the program and false for package."*", a
dependency’s wrapping hash returned 2146775615 and the program’s own a + 1
aborted with attempt to add with overflow, in one run.
A test nails it down: a program that overflows, compiled in the shipping build and run, must abort. Without it the setting disappears unnoticed one day.
The known limit, written down rather than left out: whoever takes the generated Cargo project and builds it with other settings gets different numbers, silently. That is the same class as someone rewriting the generated code by hand.
Keeping the low digits is said rather than assumed, exactly as wrapping is
(D2). The name maps onto Rust’s as, which is the operation it is.
Three cases belong to this family and would otherwise each arrive on its own later:
1e20 as i32 is 2147483647, -1e20 as i32 is -2147483648, and
f64::NAN as i32 is 0. Same treatment — check and abort.9007199254740993 through an f64 and back is
9007199254740992. This is not an abort case. Every language does it this
way and there is no sensible point to stop at, so it is written down as a known
limit and not checked.And a test like the arithmetic one: a number that does not fit the smaller type, compiled in the shipping build and run, must abort.
Part III A.2 gains a row and Part I gains D2’s methods, D4’s rule and D5’s. None of it contradicts a sentence that is there; the area was simply unwritten.
as turns out to be unspecified altogether. The parser has cast_expr and
examples/1brc.nika uses it four times (first as i64, self.sum as f64), and
no section of Parts I–III says what a cast is or does. D4 cannot be written
without that being written first, so the cast is specified here by necessity
rather than by plan.
A third case was decided at compile time by rustc already, and that was a
problem. let b = a + 1 where both are constants was refused by rustc with
“this arithmetic operation will overflow” — a message about the generated file,
which is what Part III C.1 forbids. So D5’s compile-time check had to cover the
constant-expression case and not only the literal, or the gap would have moved
rather than closing. It does now (§4), by folding the expression where an
operand’s declaration pins the type — and the pin is what keeps the rule inside
C.4, because a literal standing alone still has no type and is still not refused
(Part I 2.4).
Looking for that case found one more, one operator over and of the same
class: a division by a constant zero, rustc’s unconditional_panic. D5.5
covers it.
This adds nothing to the cache key. The check is not a switch — it is on in
every build — so ADR-021 D5’s enumeration and
ADR-037 D4 are untouched. It is deliberately unlike
ADR-033 D8’s ordering, which can be turned off: that record’s
argument is that a semantic default should be offered rather than imposed, and it
does not reach here, because turning this one off would change what a program
computes.
Not now, and recorded so it does not come back as a new idea. A prover that rules overflows out in advance does not replace this decision: whatever it cannot prove still needs an answer at run time.
All of it. The generated project carries the arithmetic check for the program
and turns it off for every dependency; std’s ledger has the wrapping_ and
saturating_ names for i32 and i64, the two integer types Part I 2.2 offers;
an out-of-range literal is NK1116 in this language’s words.
Including the constant expression §3 left open. NK1116 now folds: a literal,
an immutable let whose value folded, + - * / % and a negation, in an i128 so
that a sum which cannot fit an i64 is a number the message can name rather than
one the fold wrapped. The type it answers to is what stands beside it, or — where
nothing does — what an operand’s declaration pinned, which is what makes
let b = a + 1 answerable with no annotation on the line.
Every step is a checked one and None claims nothing: a mut local (this checker
does not follow assignments), a parameter, a for binding, a cast, an operator
outside the five, or a fold that leaves the i128 each stop the whole expression,
and an expression that does not fold is never refused.
One residue, and it is not this record’s. A literal that nothing constrains —
let b = 3000000000 + 1 — is still rustc’s refusal, because rustc has the
inference that defaults it to an i32 and this checker has none. Refusing it here
would refuse the program that passes the sum to an i64, which C.4 forbids; it is
one entry (#142, the out-of-range literal nothing constrains) and
not two.
And NK1118 for D5.5.
D4. A narrowing conversion is emitted as a checked one. Which conversions
those are is the checker’s answer and not the emitter’s, because as i32
narrows or widens by what it is given: the checker records a pair of
(statement, target type) where every such conversion in that statement narrows,
and the emitter looks the pair up — the same shape already used for fallible loops
and methods (ADR-025 D7, ADR-028). A statement where one conversion to a type
narrows and another widens records nothing, which leaves both as they were.
The list in the checker is written the other way round — which conversions
always fit — so a pair nobody thought about is checked rather than truncated
in silence (ADR-010 D1’s direction). Between integers the check is
try_from; out of an f64 it is nikaia_std::num, which Rust gives no
try_from for. That helper goes through i128 rather than comparing against the
target’s limits as f64, and the reason is the measurement D7 rests on:
i64::MAX as f64 rounds up, so the obvious comparison accepts a value one
past what the type holds. f64 as i128 saturates far outside every target’s
range, so try_from then answers exactly — and for a type whose width the machine
decides, without a constant that would have to differ per machine.
D7. truncating_i32 and truncating_i64 are std entries whose signature
the ledger carries, and the emitter lowers them to Rust’s as in parentheses —
as sits between the unary and the binary operators in Rust’s precedence while a
method call sits above both, so -x.truncating_i32() has to keep meaning
-(x as i32). The sources are the three a Nikaia program can narrow from: i64,
f64, and the machine-width type len hands back. crates/nikaia/tests/overflow.rs
compiles the conversions with -O and no check flag, which is the point: a
conversion carries its own answer and takes none from the build.
isize is checked and has no truncating_ name. Nothing in the ledger hands
one back, so no program can reach a conversion out of one — but it is in the
checked list anyway rather than left to truncate, which is the same fail-closed
direction. The day something produces an isize, the name comes with it.
Still reaching rustc rather than this compiler: a sum of constants that
cannot fit, which §3 names.