↓ Skip to main content
  1. Posts/

Working with Arrays, Slices and ArrayList in Zig 0.17.0

definitepotato
Author
definitepotato
Code slinger, golang, ziglang, grokking life, tabletop games enthusiast, obsession with keebs and numbers.
Table of Contents

Concepts to understand before we start working with arrays
#

1. Arrays own the data, slices view the data
#

Everything in this article branches from one distinction, array vs slice:

  • An array ([2]u8) owns the stored bytes: exactly 2 bytes, living right where it’s declared, with the length baked into the type at compile type. It can’t change size or have it’s length decided at runtime.
  • A slice ([]u8) is a view: a pointer and a length, pointing into memory that something else owns. The length lives in the value, not the type, so one slice type covers every possible length. Creating slices doesn’t copy anything.

For this reason functions almost always take slices. A function taking [2]u8 accepts only a byte array of exactly length 2. A function taking []u8 accepts of view of anything. The recurring theme here isn’t going to be how to make a slice but who owns the memory my slice will point to?.

2. The & coercion
#

You have an array but the function you want to call wants a slice. The bridge is &:

var numbers = [_]i32{ 10, 20, 30, 40 };
sum(&numbers);

&numbers is a pointer to an array (*[4]i32), and Zig coerces pointer-to-an-array into a slice automatically, because it has an address and a length.

3. Where const sits decides what you can pass
#

A slice type has room for one const per level, and each one is a promise about mutability:

  • []const i32 says I will only read values, accepts a view of anything var or const arrays.
  • []i32 says I can write values which means the compiler will refuse const values.

General rules of thumb:

  • Writing a function? take []const T unless you need to mutate values. Every unnecessary []T shrinks what callers can pass.
  • Calling a function? read its parameter type as a demand. []const T demands nothing (any shape works). []T demands writable backing memory, a var somewhere, and the compiler will hold you to it.

Let’s increase complexity a little with nesting.

[]const []const u8  // read-only list of read-only strings (accepts anything)
[][]const u8        // can reorder the list, cannot mutate individual bytes (outer array must be var)
[][]u8              // can reorder and mutate bytes (needs var at both levels)

When reading nested values read them right to left, innermost first. So a [][]const u8 is a mutable slice of immutable slices of bytes.

4. Slices on the heap using an allocator
#

All heap memory comes from an allocator, and the heap’s native shape is the slice.

const values = try allocator.alloc(u8, n);  // []u8, n decided at runtime
defer allocator.free(values);

Two allocator methods matter here:

  • alloc(T, n): n values as a mutable slice.
  • dupe(T, source): alloc + copy in one call. This is what you call when you have const values but need to mutate. Better to copy into writable memory and mutate the copy than to fight the const.

The code we’re going to write use std.heap.DebugAllocator to detect leaks.

5. The growable std.ArrayList array
#

alloc needs the count before you fill. When items trickle items trickle in with no known count ArrayList manages a heap slice for you and grows it on demand.

var list: std.ArrayList(u8) = .empty;
defer list.deinit(allocator);
try list.append(allocator, 42);         // each call grows as needed hence `try`
sortAscending(list.items);              // .items contains the values

Three things to know:

  • .items holds the values. It’s the list’s buffer as a []T not a copy, so every slice-taking function works on it unchanged.
  • .items goes stale when it grows. Appending might trigger reallocation and move the buffer to another part of memory so a slice from before the move dangles. Take .items fresh after any append.
  • When collecting is finished toOwnedSlice(allocator) hands you the buffer as an exact-length slice and empties the list.

6. Lifetimes
#

Slices are views into an array (the memory behind the view), therefore every slice depends on the memory behind it still being alive and at the same address. This can bite you in a few ways:

  • Returning a slice of a function’s local array. The array dies with the function, the slice points to an old address. Return an owned allocated slice instead (toOwnedSlice, dupe, etc).
  • Nested slices like [][]u8 have two lifetimes, the outer buffer and the inner buffer each have their own lifetimes.

7. Choosing a shape
#

When a functions wants a slice and you’re deciding the shape of your data, ask two questions:

  1. Will it be mutated?
  2. When is the length known?

Let’s write code
#

Approach 1: Two functions, one array
#

const std = @import("std");

fn sum(values: []const i32) i32 {
    var total: i32 = 0;

    for (values) |i| {
        total += i;
    }

    return total;
}

fn sortAscending(values: []i32) void {
    var i: usize = 1;
    while (i < values.len) : (i += 1) {
        const key = values[i];
        var j = i;
        while (j > 0 and values[j - 1] > key) : (j -= 1) {
            values[j] = values[j - 1];
        }

        values[j] = key;
    }
}

pub fn main() void {
    var numbers = [_]i32{ 10, 20, 30, 40 };

    std.debug.print("before: {any}\n", .{numbers});
    std.debug.print("sum:    {d}\n", .{sum(&numbers)});

    sortAscending(&numbers);

    std.debug.print("after:  {any}\n", .{numbers});
    std.debug.print("sum:    {d} => order changed, values did not\n", .{sum(&numbers)});
}

