Allocation is not Initialization

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;
    }
};

Use Some Comptime Awesomeness

fn createDefault(comptime T: type, allocator: Allocator) !*T {
    // Allocate the space in memory. It will be uninitialized!
    const result = try allocator.create(T);

    // Loop over the fields of the type at comptime, setting
    // their default values as defined by the field's type.
    inline for (std.meta.fields(T)) |field| {
        if (field.default_value) |dv| {
            @field(result, field.name) = @as(
                *const field.type,
                @ptrCast(@alignCast(dv)),
            ).*;
        }
    }
    return result;
}

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.

13 Likes

I think, it’s even better to have both init and create “constructors”, like this:

const std = @import("std");
const Allocator = std.mem.Allocator;

const User = struct {
    domain: []const u8 = "ziggit.dev",
    enabled: bool = false,

    fn init(d: []const u8, e: bool) User {
        return .{.domain = d, .enabled  = e};
    }

    fn create(a: Allocator, d: []const u8, e: bool) !*User {
        var u = try a.create(User);
        u.* = init(d, e);
        return u;
    }
};

pub fn main() !void {
    const a = std.heap.c_allocator;
    const u_on_stack = User.init("dom1.org", true);
    const u_on_heap = try User.create(a, "dom2.org", true);

    std.debug.print(
        "user on stack : d = '{s}', e = {} ({*})\n",
        .{u_on_stack.domain, u_on_stack.enabled, &u_on_stack}
    );

    std.debug.print(
        "user on heap  : d = '{s}', e = {} ({*})\n",
        .{u_on_heap.domain, u_on_heap.enabled, u_on_heap}
    );
}

Note: compile with zig build-exe aini.zig -lc

This way we can conveniently use either stack allocated objects or heap allocated objects, depending on whatever is needed at the moment.

Output of the program:

$ ./aini 
user on stack : d = 'dom1.org', e = true (aini.User@7ffc6442dff0)
user on heap  : d = 'dom2.org', e = true @aini.User@4892a0)
3 Likes

Yes this would be a great solution for a complete program or library indeed. But since the topic is specifically about memory allocation on the heap using an allocator, I tried to keep the example as focused on that as possible.

1 Like

You can do the following (although I think this should be part of the allocator interface):

fn createDefault(comptime T: type, allocator: std.mem.Allocator) !*T {
    const result = try allocator.create(T);
    inline for (std.meta.fields(T)) |field| {
        if (field.default_value) |dv| {
            @field(result, field.name) = @as(*const field.type, @ptrCast(@alignCast(dv))).*;
        }
    }
    return result;
}
5 Likes

That’s an interesting take, but this problem goes further than struct fields and I think we need to add that to this Doc.

You can allocate items that do not have default values and fundamentally end up with the same problem:

const x = try allocator.create(usize);

// what is the value of y?
const y = x.*;

Allocation is fundamentally not initialization because it concerns itself with reserving memory, not the value at that memory location.

Now, that said, I think you have a cool idea, but the issue here is we need to expand the doc beyond struct field defaults.

@alp , @AndrewCodeDev : I added a new section to address the topic of memory allocation in general and also added the comptime field initialization example. @dee0xeed , I added your init function to the create example.

Thanks all for the great input!

1 Like

I think I would prefer this:

user_ptr.* = .{
    .domain = domain,
    .enabled = enabled,
};

This is the same, except that it doesn’t set the defaults, just to override them anyway.

And instead of:

This:

user_ptr.* = .{ .domain = "example.com" };

Is there a reason to separate them into multiple steps?
I find it relatively rare that I need to do that…

3 Likes

And this is absolutely obvious for C programmers, since there is no default fields’ values at all :grin:

I am wondering how it is possible to think this way. Is this this very feature (defaults for struct fields) that could make people think that heap allocated structures will be filled automagically?

I’m potentially guilty of that - even though from a semantic point of view, I would not assume that allocation and initialization are the same :wink: I guess that if you’re coming from “higher” languages, you might be used to the fact that some magic things happen in the background that do this stuff for you?

2 Likes

Exactly! This is the primary reason for me writing this up in the first place. Especially programmers coming from Go, where everything always has a default value. Go made it one of their language design decisions to always set the default value if no explicit value is assigned. So if you’re coming from a language like that, it’s easy to think that when you allocate memory for a struct that has default values for its fields, those fields would be initialized with those default values.

This is exactly why I wanted to focus this topic on structs with default field values and not the general case of any allocation. The “default values” part of the language is what may cause the confusion and thus the footgun. Putting it another way, if there were no default field values feature, there would be less ground for confusion.

3 Likes

ah, the RAINI technique. never forget your umbrella**!

4 Likes