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
//! 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);
}
$ zig test chapters-data/code/60__advanced-result-location-semantics/01_histogram_report.zigAll 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
//! 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]);
}
$ zig test chapters-data/code/60__advanced-result-location-semantics/02_numbers_parse_into.zigAll 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.
//! 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),
}
}
$ zig test chapters-data/code/60__advanced-result-location-semantics/03_union_forwarding.zigAll 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
*_intovariants 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
buildReportto 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
lookupto 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
comptimeconstructs, result locations may exist entirely in compile-time memory; use@TypeOfto confirm whether your data ever escapes to runtime. 15 - When interfacing with C APIs that expect you to manage buffers, combine RLS with
externstructs, so you match their layout while still avoiding intermediate copies. 33 - Profile hot paths before micro-optimizing: sometimes using
std.ArrayListor a streaming writer is clearer, and RLS will still erase intermediate temporaries for you. array_list.zig