A Zig function can return a type.
That type can contain fields, constants, and functions.
This is how Zig writes many generic containers.
fn Box(comptime T: type) type {
return struct {
value: T,
};
}Use it like this:
const IntBox = Box(i32);
var b = IntBox{
.value = 123,
};Box(i32) returns a struct type. The returned type has one field:
value: i32Calling the same function with another type gives another struct type.
const BoolBox = Box(bool);Now the field is:
value: boolThis is declaration generation. The declarations are not written by a preprocessor. They are produced by ordinary Zig code during compilation.
A returned struct may contain methods.
fn Counter(comptime T: type) type {
return struct {
value: T,
fn init() Counter(T) {
return .{ .value = 0 };
}
fn inc(self: *Counter(T)) void {
self.value += 1;
}
};
}Use it:
const U32Counter = Counter(u32);
var c = U32Counter.init();
c.inc();The function Counter returns a type. That type contains the field value and the functions init and inc.
Inside the returned struct, the type can refer to itself by calling the generator again:
Counter(T)A common idiom is to name the returned struct with Self.
fn Counter(comptime T: type) type {
return struct {
const Self = @This();
value: T,
fn init() Self {
return .{ .value = 0 };
}
fn inc(self: *Self) void {
self.value += 1;
}
};
}@This() means the type currently being declared.
This makes the code easier to read and avoids repeating Counter(T).
A generated type can also contain compile-time constants.
fn Buffer(comptime T: type, comptime N: usize) type {
return struct {
const Self = @This();
const capacity = N;
data: [N]T,
len: usize,
fn init() Self {
return .{
.data = undefined,
.len = 0,
};
}
};
}Now each generated buffer type carries its capacity as a declaration.
const B = Buffer(u8, 128);
std.debug.print("{d}\n", .{B.capacity});The value printed is:
128The constant belongs to the type, not to a particular value.
Generated declarations are useful when the type needs to remember compile-time parameters.
Here is a small stack.
fn Stack(comptime T: type, comptime N: usize) type {
return struct {
const Self = @This();
data: [N]T,
len: usize,
fn init() Self {
return .{
.data = undefined,
.len = 0,
};
}
fn push(self: *Self, value: T) !void {
if (self.len == N)
return error.Full;
self.data[self.len] = value;
self.len += 1;
}
fn pop(self: *Self) ?T {
if (self.len == 0)
return null;
self.len -= 1;
return self.data[self.len];
}
};
}Use it:
const std = @import("std");
pub fn main() !void {
var s = Stack(i32, 4).init();
try s.push(10);
try s.push(20);
std.debug.print("{?d}\n", .{s.pop()});
std.debug.print("{?d}\n", .{s.pop()});
std.debug.print("{?d}\n", .{s.pop()});
}The output is:
20
10
nullThe stack has no heap allocation. Its size is part of its type.
Stack(i32, 4)and
Stack(i32, 8)are different types.
The compiler can check their sizes and operations separately.
Generated declarations may also select implementations.
fn Storage(comptime T: type, comptime small: bool) type {
return if (small)
struct {
value: T,
}
else
struct {
ptr: *T,
};
}The returned type depends on a compile-time value.
This is not runtime branching. The compiler chooses one type while compiling.
Use declaration generation when the shape of a type depends on compile-time arguments.
Do not use it where a plain struct is enough.
This is enough for ordinary data:
const Point = struct {
x: i32,
y: i32,
};A generator is useful only when the declarations themselves must vary.
const Point3 = Vector(f64, 3);
const Point4 = Vector(f64, 4);Zig keeps this mechanism simple. A function receives compile-time values and returns a type. The returned type contains ordinary declarations.
Exercise 10-21. Write Box(comptime T: type) type.
Exercise 10-22. Add get and set methods to Box.
Exercise 10-23. Write Stack(comptime T: type, comptime N: usize) type.
Exercise 10-24. Add a capacity declaration to Stack.