⚡ Zig Guide LiveUnofficialbut fully verified
✓ Zig 0.17.0-dev.1503+1f1bee62eOn an older Zig?

Making It Generic

The list in the previous chapter holds i32 and nothing else. Making it hold anything does not need a new language feature, because Zig already has functions and types are already values at compile time.

fn List(comptime T: type) type {
    return struct {
        head: ?*Node = null,
        // ...
        const Self = @This();
        pub const Node = struct {
            value: T,
            next: ?*Node = null,
        };
    };
}

List is an ordinary function. Its argument happens to be a type, its return value happens to be a type, and comptime on the parameter says it must be known when the function is called. There is no separate generics system to learn; this is the same mechanism comptime describes, applied to a struct definition.

const std = @import("std");

/// A function from a type to a type. Called at compile time, so by the time the
/// program runs `List(i32)` is an ordinary struct with no type parameter left
/// in it and no indirection to pay for.
fn List(comptime T: type) type {
    return struct {
        head: ?*Node = null,
        tail: ?*Node = null,
        len: usize = 0,
        allocator: std.mem.Allocator,

        /// `@This()` names the struct being defined. Inside a type function the
        /// struct is anonymous, so there is no other way to refer to it, and
        /// `Self` is the conventional name for the result.
        const Self = @This();

        /// The node type is nested, so `List(i32).Node` and `List(u8).Node` are
        /// distinct types and neither can be passed to the other's list.
        pub const Node = struct {
            value: T,
            next: ?*Node = null,
        };

        pub fn init(allocator: std.mem.Allocator) Self {
            return .{ .allocator = allocator };
        }

        pub fn deinit(self: *Self) void {
            var current = self.head;
            while (current) |node| {
                const next = node.next;
                self.allocator.destroy(node);
                current = next;
            }
            self.* = init(self.allocator);
        }

        pub fn append(self: *Self, value: T) !void {
            const node = try self.allocator.create(Node);
            node.* = .{ .value = value };

            if (self.tail) |tail| tail.next = node else self.head = node;
            self.tail = node;
            self.len += 1;
        }

        /// Equality has to come from somewhere. `std.meta.eql` compares field
        /// by field and works for integers, structs of integers and enums; it
        /// compares slices by pointer, not by content, which is why the string
        /// list below uses its own comparison.
        pub fn contains(self: *const Self, value: T) bool {
            var current = self.head;
            while (current) |node| : (current = node.next) {
                if (std.meta.eql(node.value, value)) return true;
            }
            return false;
        }

        pub fn write(self: *const Self, out: *std.Io.Writer) !void {
            var current = self.head;
            try out.writeAll("[");
            while (current) |node| : (current = node.next) {
                try out.print("{any}", .{node.value});
                if (node.next != null) try out.writeAll(", ");
            }
            try out.writeAll("]\n");
        }
    };
}

const Point = struct { x: i32, y: i32 };

