Chapter 60Advanced Result Location Semantics

Appendix F. Advanced Result Location Semantics

Overview

Result Location Semantics (RLS) are the quiet engine that powers Zig’s zero-copy aggregates, type inference, and efficient error propagation. After experimenting with inline assembly in Appendix E, we now dive back into the compiler to see how Zig steers values directly into their final home. It eliminates temporaries whether you build structs, unions, or manually fill caller-provided buffers. 59

Zig 0.15.2 clarifies RLS diagnostics around pointer alignment and optional result pointers, making it easier to reason about where your data lives during construction. v0.15.2

Learning Goals

  • Trace how struct literals and coercions forward result locations to every field without hidden copies.
  • Apply explicit result pointers when you want to reuse caller-owned storage while still offering a value-returning API.
  • Combine unions with RLS so each variant writes directly into its own payload without allocating scratch buffers at runtime.

Struct Forwarding in Practice

When you assign a struct literal to a variable, Zig rewrites the operation into a series of field writes, allowing each sub-expression to inherit the final destination. The first recipe summarizes a handful of sensor readings into a Report, demonstrating how nested literals (range inside Report) inherit result locations transitively. math.zig

Zig
//! Builds a statistics report using struct literals that forward into the caller's result location.
const std = @import("std");

pub const Report = struct {
    range: struct {
        min: u8,
        max: u8,
    },
    buckets: [4]u32,
};

pub fn buildReport(values: []const u8) Report {
    var histogram = [4]u32{ 0, 0, 0, 0 };

    if (values.len == 0) {
        return .{
            .range = .{ .min = 0, .max = 0 },
            .buckets = histogram,
        };
    }

    var current_min: u8 = std.math.maxInt(u8);
    var current_max: u8 = 0;

    for (values) |value| {
        current_min = @min(current_min, value);
        current_max = @max(current_max, value);
        const bucket_index = value / 64;
        histogram[bucket_index] += 1;
    }

    return .{
        .range = .{ .min = current_min, .max = current_max },
        .buckets = histogram,
    };
}

test "buildReport summarises range and bucket counts" {
    const data = [_]u8{ 3, 19, 64, 129, 200 };
    const report = buildReport(&data);

    try std.testing.expectEqual(@as(u8, 3), report.range.min);
    try std.testing.expectEqual(@as(u8, 200), report.range.max);
    try std.testing.expectEqualSlices(u32, &[_]u32{ 2, 1, 1, 1 }, &report.buckets);
}
Run
Shell
$ zig test chapters-data/code/60__advanced-result-location-semantics/01_histogram_report.zig
Output
Shell
All 1 tests passed.

Because the literal .{ .range = …, .buckets = histogram } writes field-by-field, you can safely seed histogram with var data—no temporary copy of the 16-byte array is ever produced. 36

Manual Result Pointers for Reuse

Sometimes you want both worlds: a value-returning helper for ergonomic callers and an in-place variant for hot loops that reuse storage. By exposing a parseInto routine that receives a *Numbers, you determine the result location explicitly while still offering parseNumbers that benefits from automatic elision. 4 Note how the slice method accepts *const Numbers; returning a slice from a by-value parameter would point at a temporary and violate safety rules. mem.zig

Zig
//! Demonstrates manual result locations by filling a struct through a pointer parameter.
const std = @import("std");

pub const ParseError = error{
    TooManyValues,
    InvalidNumber,
};

pub const Numbers = struct {
    len: usize = 0,
    data: [16]u32 = undefined,

    pub fn slice(self: *const Numbers) []const u32 {
        return self.data[0..self.len];
    }
};

pub fn parseInto(result: *Numbers, text: []const u8) ParseError!void {
    result.* = Numbers{};
    result.data = std.mem.zeroes([16]u32);

    var tokenizer = std.mem.tokenizeAny(u8, text, ", ");
    while (tokenizer.next()) |word| {
        if (result.len == result.data.len) return ParseError.TooManyValues;
        const value = std.fmt.parseInt(u32, word, 10) catch return ParseError.InvalidNumber;
        result.data[result.len] = value;
        result.len += 1;
    }
}

pub fn parseNumbers(text: []const u8) ParseError!Numbers {
    var scratch: Numbers = undefined;
    try parseInto(&scratch, text);
    return scratch;
}

test "parseInto fills caller-provided storage" {
    var numbers: Numbers = .{};
    try parseInto(&numbers, "7,11,42");
    try std.testing.expectEqualSlices(u32, &[_]u32{ 7, 11, 42 }, numbers.slice());
}

