2  Structs, Modules and Control Flow

I introduced a lot of the Zig’s syntax to you in the last chapter, specially at Section 1.2.2 and Section 1.2.3. But we still need to discuss about some other very important elements of the language that you will use constantly on your day-to-day routine.

We begin this chapter by talking about the different keywords and structures in Zig related to control flow (e.g. loops and if statements). Then, we talk about structs and how they can be used to do some basic Object-Oriented (OOP) patterns in Zig. We also talk about type inference, which help us to write less code and achieve the same results. Finally, we end this chapter by discussing modules, and how they relate to structs.

2.1 Control flow

Sometimes, you need to make decisions in your program. Maybe you need to decide wether to execute or not a specific piece of code. Or maybe, you need to apply the same operation over a sequence of values. These kinds of tasks, involve using structures that are capable of changing the “control flow” of our program.

In computer science, the term “control flow” usually refers to the order in which expressions (or commands) are evaluated in a given language or program. But this term is also used to refer to structures that are capable of changing this “evaluation order” of the commands executed by a given language/program.

These structures are better known by a set of terms, such as: loops, if/else statements, switch statements, among others. So, loops and if/else statements are examples of structures that can change the “control flow” of our program. The keywords continue and break are also examples of symbols that can change the order of evaluation, since they can move our program to the next iteration of a loop, or make the loop stop completely.

2.1.1 If/else statements

An if/else statement performs an “conditional flow operation”. A conditional flow control (or choice control) allows you to execute or ignore a certain block of commands based on a logical condition. Many programmers and computer science professionals also use the term “branching” in this case. In essence, we use if/else statements to use the result of a logical test to decide whether or not to execute a given block of commands.

In Zig, we write if/else statements by using the keywords if and else. We start with the if keyword followed by a logical test inside a pair of parentheses, and then, a pair of curly braces with contains the lines of code to be executed in case the logical test returns the value true.

After that, you can optionally add an else statement. Just add the else keyword followed by a pair of curly braces, with the lines of code to executed in case the logical test defined in the if returns false.

In the example below, we are testing if the object x contains a number that is greater than 10. Judging by the output printed to the console, we know that this logical test returned false. Because the output in the console is compatible with the line of code present in the else branch of the if/else statement.

const x = 5;
if (x > 10) {
    try stdout.print(
        "x > 10!\n", .{}
    );
} else {
    try stdout.print(
        "x <= 10!\n", .{}
    );
}
x <= 10!

2.1.2 Swith statements

Switch statements are also available in Zig. A switch statement in Zig have a similar syntax to a switch statement in Rust. As you would expect, to write a switch statement in Zig we use the switch keyword. We provide the value that we want to “switch over” inside a pair of parentheses. Then, we list the possible combinations (or “branchs”) inside a pair of curly braces.

Let’s take a look at the code example below. You can see in this example that, I’m creating an enum type called Role. We talk more about enums at Section 7.6. But in essence, this Role type is listing different types of roles in a fictitious company, like SE for Software Engineer, DE for Data Engineer, PM for Product Manager, etc.

Notice that we are using the value from the role object in the switch statement, to discover which exact area we need to store in the area variable object. Also notice that we are using type inference inside the switch statement, with the dot character, as we described at Section 2.4. This makes the zig compiler infer the correct data type of the values (PM, SE, etc.) for us.

Also notice that, we are grouping multiple values in the same branch of the switch statement. We just separate each possible value with a comma. So, for example, if role contains either DE or DA, the area variable would contain the value "Data & Analytics", instead of "Platform".

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const Role = enum {
    SE, DPE, DE, DA, PM, PO, KS
};

pub fn main() !void {
    var area: []const u8 = undefined;
    const role = Role.SE;
    switch (role) {
        .PM, .SE, .DPE, .PO => {
            area = "Platform";
        },
        .DE, .DA => {
            area = "Data & Analytics";
        },
        .KS => {
            area = "Sales";
        },
    }
    try stdout.print("{s}\n", .{area});
}
Platform

Now, one very important aspect about this switch statement presented in the code example above, is that it exhaust all existing possibilities. In other words, all possible values that could be found inside the order object are explicitly handled in this switch statement.

Since the role object have type Role, the only possible values to be found inside this object are PM, SE, DPE, PO, DE, DA and KS. There is no other possible value to be stored in this role object. This what “exhaust all existing possibilities” means. The switch statement covers every possible case.

