Sentinel-terminated Arrays

A sentinel-terminated array is an array with one extra element to mark the end. They’re great for compatilibity with C APIs, where zero-terminated arrays are a common convention, but we can also just use them in Zig:

const std = @import("std");
const print = std.debug.print;

pub fn main() void {
    // [3:0] means length 3 and 0 as sentinel
    const array = [3:0]u64{ 100, 200, 300 };
    print("array length: {}\n", .{array.len});
    print("size in bytes: {}\n", .{
        @sizeOf(@TypeOf(array)),
    });
    print("elements:", .{});
    print(" {}", .{array[0]});
    print(" {}", .{array[1]});
    print(" {}", .{array[2]});
    print(" {}", .{array[3]});
    print("\n", .{});
}
$ zig run sentinel.zig
array length: 3
size in bytes: 32
elements: 100 200 300 0

Sentinel-terminated pointers

A sentinel-terminated pointer points into a sentinel-terminated array:

const std = @import("std");
const print = std.debug.print;

fn printNumbers(ptr: [*:0]const u64) void {
    var i: usize = 0;
    while (ptr[i] != 0) : (i += 1)
        print("{}\n", .{ptr[i]});
}

pub fn main() void {
    const array = [3:0]u64{ 100, 200, 300 };
    printNumbers(&array);
}
$ zig run sentinel-2.zig
100
200
300

Sentinel-terminated slices

A sentinel-terminated slice is a slice that also guarantees a sentinel value after the last element:

const std = @import("std");
const print = std.debug.print;

pub fn main() void {
    const array = [3:0]u64{ 100, 200, 300 };
    const slice: [:0]const u64 = &array;

    print("used as pointer:\n", .{});
    const ptr: [*:0]const u64 = slice.ptr;
    for (0..4) |i|
        print("    {}\n", .{ptr[i]});

    print("used as slice:\n", .{});
    const regular_slice: []const u64 = slice;
    for (regular_slice) |n|
        print("    {}\n", .{n});
}
$ zig run sentinel-3.zig 
used as pointer:
    100
    200
    300
    0
used as slice:
    100
    200
    300

Allocating a sentinel-terminated array

We can request a sentinel-terminated slice from an allocator:

const std = @import("std");
const print = std.debug.print;

pub fn main(init: std.process.Init) !void {
    const allocator = init.arena.allocator();

    const slice = try allocator.allocSentinel(u64, 3, 0);
    slice[0] = 100;
    slice[1] = 200;
    slice[2] = 300;

    print("used as pointer:\n", .{});
    const ptr: [*:0]const u64 = slice.ptr;
    for (0..4) |i|
        print("    {}\n", .{ptr[i]});

    print("used as slice:\n", .{});
    const regular_slice: []const u64 = slice;
    for (regular_slice) |n|
        print("    {}\n", .{n});
}
$ zig run sentinel-4.zig
used as pointer:
    100
    200
    300
    0
used as slice:
    100
    200
    300

Other sentinel values

The sentinel doesn’t have to be zero:

const std = @import("std");
const print = std.debug.print;

pub fn main() void {
    // sentinel is -1
    const numbers = [_:-1]i64{ 100, 200, 300 };
    print("numbers:", .{});
    print(" {}", .{numbers[0]});
    print(" {}", .{numbers[1]});
    print(" {}", .{numbers[2]});
    print(" {}", .{numbers[3]});
    print("\n", .{});

    // sentinel is a null pointer
    const a: u64 = 1;
    const b: u64 = 2;
    const pointers = [_:null]?*const u64{ &a, &b };
    print("pointers:", .{});
    print(" {?}", .{pointers[0]});
    print(" {?}", .{pointers[1]});
    print(" {?}", .{pointers[2]});
    print("\n", .{});

    // sentinel is an enum value
    const Dir = enum { up, down, left, right, end };
    const directions = [_:.end]Dir{ .left, .right };
    print("enum values:", .{});
    print(" {}", .{directions[0]});
    print(" {}", .{directions[1]});
    print(" {}", .{directions[2]});
    print("\n", .{});
}
$ zig run sentinel-5.zig
numbers: 100 200 300 -1
pointers: u64@1270948 u64@1270958 null
enum values: .left .right .end

Next example: Labeled Switch.