Building Blocks Of Zig: Understanding Structs

Structs are a fundamental building block of Zig and are used to group related data together in a logical and structured way.
Wednesday, May 29, 2024

To learn more about Zig and why I think it’s an amazing language check out my blog post Zig is The Next Big Programming Language

What is a Struct in Zig?

Like many other programming languages Zig has structs. A struct is a pretty simple and straightforward concept, it’s a user defined data model that can contain multiple fields or members. That’s a lot of words to say that a struct is a way to group related data together in a logical and structured way.

Traditional OOP languages like C++, C# and Java use classes to achieve the same goal, we all know the old and tired analogies of classes representing a generic concept like animal and then subclasses like dog and cat inheriting from the animal class and adding their own specific behavior and data or implementing interfaces to achieve polymorphism.

Does Zig have classes?

Zig like C does not have classes but it does have structs, enums and unions. In Zig structs are the most common way to group related data together.

How to define a Struct in Zig

In Zig you define a struct by using the struct keyword followed by the name of the struct and then the body of the struct enclosed in curly braces {}. Inside the body of the struct you define the fields or members of the struct.

Here is an example of a struct that represents a game character:

const Character = struct {
    name: []const u8,
    health: u32,
    stamina: u32,
    say_hello: fn ([]const u8) void,
};

Now that we have defined the Character struct we can create instances of it like this:

const player = Character{
    .name = "Ziggy Stardust",
    .health = 100,
    .stamina = 100,
    .say_hello = fn(name: []const u8) void {
        std.log.info("Hello, {s}!\n", .{name}),
    },
};

// Call the say_hello function
player.say_hello(player.name);

In this example we create a new instance of the Character struct called player and we initialize the fields of the struct with the values "Ziggy Stardust", 100, 100 and a function that logs a message to the console.

Super simple right? Fairly similar to how you would define a struct in C. But wait there’s more

Struct Fields can have default values

Default values in Zig Structs are executed at comptime(compile time) and are allow the field to be omitted when creating an instance of the struct. This allows us to essentially have optional typesafe fields in our structs that have a default value if not provided.

For example we can modify the Character struct to have default values for the health and stamina fields:

const Character = struct {
    name: []const u8,
    health: u32 = 100,
    stamina: u32 = 100,
    say_hello: fn ([]const u8) void,
};

This way when we create a new instance of the Character struct we can omit the health and stamina fields and they will be initialized with the default values, which in this case will be 100 for both fields.

const player = Character{
    .name = "Ziggy Stardust",
    .say_hello = fn(name: []const u8) void {
        std.log.info("Hello, {s}!\n", .{name}),
    },
};

Structs can be packed

By default structs in zig do not maintain a specific order of fields regardless of the order in which you define the fields in the struct. This is not always optimal since sometimes you may wanna have a specific order of your fields to optimize memory usage or interact with certain libraries like OpenGL that require a specific order of fields in a struct. for this we can use the packed keyword when defining a struct to ensure that the fields are ordered in the same order as they are defined in the struct.

Additionally there will be no padding between the fields in a packed struct.

packed structs can participate in Bit Cast or Pointer Cast operations including during comptime.

  const Character = packed struct {
    name: []const u8,
    health: u32,
    stamina: u32,
    say_hello: fn ([]const u8) void,
};

Now the bytes of the struct will be ordered in the same order as they are defined in the struct. Simple as that.

Struct fields can be undefined

If you are not yet ready to set a value to a field in a zig struct, you can use the undefined keyword to set the field to an undefined state. This is useful when you want to create a struct with some fields that you will set later on in your code.

const Goblin = Character{
  .name = undefined,
  .health = 100,
  .stamina = 100,
  .say_hello = fn(name: []const u8) void {
    std.log.info("Hello, {s}!\n", .{name}),
  },
}

In this example we create a new instance of the Character struct called Goblin and we set the name field to undefined and the health and stamina fields to 100. This way we can create a new instance of the Goblin struct and set the name field later on in our code, perhaps we wanna give the goblin a randomly generated name when the goblin enters the view of the player.

Struct can have Methods / Functions

You may have noticed that we have a function as a field in our Character struct. Zig like many other languages allows you to define functions as struct fields which can be called on instances of the struct.
The say_hello field in the Character struct is a function that takes a []const u8 parameter and returns void meaning no value is returned. But we could for example have a function that allows us to attack another character by passing in the character reference as a parameter.

const Character = struct {
    name: []const u8,
    health: u32,
    stamina: u32,
    say_hello: fn ([]const u8) void,
    attack: fn (target: *Character) void,
};

const player = Character{
    .name = "Ziggy Stardust",
    .health = 100,
    .stamina = 100,
    .say_hello = fn(name: []const u8) void {
        std.log.info("Hello, {s}!\n", .{name}),
    },
    .attack = fn(target: *Character) void {
        std.log.info("{s} attacks {s}!\n", .{player.name, target.name}),
    },
};

const enemy = Character{
    .name = "Goblin",
    .health = 50,
    .stamina = 50,
    .say_hello = fn(name: []const u8) void {
        std.log.info("Hello, {s}!\n", .{name}),
    },
    .attack = fn(target: *Character) void {
        std.log.info("{s} attacks {s}!\n", .{enemy.name, target.name}),
    },
};

player.attack(&enemy);
enemy.attack(&player);

In this example we have added an attack function to the Character struct that takes a pointer to another Character as a parameter and logs a message to the console. We then create two instances of the Character struct called player and enemy and call the attack function on each of them passing in the other character as a parameter.

Optimally we’d also want to add a take_damage function to the Character struct that would reduce the health of the character when attacked. But this blog post is purely educational.

Structs can be returned from a function which results in a Generic

In Zig like many other languages you can return a struct from a function. But where it gets super interesting is how you can leverage that to create generics Here


fn GoblinHorde(comptime T: type) type {
  return struct {
    pub const Goblin = struct {
      prev: ?*Goblin,
      next: ?*Goblin,
      data: T,
    }
    first: ?*Goblin,
    last: ?*Goblin,
    len: usize,
  }
}

Conclusion

Struct are a fundamental building block of Zig and are used to group related data together in a logical and structured way. Structs can have default values, be packed, have undefined fields, have methods and be returned from functions which results in a generic. Structs are a powerful and flexible way to model data in Zig and are used extensively in the standard library and in many third party libraries.

Wanna show support?

If you find my sporadic thoughts and ramblings helpful.
You can buy me a coffee if you feel like it.
It's not necessary but it's always appreciated. The content will always stay free regardless.