ADR-048: A Length Is an i64, and the Numeric Surface Is the One the Page Names

Status: Superseded by ADR-285 — its D1-D2 are D1-D2 there Related: ADR-043 D4 and D7 (the checked conversion and truncating_, whose usize sources this removes), ADR-028 D5 (an entry exists because a program asked for it), ADR-011 D2 (name-for-name lowering, which is why a conversion has to be emitted rather than resolved), ADR-024 (the checker that reads the ledger’s signatures).

1. Context

Two questions were in one, and answering the second alone leaves the first standing.

What len() hands back. A usize — Rust’s machine-width type — so every loop, sum and comparison that met a length carried an as i64 the user wrote.

And what the numeric surface actually is. Part I 2.2 offered i32, i64 and f64, and more than three were reachable: fs::read hands back a Vec[u8], std.contracts said in as many words that “the compiler accepts u32 and the rest, but the specification does not offer them”, and usize was writable.

2. Decisions

D1 — A length is an i64, and so is an index.

Vec::len, String::len, str::len and HashMap::len hand back an i64 — every entry in std.contracts that returns a length. The machine-width type leaves the surface a program can write, and usize::truncating_i32 and usize::truncating_i64 go with it: nothing narrows out of a type nobody can hold.

The two conversions are not symmetric, and that is the whole argument.

Out of a length — t.len() as i64 — cannot fail. A usize exceeds an i64 only above 9,223,372,036,854,775,807 elements; at one byte each that is eight exabytes of memory. Every one of those conversions is ceremony with nothing behind it, and the user writes it in every loop, sum and comparison that meets a length.

Into an index — v[i] where i is an i64 — can fail, for a negative number, which pos - 1 at pos == 0 produces. But a negative index is an access out of bounds, and Part III A.2 already aborts on those. It is not a new failure mode; it is the same one, reached a step earlier.

So the trade is a great deal of visible ceremony that cannot fail, against one invisible abort that already exists. And the user writes no conversion at all, in either direction — the compiler emits both.

One requirement comes with it. The emitted conversion at an index must report as an access out of bounds, not as a failed conversion. Otherwise a user gets a diagnostic about a conversion they never wrote, for a mistake they understand as a bad index.

The field agrees, with one dissenter. Go, Java, C# and Swift all hand back a signed integer for a length and index with it — Swift explicitly, on the ground that unsigned arithmetic causes more bugs than it prevents. Rust is the outlier, and the conversions its usize demands are among the most commonly named papercuts in it.

D2 — u8 is named in Part I 2.2. The rest of the unsigned family is not.

fs::read hands back a Vec[u8], so a program that reads a file already meets one; what was missing was the specification admitting it. It gets the same conversion and arithmetic names as every other numeric type.

u32, u64 and isize stay out until a program asks (ADR-028 D5). Why not a signed byte instead: Java has no unsigned type and pays for it in every byte-handling program, where a masking dance undoes the sign at each step. Go, Rust, C# and Swift all carry a byte type.

What it costs: four numeric types where the front page promised three, and a beginner meets an unsigned one the first time they read a file. What it buys is that the list of types is the list of types.

3. Consequences

check’s NUMERIC list is the writable surface, and it is now i32, i64, f64, u8. That list decides which conversions are checked at run time (ADR-043 D4); the machine-width types were in it because len handed one back, and that is why they leave with it. contracts::send’s plain-type list is untouched: it answers whether a type may cross a thread, which is a different question and not about what a program may write.

Two truncating_ entries go. Narrowing a length is still possible and still said by name — xs.len().truncating_i32() is now i64::truncating_i32, which is the entry that was always there.

A std function whose parameter is a Rust usize still needs the written conversion. " ".repeat(indent as usize) in examples/json/src/main.nika is the one in this repository. D1’s scope is what a length returns, deliberately — the four signatures, not a neighbourhood — and this is the edge of it, written down rather than left to be discovered.

4. What this does not decide

Whether the parameters above should follow. It is the same shape of question and a different set of signatures, and no program has yet been written that makes the answer obvious.

5. What is built

All of it.

std.contracts has the four i64 lengths and no usize entry at all. NUMERIC is the four writable types. Part I 2.2 names u8 and says which types the compiler accepts without offering.

Two emitted conversions, and the shape of the second is where the work was:

And the parentheses around a conversion are now decided in one place (Emitter::emits_as_cast), which this change forced and ADR-043 D7 needed already. Three things come out as a Rust as: a written cast, truncating_i32, and a length. Rust reads such an expression wrongly in exactly two positions — beside a unary operator (-x as i64 is (-x) as i64) and as the left of a comparison (x as i64 < k is read as i64<k>, and the program does not parse) — and nowhere else, so parentheses go in those two and nowhere else. truncating_ used to parenthesise always, which is the other half of C.1: let n = (big as i32); is a Rust warning about a file nobody wrote.

This site is open source. Improve this page.