Zig Function Pointers

A function pointer stores the address of a function. Instead of calling a function by name, you call it through a variable that holds a reference to that function. This lets you choose at runtime which function to call — the foundation of callbacks, plugin systems, dispatch tables, and event handlers.

The Concept

  Normal call:
  add(3, 4)  → compiler knows exactly which function to jump to

  Function pointer call:
  var op = add;      // op holds the address of add
  op(3, 4)           → follows the pointer, then calls add

  Change the pointer:
  op = subtract;
  op(3, 4)           → now calls subtract instead

Function Pointer Type Syntax

  *const fn(param_types) return_type

  Examples:
  *const fn(i32, i32) i32       → pointer to fn taking two i32, returning i32
  *const fn([]const u8) void    → pointer to fn taking a string, returning nothing
  *const fn() !void             → pointer to fn that can fail

Basic Function Pointer

const std = @import("std");

fn add(a: i32, b: i32) i32 { return a + b; }
fn sub(a: i32, b: i32) i32 { return a - b; }
fn mul(a: i32, b: i32) i32 { return a * b; }

pub fn main() void {
    const Operation = *const fn(i32, i32) i32;

    var op: Operation = add;
    std.debug.print("add: {d}\n", .{op(10, 3)});  // 13

    op = sub;
    std.debug.print("sub: {d}\n", .{op(10, 3)});  // 7

    op = mul;
    std.debug.print("mul: {d}\n", .{op(10, 3)});  // 30
}
  op → [address of add] → call → 13
  op → [address of sub] → call → 7
  op → [address of mul] → call → 30

Function Pointers as Parameters — Callbacks

Passing a function pointer to another function creates a callback — the receiving function calls back into code you supply:

fn applyToAll(
    data:   []i32,
    transform: *const fn(i32) i32,
) void {
    for (data) |*item| {
        item.* = transform(item.*);
    }
}

fn doubleIt(x: i32) i32 { return x * 2; }
fn squareIt(x: i32) i32 { return x * x; }

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

    applyToAll(&nums, doubleIt);
    // nums = [2, 4, 6, 8, 10]

    applyToAll(&nums, squareIt);
    // nums = [4, 16, 36, 64, 100]

    for (nums) |n| std.debug.print("{d} ", .{n});
    std.debug.print("\n", .{});
}
  applyToAll with doubleIt:
  [1,2,3,4,5] → each item × 2 → [2,4,6,8,10]

  applyToAll with squareIt:
  [2,4,6,8,10] → each item² → [4,16,36,64,100]

Dispatch Table

An array of function pointers creates a dispatch table — a lookup structure that maps keys to behaviors without a long if-else chain:

const std = @import("std");

fn handleGet()    void { std.debug.print("GET handler\n",    .{}); }
fn handlePost()   void { std.debug.print("POST handler\n",   .{}); }
fn handleDelete() void { std.debug.print("DELETE handler\n", .{}); }

const Method = enum { GET, POST, DELETE };
const HandlerFn = *const fn() void;

const dispatch = std.EnumArray(Method, HandlerFn).init(.{
    .GET    = handleGet,
    .POST   = handlePost,
    .DELETE = handleDelete,
});

pub fn main() void {
    const req = Method.POST;
    dispatch.get(req)();   // calls handlePost
}
  dispatch table:
  ┌────────┬──────────────────┐
  │ GET    │ → handleGet()    │
  │ POST   │ → handlePost()   │ ← req=POST, calls this
  │ DELETE │ → handleDelete() │
  └────────┴──────────────────┘

Optional Function Pointers

Function pointers can be optional — useful for optional callbacks that may or may not be registered:

const Logger = struct {
    log_fn: ?*const fn([]const u8) void = null,

    fn log(self: Logger, msg: []const u8) void {
        if (self.log_fn) |f| f(msg);
        // if no log_fn registered, silently do nothing
    }
};

fn consoleLog(msg: []const u8) void {
    std.debug.print("[LOG] {s}\n", .{msg});
}

pub fn main() void {
    var logger = Logger{};
    logger.log("This message is silently ignored.");

    logger.log_fn = consoleLog;
    logger.log("Now this appears.");  // [LOG] Now this appears.
}

Function Pointers in Structs — Vtable Pattern

A struct of function pointers creates a vtable — the mechanism behind runtime polymorphism (similar to virtual functions in C++):

const Renderer = struct {
    drawRect:   *const fn(x: i32, y: i32, w: i32, h: i32) void,
    drawCircle: *const fn(x: i32, y: i32, r: i32) void,
    clear:      *const fn() void,
};

fn terminalDrawRect(x: i32, y: i32, w: i32, h: i32) void {
    std.debug.print("TERM rect({d},{d},{d},{d})\n", .{x,y,w,h});
}
fn terminalDrawCircle(x: i32, y: i32, r: i32) void {
    std.debug.print("TERM circle({d},{d},{d})\n", .{x,y,r});
}
fn terminalClear() void {
    std.debug.print("TERM clear\n", .{});
}

const terminal_renderer = Renderer{
    .drawRect   = terminalDrawRect,
    .drawCircle = terminalDrawCircle,
    .clear      = terminalClear,
};

pub fn main() void {
    const r = terminal_renderer;
    r.clear();
    r.drawRect(0, 0, 100, 50);
    r.drawCircle(50, 25, 20);
}

anyopaque Context — Generic Callbacks

When a callback needs access to external state but the caller does not know the type of that state, pair the function pointer with a type-erased context pointer:

const Callback = struct {
    ctx: *anyopaque,                          // type-erased context
    fn_ptr: *const fn(*anyopaque, i32) void,  // function pointer

    fn call(self: Callback, value: i32) void {
        self.fn_ptr(self.ctx, value);
    }
};

This pattern — a function pointer plus a context pointer — is the basis for nearly every callback API in C, and Zig models it the same way with the added safety of explicit types.

Leave a Comment

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