Zig Pointers

A pointer stores the memory address of another value. Instead of holding a number or a string directly, a pointer holds the location in memory where that value lives. Pointers let you access large data efficiently, modify values from inside functions, and build dynamic data structures.

The Address Model

  Memory (simplified):
  Address │ Value
  ────────┼──────
  0x1000  │  42        ← variable x lives here
  0x1004  │  ...
  0x1008  │  0x1000    ← pointer p stores address of x
  0x100C  │  ...
  var x: i32 = 42;
  const p = &x;        // p holds the address of x

  x  → value  42
  &x → address 0x1000 (example)
  p  → holds  0x1000
  p.* → reads value at 0x1000 → 42

Creating and Using Pointers

var number: i32 = 10;
const ptr = &number;   // ptr has type *i32

std.debug.print("Value:   {d}\n", .{number});   // 10
std.debug.print("Address: {}\n",  .{ptr});       // memory address
std.debug.print("Via ptr: {d}\n", .{ptr.*});     // 10

ptr.* = 99;  // write through pointer
std.debug.print("Now: {d}\n", .{number});        // 99

The & operator takes the address of a variable. The .* operator (called dereferencing) reads or writes the value at that address. Modifying ptr.* changes number directly — they point to the same memory.

Pointer Types

  *T          → single-item pointer (points to one T)
  [*]T        → many-item pointer (points to an array of T, unknown length)
  []T         → slice (pointer + length, the safe way to handle arrays)
  *const T    → read-only pointer (cannot modify through this pointer)
  *volatile T → pointer to hardware-mapped memory (do not optimize away reads/writes)
  Choosing a pointer type:

  One value?          →  *T
  An array?           →  []T (slice — always prefer this)
  C interop?          →  [*]T or [*:0]T
  Cannot modify?      →  *const T
  Hardware register?  →  *volatile T

Why Functions Use Pointers

Zig passes function arguments by value — the function gets a copy. To modify the original, pass a pointer:

fn doubleValue(n: *i32) void {
    n.* = n.* * 2;
}

var score: i32 = 50;
doubleValue(&score);
std.debug.print("Score: {d}\n", .{score});  // 100
  Without pointer:
  score=50 → [copy 50] → doubleValue → result 100 → copy discarded
  score is still 50

  With pointer:
  score=50 → [address of score] → doubleValue → score.* = 100
  score is now 100

Const Pointers

const message = "hello";
const ptr: *const u8 = &message[0];
// ptr.*        → 'h'  (reading allowed)
// ptr.* = 'H'  → COMPILE ERROR (const pointer, cannot write)

A *const T pointer lets you read the value but not modify it. Use const pointers to signal that a function will not change the data it receives — this is a common pattern for read-only parameters.

Optional Pointers

In C, a NULL pointer represents "no value" — but nothing stops you from accidentally dereferencing it. Zig makes null explicit through optional pointers:

var x: i32 = 42;
var maybe_ptr: ?*i32 = null;

maybe_ptr = &x;  // now it points somewhere

if (maybe_ptr) |ptr| {
    std.debug.print("Value: {d}\n", .{ptr.*});  // 42
} else {
    std.debug.print("Pointer is null\n", .{});
}
  maybe_ptr = ?*i32
        |
  Has a value?
        |
     yes → ptr → dereference safely
     no  → null branch, no crash

You cannot dereference an optional pointer without first checking it is not null. The compiler enforces this, eliminating the null dereference crashes common in C programs.

Pointer to Struct

const Player = struct { name: []const u8, health: i32 };

fn heal(p: *Player, amount: i32) void {
    p.health += amount;
    // Note: p.health is shorthand for p.*.health
    // Zig auto-dereferences struct fields through pointers
}

var hero = Player{ .name = "Asha", .health = 70 };
heal(&hero, 30);
std.debug.print("{s} health: {d}\n", .{hero.name, hero.health}); // 100

When a pointer points to a struct, you access fields directly with ptr.field rather than ptr.*.field. Zig inserts the dereference automatically for struct field access.

Pointer Arithmetic

Moving a pointer forward or backward through memory — pointer arithmetic — is possible in Zig but requires care:

var arr = [_]i32{ 10, 20, 30, 40 };
const first: [*]i32 = &arr;    // many-item pointer

const second = first + 1;      // advance by one element
std.debug.print("{d}\n", .{second[0]});  // 20
  arr: [ 10 | 20 | 30 | 40 ]
         ↑
       first  (address of arr[0])
              ↑
           first+1 (address of arr[1])

Pointer arithmetic on [*]T pointers does not include bounds checking. Use slices ([]T) whenever possible — they carry their length and Zig checks bounds automatically.

Align — Pointer Alignment

Every type has an alignment requirement — the memory address must be a multiple of a certain number. Zig tracks alignment in the type system:

const aligned: *align(16) u8 = ...; // pointer is 16-byte aligned

Most of the time, Zig handles alignment automatically. You specify alignment explicitly when working with SIMD instructions, DMA buffers, or hardware that requires specific alignment guarantees.

Practical Example: Swap Two Values

const std = @import("std");

fn swap(a: *i32, b: *i32) void {
    const temp = a.*;
    a.* = b.*;
    b.* = temp;
}

pub fn main() void {
    var x: i32 = 100;
    var y: i32 = 200;

    std.debug.print("Before: x={d}, y={d}\n", .{x, y});
    swap(&x, &y);
    std.debug.print("After:  x={d}, y={d}\n", .{x, y});
}

Output:

Before: x=100, y=200
After:  x=200, y=100

Without pointers, the function would swap copies and the original variables would be unchanged. Passing addresses lets the function reach into the caller's memory and modify the actual variables.

Leave a Comment

Your email address will not be published. Required fields are marked *