test "parseNumbers returns the same shape without extra copies" {
    const owned = try parseNumbers("1 2 3");
    try std.testing.expectEqual(@as(usize, 3), owned.len);
    try std.testing.expectEqualSlices(u32, &[_]u32{ 1, 2, 3 }, owned.data[0..owned.len]);
}
Run
Shell
$ zig test chapters-data/code/60__advanced-result-location-semantics/02_numbers_parse_into.zig
Output
Shell
All 2 tests passed.

Resetting Numbers with a fresh value and zeroing the backing array ensures the result location is ready for reuse even if the previous parse only filled part of the buffer. 57

Union Variants and Branch-Specific Destinations

Unions expose the same mechanics: once the compiler knows which variant you are constructing, it wires the payload’s result location to the appropriate field. The lookup helper below either streams bytes into a Resource payload or returns metadata for malformed queries, without allocating interim buffers. The same approach scales to streaming parsers, FFI bridges, or caches that must avoid heap traffic.

Zig
//! Demonstrates union construction that forwards nested result locations.
const std = @import("std");

pub const Resource = struct {
    name: []const u8,
    payload: [32]u8,
};

pub const LookupResult = union(enum) {
    hit: Resource,
    miss: void,
    malformed: []const u8,
};

const CatalogEntry = struct {
    name: []const u8,
    data: []const u8,
};

pub fn lookup(name: []const u8, catalog: []const CatalogEntry) LookupResult {
    for (catalog) |entry| {
        if (std.mem.eql(u8, entry.name, name)) {
            var buffer: [32]u8 = undefined;
            const len = @min(buffer.len, entry.data.len);
            std.mem.copyForwards(u8, buffer[0..len], entry.data[0..len]);
            return .{ .hit = .{ .name = entry.name, .payload = buffer } };
        }
    }

    if (name.len == 0) return .{ .malformed = "empty identifier" };
    return .miss;
}

test "lookup returns hit variant with payload" {
    const items = [_]CatalogEntry{
        .{ .name = "alpha", .data = "hello" },
        .{ .name = "beta", .data = "world" },
    };

    const result = lookup("beta", &items);
    switch (result) {
        .hit => |res| {
            try std.testing.expectEqualStrings("beta", res.name);
            try std.testing.expectEqualStrings("world", res.payload[0..5]);
        },
        else => try std.testing.expect(false),
    }
}

test "lookup surfaces malformed input" {
    const items = [_]CatalogEntry{.{ .name = "alpha", .data = "hello" }};
    const result = lookup("", &items);
    switch (result) {
        .malformed => |msg| try std.testing.expectEqualStrings("empty identifier", msg),
        else => try std.testing.expect(false),
    }
}
Run
Shell
$ zig test chapters-data/code/60__advanced-result-location-semantics/03_union_forwarding.zig
Output
Shell
All 2 tests passed.

When copying into fixed-size buffers, clamp the length as shown, so you do not accidentally write past the payload. If you require full-length retention, switch to a slice field and pair it with lifetimes that outlive the union value. 10

Patterns to Keep on Hand

  • Treat return .{ … }; as sugar for field-wise writes—the compiler already knows the destination, so lean on literals for clarity. 36
  • Offer pointer-based *_into variants when parsing or formatting—they turn RLS into a conscious API lever instead of an implicit optimization. 4
  • When unions carry large payloads, construct them inline so variants do not require heap allocations or temporary buffers. 8

Notes & Caveats

  • Return slices from by-value methods (like fn slice(self: Numbers)) capture a temporary copy; prefer pointer receivers to keep the result location stable.
  • Many standard-library builders accept result pointers—read their signatures before re-implementing similar plumbing yourself. fmt.zig
  • RLS bypasses no validation: if a sub-expression fails (for example, parsing errors), the partially written destination remains in your control, so remember to reset or discard it before reuse. 57

Exercises

  • Extend buildReport to parameterize the bucket size, then inspect how nested loops still forward their destinations without copies. 36
  • Add overflow detection to parseInto, so it rejects integers above a configurable limit, resetting the result buffer when the error fires. 57
  • Teach lookup to stream into a caller-provided scratch buffer when the payload exceeds 32 bytes, mirroring the pointer-based pattern from the previous section. 4

Alternatives & Edge Cases

  • For comptime constructs, result locations may exist entirely in compile-time memory; use @TypeOf to confirm whether your data ever escapes to runtime. 15
  • When interfacing with C APIs that expect you to manage buffers, combine RLS with extern structs, so you match their layout while still avoiding intermediate copies. 33
  • Profile hot paths before micro-optimizing: sometimes using std.ArrayList or a streaming writer is clearer, and RLS will still erase intermediate temporaries for you. array_list.zig

Help make this chapter better.

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