In Zig, switch statements must exhaust all existing possibilities. You cannot write a switch statement, and leave an edge case with no expliciting action to be taken. This is a similar behaviour to switch statements in Rust, which also have to handle all possible cases.

Take a look at the dump_hex_fallible() function below as an example. This function also comes from the Zig Standard Library, but this time, it comes from the debug.zig module1. There are multiple lines in this function, but I omitted them to focus solely on the switch statement found in this function. Notice that this switch statement have four possible cases, or four explicit branches. Also, notice that we used an else branch in this case. Whenever you have multiple possible cases in your switch statement which you want to apply the same exact action, you can use an else branch to do that.

pub fn dump_hex_fallible(bytes: []const u8) !void {
    // Many lines ...
    switch (byte) {
        '\n' => try writer.writeAll("␊"),
        '\r' => try writer.writeAll("␍"),
        '\t' => try writer.writeAll("␉"),
        else => try writer.writeByte('.'),
    }
}

Many users would also use an else branch to handle a “not supported” case. That is, a case that cannot be properly handled by your code, or, just a case that should not be “fixed”. So many programmers use an else branch to panic (or raise an error) to stop the current execution.

Take the code example below as an example. We can see that, we are handling the cases for the level object being either 1, 2, or 3. All other possible cases are not supported by default, and, as consequence, we raise an runtime error in these cases, through the @panic() built-in function.

Also notice that, we are assigning the result of the switch statement to a new object called category. This is another thing that you can do with switch statements in Zig. If the branchs in this switch statement output some value as result, you can store the result value of the switch statement into a new object.

const level: u8 = 4;
const category = switch (level) {
    1, 2 => "beginner",
    3 => "professional",
    else => {
        @panic("Not supported level!");
    },
};
try stdout.print("{s}\n", .{category});
thread 13103 panic: Not supported level!
t.zig:9:13: 0x1033c58 in main (switch2)
            @panic("Not supported level!");
            ^

Furthermore, you can also use ranges of values in switch statements. That is, you can create a branch in your switch statement that is used whenever the input value is contained in a range. These range expressions are created with the operator .... Is important to emphasize that the ranges created by this operator are inclusive on both ends.

For example, I could easily change the code example above to support all levels between 0 and 100. Like this:

const level: u8 = 4;
const category = switch (level) {
    0...25 => "beginner",
    26...75 => "intermediary",
    76...100 => "professional",
    else => {
        @panic("Not supported level!");
    },
};
try stdout.print("{s}\n", .{category});
beginner

This is neat, and it works with character ranges too. That is, I could simply write 'a'...'z', to match any character value that is a lowercase letter, and it would work fine.

2.1.3 The defer keyword

With the defer keyword you can execute expressions at the end of the current scope. Take the foo() function below as an example. When we execute this function, the expression that prints the message “Exiting function …” get’s executed only at the end of the function scope.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
fn foo() !void {
    defer std.debug.print(
        "Exiting function ...\n", .{}
    );
    try stdout.print("Adding some numbers ...\n", .{});
    const x = 2 + 2; _ = x;
    try stdout.print("Multiplying ...\n", .{});
    const y = 2 * 8; _ = y;
}

pub fn main() !void {
    try foo();
}
Adding some numbers ...
Multiplying ...
Exiting function ...

It doesn’t matter how the function exits (i.e. because of an error, or, because of an return statement, or whatever), just remember, this expression get’s executed when the function exits.

2.1.4 For loops

A loop allows you to execute the same lines of code multiple times, thus, creating a “repetition space” in the execution flow of your program. Loops are particularly useful when we want to replicate the same function (or the same set of commands) over several different inputs.

There are different types of loops available in Zig. But the most essential of them all is probably the for loop. A for loop is used to apply the same piece of code over the elements of a slice or an array.

For loops in Zig have a slightly different syntax that you are probably used to see in other languages. You start with the for keyword, then, you list the items that you want to iterate over inside a pair of parentheses. Then, inside of a pair of pipes (|) you should declare an identifier that will serve as your iterator, or, the “repetition index of the loop”.

for (items) |value| {
    // code to execute
}

Instead of using a (value in items) syntax, in Zig, for loops use the syntax (items) |value|. In the example below, you can see that we are looping through the items of the array stored at the object name, and printing to the console the decimal representation of each character in this array.

