zig / under the hood
DUE 000 DECK 000 STREAK 0d LESSONS 0/14 saved in this browser only
0x09
Part II · Zig

Pointers & slices

Single, many and fat pointers, sentinel-terminated strings, and nullable pointers that cost nothing.

Why Zig has several pointer types

In C there’s basically one pointer type, T*, and it’s used for everything: one object, an array, a string, “maybe null”. The compiler can’t tell which you meant, so it can’t help you. Zig splits these into separate types, so the compiler knows your intent and can check it. | Type | Meaning | *T | exactly one T | *const T | one T, read-only through this pointer | [N]T | an array: exactly N values of T, stored inline | []T | a slice: pointer + length | [*]T | an unknown number of T (C-style, no length) | ?*T | a pointer that may be null Let’s go through the ones you’ll use daily.

Single-item pointers: *T

*T points to exactly one value of type T. You get one with & and follow it with .*:

var x: u32 = 10;
const p: *u32 = &x;
p.* += 1; // dereference with .*
// x is now 11

Function parameters in Zig are read-only, so to let a function change the caller’s variable, you pass a pointer to it:

fn increment(n: *u32) void {
    n.* += 1;
}

var count: u32 = 0;
increment(&count); // count is now 1

*const T is a pointer you can only read through. Zig is strict about this: taking the address of a const value gives you a *const T, never a *T, so you can’t modify a constant by accident.

Arrays: fixed size, known at compile time

An array [N]T is N values of type T stored back to back. The length is part of the type, so [4]u8 and [5]u8 are different types. Arrays are values: assigning one to another variable copies all of it.

var arr = [_]u8{ 10, 20, 30, 40, 50 }; // [_] means "count them for me", so [5]u8
arr[0] = 11;
std.debug.print("{d}\n", .{arr.len}); // 5

Slices: the type you’ll use most

Usually you don’t know a length at compile time: a line from a file, a user’s name, a list from JSON. For that Zig has slices. A slice []T is two things packed together: a pointer to the first element, and a length.

arr in memory:     11   20   30   40   50
index:              0    1    2    3    4
s = arr[1..4]:          [-------------]       s.ptr = &arr[1], s.len = 3

Things to know about slices:

  • arr[a..b] takes indices from a up to but not including b. arr[1..4] is indices 1, 2 and 3, a length of 4 − 1 = 3. arr[2..] goes to the end.
  • A slice doesn’t copy anything. It’s a view into existing memory: writing s[0] = 99 changes arr[1].
  • Since it’s a pointer plus a length, @sizeOf([]u8) is 16 bytes on a 64-bit machine. This pairing is often called a fat pointer.
  • Every index is checked against the length in safe builds. s[3] on a slice of length 3 stops the program with “index out of bounds” instead of quietly reading someone else’s memory. That one check prevents a huge class of C bugs.
var arr = [_]u8{ 10, 20, 30, 40, 50 };
const s: []u8 = arr[1..4]; // 20, 30, 40 (len 3)
s[0] = 99; // writes arr[1]
for (s, 0..) |v, i| std.debug.print("{d}: {d}\n", .{ i, v });

for (s, 0..) |v, i| loops over the slice and a counter at the same time: v is each element and i its index.

Strings are just bytes

Zig has no special string type. Text is a sequence of bytes (UTF-8), and a string is a slice of bytes, []const u8. That’s the type nearly every function taking text asks for. A string literal is slightly more specific. "hi" has type *const [2:0]u8: a pointer to an array of 2 bytes, followed by an extra 0 byte called a sentinel. The 0 is there so you can pass the literal to C functions, which find the end of a string by looking for a 0. It converts to []const u8 automatically.

  • [:0]const u8 is a slice that guarantees a 0 right after its last element, handy when calling C.
  • You can’t compare slices with ==. Use std.mem.eql(u8, a, b), which compares the bytes.

Many-item pointers: [*]T

[*]T points to an unknown number of items, like a C pointer. You can index it, but it has no length, so nothing can be bounds-checked. You’ll mostly meet it at the edges of your program, when talking to C. Turn it into a slice as soon as you know the length: ptr[0..len].

Optional pointers: null made explicit

A plain *T in Zig can never be null. If something might be missing, the type has to say so: ?*T, an optional pointer to T. The compiler won’t let you use it until you’ve checked:

fn find(items: []const u32, want: u32) ?*const u32 {
    for (items) |*item| {
        if (item.* == want) return item;
    }
    return null;
}

if (find(&nums, 7)) |ptr| {
    std.debug.print("found {d}\n", .{ptr.*});
} else {
    std.debug.print("not found\n", .{});
}

This costs nothing at runtime. A ?*T is the same 8 bytes as a *T: null is stored as address 0, which a real *T is never allowed to hold. You get null safety without using any extra memory.

Recap

  • *T points to one value: &x to take an address, p.* to follow it.
  • Arrays [N]T have a fixed, compile-time length and are copied on assignment.
  • Slices []T are pointer + length, don’t copy, and are bounds-checked in safe builds.
  • Strings are []const u8. Literals carry a hidden 0 sentinel for C. Compare with std.mem.eql.
  • Only ?*T can be null, and you must unwrap it before use.
Try it

Write fn reverse(s: []u8) void that reverses a slice in place by swapping from both ends, plus a test block that checks it with std.testing.expectEqualSlices. Run it with zig test.

14 flashcards for this lesson

not started
See the questions
  1. *T vs [*]T vs []T?
  2. @sizeOf([]u8) on a 64-bit machine?
  3. How do you dereference a pointer p in Zig?
  4. Type of the string literal "hello"?
  5. Which elements does arr[1..3] include?
  6. What does [:0]u8 mean?
  7. Size of ?*T compared to *T?
  8. Out-of-bounds slice index in Debug vs ReleaseFast?
  9. How do you compare two strings in Zig?
  10. Can a plain *T be null in Zig?
  11. What does arr[2..] mean?
  12. How do you compare two strings in Zig?
  13. Can a plain *T be null in Zig?
  14. What does arr[2..] mean?