Approach 2: Slicing views from an array
#

const std = @import("std");

fn sum(values: []const i32) i32 {
    var total: i32 = 0;
    for (values) |v| {
        total += v;
    }

    return total;
}

fn sortAscending(values: []i32) void {
    std.mem.sort(i32, values, {}, std.sort.asc(i32));
}

pub fn main() void {
    var numbers = [_]i32{ 10, 20, 30, 40, 50, 60 };

    std.debug.print("whole:    {any}, sum {d}\n", .{ numbers, sum(&numbers) });
    std.debug.print("middle:   {any}, sum {d}\n", .{ numbers[1..4], sum(numbers[1..4]) });

    sortAscending(numbers[1..4]);
    std.debug.print("after sorting [1..4]: {any}\n", .{numbers});

    var n: usize = 4;
    n += 0;
    std.debug.print("first {d}: sum {d}\n", .{ n, sum(numbers[0..n]) });

    var storage: [16]i32 = undefined;
    var count: usize = 0;

    for (numbers) |v| {
        if (v > 25) {
            storage[count] = v;
            count += 1;
        }
    }

    const collected = storage[0..count];
    std.debug.print("collected {d} values: {any}\n", .{ count, collected });

    sortAscending(collected);
    std.debug.print("sorted:  {any}, sum {d}\n", .{ collected, sum(collected) });
}

Approach 3: Nesting, arrays of arrays and arrays of slices
#

const std = @import("std");

fn sum(values: []const i32) i32 {
    var total: i32 = 0;
    for (values) |v| total += v;
    return total;
}

fn totalLetters(words: []const []const u8) usize {
    var total: usize = 0;
    for (words) |w| total += w.len;
    return total;
}

fn sortByLength(words: [][]const u8) void {
    std.mem.sort([]const u8, words, {}, struct {
        fn shorter(_: void, a: []const u8, b: []const u8) bool {
            return a.len < b.len;
        }
    }.shorter);
}

fn shoutAll(words: [][]u8) void {
    for (words) |w| {
        for (w) |*ch| ch.* = std.ascii.toUpper(ch.*);
    }
}

pub fn main() void {
    var grid = [3][2]i32{
        .{ 1, 2 },
        .{ 3, 4 },
        .{ 5, 6 },
    };

    var grand_total: i32 = 0;
    for (&grid) |*row| {
        grand_total += sum(row);
    }
    std.debug.print("grid total: {d}\n", .{grand_total});

    var words = [_][]const u8{ "persimmon", "fig", "apple" };

    std.debug.print("total letters: {d}\n", .{totalLetters(&words)});

    sortByLength(&words);
    std.debug.print("by length: {s}, {s}, {s}\n", .{ words[0], words[1], words[2] });

    var w1 = [_]u8{ 'h', 'e', 'l', 'l', 'o' };
    var w2 = [_]u8{ 'z', 'i', 'g' };

    var mutable_words = [_][]u8{ &w1, &w2 };

    shoutAll(&mutable_words);
    std.debug.print("shouted: {s} {s}\n", .{ mutable_words[0], mutable_words[1] });

    std.debug.print("their letters: {d}\n", .{totalLetters(&mutable_words)});
}

Approach 4: std.ArrayList when you don’t know the size
#

const std = @import("std");

fn sum(values: []const i32) i32 {
    var total: i32 = 0;
    for (values) |v| total += v;
    return total;
}

fn sortAscending(values: []i32) void {
    std.mem.sort(i32, values, {}, std.sort.asc(i32));
}

fn totalLetters(words: []const []const u8) usize {
    var total: usize = 0;
    for (words) |w| total += w.len;
    return total;
}

pub fn main() !void {
    var debug_alloc: std.heap.DebugAllocator(.{}) = .init;
    defer _ = debug_alloc.deinit();
    const allocator = debug_alloc.allocator();

    var readings: std.ArrayList(i32) = .empty;
    defer readings.deinit(allocator);

    const sensor_feed = [_]i32{ 17, -3, 42, 8, -1, 25, 3 };
    for (sensor_feed) |r| {
        if (r > 0) {
            try readings.append(allocator, r);
        }
    }

    std.debug.print("kept {d} readings: {any}\n", .{ readings.items.len, readings.items });
    std.debug.print("sum: {d}\n", .{sum(readings.items)});

    sortAscending(readings.items);
    std.debug.print("sorted: {any}\n", .{readings.items});

    const final = try readings.toOwnedSlice(allocator);
    defer allocator.free(final);
    std.debug.print("owned result: {any} (list now has {d} items)\n", .{ final, readings.items.len });

    var words: std.ArrayList([]const u8) = .empty;
    defer words.deinit(allocator);

    const sentence = "the quick brown fox";
    var it = std.mem.splitScalar(u8, sentence, ' ');
    while (it.next()) |word| {
        try words.append(allocator, word);
    }

    std.debug.print("{d} words, {d} letters\n", .{ words.items.len, totalLetters(words.items) });
}

Related