Ruta graveolens  ·  notes from a language experiment  ·  cultivated since 2025

Arrays, Slices, and Buffers

Rue has three ways to hold a sequence of values, and they form a ladder:

  • a fixed array [T; N] whose length is part of its type;
  • a slice [T], a borrowed view of any array, that exists only as a function parameter;
  • a growable buffer ArrayBuf(T) from the standard library, which owns heap memory and can change length at runtime.
const std = @import("std");
const Ints = std.arraybuf.ArrayBuf(i64);

fn total(borrow xs: [i64]) -> i64 {
    let mut sum: i64 = 0;
    let mut i: u64 = 0;
    while i < xs.len() {
        sum += xs[i];
        i += 1;
    }
    sum
}

fn main() -> i32 {
    let fixed: [i64; 4] = [1, 2, 3, 4];
    println("fixed total = " + @to_string(total(borrow fixed)));

    let mut growable = Ints.new();
    let mut n: i64 = 1;
    while n <= 100 {
        growable.push(n);
        n += 1;
    }
    println("growable holds " + @to_string(growable.len()) + " values");
    0
}
fixed total = 10
growable holds 100 values

Fixed arrays

An array literal lists its elements. The type [i32; 3] reads "three i32s", and [i32; 3] and [i32; 5] are different types.

const std = @import("std");

fn main() -> i32 {
    let numbers = [10, 20, 30, 40, 50];
    let flags: [bool; 2] = [true, false];
    let zeros = [0; 8];  // eight zeros
    println(@to_string(numbers[0]) + " " + @to_string(numbers[4]));
    if flags[1] {
        println("flag set");
    }
    println(@to_string(zeros[7]));
    0
}
10 50
0

Indices start at zero. The index expression must be an unsigned integer; an untyped literal like numbers[4] works, and a loop counter should be declared u64, the type of array lengths.

The best way to visit every element is for, which needs no index at all:

const std = @import("std");

fn main() -> i32 {
    let numbers = [64, 34, 25, 12, 22];
    let mut max = numbers[0];
    for n in numbers {
        if n > max {
            max = n;
        }
    }
    println("max = " + @to_string(max));
    0
}
max = 64

Arrays declared let mut can be assigned element by element:

const std = @import("std");

fn main() -> i32 {
    let mut scores = [0, 0, 0];
    scores[0] = 100;
    scores[1] = 85;
    scores[2] = 92;
    let mut sum = 0;
    for s in scores {
        sum += s;
    }
    println("average = " + @to_string(sum / 3));
    0
}
average = 92

Bounds are checked

An index the compiler can see is out of range is rejected before the program runs:

fn main() -> i32 {
    let arr = [1, 2, 3];
    arr[10]
}
error: [E0902]: index out of bounds

An index it cannot see is checked at runtime. Reading past the end is a trap: the program prints error: index out of bounds and exits with status 101 instead of reading whatever memory happened to be there.

const std = @import("std");

fn main() -> i32 {
    let xs = [1, 2, 3];
    let mut i: u64 = 0;
    let mut total = 0;
    while i < 4 {
        println("reading index " + @to_string(i));
        total += xs[i];
        i += 1;
    }
    total
}
reading index 0
reading index 1
reading index 2
reading index 3

A for loop can never index out of range, which is one more reason to prefer it.

Slices

A function that takes [i64; 3] cannot be called with a [i64; 5]. To write a function over "an array of any length", take a slice: borrow xs: [T]. The caller passes any fixed array with borrow, and inside the function xs.len() gives the length at runtime.

const std = @import("std");

fn largest(borrow xs: [i64]) -> i64 {
    let mut best = xs[0];
    let mut i: u64 = 1;
    while i < xs.len() {
        if xs[i] > best {
            best = xs[i];
        }
        i += 1;
    }
    best
}

fn main() -> i32 {
    let three: [i64; 3] = [3, 9, 4];
    let five: [i64; 5] = [1, 2, 3, 4, 5];
    println(@to_string(largest(borrow three)) + " " + @to_string(largest(borrow five)));
    0
}
9 5

Slices are the newest rung of the ladder, and today they only work for elements that are 64 bits wide, such as i64, u64, and f64. A [i32] parameter is rejected with a diagnostic that says so. That restriction is expected to lift.

A slice is a view: it does not own its elements, and it is only ever a parameter. You cannot return one, store one in a struct, or bind one to a local. That restriction is what lets Rue have slices with no lifetime annotations, because a view that cannot escape the call cannot outlive the array it views:

fn first_part(borrow xs: [i64]) -> [i64] {
    xs
}

fn main() -> i32 {
    0
}
error: [E0487]: a slice type `[T]` cannot be returned: slices are second-class views valid only in argument position

ArrayBuf: a growable buffer

When the number of elements is only known at runtime, use ArrayBuf(T) from std.arraybuf. Like Option, it is a function from a type to a type, so bind the instantiation to a name.

const std = @import("std");
const Ints = std.arraybuf.ArrayBuf(i32);
const OptI32 = std.option.Option(i32);

fn main() -> i32 {
    let mut values = Ints.new();
    values.push(10);
    values.push(20);
    values.push(30);
    println("len = " + @to_string(values.len()));

    match values.get(1) {
        OptI32.Some(v) => println("values[1] = " + @to_string(v)),
        OptI32.None => println("no values[1]"),
    }
    match values.get(7) {
        OptI32.Some(v) => println("values[7] = " + @to_string(v)),
        OptI32.None => println("no values[7]"),
    }

    match values.pop() {
        OptI32.Some(v) => println("popped " + @to_string(v)),
        OptI32.None => println("nothing to pop"),
    }
    println("len = " + @to_string(values.len()));
    0
}
len = 3
values[1] = 20
no values[7]
popped 30
len = 2

get returns an Option rather than trapping, so a missing index is a value you handle. get_or(i, default) is the shortcut when a fallback value makes sense. push, pop, set, len, first, last, contains, and clear are the rest of the everyday API.

An ArrayBuf owns a heap allocation and frees it in its destructor, exactly as chapter 8 described. It is a move type, so pass it borrow or inout:

const std = @import("std");
const Ints = std.arraybuf.ArrayBuf(i32);

fn fill(inout buf: Ints, count: i32) {
    let mut i = 0;
    while i < count {
        buf.push(i * i);
        i += 1;
    }
}

fn sum(borrow buf: Ints) -> i32 {
    let mut total = 0;
    let mut i: u64 = 0;
    while i < buf.len() {
        total += buf.get_or(i, 0);
        i += 1;
    }
    total
}

fn main() -> i32 {
    let mut squares = Ints.new();
    fill(inout squares, 5);
    println("sum of squares = " + @to_string(sum(borrow squares)));
    0
}
sum of squares = 30

for does not iterate an ArrayBuf yet; an index loop over len() is the current idiom.

Choosing a rung

Use a fixed array when the size is known when you write the code. Take a slice when a function should work on any array. Use ArrayBuf when the program discovers the size as it runs. All three are cheap, and the compiler stops you from confusing them.

Next: the same ladder for text.