9 Sequences — arrays and slices
What to know first
for walks a slice, and guard turns a condition into a factprocLooking back
In chapter 7′s head_or_zero, why was idx data. 0 . safe?
A. Because the guard ge len data. . 1 . else return 0 . . right before it handed the code below the fact that the slice was not empty. Code after a guard lives only in a world where the condition is true. This chapter covers what that slice is, and when bounds checks remain and when they disappear.
The need for this chapter, and its context
By the end of this chapter
array n t and slice t, how to read with len, idx and for, and that out-of-range access stops. You will pick up the rule that writing elements requires a mut slice, and how to narrow a window with subslice. You will also see the principle by which contracts remove bounds checks in the body, and that a string literal is a table that can be indexed directly.The questions this chapter answers
- Is there no separate string type?
- Can
[3,4,5]given to--runbe used for aslice u64?
9.1 Start and length together#
slice t is a contiguous run of values of type t. A slice value carries its start address and its length together, so nobody has to remember separately “how many are here”.
examples/ch09/basics.low
module basics .
rem run: head [9,8,7]
rem run: last4 [1,2,3,4]
rem run: total [10,20,30]
rem trap: at [1,2,3] 5
fn head input data slice u8 .
output u8 .
requires ge len data. . 1 . .
do
return idx data. 0 . .
end .
fn last4 input xs array 4 u8 . output u8 .
do
return idx xs. 3 . .
end .
fn total input xs slice u8 . output u64 .
do
var acc u64 0 .
for x xs. do
set acc. add acc. widen u64 x. . . .
end .
return acc. .
end .
fn at input xs slice u8 . input i usize . output u8 .
do
return idx xs. i. . .
end .
Output
$ lowentc --run head basics.low [9,8,7]
head([9,8,7]) = 9
arg0 (written) = [9,8,7]
$ lowentc --run last4 basics.low [1,2,3,4]
last4([1,2,3,4]) = 4
arg0 (written) = [1,2,3,4]
$ lowentc --run total basics.low [10,20,30]
total([10,20,30]) = 60
arg0 (written) = [10,20,30]
$ lowentc --run at basics.low [1,2,3] 5
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
len data. .is the number of elements, andidx data. 0 .is the first element. Numbering starts at 0.array 4 u8inlast4is a sequence of exactly 4. Anarrayin an input position means it receives a slice of length 4, and that length is checked on entry, likerequires eq len xs. . 4 . ..for x xs. do … end .walks the elements in order.- Passing a slice of length 3 and 5 to
atstops withE-VM-BOUNDS. It does not read someone else’s memory.
An array writes the length first and the element type after it — the same order as vec 4 u32 and matrix 2 3 4 u32. Because the element type comes last, an array of arrays needs no parentheses: array 3 array 4 u8. The reverse order is rejected, and lowentc --fmt rewrites it.
examples/ch09/array_order.low
module array_order .
rem expect: E-TYPE-ORDER
fn last input xs array u8 4 . output u8 .
do
return idx xs. 3 . .
end .
Output
$ lowentc --check array_order.low
4:24 E-TYPE-ORDER: the count comes first: `array <count> <element type>` — `array 4 u8`, and an array of arrays is `array 3 array 4 u8`. The old order `array u8 4` is no longer read; `lowentc --fmt` rewrites it — found `u8` (RFC-0142: a name opens, a stop closes; a vari
For one month from 2026-09-27 the element type came first (array u8 4). On 2026-10-29 the length moved back to the front, and vec and matrix moved with it, so all three share one order. Today array is used only in op input positions; in outputs, locals and struct fields, write slice with a contract.
Q. Is there no separate string type?
A. There is not. A string literal "hello" is a slice u8. Questions such as how many characters it has or whether it is valid UTF-8 are answered by library ops, not by a type (chapter 33). It is a choice to avoid the defects that start the moment bytes and characters are treated as the same thing.
9.2 Writing needs mut slice#
To write the elements of a slice, the slice must be mut. And changing bytes the caller passed is visible to the caller, so it must be a proc (chapter 5).
examples/ch09/fill.low
module fill .
rem run: fill_two [0,0,5]
proc fill_two input xs mut slice u8 . output u64 . effects state .
requires ge len xs. . 2 . .
do
set idx xs. 0 . 10 .
set idx xs. 1 . 20 .
var s u64 0 .
for x xs. do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
Output
$ lowentc --run fill_two fill.low [0,0,5]
fill_two([10,20,5]) = 35
arg0 (written) = [10,20,5]
The fill_two([10,20,5]) the VM shows is the argument’s state after the op ended; the caller’s bytes changed. Writing without mut is rejected.
examples/ch09/readonly.low
module readonly .
rem expect: E-TYPE-MUT
fn poke input xs slice u8 . output u64 .
do
set idx xs. 0 . 1 .
return 0 .
end .
Output
$ lowentc --check readonly.low
readonly.low:6:0 E-TYPE-MUT: writing an element of a slice that is not declared `mut` (a shared slice is read-only)
mut is a permission on the slice’s elements. It is a different question from whether the name holding the slice was made with let (chapter 6). It is followed by the rule that a slice whose elements can be written may be borrowed in only one place at a time (chapter 12).
9.3 Narrowing the window#
subslice s. from. to. . makes a new slice pointing from position from of s up to, but not including, to. It copies no bytes; it just places another, narrower window over the same bytes.
examples/ch09/windows.low
module windows .
rem run: middle_sum [1,2,3,4,5]
rem run: prefix_char 4
fn middle_sum input xs slice u8 . output u64 .
requires ge len xs. . 3 . .
do
let mid slice u8 subslice xs. 1 sub len xs. . 1 . . .
var s u64 0 .
for x mid. do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
fn prefix_char input k usize . output u8 .
requires lt k. 11 . .
do
return idx "/api/users/" k. . .
end .
Output
$ lowentc --run middle_sum windows.low [1,2,3,4,5]
middle_sum([1,2,3,4,5]) = 9
arg0 (written) = [1,2,3,4,5]
$ lowentc --run prefix_char windows.low 4
prefix_char(4) = 47
middle_sum sums the middle elements, leaving out both ends. prefix_char indexes a string literal directly, because a literal is a slice u8.
In practice. From an if chain to one table
if eq k. 0 . do return 47 . end .. The emitted C then has one comparison and branch per character. Indexing a string literal becomes one static table and one index. In a benchmark that reads HTTP requests, replacing such a chain with a table lookup brought branch mispredictions down to the level of hand-written C and cut run time by about 25%. The answer did not change by a single bit.examples/ch09/table.low
module table .
rem run: prefix_char 4
rem run: prefix_chain 4
rem a string literal is a slice u8 and can be indexed as it is
fn prefix_char input k usize . output u8 .
requires lt k. 11 . .
do
return idx "/api/users/" k. . .
end .
rem the same job as an if chain costs one comparison and one branch per character
fn prefix_chain input k u64 . output u8 . do
if eq k. 0 . do return 47 . end .
if eq k. 1 . do return 97 . end .
if eq k. 2 . do return 112 . end .
if eq k. 3 . do return 105 . end .
if eq k. 4 . do return 47 . end .
return 0 .
end .
Output
$ lowentc --run prefix_char table.low 4
prefix_char(4) = 47
$ lowentc --run prefix_chain table.low 4
prefix_chain(4) = 47
The two ops give the same answer. Looking with --emit-c, prefix_char is one string table and one index, while prefix_chain is a chain of comparisons and gotos. In this edition, though, the index check in prefix_char remains — the analysis does not connect the literal’s length 11 with the contract lt k. 11 ..
9.4 Writing a sequence directly — list literals#
When you need a sequence written out as values, list it with lit. lit array 5 u32 2 3 5 7 11 . is an array 5 u32 value written as length, element type, then the elements; lit slice u32 4 5 6 . is a slice value with no length written (the element count is the length). A list closes with its own period. At the end of a statement the list’s period and the statement’s period come together (. .); in the middle of a call one period closes the list and the next argument follows.
examples/ch09/listlit.low
module listlit .
rem run: primes
rem run: padded
rem run: inside
fn total input xs slice u32 . output u64 .
do
var s u64 0 .
for x xs. do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
rem an array list --- element type, length, elements, then the list's own dot
export fn primes output u64 .
do
let p lit array 5 u32 2 3 5 7 11 . .
return total p. . .
end .
rem a trailing _ means the remaining cells are zero
export fn padded output u64 .
do
let p lit array 6 u32 1 2 _ . .
return add cast u64 len p. . . total p. . . .
end .
rem a slice list has no length --- the element count is the length. In the middle it closes with one dot
export fn inside output u64 .
do
return add total lit slice u32 4 5 6 . . 1 . .
end .
Output
$ lowentc --run primes listlit.low
primes() = 28
$ lowentc --run padded listlit.low
padded() = 9
$ lowentc --run inside listlit.low
inside() = 16
If there are fewer elements than the length, end the list with _ to say “the remaining cells are zero”. Without it the list is E-LIT-COUNT — cells are never filled silently. Each element must fit its type (E-TYPE-WIDTH).
A let list whose elements are all constants is read-only bytes baked into the program. Passing that name to a mut position is refused (E-TYPE-ARGMUT). When you need a sequence you can change, bind it with var. A list bound with var, and a list with elements computed at run time, live in this op’s frame — like a local array in C. Write a cell with set idx buf. i. . v. ..
examples/ch09/framelist.low
module framelist .
rem run: squares 5
rem run: spread 3 4
rem run: again 3
rem a list bound with var is a slot in this op's frame --- write its cells with set (index …)
export fn squares input n usize . output u64 .
do
var buf lit array 8 u64 _ . .
var i usize 0 .
while lt i. 8 . do
set idx buf. i. . cast u64 mul i. i. . . .
set i. add i. 1 . .
end .
guard lt n. 8 . else return 0 . .
return idx buf. n. . .
end .
rem elements may be computed at run time --- such a let list also lives in the frame
export fn spread input a u64 . input b u64 . output u64 .
do
let t lit array 3 u64 a. b. mul a. b. . . .
return add idx t. 2 . cast u64 len t. . . . .
end .
rem a declaration inside a loop is filled afresh on each pass --- it never sees the last pass
export fn again input n u64 . output u64 .
do
var total u64 0 .
var k u64 0 .
while lt k. n. . do
var cell lit array 1 u64 _ . .
set idx cell. 0 . add idx cell. 0 . 10 . .
set total. add total. idx cell. 0 . . .
set k. add k. 1 . .
end .
return total. .
end .
Output
$ lowentc --run squares framelist.low 5
squares(5) = 25
$ lowentc --run spread framelist.low 3 4
spread(3, 4) = 15
$ lowentc --run again framelist.low 3
again(3) = 30
A frame slot lives until the end of the block that declares it. When the declaration runs again (the next pass of a loop), the slot is filled afresh. So a slice that sees the slot may not leave that block — returning it, storing it in a name declared further out or in a parameter’s cell, or sending it to an actor is refused (E-LIT-ESCAPE). Reading a cell out as a scalar is fine.
examples/ch09/mistake_frameescape.low
module mistake_frameescape .
rem expect: E-LIT-ESCAPE
rem ✘ returning a var list --- its slot becomes something else when this op returns
fn hold output slice u8 . do
var buf lit array 4 u8 1 2 3 4 . .
return buf. .
end .
Output
$ lowentc --check mistake_frameescape.low
mistake_frameescape.low:7:0 E-LIT-ESCAPE: this carries a view of a list literal's frame storage — or of bytes an allocator gets back at the end of the block (RFC-0135 D11) — OUT of the block that declared it (a `return`, a name declared further out, a parameter's field or element, or an actor). That storage belongs to the declaring block (RFC-0132 §13.2 ⓐ): after the block — or on the next pass of a loop — the bytes are something else. Copy what you need into storage that outlives the block, or declare the list further out
mistake_frameescape.low:6:0 W-VAR-NEVER-SET: this `var` is never changed — write `let` (a `let` cannot be reassigned, which is what this name already does, and the interval analysis keeps its facts) — RFC-0113 O4
Re-pointing the array name itself with set buf … is E-ARRAY-SET — write the cells one by one. The frame lists of one op may not add up to more than 64 KiB (4 KiB on a small bare-metal machine) (E-FRAME-SIZE). An op holding frame lists may not recurse (E-FRAME-RECURSIVE) — the frame would repeat at every level, and the depth is not known at translation. If you need a buffer inside a recursion, take it from an allocator (chapter 20). And if the lists alive on the stack at once along a call path exceed the budget (4 MiB), that is E-STACK-BUDGET. An op that runs as a task (spawn) runs on a small stack, so its budget is 64 KiB. Running out of stack is ruled out before the program runs. lowentc --stack-report <file> prints, for every entry op and task, the list bytes on its deepest path and its budget.
When only a few cells of a long array differ, fill the cells you choose. Inside do … end write <idx> <value> ., and a final _ <value> . fills every remaining cell. Every cell must be decided, so a leftover cell without _ is E-LIT-COUNT. An index is a constant inside the length, written once (E-LIT-INDEX). The block closes the list, so the list has no period of its own.
examples/ch09/cellfill.low
module cellfill .
rem run: table 3 5
rem run: table 3 1
rem fill chosen cells --- name cells 0 and 5, every other cell holds 1
export fn table input a u64 . input i usize . output u64 .
do
let t lit array 6 u64 do 0 100 . 5 mul a. 2 . . _ 1 . end . .
guard lt i. 6 . else return 0 . .
return idx t. i. . .
end .
Output
$ lowentc --run table cellfill.low 3 5
table(3, 5) = 6
$ lowentc --run table cellfill.low 3 1
table(3, 1) = 1
Values may be expressions and are computed in the order written.
The elements may be structs too, as long as the struct has a byte layout — every field a sized number.
examples/ch09/structlist.low
module structlist .
rem run: total 7
struct pt do
x u32 .
y u64 .
end .
rem a list whose elements are structs --- each element is a copy of its value, and a field is written with `(field ps 1 y)`
export fn total input a u64 . output u64 .
do
let p pt lit pt do x 1 . y a. . end . .
var ps lit array 3 pt p. lit pt do x 2 . y 20 . end . _ . .
set field ps. 2 y . 100 .
var s u64 0 .
for q ps. do
set s. add s. add widen u64 field q. x . . field q. y . . . .
end .
return s. .
end .
Output
$ lowentc --run total structlist.low 7
total(7) = 130
- Each element is a copy of the value given. Changing a field in the list after putting
pin it leavespas it was. - An element’s field is written as
set field ps. 2 y . 100 .— the list’s name, the position, then the field. - Cell fills (
do <idx> <value> . _ <value> . end) andlit slice pt …work the same way. Aletlist of only_is read-only zeros. - A struct with a slice,
ownedor array field cannot be an element yet (E-LIT-UNBUILT). A number where a struct goes isE-TYPE-FIELD, and a different struct isE-TYPE-STRUCT.
To copy one slice’s contents into another, write copy <to> <from> ..
examples/ch09/copying.low
module copying .
rem run: shift 9
rem `copy` copies one slice's contents into another --- overlap is fine
export fn shift input a u8 . output u64 .
do
var buf lit array 6 u8 1 2 3 4 5 6 . .
var dst lit array 3 u8 _ . .
copy dst. subslice buf. 0 3 . .
copy subslice buf. 1 6 . subslice buf. 0 5 . .
set idx dst. 0 . a. .
var s u64 0 .
for x buf. do
set s. add mul s. 10 . widen u64 x. . . .
end .
return add mul s. 1000 . add mul widen u64 idx dst. 0 . . 100 . widen u64 idx dst. 2 . . . . .
end .
Output
$ lowentc --run shift copying.low 9
shift(9) = 112345903
- The two slices must have the same length. If they are known to differ at translation it is
E-COPY-LEN; if they differ at run time the program stops. - Two overlapping parts of one array are copied correctly —
1 2 3 4 5 6becomes1 1 2 3 4 5. - The receiver must be writable (
E-TYPE-MUT). Use this instead ofset buf …, which would re-point avararray’s name.
When the elements are fixed-length arrays, the list is a list of rows. Its type is slice array 4 u8.
examples/ch09/rows.low
module rows .
rem run: grid 5
rem a list of rows --- `index g 1` is a view of that row (no copy)
export fn grid input a u8 . output u64 .
do
var g lit slice array 4 u8 lit array 4 u8 1 2 3 4 . lit array 4 u8 _ . lit array 4 u8 _ . . .
let row mut slice u8 idx g. 1 . .
set idx row. 2 . a. .
set idx idx g. 2 . 3 . 9 .
var s u64 0 .
for r g. do
for x r. do
set s. add s. widen u64 x. . . .
end .
end .
return add mul s. 100 . add cast u64 len g. . . mul cast u64 len idx g. 0 . . . 10 . . . .
end .
Output
$ lowentc --run grid rows.low 5
grid(5) = 2443
len g. .counts rows (3), andidx g. 1 .is aslice u8that views the second row. It is not a copy, so writing torowchangesg.- A cell is written through its row:
set idx idx g. 2 . 3 . 9 .. Replacing a whole row withset idx g. 1 . …isE-TYPE-SET. - A row value must have the same shape (
lit array 4 u8 …). A row of a frame list views the frame’s bytes, so returning it isE-LIT-ESCAPE.
A row table can also sit on bytes you already have. view_array array 4 u8 b. is a slice array 4 u8 that reads b four bytes at a time — nothing is copied, and the cells can be written when b can. If the length of b is not a multiple of 4, the program stops. So one op takes a table written in the source and bytes read from elsewhere alike.
9.5 Arrays of arrays#
When the elements are arrays again, it is an array of arrays. array 2 array 3 u8 is a table of 2 rows of 3 cells laid one after the other — the same layout as C’s u8 a[2][3] — and, because the element type comes last, it needs no parentheses.
examples/ch09/nested.low
module nested .
rem run: table 7
rem an array of arrays --- 2 rows of 3. `idx g. r.` is a view of that row (no copy)
struct board do
id u8 .
cells array 2 array 3 u8 .
end .
fn rowsum input g array 2 array 3 u8 . input r usize . output u64 .
requires lt r. 2 . .
do
var s u64 0 .
for x idx g. r. . do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
export fn table input a u8 . output u64 .
do
var b lit board do id 1 . cells lit array 2 array 3 u8 lit array 3 u8 1 2 3 . _ . . end . .
set idx idx field b. cells . 1 . 2 . a. .
return add mul rowsum field b. cells . 0 . 100 . rowsum field b. cells . 1 . . .
end .
Output
$ lowentc --run table nested.low 7
table(7) = 607
- A value is
lit array 2 array 3 u8 <row>… .; a trailing_fills the remaining rows with zeros. idx g. r. .is a slice that views row r (no copy). The rules are those of a list of rows, so a list of rows and a row view over bytes can be passed to the same input; the row count is checked at entry.- As a struct field the bytes lie inside the record, and copying the struct copies them.
- An op can return one —
output r array 2 array 3 u8 .. As with any array result, the caller gives the place (var g array 2 array 3 u8 f … ., chapter 5). - Any number of levels.
array 2 array 3 array 4 u8is 2 planes of 3 rows of 4;idx g. i. .is aslice array 4 u8that views plane i, and one moreidxis aslice u8that views a row. A value of another depth cannot be passed (E-TYPE-ARG). - For computing with tables of numbers whose sizes are known, there is also
matrix(next section).
9.6 Several dimensions — matrix#
When there are two or more dimensions and the sizes are known in advance, write a matrix. matrix 2 3 4 u32 is a table of u32 in 3 rows of 4: the number of dimensions (2), one size per dimension, then the element type (the same order as array). Because the number of dimensions comes first, it is clear where the sizes end.
examples/ch09/matrix.low
module matrix .
rem run: row_sum 2
rem a table of 3 rows and 4 columns --- the number of dimensions (2), then the sizes
fn table output t matrix 2 3 4 u32 .
do
repeat r usize dim t. 0 . do
repeat c usize dim t. 1 . do
set idx t. r. c. . cast u32 add mul r. 10 . c. . . .
end .
end .
return t. .
end .
rem one index per dimension --- each is checked against its own size
export fn row_sum input r usize . output u64 .
requires lt r. 3 . .
do
let t matrix 2 3 4 u32 table . .
var s u64 0 .
repeat c usize dim t. 1 . do
set s. add s. widen u64 idx t. r. c. . . . .
end .
return s. .
end .
rem `flat` sees the same elements as one row
export fn total output u64 .
do
let t matrix 2 3 4 u32 table . .
var s u64 0 .
for x flat t. . do
set s. add s. widen u64 x. . . .
end .
return s. .
end .
Output
$ lowentc --run row_sum matrix.low 2
row_sum(2) = 86
idx t. r. c. .takes one index per dimension, and each index is checked against its own size — whencis 4 the program stops, whether or not cells remain in the row. With a flat array andr × 4 + c, the same mistake silently reads a cell of the next row.dim t. 0 .is the size of the first dimension (3) anddim t. 1 .the second (4). Both become numbers at translation time.len t.is rejected, because readers would disagree on whether it means the first size or the element count.- The elements lie in one run, row-major (the last index moves fastest).
flat t. .sees that run as aslice u32— it is not a copy. To walk every element, writefor x flat t. . do. - Another size is another type. Passing
matrix 2 4 3 u32orarray 12 u32to an op that takesmatrix 2 3 4 u32isE-MATRIX-SHAPE. When a matrix is passed or returned whole, the shapes are compared at translation time. - The result of an op that returns a matrix goes to one of two places: a name declared with the same shape (
let t matrix 2 3 4 u32 table . .), or thereturnof an op whose output is that shape. Binding it to anarrayname isE-MATRIX-SHAPEtoo. - C sees the same layout as
uint32_t a[3][4]. Passflat t. .to anexternop that takes aslice u32; C receives a pointer and the element count. - To write the values directly, list them row by row:
lit matrix 2 2 3 u32 1 2 3 4 5 6 .. If there are fewer, end with_to fill the rest with zero. - A
matrixis written atlet,var, an op’sinputandoutput, and a struct field. The type fixes only where the elements lie — arithmetic such as a matrix product is yours to write.
Give as many indexes as there are dimensions. An idx with one index fewer does not mean a row — it could not be told apart from a forgotten index.
examples/ch09/mistake_matrixrow.low
module mistake_matrixrow .
rem expect: E-MATRIX-INDEX
fn f output u64 .
do
let m lit matrix 2 2 3 u32 1 2 3 4 5 6 . .
rem ✘ two dimensions, one index --- there is no form that takes a single row
return widen u64 idx m. 1 . . .
end .
Output
$ lowentc --check mistake_matrixrow.low
mistake_matrixrow.low:8:20 E-MATRIX-INDEX: `idx <matrix>. i1 … ik .` takes one index per dimension — as many indexes as the type has dimensions. One row is `row m. i. .`; all the elements as one row are `flat m. .`
When you want one row, say row. The same example holds a table as a struct field and an op that does not fix its sizes.
examples/ch09/matrixmore.low
module matrixmore .
rem run: total
rem a table as a struct field
struct board do
id u64 .
g matrix 2 3 4 u32 .
end .
rem any size --- the sizes are translation-time inputs
fn sum input comptime r usize . input comptime c usize . input m matrix 2 r c u32 . output u64 .
do
var t u64 0 .
repeat i usize dim m. 0 . do
rem `row` sees one row as a slice
for x row m. i. . do
set t. add t. widen u64 x. . . .
end .
end .
return t. .
end .
export fn total output u64 .
do
var b lit board do id 1 . g lit matrix 2 3 4 u32 1 2 3 4 5 6 7 8 9 10 11 12 . . end . .
set idx field b. g . 0 0 . 100 .
let small lit matrix 2 2 2 u32 1 2 3 4 . .
return add widen u64 idx field b. g . 0 0 . . sum 2 2 small. . . .
end .
Output
$ lowentc --run total matrixmore.low
total() = 110
row m. i. .sees that row as aslice u32— it is not a copy. It takes one index for every dimension but the last (two for three dimensions). Whenmis avar,let w mut slice u32 row m. i. . .binds the row for writing; whenmis alet, that binding isE-TYPE-MUT.- A struct whose field is a
matrixis read and written withidx field b. g . r. c. .. In a literal, the value of that field is alit matrixof the same shape. - To leave the sizes open, take them as translation-time inputs: write
input comptime r u64 .and usematrix 2 r c u32as the type. The caller writes the sizes (sum 2 2 small. .), and passing a table of another shape isE-MATRIX-SHAPE. A size given by name stands only in an input — locals, outputs and fields write their sizes as numbers.
9.7 Contracts remove bounds checks#
An idx bounds check remains only where it is not proven. Write the length condition as requires, and the check happens once on entry to the op while the index checks in the body disappear.
examples/ch09/bounds.low
module bounds .
rem run: sum_first [3,4,5,6] 3
fn sum_first input a slice u8 . input n usize . output u64 .
requires le n. len a. . . .
do
var s u64 0 .
var i usize 0 .
while lt i. n. . do
set s. add s. widen u64 idx a. i. . . . .
set i. add i. 1 . .
end .
return s. .
end .
Output
$ lowentc --run sum_first bounds.low [3,4,5,6] 3
sum_first([3,4,5,6], 3) = 12
arg0 (written) = [3,4,5,6]
With requires le n. len a. . . ., i inside while lt i n is always less than len a. .. The compiler’s interval analysis works that out and removes the check in idx a. i. .. The rules behind this reasoning are proven in Coq, and an independent checker re-verifies the arithmetic evidence the compiler leaves at every removed check (chapter 41).
One thing to watch: a count n uses le, but an index itself uses lt. requires le i. len a. . . . allows i = len a, which is one past the end.
A common misconception. Putting len in a loop condition recounts every time
len s. . is neither a call nor a memory read. It just takes the length field out of the slice value {start, length}. Writing elements does not change the length; the only way the length changes is putting a different slice into the name. In fact, storing the length ahead as let m u64 len xs. . . and then replacing xs with a shorter slice leaves m as a stale length that can go out of range. Storing it ahead is a choice of meaning, not an optimisation.examples/ch09/stale_len.low
module stale_len .
rem run: fresh [1,2,3,4]
rem trap: stale [1,2,3,4]
rem len stays in the condition --- each round reads the current length
fn fresh input xs slice u8 . output u64 . do
var s slice u8 xs. .
var t u64 0 .
var i usize 0 .
while lt i. len s. . . do
set t. wrap_add t. widen u64 idx s. i. . . . .
set s. subslice s. 0 2 . .
set i. add i. 1 . .
end .
return t. .
end .
rem the length was stored ahead --- m stays even when s gets shorter
fn stale input xs slice u8 . output u64 . do
var s slice u8 xs. .
let m usize len s. . .
var t u64 0 .
var i usize 0 .
while lt i. m. . do
set t. wrap_add t. widen u64 idx s. i. . . . .
set s. subslice s. 0 2 . .
set i. add i. 1 . .
end .
return t. .
end .
Output
$ lowentc --run fresh stale_len.low [1,2,3,4]
fresh([1,2,3,4]) = 3
arg0 (written) = [1,2,3,4]
$ lowentc --run stale stale_len.low [1,2,3,4]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
fresh reads the current length each time round, so when s shrinks to two elements the loop stops there too. stale stored the first length 4 in m, so on the third round it tries to read element 2 of the two-element s and stops. len reads the current value every time, and that is what keeps it safe.
Q. Can [3,4,5] given to --run be used for a slice u64?
A. Bracketed --run arguments are sequences of bytes. That is why this book’s runnable examples take slices as slice u8. Passing one to a slice u64 stops the VM, because the byte count is not a multiple of 8. Slices of wider elements are usually built inside the program and passed along.
9.8 Common mistakes#
Almost every slice mistake comes down to being off by one. Where C would read someone else’s memory and carry on, Lowent stops.
Counter-example. Looping one step too far with le
examples/ch09/mistake_offbyone.low
module mistake_offbyone .
rem trap: total [1,2,3]
fn total input xs slice u8 . output u64 .
do
var t u64 0 .
var i usize 0 .
rem ✘ with `le` it also runs when i is 3 --- a slice of length 3 has indexes 0, 1, 2 only
while le i. len xs. . . do
set t. add t. widen u64 idx xs. i. . . . .
set i. add i. 1 . .
end .
return t. .
end .
Output
$ lowentc --run total mistake_offbyone.low [1,2,3]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
A slice of length 3 has indexes 0, 1 and 2. le i. len xs. . . is still true when i is 3, so reading the fourth cell stops with E-VM-BOUNDS. Because indexes start at 0, the right condition is “less than the count” — while lt i. len xs. . .. If you are walking every element, for x xs. do uses no index at all and removes the mistake completely.
Counter-example. Reading with brackets, as in xs[0]
examples/ch09/mistake_cindex.low
module mistake_cindex .
rem expect: E-CHAR
fn first input xs slice u8 . output u8 .
do
rem ✘ there is no bracket indexing --- read with `index xs 0`
return xs[0] .
end .
Output
$ lowentc --check mistake_cindex.low
7:12 E-CHAR: unexpected character
7:14 E-CHAR: unexpected character
There is no bracket indexing. Reading an element is idx xs. 0 ., writing it is set idx xs. 0 . v. .. Using names instead of symbols makes reading, writing and the out-of-range stop all the same shape of form, with fewer symbols to remember.
Counter-example. Reading the first element of what may be an empty slice
examples/ch09/mistake_empty.low
module mistake_empty .
rem trap: first []
fn first input xs slice u8 . output u8 .
do
rem ✘ an empty slice has no element 0
return idx xs. 0 . .
end .
Output
$ lowentc --run first mistake_empty.low []
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: slice index out of bounds (panic)
It is easy to forget that an input may be empty. Index 0 exists only when there is at least one element. Filter first, then read.
examples/ch09/empty_fixed.low
module empty_fixed .
rem run: first_or [] 0
rem run: first_or [7,8] 0
fn first_or input xs slice u8 . input fallback u8 . output u8 .
do
rem leave here if empty --- below this line, having at least one element is a fact
guard gt len xs. . 0 . else return fallback. . .
return idx xs. 0 . .
end .
Output
$ lowentc --run first_or empty_fixed.low [] 0
first_or([], 0) = 0
arg0 (written) = []
$ lowentc --run first_or empty_fixed.low [7,8] 0
first_or([7,8], 0) = 7
arg0 (written) = [7,8]
Below the guard, “not empty” is a fact, so idx xs. 0 . is safe — and the compiler uses the same fact to remove the bounds check (chapter 7).
Counter-example. Swapping the start and end of subslice
examples/ch09/mistake_subrev.low
module mistake_subrev .
rem trap: tail_len [1,2,3,4]
fn tail_len input xs slice u8 . output usize .
do
rem ✘ the start (3) is after the end (1) --- `subslice s from to` needs from ≤ to ≤ len
return len subslice xs. 3 1 . . .
end .
Output
$ lowentc --run tail_len mistake_subrev.low [1,2,3,4]
== ir diagnostics (1) ==
0:0 E-VM-BOUNDS: subslice out of bounds (panic)
subslice s. from. to. . needs from ≤ to ≤ len s. Swapping them does not give an empty window; it stops — because a miscalculated boundary that quietly became an empty result would only surface much later.
A common misconception. subslice s. 1 3 . is the three cells from 1 to 3
examples/ch09/subslice_end.low
module subslice_end .
rem run: middle [10,20,30,40,50]
fn middle input xs slice u8 . output usize .
do
rem from index 1 up to (not including) 3 --- indexes 1 and 2, two elements
return len subslice xs. 1 3 . . .
end .
Output
$ lowentc --run middle subslice_end.low [10,20,30,40,50]
middle([10,20,30,40,50]) = 2
arg0 (written) = [10,20,30,40,50]
The end index is not included: indexes 1 and 2, two cells. With this rule the length is simply to − from, and subslice s. 0 k. . and subslice s. k. len s. . . split the slice with no cell overlapping or missing. That is why most languages use the same rule (a half-open range).
Counter-example. Taking a string literal as mut slice and changing it
examples/ch09/mistake_litwrite.low
module mistake_litwrite .
rem expect: E-TYPE-ARGMUT
rem ✘ taking a string literal as mut slice and changing it; a literal is read-only bytes baked into the program
fn poke output u8 . do
let buf mut slice u8 "abc" .
set idx buf. 0 . 65 .
return idx buf. 0 . .
end .
Output
$ lowentc --check mistake_litwrite.low
mistake_litwrite.low:6:0 E-TYPE-ARGMUT: a string LITERAL was bound to a name declared `mut`. A literal is bytes baked into the program, not a place that can be written: the VM used to change them while the native build did not, so the same program gave two answers, and a library op writing there killed the native build. Take bytes you will change from `alloc_bytes` or from the caller's buffer, and copy the literal into them
A string literal is bytes baked into the program, not a place to change. So binding it to a name declared mut, or passing it where a mut place is taken, is refused with E-TYPE-ARGMUT. Until 2026-09-16 both passed, and when run the VM returned 65 while native code returned 97 — the same program with two answers — and a standard-library op writing those bytes killed the native build with a segmentation fault. Take bytes you will change from alloc_bytes or from the caller’s buffer, and copy the literal into them.
9.9 This chapter’s syntax at a glance#
| Shape | Meaning | Why |
|---|---|---|
slice u8 | a run of u8 values (start + length) | no separate count to carry around |
mut slice u8 | a run whose elements may be written | writability is visible in the type |
input xs array 4 u8 . | take a run of exactly 4 (input position only) | length, then element type — checked at entry |
len xs. . | number of elements | just reads the length field; no cost |
idx xs. i. . | element i (from 0) | stops when out of range — never reads someone else’s memory |
set idx xs. i. . v. . | write element i | needs a mut slice and a proc |
for x xs. do … end . | each element in turn | no room for index mistakes |
subslice xs. from. to. . | a window from from up to, not including, to (no copy) | half-open — the length is to − from |
"hello" | a literal of type slice u8 — indexable as is | there is no separate string type |
lit array 3 u32 1 2 3 . | an array written as values — closes with its own period | the length is a promise — end with _ if short |
lit slice u32 4 5 . | a slice written as values | the element count is the length |
var buf lit array 8 u8 _ . . | a writable local array — in the op’s frame | cannot leave its declaring block |
lit array 8 u8 do 2 5 . _ 0 . end . | an array with chosen cells filled | every cell must be decided — the rest by _ |
matrix 2 3 4 u32 | u32 in 3 rows of 4 — the number of dimensions, the sizes, then the element type | the shape is in the type — another size is another type |
idx m. r. c. . · dim m. 0 . · flat m. . | one cell · the size of one dimension · the elements as one row | each index is checked against its own size |
requires le n. len xs. . . . | a length condition as a contract | bounds checks in the body are removed |
Table 9.1 — Slice syntax — shape · meaning · why it looks this way
Recap
len takes the length field, idx stops when out of range, and for walks the elements. array n t writes the length and then the element type and is used in input positions; a sequence written out as values is listed with lit array or lit slice. A list bound with var is a local array in the frame that you may write inside its block; writing someone else’s elements needs a mut slice and a proc. subslice narrows the window without copying. Writing the length condition as a contract removes bounds checks in the body, and a string literal is a table that can be indexed directly.