Chapter 36Style And Best Practices

Style & Best Practices

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).

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"));
}
Run
Shell
$ zig test 01_style_baseline.zig
Output
Shell
All 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.

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"));
}
Run
Shell
$ zig test 02_error_handling_patterns.zig
Output
Shell
All 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).

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());
}
Run
Shell
$ zig test 03_invariant_guard.zig
Output
Shell
All 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 fmt only 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.debug checks 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-safe to 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 fmt is 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.

Help make this chapter better.

Found a typo, rough edge, or missing explanation? Open an issue or propose a small improvement on GitHub.