If we wanted, we could also iterate through a slice (or a portion) of the array, instead of iterating through the entire array stored in the name object. Just use a range selector to select the section you want. For example, I could provide the expression name[0..3] to the for loop, to iterate just through the first 3 elements in the array.

const name = [_]u8{'P','e','d','r','o'};
for (name) |char| {
    try stdout.print("{d} | ", .{char});
}
80 | 101 | 100 | 114 | 111 | 

In the above example we are using the value itself of each element in the array as our iterator. But there are many situations where we need to use an index instead of the actual values of the items.

You can do that by providing a second set of items to iterate over. More precisely, you provide the range selector 0.. to the for loop. So, yes, you can use two different iterators at the same time in a for loop in Zig.

But remember from Section 1.4 that, every object you create in Zig must be used in some way. So if you declare two iterators in your for loop, you must use both iterators inside the for loop body. But if you want to use just the index iterator, and not use the “value iterator”, then, you can discard the value iterator by maching the value items to the underscore character, like in the example below:

for (name, 0..) |_, i| {
    try stdout.print("{d} | ", .{i});
}
0 | 1 | 2 | 3 | 4 |

2.1.5 While loops

A while loop is created from the while keyword. A for loop iterates through the items of an array, but a while loop will loop continuously, and infinitely, until a logical test (specified by you) becomes false.

You start with the while keyword, then, you define a logical expression inside a pair of parentheses, and the body of the loop is provided inside a pair of curly braces, like in the example below:

var i: u8 = 1;
while (i < 5) {
    try stdout.print("{d} | ", .{i});
    i += 1;
}
1 | 2 | 3 | 4 | 

2.1.6 Using break and continue

In Zig, you can explicitly stop the execution of a loop, or, jump to the next iteration of the loop, using the keywords break and continue, respectively. The while loop present in the example below, is at first sight, an infinite loop. Because the logical value inside the parenthese will always be equal to true. What makes this while loop stop when the i object reaches the count 10? Is the break keyword!

Inside the while loop, we have an if statement that is constantly checking if the i variable is equal to 10. Since we are increasing the value of this i variable at each iteration of the while loop. At some point, this i variable will be equal to 10, and when it does, the if statement will execute the break expression, and, as a result, the execution of the while loop is stopped.

Notice the expect() function from the Zig standard library after the while loop. This expect() function is an “assert” type of function. This function checks if the logical test provided is equal to true. If this logical test is false, the function raises an assertion error. But it is equal to true, then, the function will do nothing.

var i: usize = 0;
while (true) {
    if (i == 10) {
        break;
    }
    i += 1;
}
try std.testing.expect(i == 10);
try stdout.print("Everything worked!", .{});
Everything worked!

Since this code example was executed succesfully by the zig compiler, without raising any errors, then, we known that, after the execution of while loop, the i variable is equal to 10. Because if it wasn’t equal to 10, then, an error would be raised by expect().

Now, in the next example, we have an use case for the continue keyword. The if statement is constantly checking if the current index is a multiple of 2. If it is, then we jump to the next iteration of the loop directly. But it the current index is not a multiple of 2, then, the loop will simply print this index to the console.

const ns = [_]u8{1,2,3,4,5,6};
for (ns) |i| {
    if ((i % 2) == 0) {
        continue;
    }
    try stdout.print("{d} | ", .{i});
}
1 | 3 | 5 | 

2.2 Function parameters are immutable

We have already discussed a lot of the syntax behind function declarations at Section 1.2.2 and Section 1.2.3. But I want to emphasize a curious fact about function parameters (a.k.a. function arguments) in Zig. In summary, function parameters are immutable in Zig.

Take the code example below, where we declare a simple function that just tries to add some amount to the input integer, and returns the result back. But if you look closely at the body of this add2() function, you will notice that we try to save the result back into the x function argument.

In other words, this function not only use the value that it received through the function argument x, but it also tries to change the value of this function argument, by assigning the addition result into x. However, function arguments in Zig are immutable. You cannot change their values, or, you cannot assign values to them inside the body’s function.

This is the reason why, the code example below do not compile successfully. If you try to compile this code example, you get a compile error warning you that you are trying to change the value of a immutable (i.e. constant) object.

const std = @import("std");
fn add2(x: u32) u32 {
    x = x + 2;
    return x;
}

pub fn main() !void {
    const y = add2(4);
    std.debug.print("{d}\n", .{y});
}
t.zig:3:5: error: cannot assign to constant
    x = x + 2;
    ^

