structs #
written against zig 0.15.
designing structs that behave correctly when copied.
slices vs arrays in struct fields #
when you assign a struct, zig copies all fields by value. this has different implications depending on field type:
arrays are value types - the data lives inside the struct:
const Foo = struct {
buffer: [64]u8, // 64 bytes stored in the struct
};
var a = Foo{ .buffer = "hello".* ++ .{0} ** 59 };
var b = a; // copies all 64 bytes - b has its own data
slices are fat pointers - they store a pointer and length, not data:
const Bar = struct {
data: []const u8, // 16 bytes: pointer + length
};
var a = Bar{ .data = "hello" };
var b = a; // copies the pointer - b.data points to same memory as a.data
this is documented in zig.guide/slices: "slices can be thought of as many-item pointers with a length... the validity and lifetime of the backing memory is in the hands of the programmer."
the copy problem #
if you build a struct, store a slice pointing to temporary data, then copy that struct - the copy's slice becomes dangling:
fn makeAttribute(temp_string: []const u8) Attribute {
return .{ .value = temp_string }; // slice points to temp_string
}
// later, after temp_string goes out of scope:
const attr = makeAttribute(some_temp);
const copy = attr; // copy.value is now a dangling pointer
this happened multiple times in logfire-zig when attributes were queued for batch export - by the time they were serialized, the original strings were gone.
copy-safe pattern: internal storage #
if a struct must survive being copied and needs string data, store the data internally:
pub const Attribute = struct {
key: []const u8,
value: Value,
_string_storage: [max_len]u8 = undefined,
_string_len: usize = 0,
pub const max_len = 512;
pub const Value = union(enum) {
string, // data in _string_storage[0.._string_len]
int: i64,
float: f64,
bool_val: bool,
};
pub fn getString(self: *const Attribute) ?[]const u8 {
return switch (self.value) {
.string => self._string_storage[0..self._string_len],
else => null,
};
}
fn setString(self: *Attribute, str: []const u8) void {
const len = @min(str.len, max_len);
@memcpy(self._string_storage[0..len], str[0..len]);
self._string_len = len;
self.value = .string;
}
};
key points:
_string_storageis a fixed array, not a slice - data is copied with the struct_string_lentracks how much of the buffer is usedgetString()reconstructs the slice from internal storage- the slice returned by
getString()points intoself, so copies get slices into their own storage
see: logfire-zig/attribute.zig
@constCast for inline struct literal slices #
when you need to pass an inline struct literal as a slice parameter, zig may need @constCast because the literal is const:
// this builds an array of MapEntry inline and passes it as a slice
try op_values.append(allocator, .{ .map = @constCast(&[_]cbor.Value.MapEntry{
.{ .key = "action", .value = .{ .text = action_str } },
.{ .key = "path", .value = .{ .text = path } },
}) });
without @constCast, you get a type mismatch — the literal produces *const [N]MapEntry but the field expects []const MapEntry through a mutable pointer. this is safe because the data is embedded in the struct value being appended.
see: zat/firehose.zig encodeCommitPayload (zat is a library of atproto building blocks for zig)
std.math.cast for safe integer narrowing #
prefer std.math.cast over @intCast when narrowing integers from untrusted input. @intCast panics on overflow, std.math.cast returns null:
// dangerous — panics on 32-bit if header_len > maxInt(usize)
const len: usize = @intCast(varint_u64);
// safe — returns an error instead of panicking
const len = std.math.cast(usize, varint_u64) orelse return error.InvalidHeader;
use @intCast for values you've already bounds-checked or that come from trusted internal code. use std.math.cast at system boundaries (parsing wire formats, external input).
see: zat/car.zig
when to use this pattern #
use internal storage when:
- structs are queued, batched, or stored for later processing
- structs are copied into collections (ArrayList, hashmap values)
- ownership of string data is unclear or temporary
use slices when:
- struct lifetime is shorter than the data it references
- data is compile-time constant (string literals)
- you explicitly manage the backing memory
&.{...} with runtime values: don't let it outlive the statement #
&.{ a, b } where a/b are comptime-known produces a pointer to static
memory — safe anywhere. With runtime values it's a stack temporary, and
the langref does not pin down how long it lives (result-location semantics;
temporaries' lifetime is famously underspecified). Passing it straight into
a call is fine — the callee consumes it before the statement ends. The trap
is storing it into something longer-lived:
var stmts: std.ArrayList(Statement) = .empty;
if (stale_uri.len > 0) {
// pointer to a temporary born inside this if-block…
try stmts.append(alloc, .{ .sql = "...", .args = &.{stale_uri} });
}
// …dereferenced here, after the block. does it still live? unspecified.
try client.batch(stmts.items);
An empirical probe (block-scoped &.{runtime}, stack stomping, read after
the block) happened to pass on 0.16 — which proves nothing; it's the kind
of latent bug that appears when frame layout shifts. Defensive rule: when a
Statement-like struct stores arg pointers for later use, materialize the
args as named locals (or fields of a caller-pinned struct) whose scope
provably covers the consumption point, and reserve any growable storage
up front so appended pointers never move (learned on pub-search's
DocWriteBatch, 0.16).
sources #
- logfire-zig — src/attribute.zig, batch-export string lifetime bug
- zat — src/internal/firehose.zig, src/internal/car.zig
- pub-search — DocWriteBatch, 0.16