Overview
This project turns the raw allocator patterns from the previous chapter into a focused utility: a dynamic string builder that can stitch together reports, logs, and templates without scattering []u8 bookkeeping throughout your code. By wrapping std.ArrayList(u8) we keep amortized O(1) appends, expose growth metrics for debugging, and make it trivial to hand ownership to callers when the buffer is ready; see 10 and array_list.zig.
Real programs live on more than one allocator, so we also stress-test the builder against stack buffers, arenas, and the general-purpose allocator. The result is a pattern you can drop into CLIs, templating tasks, or logging subsystems whenever you need flexible but explicit string assembly; see heap.zig.
Learning Goals
- Craft a reusable
StringBuilderwrapper that tracks growth events while leaning onstd.ArrayList(u8)for storage; see string_builder.zig. - Drive the builder through
std.io.GenericWriterso formatted printing composes with ordinary appends; see writer.zig. - Choose between stack buffers, arenas, and heap allocators for dynamic text workflows using
std.heap.stackFallback.
Builder Blueprint
The core utility lives in string_builder.zig: a thin struct that stores the caller’s allocator, an std.ArrayList(u8) buffer, and a handful of helpers for appends, formatting, and growth telemetry. Each operation goes through your chosen allocator, so handing the builder a different allocator instantly changes its behavior.
Rendering structured summaries
To see the builder in action, the following program composes a short report, captures a snapshot of length/capacity/growth, and returns an owned slice to the caller. The builder defers cleanup to defer builder.deinit(), so even if toOwnedSlice moves the buffer, the surrounding scope stays leak-free.
const std = @import("std");
const builder_mod = @import("string_builder.zig");
const StringBuilder = builder_mod.StringBuilder;
pub fn main() !void {
// Initialize a general-purpose allocator with leak detection
// This allocator tracks all allocations and reports leaks on deinit
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
if (gpa.deinit() == .leak) std.log.err("leaked allocations detected", .{});
}
const allocator = gpa.allocator();
// Create a StringBuilder with 64 bytes of initial capacity
// Pre-allocating reduces reallocation overhead for known content size
var builder = try StringBuilder.initCapacity(allocator, 64);
defer builder.deinit();
// Build report header using basic string concatenation
try builder.append("Report\n======\n");
try builder.append("source: dynamic builder\n\n");
// Define structured data for report generation
// Each item represents a category with its count
const items = [_]struct {
name: []const u8,
count: usize,
}{
.{ .name = "widgets", .count = 7 },
.{ .name = "gadgets", .count = 13 },
.{ .name = "doodads", .count = 2 },
};
// Obtain a writer interface for formatted output
// This allows using std.fmt.format-style print operations
var writer = builder.writer();
for (items, 0..) |item, index| {
// Format each item as a numbered list entry with name and count
try writer.print("* {d}. {s}: {d}\n", .{ index + 1, item.name, item.count });
}
// Capture allocation statistics before adding summary
// Snapshot preserves metrics for analysis without affecting builder state
const snapshot = builder.snapshot();
try writer.print("\nsummary: appended {d} entries\n", .{items.len});
// Transfer ownership of the constructed string to caller
// After this call, builder is reset and cannot be reused without re-initialization
const result = try builder.toOwnedSlice();
defer allocator.free(result);
// Display the generated report alongside allocation statistics
std.debug.print("{s}\n---\n{any}\n", .{ result, snapshot });
}
$ zig run builder_core.zigReport
======
source: dynamic builder
* 1. widgets: 7
* 2. gadgets: 13
* 3. doodads: 2
summary: appended 3 entries
---
.{ .length = 88, .capacity = 224, .growth_events = 1 }snapshot() is cheap enough to sprinkle through your code whenever you need to confirm that a given workload stays inside a particular capacity envelope.
Allocators in Action
Allocators define how the builder behaves under pressure: stackFallback gives blazing-fast stack writes until the buffer spills, an arena lets you bulk-free whole generations, and the GPA keeps leak detection in play. This section demonstrates how the same builder code adapts to different allocation strategies.
Stack buffer with an arena safety net
Here we wrap the builder in a stack-backed allocator that falls back to an arena once the 256-byte scratch space fills up. The output shows how the small report stays within the stack buffer while the larger one spills into the arena and grows four times; see 10.
const std = @import("std");
const builder_mod = @import("string_builder.zig");
const StringBuilder = builder_mod.StringBuilder;
const Stats = builder_mod.Stats;
/// Container for a generated report and its allocation statistics
const Report = struct {
text: []u8,
stats: Stats,
};
/// Builds a text report with random sample data
/// Demonstrates StringBuilder usage with various allocator strategies
fn buildReport(allocator: std.mem.Allocator, label: []const u8, sample_count: usize) !Report {
// Initialize StringBuilder with the provided allocator
var builder = StringBuilder.init(allocator);
defer builder.deinit();
// Write report header
try builder.append("label: ");
try builder.append(label);
try builder.append("\n");
// Initialize PRNG with a seed that varies based on sample_count
// Ensures reproducible but different sequences for different report sizes
var prng = std.Random.DefaultPrng.init(0x5eed1234 ^ @as(u64, sample_count));
var random = prng.random();
// Generate random sample data and accumulate totals
var total: usize = 0;
var writer = builder.writer();
for (0..sample_count) |i| {
// Each sample represents a random KiB allocation between 8-64
const chunk = random.intRangeAtMost(u32, 8, 64);
total += chunk;
try writer.print("{d}: +{d} KiB\n", .{ i, chunk });
}
// Write summary line with aggregated statistics
try writer.print("total: {d} KiB across {d} samples\n", .{ total, sample_count });
// Capture allocation statistics before transferring ownership
const stats = builder.snapshot();
// Transfer ownership of the built string to the caller
const text = try builder.toOwnedSlice();
return .{ .text = text, .stats = stats };
}
pub fn main() !void {
// Arena allocator will reclaim all allocations at once when deinit() is called
var arena = std.heap.ArenaAllocator.init(std.heap.page_allocator);
defer arena.deinit();
// Small report: 256-byte stack buffer should be sufficient
// stackFallback tries stack first, falls back to arena if needed
var fallback_small = std.heap.stackFallback(256, arena.allocator());
const small_allocator = fallback_small.get();
const small = try buildReport(small_allocator, "stack-only", 6);
defer small_allocator.free(small.text);
// Large report: 256-byte stack buffer will overflow, forcing arena allocation
// Demonstrates fallback behavior when stack space is insufficient
var fallback_large = std.heap.stackFallback(256, arena.allocator());
const large_allocator = fallback_large.get();
const large = try buildReport(large_allocator, "needs-arena", 48);
defer large_allocator.free(large.text);
// Display both reports with their allocation statistics
// Stats will reveal which allocator strategy was used (stack vs heap)
std.debug.print("small buffer ->\n{s}stats: {any}\n\n", .{ small.text, small.stats });
std.debug.print("large buffer ->\n{s}stats: {any}\n", .{ large.text, large.stats });
}
$ zig run allocator_fallback.zigsmall buffer ->
label: stack-only
0: +40 KiB
1: +16 KiB
2: +13 KiB
3: +31 KiB
4: +44 KiB
5: +9 KiB
total: 153 KiB across 6 samples
stats: .{ .length = 115, .capacity = 128, .growth_events = 1 }
large buffer ->
label: needs-arena
0: +35 KiB
1: +29 KiB
2: +33 KiB
3: +14 KiB
4: +33 KiB
5: +20 KiB
6: +36 KiB
7: +21 KiB
8: +11 KiB
9: +58 KiB
10: +22 KiB
11: +53 KiB
12: +21 KiB
13: +41 KiB
14: +30 KiB
15: +20 KiB
16: +10 KiB
17: +39 KiB
18: +46 KiB
19: +59 KiB
20: +33 KiB
21: +8 KiB
22: +30 KiB
23: +22 KiB
24: +28 KiB
25: +32 KiB
26: +48 KiB
27: +50 KiB
28: +61 KiB
29: +53 KiB
30: +30 KiB
31: +27 KiB
32: +42 KiB
33: +24 KiB
34: +32 KiB
35: +58 KiB
36: +60 KiB
37: +27 KiB
38: +40 KiB
39: +17 KiB
40: +50 KiB
41: +50 KiB
42: +42 KiB
43: +54 KiB
44: +61 KiB
45: +10 KiB
46: +25 KiB
47: +50 KiB
total: 1695 KiB across 48 samples
stats: .{ .length = 618, .capacity = 1040, .growth_events = 4 }stackFallback(N, allocator) only tolerates one call to .get() per instance; spin up a fresh fallback wrapper when you need multiple concurrent builders.
Growth Planning
The builder records how many times capacity changed, which is perfect for profiling the difference between “append blindly” and “pre-size once.” The next example shows both paths producing identical text while the planned version keeps growth to a single reallocation.
Pre-sizing vs naive append
const std = @import("std");
const builder_mod = @import("string_builder.zig");
const StringBuilder = builder_mod.StringBuilder;
const Stats = builder_mod.Stats;
/// Container for built string and its allocation statistics
const Result = struct {
text: []u8,
stats: Stats,
};
/// Calculates the total byte length of all string segments
/// Used to pre-compute capacity requirements for efficient allocation
fn totalLength(parts: []const []const u8) usize {
var sum: usize = 0;
for (parts) |segment| sum += segment.len;
return sum;
}
/// Builds a formatted string without pre-allocating capacity
/// Demonstrates the cost of incremental growth through multiple reallocations
/// Separators are spaces, with newlines every 8th segment
fn buildNaive(allocator: std.mem.Allocator, parts: []const []const u8) !Result {
// Initialize with default capacity (0 bytes)
// Builder will grow dynamically as content is appended
var builder = StringBuilder.init(allocator);
defer builder.deinit();
for (parts, 0..) |segment, index| {
// Each append may trigger reallocation if capacity is insufficient
try builder.append(segment);
if (index + 1 < parts.len) {
// Insert newline every 8 segments, space otherwise
const sep = if ((index + 1) % 8 == 0) "\n" else " ";
try builder.append(sep);
}
}
// Capture allocation statistics showing multiple growth operations
const stats = builder.snapshot();
const text = try builder.toOwnedSlice();
return .{ .text = text, .stats = stats };
}
/// Builds a formatted string with pre-calculated capacity
/// Demonstrates performance optimization by eliminating reallocations
/// Produces identical output to buildNaive but with fewer allocations
fn buildPlanned(allocator: std.mem.Allocator, parts: []const []const u8) !Result {
var builder = StringBuilder.init(allocator);
defer builder.deinit();
// Calculate exact space needed: all segments plus separator count
// Separators: n-1 for n parts (no separator after last segment)
const separators = if (parts.len == 0) 0 else parts.len - 1;
// Pre-allocate all required capacity in a single allocation
try builder.ensureUnusedCapacity(totalLength(parts) + separators);
for (parts, 0..) |segment, index| {
// Append operations never reallocate due to pre-allocation
try builder.append(segment);
if (index + 1 < parts.len) {
// Insert newline every 8 segments, space otherwise
const sep = if ((index + 1) % 8 == 0) "\n" else " ";
try builder.append(sep);
}
}
// Capture statistics showing single allocation with no growth
const stats = builder.snapshot();
const text = try builder.toOwnedSlice();
return .{ .text = text, .stats = stats };
}
pub fn main() !void {
// Initialize leak-detecting allocator to verify proper cleanup
var gpa = std.heap.GeneralPurposeAllocator(.{}){};
defer {
if (gpa.deinit() == .leak) std.log.err("leaked allocations detected", .{});
}
const allocator = gpa.allocator();
// Sample data: 32 Greek letters and astronomy terms
// Large enough to demonstrate multiple reallocations in naive approach
const segments = [_][]const u8{
"alpha",
"beta",
"gamma",
"delta",
"epsilon",
"zeta",
"eta",
"theta",
"iota",
"kappa",
"lambda",
"mu",
"nu",
"xi",
"omicron",
"pi",
"rho",
"sigma",
"tau",
"upsilon",
"phi",
"chi",
"psi",
"omega",
"aurora",
"borealis",
"cosmos",
"nebula",
"quasar",
"pulsar",
"singularity",
"zenith",
};
// Build string without capacity planning
// Stats will show multiple allocations and growth operations
const naive = try buildNaive(allocator, &segments);
defer allocator.free(naive.text);
// Build string with exact capacity pre-allocation
// Stats will show single allocation with no growth
const planned = try buildPlanned(allocator, &segments);
defer allocator.free(planned.text);
// Compare allocation statistics side-by-side
// Demonstrates the efficiency gain from capacity planning
std.debug.print(
"naive -> {any}\n{s}\n\nplanned -> {any}\n{s}\n",
.{ naive.stats, naive.text, planned.stats, planned.text },
);
}
$ zig run growth_comparison.zignaive -> .{ .length = 186, .capacity = 320, .growth_events = 2 }
alpha beta gamma delta epsilon zeta eta theta
iota kappa lambda mu nu xi omicron pi
rho sigma tau upsilon phi chi psi omega
aurora borealis cosmos nebula quasar pulsar singularity zenith
planned -> .{ .length = 186, .capacity = 320, .growth_events = 1 }
alpha beta gamma delta epsilon zeta eta theta
iota kappa lambda mu nu xi omicron pi
rho sigma tau upsilon phi chi psi omega
aurora borealis cosmos nebula quasar pulsar singularity zenithGrowth counts depend on allocator policy—switching to a fixed buffer or arena changes when capacity expands. Track both stats and chosen allocator when comparing profiles.
Notes & Caveats
toOwnedSlicehands ownership to the caller; remember to free with the same allocator you passed intoStringBuilder.stackFallbackzeros the scratch buffer every time you call.get(); if you need persistent reuse, hold on to the returned allocator instead of calling.get()repeatedly.reset()clears contents but retains capacity, so prefer it for hot paths that rebuild strings in a tight loop.
Exercises
- Extend
StringBuilderwith anappendFormat(comptime fmt, args)helper powered bystd.io.Writer.Allocating, then compare its allocations against repeatedwriter.printcalls. - Build a CLI that streams JSON records into the builder, swapping between GPA and arena allocators via a command-line flag; see 05.
- Emit a Markdown report to disk by piping the builder into
std.fs.File.writer()and verifying the final slice matches the written bytes; see 06 and fs.zig.
Alternatives & Edge Cases
- Very large strings may allocate gigabytes—guard inputs or stream to disk once
lengthcrosses a safety threshold. - When composing multiple builders, share a single arena or GPA so ownership chains stay simple and leak detection remains accurate.
- If latency matters more than allocations, emit straight to a buffered writer and use the builder only for sections that truly need random access edits; see 09.