If a function argument receives as input a object whose data type is any of the primitive types that we have listed at Section 1.5 this object is always passed by value to the function. In other words, this object is copied to the function stack frame.

However, if the input object have a more complex data type, for example, it might be a struct instance, or an array, or a union, etc., in cases like that, the zig compiler will take the liberty of deciding for you which strategy is best. The zig compiler will pass your object to the function either by value, or by reference. The compiler will always choose the strategy that is faster for you. This optimization that you get for free is possible only because function arguments are immutable in Zig.

To overcome this barrier, we need to take the lead, and explicitly choose to pass the object by reference. That is, instead of depending on the zig compiler to decide for us, we need to explicitly mark the function argument as a pointer. This way, we are telling the compiler that this function argument will be passed by reference to the function.

By making it a pointer, we can finally use and alter directly the value of this function argument inside the body of the add2() function. You can see that the code example below compiles successfully.

const std = @import("std");
fn add2(x: *u32) void {
    const d: u32 = 2;
    x.* = x.* + d;
}

pub fn main() !void {
    var x: u32 = 4;
    add2(&x);
    std.debug.print("Result: {d}\n", .{x});
}
Result: 6

2.3 Structs and OOP

Zig is a language more closely related to C (which is a procedural language), than it is to C++ or Java (which are object-oriented languages). Because of that, you do not have advanced OOP (Object-Oriented Programming) patterns available in Zig, such as classes, interfaces or class inheritance. Nonetheless, OOP in Zig is still possible by using struct definitions.

With struct definitions, you can create (or define) a new data type in Zig. These struct definitions work the same way as they work in C. You give a name to this new struct (or, to this new data type you are creating), then, you list the data members of this new struct. You can also register functions inside this struct, and they become the methods of this particular struct (or data type), so that, every object that you create with this new type, will always have these methods available and associated with them.

In C++, when we create a new class, we normally have a constructor method (or, a constructor function) to construct or to instantiate every object of this particular class, and you also have a destructor method (or a destructor function) that is the function responsible for destroying every object of this class.

In Zig, we normally declare the constructor and the destructor methods of our structs, by declaring an init() and a deinit() methods inside the struct. This is just a naming convention that you will find across the entire Zig standard library. So, in Zig, the init() method of a struct is normally the constructor method of the class represented by this struct. While the deinit() method is the method used for destroying an existing instance of that struct.

The init() and deinit() methods are both used extensively in Zig code, and you will see both of them being used when we talk about allocators at Section 3.2. But, as another example, let’s build a simple User struct to represent an user of some sort of system. If you look at the User struct below, you can see the struct keyword, and inside of a pair of curly braces, we write the struct’s body.

Notice the data members of this struct, id, name and email. Every data member have it’s type explicitly annotated, with the colon character (:) syntax that we described earlier at Section 1.2.2. But also notice that every line in the struct body that describes a data member, ends with a comma character (,). So every time you declare a data member in your Zig code, always end the line with a comma character, instead of ending it with the traditional semicolon character (;).

Next, also notice in this example, that we registrated an init() function as a method of this User struct. This init() method is the constructor method that you use to instantiate every new User object. That is why this init() function return an User object as result.

const std = @import("std");
const stdout = std.io.getStdOut().writer();
const User = struct {
    id: u64,
    name: []const u8,
    email: []const u8,

    pub fn init(id: u64,
                name: []const u8,
                email: []const u8) User {

        return User {
            .id = id,
            .name = name,
            .email = email
        };
    }

    pub fn print_name(self: User) !void {
        try stdout.print("{s}\n", .{self.name});
    }
};

pub fn main() !void {
    const u = User.init(1, "pedro", "email@gmail.com");
    try u.print_name();
}
pedro

The pub keyword plays an important role in struct declarations, and OOP in Zig. Every method that you declare in your struct that is marked with the keyword pub, becomes a public method of this particular struct.

So every method that you create in your struct, is, at first, a private method of that struct. Meaning that, this method can only be called from within this struct. But, if you mark this method as public, with the keyword pub, then, you can call the method directly from the User object you have in your code.

In other words, the functions marked by the keyword pub are members of the public API of that struct. For example, if I did not marked the print_name() method as public, then, I could not execute the line u.print_name(). Because I would not be authorized to call this method directly in my code.

2.3.1 Anonymous struct literals

You can declare a struct object as a literal value. When we do that, we normally specify the data type of this struct literal by writing it’s data type just before the opening curly braces. For example, I could write a struct literal of type User that we defined in the previous section like this:

