structs#
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
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