When defining a struct in Zig, you have the option of providing default values for its fields. This can be very convenient because it allows you to rapidly instantiate the struct with little or no extra data required. It is a powerful language feature when defining structs that collect default option values in configuration / initialization functions:
const ServerOptions = struct {
ip_addr: []const u8 = "127.0.0.1",
port: u16 = 8080,
send_timeout_ms: usize = 10,
};
// The Server.init function takes a ServerOptions.
// Here we are only overriding the port field
var server = try Server.init(.{ .port = 8888 });
The Footgun
A problem can arise when you combine memory allocation with a struct with default field values.
const User = struct {
domain: []const u8 = "ziggit.dev",
enabled: bool = false,
};
const user_ptr = try allocator.create(User);
defer allocator.destroy(user_ptr);
// Boom!
std.debug.print("enabled: {}\n", .{user_ptr.enabled});
std.debug.print("domain: {s}\n", .{user_ptr.domain});
Building and running in Debug
and ReleaseSafe
modes produce a panic when trying to print the domain
field:
enabled: false
domain: thread 8619926 panic: reached unreachable code
In ReleaseFast
or ReleaseSmall
you get incorrect output:
enabled: false
domain:
The mistake here is thinking that by allocating a User
struct in memory, the default values will be filled in automatically. This is made even harder to detect or understand when you observe that in the case of the bool
field enabled
, everything seems to be working fine. In this case, the default of false
matches the uninitialized memory state of a bool
, so the error goes undetected. If you set the default value to true
, you will notice the error by being surprised to find the value reported as false
.
The Fundamental Problem
Going beyond the specific case of struct fields with default values and allocation, we must realize that any type of allocation only produces uninitialized space in memory. So even for simple primitive types like usize
, you can run into undefined behavior if you don’t initialize the newly allocated memory yourself:
const x = try allocator.create(usize);
// What is the value of y?
const y = x.*;
As you can see, allocation is fundamentally not initialization because it concerns itself only with reserving memory, not the value at that memory location.
Possible Workarounds
Manually Set Field Values
You can directly set the field values after allocating the struct’s memory:
// Allocate uninitialized memory for User.
const user_ptr = try allocator.create(User);
defer allocator.destroy(user_ptr);
// Initialize the memory.
user_ptr.* = .{ .domain = "example.com", .enabled = true };
Use a “create” Function
You can define a create
function that does the allocation and initializes the fields in one call:
const User = struct {
domain: []const u8 = "ziggit.dev",
enabled: bool = false,
fn init(domain: []const u8, enabled: bool) User {
return .{ .domain = domain, .enabled = enabled };
}
fn create(
allocator: Allocator,
domain: []const u8,
enabled: bool,
) !*User {
// Allocate uninitialized memory for the User.
const user_ptr = try allocator.create(User);
// Initialize the memory with the init function.
user_ptr.* = User.init(domain, enabled);
return user_ptr;
}
};
In Summary
When combining structs with default field values and memory allocation, you must be aware that the allocation process does not fill in the default values for the struct’s fields. Once allocated, it’s your responsibility to initialize the struct’s fields in order to avoid undefined behavior. The same applies for any type when allocating memory for it, you have to initialize that memory with a value of the type before you can use it.