const eu = User {
    .id = 1,
    .name = "Pedro",
    .email = "someemail@gmail.com"
};
_ = eu;

However, in Zig, we can also write an anonymous struct literal. That is, you can write a struct literal, but not especify explicitly the type of this particular struct. An anonymous struct is written by using the syntax .{}. So, we essentially replaced the explicit type of the struct literal with a dot character (.).

As we described at Section 2.4, when you put a dot before a struct literal, the type of this struct literal is automatically inferred by the zig compiler. In essence, the zig compiler will look for some hint of what is the type of that struct. It can be the type annotation of an function argument, or the return type annotation of the function that you are using, or the type annotation of a variable. If the compiler do find such type annotation, then, it will use this type in your literal struct.

Anonymous structs are very commom to use in function arguments in Zig. One example that you have seen already constantly, is the print() function from the stdout object. This function takes two arguments. The first argument, is a template string, which should contain string format specifiers in it, which tells how the values provided in the second argument should be printed into the message.

While the second argument is a struct literal that lists the values to be printed into the template message specified in the first argument. You normally want to use an anonymous struct literal here, so that, the zig compiler do the job of specifying the type of this particular anonymous struct for you.

const std = @import("std");
pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    try stdout.print("Hello, {s}!\n", .{"world"});
}
Hello, world!

2.3.2 Struct declarations must be constant

Types in Zig must be const or comptime (we are going to talk more about comptime at Section 12.1). What this means is that you cannot create a new data type, and mark it as variable with the var keyword. So struct declarations are always constant. You cannot declare a new struct using the var keyword. It must be const.

In the Vec3 example below, this declaration is allowed because I’m using the const keyword to declare this new data type.

const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,
};

2.3.3 The self method argument

In every language that have OOP, when we declare a method of some class or struct, we usually declare this method as a function that have a self argument. This self argument is the reference to the object itself from which the method is being called from.

Is not mandatory to use this self argument. But why would you not use this self argument? There is no reason to not use it. Because the only way to get access to the data stored in the data members of your struct is to access them through this self argument. If you don’t need to use the data in the data members of your struct inside your method, then, you very likely don’t need a method, you can just simply declare this logic as a simple function, outside of your struct declaration.

Take the Vec3 struct below. Inside this Vec3 struct we declared a method named distance(). This method calculates the distance between two Vec3 objects, by following the distance formula in euclidean space. Notice that this distance() method takes two Vec3 objects as input, self and other.

const std = @import("std");
const m = std.math;
const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,

    pub fn distance(self: Vec3, other: Vec3) f64 {
        const xd = m.pow(f64, self.x - other.x, 2.0);
        const yd = m.pow(f64, self.y - other.y, 2.0);
        const zd = m.pow(f64, self.z - other.z, 2.0);
        return m.sqrt(xd + yd + zd);
    }
};

The self argument corresponds to the Vec3 object from which this distance() method is being called from. While the other is a separate Vec3 object that is given as input to this method. In the example below, the self argument corresponds to the object v1, because the distance() method is being called from the v1 object, while the other argument corresponds to the object v2.

const v1 = Vec3 {
    .x = 4.2, .y = 2.4, .z = 0.9
};
const v2 = Vec3 {
    .x = 5.1, .y = 5.6, .z = 1.6
};

std.debug.print(
    "Distance: {d}\n",
    .{v1.distance(v2)}
);
Distance: 3.3970575502926055

2.3.4 About the struct state

Sometimes you don’t need to care about the state of your struct object. Sometimes, you just need to instantiate and use the objects, without altering their state. You can notice that when you have methods inside your struct declaration that might use the values that are present in the data members, but they do not alter the values in the data members of the struct in anyway.

The Vec3 struct that was presented at Section 2.3.3 is an example of that. This struct have a single method named distance(), and this method do use the values present in all three data members of the struct (x, y and z). But at the same time, this method do not change the values of these data members in any point.

As a result of that, when we create Vec3 objects we usually create them as constant objects, like the v1 and v2 objects presented at Section 2.3.3. We can create them as variable objects with the var keyword, if we want to. But because the methods of this Vec3 struct do not change the state of the objects in any point, is unnecessary to mark them as variable objects.

