Generic functions in Zig are specialized at compile time.
When a generic function is called with concrete types, the compiler generates a version of the function for those types. The selection happens during compilation, not at runtime.
Consider this function:
const std = @import("std");
fn square(x: anytype) @TypeOf(x) {
return x * x;
}
pub fn main() void {
const a = square(4);
const b = square(2.5);
std.debug.print("{d}\n", .{a});
std.debug.print("{d}\n", .{b});
}The output is:
16
6.25The compiler creates separate versions of square:
square(i32)
square(f64)Each uses operations appropriate for the concrete type.
There is no runtime type inspection. No hidden dispatch table exists. The generated machine code is direct and specialized.
This is compile-time dispatch.
The mechanism becomes more important with structs.
const std = @import("std");
const Dog = struct {
pub fn speak(self: *Dog) void {
_ = self;
std.debug.print("woof\n", .{});
}
};
const Cat = struct {
pub fn speak(self: *Cat) void {
_ = self;
std.debug.print("meow\n", .{});
}
};
fn speak(animal: anytype) void {
animal.speak();
}
pub fn main() void {
var dog = Dog{};
var cat = Cat{};
speak(&dog);
speak(&cat);
}The output is:
woof
meowThe function:
fn speak(animal: anytype)is instantiated separately for:
*Dog
*CatThe compiler resolves the method call statically.
This differs from virtual dispatch in languages such as C++ or Java.
In Zig:
- the concrete type is usually known
- specialization happens during compilation
- the generated call is direct
The result is efficient machine code with little abstraction overhead.
Compile-time dispatch also enables type-dependent logic.
const std = @import("std");
fn describe(value: anytype) void {
const T = @TypeOf(value);
switch (@typeInfo(T)) {
.int => std.debug.print("integer\n", .{}),
.float => std.debug.print("float\n", .{}),
else => std.debug.print("other\n", .{}),
}
}
pub fn main() void {
describe(10);
describe(3.14);
describe(true);
}The output is:
integer
float
otherThe switch executes during compilation because the type information is known at compile time.
This allows a single function body to generate different code depending on the argument type.
Compile-time dispatch is heavily used in the standard library.
Examples include:
- formatting
- serialization
- allocators
- containers
- parsers
- testing utilities
The formatter in std.debug.print examines argument types during compilation and generates formatting logic specialized for those types.
For example:
std.debug.print("{d}\n", .{123});
std.debug.print("{s}\n", .{"zig"});The formatting behavior is selected at compile time from the argument types.
Compile-time dispatch can also remove unused branches entirely.
fn process(comptime debug: bool) void {
if (debug) {
@compileLog("debug enabled");
}
}When debug is known at compile time, the compiler keeps only the selected branch.
This is different from an ordinary runtime condition.
Compile-time dispatch therefore serves several purposes:
- selecting operations by type
- generating specialized code
- eliminating unused branches
- implementing generic abstractions efficiently
The language relies on compile-time specialization instead of large runtime object systems.
Exercise 11-17. Write a generic function that prints different messages for integers and floats.
Exercise 11-18. Write a generic function that behaves differently for signed and unsigned integers.
Exercise 11-19. Implement a generic formatter for a small struct.
Exercise 11-20. Write a compile-time boolean parameter that enables or disables logging code.