Overview
Finishing the GPU compute project left us with a multi-file workspace that depends on consistent naming, predictable formatting, and steadfast tests (see 35). This chapter explains how to keep that discipline as codebases evolve. We will pair zig fmt conventions with documentation hygiene, surface the idiomatic error-handling patterns that Zig expects, and lean on targeted invariants to keep future refactors safe (see v0.15.2).
Learning Goals
- Adopt formatting and naming conventions that communicate intent across modules.
- Structure documentation and tests so they form an executable spec for your APIs.
- Apply
defer,errdefer, and invariant helpers to maintain resource safety and correctness in the long term.
Refs: testing.zig
Foundations: Consistency as a Feature
Formatting is not a cosmetic step: the standard formatter eliminates subjective whitespace debates and highlights semantic changes in diffs. zig fmt received incremental improvements in 0.15.x to ensure generated code matches what the compiler expects, so projects should wire formatting into editors and CI from the outset. Combine auto-formatting with descriptive identifiers, doc comments, and scoped error sets so readers can follow the control flow without rummaging through implementation details.
Documenting APIs with Executable Tests
The following example assembles naming, documentation, and testing into a single file. It exposes a small statistics helper, expands the error set when printing, and demonstrates how tests can double as usage examples (see fmt.zig).
//! Style baseline example demonstrating naming, documentation, and tests.
const std = @import("std");
/// Error set for statistical computation failures.
/// Intentionally narrow to allow precise error handling by callers.
pub const StatsError = error{EmptyInput};
/// Combined error set for logging operations.
/// Merges statistical errors with output formatting failures.
pub const LogError = StatsError || error{OutputTooSmall};
/// Calculates the arithmetic mean of the provided samples.
///
/// Parameters:
/// - `samples`: slice of `f64` values collected from a measurement series.
///
/// Returns the mean as `f64` or `StatsError.EmptyInput` when `samples` is empty.
pub fn mean(samples: []const f64) StatsError!f64 {
// Guard against division by zero; return domain-specific error for empty input
if (samples.len == 0) return StatsError.EmptyInput;
// Accumulate the sum of all sample values
var total: f64 = 0.0;
for (samples) |value| {
total += value;
}
// Convert sample count to floating-point for precise division
const count = @as(f64, @floatFromInt(samples.len));
return total / count;
}
/// Computes the mean and prints the result using the supplied writer.
///
/// Accepts any writer type that conforms to the standard writer interface,
/// enabling flexible output destinations (files, buffers, sockets).
pub fn logMean(writer: anytype, samples: []const f64) LogError!void {
// Delegate computation to mean(); propagate any statistical errors
const value = try mean(samples);
// Attempt to format and write result; catch writer-specific failures
writer.print("mean = {d:.3}\n", .{value}) catch {
// Translate opaque writer errors into our domain-specific error set
return error.OutputTooSmall;
};
}
/// Helper for comparing floating-point values with tolerance.
/// Wraps std.math.approxEqAbs to work seamlessly with test error handling.
fn assertApproxEqual(expected: f64, actual: f64, tolerance: f64) !void {
try std.testing.expect(std.math.approxEqAbs(f64, expected, actual, tolerance));
}
test "mean handles positive numbers" {
// Verify mean of [2.0, 3.0, 4.0] equals 3.0 within floating-point tolerance
try assertApproxEqual(3.0, try mean(&[_]f64{ 2.0, 3.0, 4.0 }), 0.001);
}
test "mean returns error on empty input" {
// Confirm that an empty slice triggers the expected domain error
try std.testing.expectError(StatsError.EmptyInput, mean(&[_]f64{}));
}
test "logMean forwards formatted output" {
// Allocate a fixed buffer to capture written output
var storage: [128]u8 = undefined;
var stream = std.io.fixedBufferStream(&storage);
// Write mean result to the in-memory buffer
try logMean(stream.writer(), &[_]f64{ 1.0, 2.0, 3.0 });
// Retrieve what was written and verify it contains the expected label
const rendered = stream.getWritten();
try std.testing.expect(std.mem.containsAtLeast(u8, rendered, 1, "mean"));
}
$ zig test 01_style_baseline.zigAll 3 tests passed.Treat documentation comments plus unit tests as the minimum viable API reference—both are compiled on every run, so they stay in sync with the code you ship.
Resource Management & Error Patterns
Zig’s standard library favors explicit resource ownership; pairing defer with errdefer helps ensure that temporary allocations unwind correctly. When parsing user-supplied data, keep the error vocabulary small and deterministic so callers can route failure modes without inspecting strings. See fs.zig.
//! Resource-safe error handling patterns with defer and errdefer.
const std = @import("std");
/// Custom error set for data loading operations.
/// Keeping error sets small and explicit helps callers route failures precisely.
pub const LoaderError = error{InvalidNumber};
/// Loads floating-point samples from a UTF-8 text file.
/// Each non-empty line is parsed as an f64.
/// Caller owns the returned slice and must free it with the same allocator.
pub fn loadSamples(dir: std.fs.Dir, allocator: std.mem.Allocator, path: []const u8) ![]f64 {
// Open the file; propagate any I/O errors to caller
var file = try dir.openFile(path, .{});
// Guarantee file handle is released when function exits, regardless of path taken
defer file.close();
// Start with an empty list; we'll grow it as we parse lines
var list = std.ArrayListUnmanaged(f64){};
// If any error occurs after this point, free the list's backing memory
errdefer list.deinit(allocator);
// Read entire file into memory; cap at 64KB for safety
const contents = try file.readToEndAlloc(allocator, 1 << 16);
// Free the temporary buffer once we've parsed it
defer allocator.free(contents);
// Split contents by newline; iterator yields one line at a time
var lines = std.mem.splitScalar(u8, contents, '\n');
while (lines.next()) |line| {
// Strip leading/trailing whitespace and carriage returns
const trimmed = std.mem.trim(u8, line, " \t\r");
// Skip empty lines entirely
if (trimmed.len == 0) continue;
// Attempt to parse the line as a float; surface a domain-specific error on failure
const value = std.fmt.parseFloat(f64, trimmed) catch return LoaderError.InvalidNumber;
// Append successfully parsed value to the list
try list.append(allocator, value);
}
// Transfer ownership of the backing array to the caller
return list.toOwnedSlice(allocator);
}
test "loadSamples returns parsed floats" {
// Create a temporary directory that will be cleaned up automatically
var tmp_fs = std.testing.tmpDir(.{});
defer tmp_fs.cleanup();
// Write sample data to a test file
const file_path = try tmp_fs.dir.createFile("samples.txt", .{});
defer file_path.close();
try file_path.writeAll("1.0\n2.5\n3.75\n");
// Load and parse the samples; defer ensures cleanup even if assertions fail
const samples = try loadSamples(tmp_fs.dir, std.testing.allocator, "samples.txt");
defer std.testing.allocator.free(samples);
// Verify we parsed exactly three values
try std.testing.expectEqual(@as(usize, 3), samples.len);
// Check each value is within acceptable floating-point tolerance
try std.testing.expectApproxEqAbs(1.0, samples[0], 0.001);
try std.testing.expectApproxEqAbs(2.5, samples[1], 0.001);
try std.testing.expectApproxEqAbs(3.75, samples[2], 0.001);
}
test "loadSamples surfaces invalid numbers" {
// Set up another temporary directory for error-path testing
var tmp_fs = std.testing.tmpDir(.{});
defer tmp_fs.cleanup();
// Write non-numeric content to trigger parsing failure
const file_path = try tmp_fs.dir.createFile("bad.txt", .{});
defer file_path.close();
try file_path.writeAll("not-a-number\n");
// Confirm that loadSamples returns the expected domain error
try std.testing.expectError(LoaderError.InvalidNumber, loadSamples(tmp_fs.dir, std.testing.allocator, "bad.txt"));
}
$ zig test 02_error_handling_patterns.zigAll 2 tests passed.Returning slices via toOwnedSlice keeps the lifetimes obvious and prevents leaking the backing allocation when parsing fails midway—errdefer makes the cleanup explicit (see mem.zig).
Maintainability Checklist: Guarding Invariants
Data structures that defend their own invariants are easier to refactor safely. By isolating the checks in a helper and calling it before and after mutations, you create a single source of truth for correctness. std.debug.assert makes the contract visible in debug builds without penalizing release performance (see debug.zig).
//! Maintainability checklist example with an internal invariant helper.
//!
//! This module demonstrates defensive programming practices by implementing
//! a ring buffer data structure that validates its internal state invariants
//! before and after mutating operations.
const std = @import("std");
/// A fixed-capacity circular buffer that stores i32 values.
/// The buffer wraps around when full, and uses modular arithmetic
/// to implement FIFO (First-In-First-Out) semantics.
pub const RingBuffer = struct {
storage: []i32,
head: usize = 0, // Index of the first element
count: usize = 0, // Number of elements currently stored
/// Errors that can occur during ring buffer operations.
pub const Error = error{Overflow};
/// Creates a new RingBuffer backed by the provided storage slice.
/// The caller retains ownership of the storage memory.
pub fn init(storage: []i32) RingBuffer {
return .{ .storage = storage };
}
/// Validates internal state consistency.
/// This is called before and after mutations to catch logic errors early.
/// Checks that:
/// - Empty storage implies zero head and count
/// - Head index is within storage bounds
/// - Count doesn't exceed storage capacity
fn invariant(self: *const RingBuffer) void {
if (self.storage.len == 0) {
std.debug.assert(self.head == 0);
std.debug.assert(self.count == 0);
return;
}
std.debug.assert(self.head < self.storage.len);
std.debug.assert(self.count <= self.storage.len);
}
/// Adds a value to the end of the buffer.
/// Returns Error.Overflow if the buffer is at capacity or has no storage.
/// Invariants are checked before and after the operation.
pub fn push(self: *RingBuffer, value: i32) Error!void {
self.invariant();
if (self.storage.len == 0 or self.count == self.storage.len) return Error.Overflow;
// Calculate the insertion position using circular indexing
const index = (self.head + self.count) % self.storage.len;
self.storage[index] = value;
self.count += 1;
self.invariant();
}
/// Removes and returns the oldest value from the buffer.
/// Returns null if the buffer is empty.
/// Advances the head pointer circularly and decrements the count.
pub fn pop(self: *RingBuffer) ?i32 {
self.invariant();
if (self.count == 0) return null;
const value = self.storage[self.head];
// Move head forward circularly
self.head = (self.head + 1) % self.storage.len;
self.count -= 1;
self.invariant();
return value;
}
};
// Verifies that the buffer correctly rejects pushes when at capacity.
test "ring buffer enforces capacity" {
var storage = [_]i32{ 0, 0, 0 };
var buffer = RingBuffer.init(&storage);
try buffer.push(1);
try buffer.push(2);
try buffer.push(3);
// Fourth push should fail because buffer capacity is 3
try std.testing.expectError(RingBuffer.Error.Overflow, buffer.push(4));
}
// Verifies that values are retrieved in the same order they were inserted.
test "ring buffer preserves FIFO order" {
var storage = [_]i32{ 0, 0, 0 };
var buffer = RingBuffer.init(&storage);
try buffer.push(10);
try buffer.push(20);
try buffer.push(30);
// Values should come out in insertion order
try std.testing.expectEqual(@as(?i32, 10), buffer.pop());
try std.testing.expectEqual(@as(?i32, 20), buffer.pop());
try std.testing.expectEqual(@as(?i32, 30), buffer.pop());
// Buffer is now empty, should return null
try std.testing.expectEqual(@as(?i32, null), buffer.pop());
}
$ zig test 03_invariant_guard.zigAll 2 tests passed.Capture invariants in unit tests as well—assertions guard developers, while tests stop regressions that slip past manual review.
Notes & Caveats
zig fmtonly touches syntax it understands; generated code or embedded strings may still need a manual glance.- Expand error sets deliberately—combining the smallest possible unions keeps call sites precise and avoids accidental catch-alls (see error.zig).
- Remember to test under both debug and release builds so assertions and
std.debugchecks do not mask production-only issues (see build.zig).
Exercises
- Wrap the statistics helper in a module that exposes both mean and variance; add doctests that demonstrate the API from a consumer’s perspective.
- Extend the loader to stream data instead of reading entire files; compare heap usage in release-safe builds to ensure you keep allocations bounded.
- Add a stress test to the ring buffer that interleaves pushes and pops across thousands of operations, then run it under
zig test -Drelease-safeto confirm invariants survive in optimized builds.
Alternatives & Edge Cases
- Projects with generated code may need formatting exclusions—document those directories so contributors know when
zig fmtis safe to run. - Favor small helper functions (like
invariant) over sprinkling assertions everywhere; centralized checks are easier to audit during reviews. - When adding new dependencies, gate them behind feature flags or build options so style rules remain enforceable even in minimal configurations.