But why? Why am I talkin about this here? Is because the self argument in the methods is affected depending on whether the methods present in a struct change or not the state of the object itself. More specifically, when you have a method in a struct that changes the state of the object (i.e. change the value of a data member), the self argument in this method must be annotated in a different manner.

As I described at Section 2.3.3, the self argument in methods of a struct is the argument that receives as input the object from which the method was called from. We usually annotate this argument in the methods by writing self, followed by the colon character (:), and the data type of the struct to which the method belongs to (e.g. User, Vec3, etc.).

If we take the Vec3 struct that we defined in the previous section as an example, we can see in the distance() method that this self argument is annotated as self: Vec3. Because the state of the Vec3 object is never altered by this method.

But what if we do have a method that alters the state of the object, by altering the values of it’s data members. How should we annotate self in this instance? The answer is: “we should annotate self as a pointer of x, instead of just x”. In other words, you should annotate self as self: *x, instead of annotating it as self: x.

If we create a new method inside the Vec3 object that, for example, expands the vector by multiplying it’s coordinates by a factor o two, then, we need to follow this rule specified in the previous paragraph. The code example below demonstrates this idea:

const std = @import("std");
const m = std.math;
const Vec3 = struct {
    x: f64,
    y: f64,
    z: f64,

    pub fn distance(self: Vec3, other: Vec3) f64 {
        const xd = m.pow(f64, self.x - other.x, 2.0);
        const yd = m.pow(f64, self.y - other.y, 2.0);
        const zd = m.pow(f64, self.z - other.z, 2.0);
        return m.sqrt(xd + yd + zd);
    }

    pub fn double(self: *Vec3) void {
        self.x = self.x * 2.0;
        self.y = self.y * 2.0;
        self.z = self.z * 2.0;
    }
};

Notice in the code example above that we have added a new method to our Vec3 struct named double(). This method essentially doubles the coordinate values of our vector object. Also notice that, in the case of the double() method, we annotated the self argument as *Vec3, indicating that this argument receives a pointer (or a reference, if you prefer to call it this way) to a Vec3 object as input.

var v3 = Vec3 {
    .x = 4.2, .y = 2.4, .z = 0.9
};
v3.double();
std.debug.print("Doubled: {d}\n", .{v3.x});
Doubled: 8.4

Now, if you change the self argument in this double() method to self: Vec3, like in the distance() method, you will get the compiler error exposed below as result. Notice that this error message is indicating a line from the double() method body, indicating that you cannot alter the value of the x data member.

