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] = 99changesarr[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 u8is a slice that guarantees a 0 right after its last element, handy when calling C.- You can’t compare slices with
==. Usestd.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
*Tpoints to one value:&xto take an address,p.*to follow it.- Arrays
[N]Thave a fixed, compile-time length and are copied on assignment. - Slices
[]Tare 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 withstd.mem.eql. - Only
?*Tcan be null, and you must unwrap it before use.