pub fn main(init: std.process.Init) !void {
    var buf: [4096]u8 = undefined;
    var file_writer = std.Io.File.stdout().writerStreaming(init.io, &buf);
    const out = &file_writer.interface;

    var debug: std.heap.DebugAllocator(.{}) = .init;
    defer std.debug.assert(debug.deinit() == .ok);
    const allocator = debug.allocator();

    var numbers = List(i32).init(allocator);
    defer numbers.deinit();
    for ([_]i32{ 3, 1, 4, 1, 5 }) |v| try numbers.append(v);
    try out.writeAll("List(i32):   ");
    try numbers.write(out);

    var points = List(Point).init(allocator);
    defer points.deinit();
    try points.append(.{ .x = 1, .y = 2 });
    try points.append(.{ .x = 3, .y = 4 });
    try out.writeAll("List(Point): ");
    try points.write(out);

    try out.print("\ncontains(4)          -> {}\n", .{numbers.contains(4)});
    try out.print("contains(9)          -> {}\n", .{numbers.contains(9)});
    try out.print("contains(Point 3,4)  -> {}\n", .{points.contains(.{ .x = 3, .y = 4 })});

    // The type function is memoized: calling it twice with the same argument
    // returns the same type, not two structurally identical ones. That is what
    // makes `List(i32)` usable as a type annotation in a signature.
    try out.print("\n@typeName(List(i32))  = {s}\n", .{@typeName(List(i32))});
    try out.print("List(i32) == List(i32) -> {}\n", .{List(i32) == List(i32)});
    try out.print("List(i32) == List(u8)  -> {}\n", .{List(i32) == List(u8)});

    // The nested node type is instantiated along with its list, so each
    // instantiation gets its own node type laid out for its own T. There is no
    // boxing and no shared code path: this is monomorphization, the same thing
    // a C++ template or a Rust generic does, reached without a second syntax.
    try out.print("\n@typeName(List(u8).Node)    = {s}\n", .{@typeName(List(u8).Node)});
    try out.print("@typeName(List(Point).Node) = {s}\n", .{@typeName(List(Point).Node)});
    try out.print("List(u8).Node == List(i32).Node -> {}\n", .{List(u8).Node == List(i32).Node});

    // `std.ArrayList` is the same idea with contiguous storage instead of
    // nodes. It is a type function too, and on master it does not hold the
    // allocator: every method that can allocate takes one.
    var array: std.ArrayList(i32) = .empty;
    defer array.deinit(allocator);
    for ([_]i32{ 3, 1, 4 }) |v| try array.append(allocator, v);
    try out.print("\nstd.ArrayList(i32): {any}\n", .{array.items});

    try out.flush();
}

@This() and why Self is a convention

Inside a type function the struct being defined is anonymous, so there is no name to refer to it by. @This() returns it, and const Self = @This() gives that name locally. It appears in almost every Zig container because it has to.

The call is memoized

List(i32) == List(i32) -> true
List(i32) == List(u8)  -> false

Calling a type function twice with the same argument returns the same type, not two structurally identical ones. Without that, fn takes(list: List(i32)) could not be called with a List(i32) built elsewhere, because they would be different types. It is what makes type functions usable in signatures at all.

Each instantiation is a real struct

@typeName(List(u8).Node)    = generic-list.List(u8).Node
@typeName(List(Point).Node) = generic-list.List(generic-list.Point).Node
List(u8).Node == List(i32).Node -> false

The nested Node is instantiated along with its list, so every List(T) gets its own node type laid out for its own T. There is no boxing, no type erasure and no shared code path with a size parameter threaded through it. This is monomorphization, the same thing a C++ template or a Rust generic does, reached without a second syntax and without a separate compilation phase.

The nesting also means List(i32).Node and List(u8).Node are unrelated types, so a node from one list cannot be handed to the other. That falls out of the structure rather than being a rule anyone had to write down.

Equality has to come from somewhere

contains needs to compare two T values, and T is not known when the code is written. std.meta.eql compares field by field and handles integers, enums, and structs of those, which covers the two examples in the snippet.

It has a sharp edge worth knowing: it compares slices by pointer and length, not by content. std.meta.eql on two []const u8 holding the same characters at different addresses returns false. A container that needs to compare strings has to be told how, which is why std.StringHashMap exists as a separate thing from std.AutoHashMap rather than being a special case inside it.

The general answer is to take the comparison as a parameter, the same way sorting takes a lessThan. A container that hardcodes std.meta.eql works until the first type it is wrong for.

This is what std is

var array: std.ArrayList(i32) = .empty;
defer array.deinit(allocator);
try array.append(allocator, 3);

ArrayList is the same idea with contiguous storage instead of nodes, and std.ArrayList(i32) is a call to a type function exactly like List(i32). Reading std gets easier once that stops looking like special syntax.

One difference worth copying: on master ArrayList does not store the allocator. Every method that can allocate takes one as an argument. The list in this chapter stores it, which is more convenient and costs a pointer in every instance, and means two lists can never share one arena’s worth of bookkeeping. The unmanaged form is the direction std has moved in.