// If we change the function signature of double to:
    pub fn double(self: Vec3) void {

This error message indicates that the x data member belongs to a constant object, and, because of that, it cannot be changed. Ultimately, this error message is telling us that the self argument is constant.

t.zig:16:13: error: cannot assign to constant
        self.x = self.x * 2.0;
        ~~~~^~

If you take some time, and think hard about this error message, you will understand it. You already have the tools to understand why we are getting this error message. We have talked about it already at Section 2.2. So remember, every function argument is immutable in Zig, and self is included in this rule.

It does not matter if the object that you pass as input to the function argument is a variable object or not. In this example, we marked the v3 object as a variable object. But this does not matter. Because it is not about the input object, it is about the function argument.

The problem begins when we try to alter the value of self directly, which is a function argument, and, every function argument is immutable by default. You may quest yourself how can we overcome this barrier, and once again, the solution was also discussed at Section 2.2. We overcome this barrier, by explicitly marking the self argument as a pointer.

Note

If a method of your x struct alters the state of the object, by changing the value of any data member, then, remember to use self: *x, instead of self: x in the function signature of this method.

You could also interpret the content discussed in this section as: “if you need to alter the state of your x struct object in one of it’s methods, you must explicitly pass the x struct object by reference to the self argument of this method”.

2.4 Type inference

Zig is kind of a strongly typed language. I say “kind of” because there are situations where you don’t have to explicitly write the type of every single object in your source code, as you would expect from a traditional strongly typed language, such as C and C++.

In some situations, the zig compiler can use type inference to solves the data types for you, easing some of the burden that you carry as a developer. The most commom way this happens is through function arguments that receives struct objects as input.

In general, type inference in Zig is done by using the dot character (.). Everytime you see a dot character written before a struct literal, or before a enum value, or something like that, you know that this dot character is playing a special party in this place. More specifically, it is telling the zig compiler something on the lines of: “Hey! Can you infer the type of this value for me? Please!”. In other words, this dot character is playing a role similar to the auto keyword in C++.

I give you some examples of this at Section 2.3.1, where we present anonymous struct literals. Anonymous struct literals are, essentially, struct literals that use type inference to infer the exact type of this particular struct literal. This type inference is done by looking for some minimal hint of the correct data type to be used. You could say that the zig compiler looks for any neighbouring type annotation that might tell him what would be the correct type.

Another commom place where we use type inference in Zig is at switch statements (which we talk about at Section 2.1.2). So I also gave some other examples of type inference at Section 2.1.2, where we were inferring the data types of enum values listed inside of switch statements (e.g. .DE). But as another example, take a look at this fence() function reproduced below, which comes from the atomic.zig module2 of the Zig Standard Library.

There are a lot of things in this function that we haven’t talked about yet, such as: what comptime means? inline? extern? What is this star symbol before Self? Let’s just ignore all of these things, and focus solely on the switch statement that is inside this function.

We can see that this switch statement uses the order object as input. This order object is one of the inputs of this fence() function, and we can see in the type annotation, that this object is of type AtomicOrder. We can also see a bunch of values inside the switch statements that begins with a dot character, such as .release and .acquire.

Because these weird values contain a dot character before them, we are asking the zig compiler to infer the types of these values inside the switch statement. Then, the zig compiler is looking into the current context where these values are being used, and it is trying to infer the types of these values.

Since they are being used inside a switch statement, the zig compiler looks into the type of the input object given to the switch statement, which is the order object in this case. Because this object have type AtomicOrder, the zig compiler infers that these values are data members from this type AtomicOrder.

pub inline fn fence(self: *Self, comptime order: AtomicOrder) void {
    // LLVM's ThreadSanitizer doesn't support the normal fences so we specialize for it.
    if (builtin.sanitize_thread) {
        const tsan = struct {
            extern "c" fn __tsan_acquire(addr: *anyopaque) void;
            extern "c" fn __tsan_release(addr: *anyopaque) void;
        };

        const addr: *anyopaque = self;
        return switch (order) {
            .unordered, .monotonic => @compileError(@tagName(order) ++ " only applies to atomic loads and stores"),
            .acquire => tsan.__tsan_acquire(addr),
            .release => tsan.__tsan_release(addr),
            .acq_rel, .seq_cst => {
                tsan.__tsan_acquire(addr);
                tsan.__tsan_release(addr);
            },
        };
    }

    return @fence(order);
}

This is how basic type inference is done in Zig. If we didn’t use the dot character before the values inside this switch statement, then, we would be forced to write explicitly the data types of these values. For example, instead of writing .release we would have to write AtomicOrder.release. We would have to do this for every single value in this switch statement, and this is a lot of work. That is why type inference is commonly used on switch statements in Zig.

2.5 Modules

We already talked about what modules are, and also, how to import other modules into you current module through import statements, so that you can use functionality from these other modules in your current module. But in this section, I just want to make it clear that modules are actually structs in Zig.

In other words, every Zig module (i.e. a .zig file) that you write in your project is internally stored as a struct object. Take the line exposed below as an example. In this line we are importing the Zig Standard Library into our current module.

const std = @import("std");

When we want to access the functions and objects from the standard library, we are basically accessing the data members of the struct stored in the std object. That is why we use the same syntax that we use in normal structs, with the dot operator (.) to access the data members and methods of the struct.

When this “import statement” get’s executed, the result of this expression is a struct object that contains the Zig Standard Library modules, global variables, functions, etc. And this struct object get’s saved (or stored) inside the constant object named std.

Take the thread_pool.zig module from the project zap3 as an example. This module is written as if it was a big struct. That is why we have a top-level and public init() method written in this module. The idea is that all top-level functions written in this module are methods from the struct, and all top-level objects and struct declarations are data members of this struct. The module is the struct itself.

So you would import and use this module by doing something like this:

const std = @import("std");
const ThreadPool = @import("thread_pool.zig");
const num_cpus = std.Thread.getCpuCount()
    catch @panic("failed to get cpu core count");
const num_threads = std.math.cast(u16, num_cpus)
    catch std.math.maxInt(u16);
const pool = ThreadPool.init(
    .{ .max_threads = num_threads }
);

  1. https://github.com/ziglang/zig/blob/master/lib/std/debug.zig↩︎

  2. https://github.com/ziglang/zig/blob/master/lib/std/atomic.zig.↩︎

  3. https://github.com/kprotty/zap/blob/blog/src/thread_pool.zig↩︎