Zig Study Projects
Singly Linked List
The linked list is the classic exercise for pointers and optionals together: nodes are allocated on the heap, the next pointer is a ?*Node, and walking the list is a while loop with an optional capture.
Node and Operations
const std = @import("std");
const Node = struct {
value: u32,
next: ?*Node = null,
};
fn prepend(list: *?*Node, allocator: std.mem.Allocator, value: u32) !void {
const node = try allocator.create(Node); // allocate one Node
node.* = .{ .value = value, .next = list.* };
list.* = node;
}
test "prepend three nodes" {
var head: ?*Node = null;
try prepend(&head, std.testing.allocator, 3);
try prepend(&head, std.testing.allocator, 2);
try prepend(&head, std.testing.allocator, 1);
try std.testing.expect(head.?.value == 1);
}
Iteration with Optionals
Freeing the list is the mirror image: walk it, save the next pointer before freeing, and use errdefer-style discipline so no path leaks. The optional while loop makes the boundary conditions explicit.
fn destroy(list: *?*Node, allocator: std.mem.Allocator) void {
var cur = list.*;
while (cur) |node| {
const next = node.next; // remember before freeing
allocator.destroy(node);
cur = next;
}
list.* = null;
}
Generic Stack
A stack over any element type is the canonical comptime container: the type parameter flows into an internal array, and every instantiation is monomorphized and type-safe.
comptime Implementation
fn Stack(comptime T: type, comptime capacity: usize) type {
return struct {
items: [capacity]T = undefined,
len: usize = 0,
fn push(self: *@This(), value: T) error{StackFull}!void {
if (self.len == capacity) return error.StackFull;
self.items[self.len] = value;
self.len += 1;
}
fn pop(self: *@This()) ?T {
if (self.len == 0) return null;
self.len -= 1;
return self.items[self.len];
}
};
}
Unit Tests
test "stack push and pop" {
var s = Stack(u32, 4){};
try s.push(10);
try s.push(20);
try std.testing.expectEqual(@as(u32, 20), s.pop().?);
try std.testing.expectEqual(@as(u32, 10), s.pop().?);
// pop empty: returns null — absence is encoded in the type
try std.testing.expect(s.pop() == null);
}
CLI Journal Tool
A real command-line program touches nearly every lesson: arguments, allocators, formatting, errors, and file I/O. This journal appends a line to a file and reads the whole journal back.
Appending Entries
const std = @import("std");
pub fn main() !void {
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer _ = gpa.deinit();
const allocator = gpa.allocator();
const args = try std.process.argsAlloc(allocator);
defer std.process.argsFree(allocator, args);
if (args.len < 2) {
std.debug.print("usage: journal <entry>\n", .{});
return error.InvalidArguments;
}
var file = try std.fs.cwd().createFile("journal.txt", .{ .truncate = false });
defer file.close();
try file.seekFromEnd(0); // append at the end
try file.writeAll(args[1]);
try file.writeAll("\n");
std.debug.print("entry saved.\n", .{});
}
Reading the Journal
Reading a whole file is one allocator call and one free — no manual buffer plumbing, and the leak detector stands guard.
const log = try std.fs.cwd().readFileAlloc(allocator, "journal.txt", 1 << 20);
defer allocator.free(log);
std.debug.print("journal:\n{s}", .{log});
zig test. Every skill this track teaches — types, errors, allocators, string slicing — appears in that one feature.