Writing Zero-Cost Abstractions
An abstraction is a way to hide detail behind a simpler interface.
A function is an abstraction.
A struct is an abstraction.
A generic container is an abstraction.
An iterator is an abstraction.
A parser API is an abstraction.
Abstractions are necessary because large programs cannot be written as one giant block of code. Good abstractions make code easier to read, test, change, and reuse.
But abstractions can also have a cost.
A zero-cost abstraction is an abstraction that helps the programmer without adding runtime overhead compared with hand-written low-level code.
The Basic Idea
Suppose you write this function:
fn square(x: i32) i32 {
return x * x;
}Calling it is clearer than writing x * x everywhere:
const y = square(value);In an optimized build, the compiler may inline the function. That means the function call disappears, and the generated code is similar to:
const y = value * value;The abstraction improved readability, but did not leave extra runtime work.
That is the goal.
Zig’s Approach
Zig supports zero-cost abstractions mainly through:
- explicit types
comptime- generics through compile-time parameters
- inline functions
- direct memory layout control
- no hidden allocation
- no hidden exceptions
- no mandatory runtime reflection
- no mandatory garbage collector
Zig prefers abstractions that compile down to simple machine code.
The programmer can often inspect where costs enter the program.
Function Abstractions
Small helper functions are usually fine.
fn clamp(value: i32, min_value: i32, max_value: i32) i32 {
if (value < min_value) return min_value;
if (value > max_value) return max_value;
return value;
}Using this function makes the call site clearer:
const safe_volume = clamp(volume, 0, 100);In an optimized build, the compiler may inline it.
Do not avoid functions because you fear function call overhead. Write clear functions first. Measure before manually flattening code.
inline Functions
Zig allows explicit inline on functions:
inline fn addOne(x: u32) u32 {
return x + 1;
}This asks the compiler to inline the function.
Use this carefully. Most of the time, the optimizer can decide well. Explicit inline is more useful when combined with compile-time behavior or when you need guaranteed inline semantics for language reasons.
Do not mark every small function inline.
Generic Functions
Zig does not have a separate generic syntax like some languages. Instead, you pass types at compile time.
fn max(comptime T: type, a: T, b: T) T {
return if (a > b) a else b;
}Use it like this:
const a = max(i32, 10, 20);
const b = max(f64, 1.5, 2.5);The type T is known at compile time.
The compiler can generate specialized code for each type. There is no need for runtime type checking here.
Generic Containers
A generic container can also use comptime.
fn Stack(comptime T: type, comptime capacity: usize) type {
return struct {
items: [capacity]T = undefined,
len: usize = 0,
const Self = @This();
fn push(self: *Self, value: T) !void {
if (self.len >= capacity) return error.Full;
self.items[self.len] = value;
self.len += 1;
}
fn pop(self: *Self) ?T {
if (self.len == 0) return null;
self.len -= 1;
return self.items[self.len];
}
};
}Create a stack type:
const IntStack = Stack(i32, 16);
var stack = IntStack{};
try stack.push(10);
try stack.push(20);
const value = stack.pop();The element type and capacity are known at compile time.
The array is stored directly inside the struct.
No heap allocation is required.
No runtime type dispatch is required.
This is a good example of a Zig-style abstraction.
Avoid Hidden Allocation
A common abstraction cost is allocation.
Bad abstraction:
fn formatName(allocator: std.mem.Allocator, name: []const u8) ![]u8 {
return try std.fmt.allocPrint(allocator, "name: {s}", .{name});
}This is not always bad, but it allocates. The caller must free the result.
A lower-cost version writes into caller-provided memory:
fn formatName(buffer: []u8, name: []const u8) ![]u8 {
return try std.fmt.bufPrint(buffer, "name: {s}", .{name});
}Call it like this:
var buffer: [128]u8 = undefined;
const text = try formatName(buffer[0..], "zig");The abstraction remains useful, but allocation is removed.
Avoid Hidden Copies
Another abstraction cost is copying.
Bad for large data:
fn process(data: [4096]u8) void {
_ = data;
}This takes the array by value.
Better:
fn process(data: []const u8) void {
_ = data;
}The slice is only a view into memory.
A good abstraction should make ownership and copying clear.
Static Dispatch
Static dispatch means the function to call is known at compile time.
Example:
fn run(comptime Handler: type, handler: Handler, value: i32) void {
handler.handle(value);
}If Handler is known at compile time, the compiler can often inline through the call.
This avoids runtime dispatch.
Runtime dispatch usually uses function pointers or tagged unions. Sometimes that is necessary, but it has a cost.
Runtime Dispatch
Function pointers are flexible:
const Handler = *const fn (i32) void;
fn run(handler: Handler, value: i32) void {
handler(value);
}This lets the caller choose behavior at runtime.
But the compiler may not know which function will be called. That can prevent inlining and make branch prediction harder.
Runtime dispatch is not wrong. It is just not zero-cost in the same way as static dispatch.
Use it when you need runtime flexibility.
Tagged Union Abstractions
Tagged unions can model several cases clearly.
const Shape = union(enum) {
circle: Circle,
rectangle: Rectangle,
fn area(self: Shape) f32 {
return switch (self) {
.circle => |c| c.radius * c.radius * 3.14159,
.rectangle => |r| r.width * r.height,
};
}
};This is clear and safe.
The cost is a tag and a branch.
That cost is usually acceptable. In hot loops, you may want to group shapes by type:
for (circles) |c| {
total += c.radius * c.radius * 3.14159;
}
for (rectangles) |r| {
total += r.width * r.height;
}The best abstraction depends on the workload.
Compile-Time Branch Removal
If a condition is known at compile time, Zig can remove the unused path.
fn logValue(comptime enabled: bool, value: i32) void {
if (enabled) {
std.debug.print("value = {}\n", .{value});
}
}Call it with:
logValue(false, 123);When enabled is false at compile time, the logging branch can disappear.
This is useful for debug options, feature flags, and specialized code paths.
Abstractions Over Memory Layout
Zig lets you build abstractions without giving up memory layout.
Example:
const Bytes = struct {
data: []u8,
fn len(self: Bytes) usize {
return self.data.len;
}
fn first(self: Bytes) ?u8 {
if (self.data.len == 0) return null;
return self.data[0];
}
};This wrapper gives names to operations, but still stores only a slice.
The cost is small and clear.
Avoid wrappers that secretly allocate, copy, or perform expensive work in simple-looking methods.
Make Cost Visible in Names
Names should reveal expensive operations.
Good names:
clone
allocPrint
readToEndAlloc
copy
dupeThese names suggest allocation or copying.
Less clear names:
get
value
text
loadA function named getName should not secretly allocate a new string unless the API makes that obvious.
In performance-sensitive Zig code, naming is part of cost control.
Check the Generated Code When Needed
For very hot code, you can inspect generated assembly or intermediate output.
This helps answer questions like:
- Was the function inlined?
- Was the branch removed?
- Was the loop vectorized?
- Did this abstraction allocate?
- Did this create an unexpected copy?
You do not need to inspect assembly for ordinary code. But when writing core library code, parsers, allocators, crypto, codecs, or game loops, checking output can be useful.
Do Not Confuse Zero-Cost with No Cost
Zero-cost abstraction does not mean the program does no work.
It means the abstraction does not add extra work beyond the operation itself.
For example, adding two numbers still costs an addition.
Parsing still costs parsing.
Allocating still costs allocation.
A zero-cost abstraction means the wrapper around the operation does not add unnecessary overhead.
A Practical Rule
Write the simple, clear abstraction first.
Then ask:
Does this allocate?
Does this copy large data?
Does this force runtime dispatch?
Does this block inlining?
Does this add branches inside a hot loop?
Does this hide ownership?
Does this make data layout worse?
If the answer is no, the abstraction is likely fine.
If the answer is yes, the abstraction may still be fine, but the cost must be intentional.
Mental Model
A good Zig abstraction should make code easier to use while keeping cost visible.
The best Zig abstractions often:
- use slices instead of owned buffers
- accept caller-provided allocators
- accept caller-provided storage
- use
comptimefor type specialization - avoid hidden heap allocation
- avoid hidden large copies
- keep memory layout explicit
- use runtime dispatch only when runtime flexibility is needed
Zig does not ask you to avoid abstraction.
It asks you to build abstractions whose costs are clear, controlled, and measurable.