2023-01-13: Zig Basics - Part 1

This is the second set of notes covering the Zig programming language following from Zig - First Steps.

This note uses the following version of Zig on Windows 10:

> zig version
0.10.0

Expressions and Output

This section covers the output of information using a print statement, as well as simple expressions and storing values in variables.

Printing Program Output

One of the simplest programs to run is the classic "hello world". Here is the version we will start with: in Zig, there are various ways of printing some text to the terminal - this is just one.

const print = @import("std").debug.print;   // <1>

pub fn main() void {                        // <2>
  print("hello", .{});                      // <3>
}
  1. Imports the print function that we will be using from the standard library.
  2. Creates a public main function, which is where our program starts. The return type is void, meaning the function does not return anything.
  3. Print a simple greeting.

Save this text into a file called "hello1.zig" and run from the command line, using:

> zig run hello1.zig
hello

NOTE: If you don't understand them yet, you can ignore the first two lines: the comments given for each line should be enough to indicate their purpose, but we'll look more into each later.

The print function takes two arguments: the first is "hello", the text that ends up being printed. The second is .{}, what does this mean? This is a container (technically, an "anonymous struct literal") for things that we want to print. Consider this second example:

const print = @import("std").debug.print;

pub fn main() void {
    print("hello {s}", .{"world"});
}

And see what happens when you run it:

> zig run hello2.zig
hello world

The first argument now has {s} in it - this does not get printed, instead, the text "world" is substituted in before the string is printed. This text is in the container: .{"world"}.

We can put arbitrary expressions into the container, and have them printed out:

const print = @import("std").debug.print;

pub fn main() void {
    print("hello {s} \na number {d} \na character {c} \nan expression {d} \nmathematical number {e}", 
          .{ "world", 3, 'z', (4.0 + 5.0) / 2.0, 15_000_000.0 });
}

When run:

> zig run hello3.zig
hello world
a number 3
a character z
an expression 4.5
mathematical number 1.5e+07

Notice that:

Zig supports different types of values, as we'll find as we go deeper into the language.

Expressions

In the previous example, we casually used the expression (4.0 + 5.0) / 2.0.

Zig supports the usual range of mathematical operators. I won't go over them here, but will describe any "surprises" as they come up. For reference, a complete table of operators and precedence is in the language reference: https://ziglang.org/documentation/master/#Operators

Variables

A variable is a name we give a value: values are stored somewhere in computer memory, so variables are names given to some place in memory.

Zig supports two kinds of variables: those that do not change, and those that do. Non-changing variables are "constants", referred to as const, and the changing variables are "variables", referred to as var.

A simple example:

const print = @import("std").debug.print;         // <1>

pub fn main() void {
    const x = 3;                                  // <2>
    const y = 4;
    const z = x + y;                              // <3>
    print("{d} + {d} = {d}\n", .{x, y, z});
}
  1. Notice that print is an example of a constant variable!
  2. Create the constant x referring to the value 3.
  3. Use the names to retrieve two values, add them, and store the result in a new constant.

What happens if we try to change a const?

const print = @import("std").debug.print;

pub fn main() void {
    const x = 4;
    x = x + 1;
}

When run:

> zig run .\variables-2.zig
variables-2.zig:5:5: error: cannot assign to constant
    x = x + 1;
    ^

Zig complains, and tells us the reason.

However, changing the const to var does not make Zig happy:

> zig run .\variables-2.zig
variables-2.zig:4:9: error: variable of type 'comptime_int' must be const or comptime
    var x = 4;
        ^
variables-2.zig:4:9: note: to modify this variable at runtime, it must be given an explicit fixed-size number type

Zig requires us to define the "type" of a variable which we will be changing. There's a lot that can be said about types, but for now we will introduce the following:

The type declaration is placed after the variable name:

const print = @import("std").debug.print;

pub fn main() void {
    var x: u32 = 4;                 // <1>
    x = x + 1;                      // <2>
}
  1. Declare "x" to be a var of type u32.
  2. We can now update the value of "x".

Blocks, Conditionals and Loops

Here, we will look at the basic syntax that Zig provides for conditions and loops, and how these depend upon blocks. Zig supports some additional uses of these expressions, such as when used with optional types, but here we will stay with the basics.

Blocks

Blocks are parts of code delimited with curly brackets, the parts within { and } which you will find throughout Zig programs.

Some syntax, such as if and switch expressions, use blocks to delimit groups of statements which are associated with each other. We need to understand blocks in order to understand how such syntax works.

There are two main things to understand about blocks:

Scope and Shadowing

The scope of a variable is the part of your program within which it can be used. We can limit a variable's scope using blocks. Zig does not permit shadowing, this is where we try to make a new variable definition using a variable name that is still in scope.

The following program introduces a new block. Notice that variables defined in the inner block cannot be used outside of it, and, although we can use a variable name from the outer block, we cannot redefine it.

const print = @import("std").debug.print;

pub fn main() void {                                      // <1>
    var i: u32 = 13;                                      // <2>

    {                                                     // <3>
        const j = 2;                                      // <4>

        print("inner-block: i is value {d}\n", .{i});     // <5>
        i = i + 1;
        print("inner-block: j is value {d}\n", .{j});
    }

    print("outer-block: i is value {d}\n", .{i});         // <6>
}
  1. Opens the block for the function definition.
  2. i is defined within this outer block.
  3. Opens a new "inner" block, within the outer one.
  4. j is defined within this inner block.
  5. i is accessible within the inner block.
  6. Changes to i are reflected in the outer block.

Labelled

Blocks are expressions too! This means return values from blocks can be used, e.g., in variable definitions. To return a value from a block we use the break statement: to specify which block we are returning from, we label the block.

const print = @import("std").debug.print;

pub fn main() void {
    const i = 13;

    const j = blk: {                      // <1>
        const z = 2;
        break :blk i + z;                 // <2>
    };

    print("j is value {d}\n", .{j});
}
  1. Starts a labelled block whose value will be used to initialise j.
  2. Returns a value from this labelled block.

Conditional Expressions

Conditional expressions are used to select a block of code to execute.

if expressions

The if expression uses a boolean value to decide whether to execute its then-block or its optional else-block.

const print = @import("std").debug.print;

pub fn main() void {
    const i = 10;

    if (i < 10) {                                     // <1>
        print("i is {d} and is less than 10\n", .{i});
    }

    if (i % 2 == 1) {                                 // <2>
        print("i is {d} and is an odd number\n", .{i});
    } else {
        print("i is {d} and is an even number\n", .{i});
    }

    const value = if (i < 10) 1 else 10;              // <3>
    print("Value is {d}\n", .{value});
}
  1. Simple if statement, without an else-block: notice in this case, nothing will be evaulated.
  2. Again, a simple if statement, using an else-block.
  3. The if expression can return a value, and so be used in a variable declaration.

Rerun the above program with a different value of i, such as 3, and see the difference.

switch expressions

Switch expressions are multi-way conditional expressions, which select the next block to evaluate based on the value of a given expression.

We can replicate if expressions using switch: in this case, the expression value to switch on is a boolean, and the blocks will be selected if the value is true or false:

const print = @import("std").debug.print;

pub fn main() void {
    const i = 10;

    switch (i < 10) {
        true => print("i is {d} and is less than 10\n", .{i}),
        false => print("i is {d} and is not less than 10\n", .{i}),
    }
}

In the above example, the expression i < 10 is evaluated. Its value will be true or false. The conditional blocks are each specified in a "value" => "action" format: in this case, there are two possible values, true and false, and the actions are print statements. Note that each condition is ended with a comma.

Switch expressions return a value:

const print = @import("std").debug.print;

pub fn main() void {
    const i = 34;

    const msg = switch (i) {              // <1>
        1, 3, 5, 7, 9 => "odd",           // <2>
        2, 4, 6, 8, 10 => "even",
        else => "large",
    };

    print("{d} is {s}\n", .{ i, msg });
}
  1. The switch expression can be used to determine a value in a variable assignment.
  2. The given value is returned from the switch expression.

More complex switch expressions will cover larger groups of values. A branch can be chosen by listing several values together or using a range, for numeric values. Any un-defined values can be collected into an else branch. Finally, actions can be specified in a block, so more than one statement can be executed - returning values from this block requires the block to be labelled, as we saw above.

const print = @import("std").debug.print;

pub fn main() void {
    const i = 13;

    const msg = switch (i) {
        1...10 => "small number",                         // <1>
        11, 13, 17, 19 => "mid-level prime",              // <2>
        else => blk: {                                    // <3>
            const is_even = (i % 2 == 0);
            if (is_even) {
                break :blk "this large number is even";   // <4>
            } else {
                break :blk "this large number is odd";
            }
        },
    };

    print("{d} is {s}\n", .{ i, msg });
}
  1. Check if value is within a range (inclusive of end-points).
  2. Check if value is within a listed set of values.
  3. Note the labelled block, blk:
  4. We use break to return a value from the labelled block :blk.

There is more that can be said about switch statements - especially in regard to enum and union types - which will be covered in a later note.

Loops

Loops are used to repeat blocks of code, many times. The main looping construct in Zig is the while loop.

(What happened to for? Yes, Zig does have a for loop, but it is used only for looping over containers, which will be covered in a later note.)

Simple while loop

The simplest while loop takes a "condition" and a "block", evaluating the block of code while the condition remains true.

For example, to print all numbers from a given value up to a limit:

const print = @import("std").debug.print;

pub fn main() void {
    var i: u32 = 1;

    while (i <= 10) {                     // <1>
        print("i is {d}\n", .{i});        // <2>
        i = i + 1;                        // <3>
    }
}
  1. The condition is that i is less than or equal to 10.
  2. This is what we do on each iteration through the loop - print the value of i.
  3. We need to update i on each iteration, so ultimately the loop condition will be false.

This example prints the numbers from 1 to 10.

With an update

The pattern of testing a condition on a variable, and updating the variable on each iteration through the loop, is very common, so Zig provides a special syntax to make the update step clearer:

const print = @import("std").debug.print;

pub fn main() void {
    var i: u32 = 1;

    while (i <= 10) : (i = i + 1) {       // <1>
        print("i is {d}\n", .{i});        // <2>
    }
}
  1. The condition is that i is less than or equal to 10, and the update step increments i.
  2. This is what we do on each iteration through the loop - print the value of i.

This does the same as the previous example: print every number from 1 to 10.

Use of break and continue

Two keywords are used within loops:

The following example is the same as above, except it skips the number 1 and stops at 7:

const print = @import("std").debug.print;

pub fn main() void {
    var i: u32 = 1;

    while (i <= 10) : (i = i + 1) {
        if (i == 1) {                     // <1>
            continue;
        }
        if (i == 7) {                     // <2>
            break;
        }
        print("i is {d}\n", .{i});
    }
}
  1. Skips the rest of the loop body if i is 1.
  2. Breaks out of the loop if i is 7.

When run, this version only prints the numbers from 2 to 6.

Labelled loops

The while loop can be labelled, enabling break and continue to identify which particular while loop they are referencing. As break can return a value, this also enables while loops to be used as expressions, returning values. To ensure a loop returns a value, an optional else clause can be added to the end of such a loop.

The following program tries to illustrate this with a nested while loop. It tries to see if there exist two numbers from 1 to 100 which multiply together to reach a given target value: if they do, the loop should exit with true, or otherwise the value is false.

const print = @import("std").debug.print;

pub fn main() void {
    const target = 1000000; // change to e.g. 90 to get a 'true' result

    var i: u32 = 1;
    const answer = blk: while (i <= 100) : (i = i + 1) {      // <1>
        var j: u32 = 1;
        while (j <= 100) : (j = j + 1) {
            if (i * j == target) {
                break :blk true;                              // <2>
            }
        }
    } else false;                                             // <3>

    print("Answer is {}\n", .{answer});                       // <4>
}
  1. The blk: label is assigned to the outer while.
  2. If the end condition is reached, break out of the outer loop, returning value true.
  3. In case the loop did not finish before, return a default value of false.
  4. Note the string substitution is merely {} as answer is a boolean.

Functions

Functions are an important organisational tool for programs. Functions encapsulate pieces of computation together so they can be used as a unit elsewhere in the program.

For the simplest uses, functions are straightforward:

pub fn main() void {
  // DO SOMETHING
}

The signature of the function states that this is a public function, name of "main", it takes no parameters and returns nothing (return type is void).

pub fn add_one(n: u64) u64 {
  return n + 1;
}

This function takes a single parameter, n, of type u64, and returns a u64.

There is a lot more to say when you consider interfacing with C, compile time operations, or reflection, but those are topics for a distant future.

Error Handling

In the examples above, we used the print function from debug. This function silently fails on any errors. What happens if we use the print function on stdout?

Compare:

const print = @import("std").debug.print;

pub fn main() void {
    print("hello", .{});
}

with:

const std = @import("std");

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

Ignoring the slightly longer path to the print function, two main differences stand out:

  1. The return type from main becomes !void instead of just void.
  2. The line calling the print statement is prefixed with try.

The reason for this is that stdout.print can raise an error. How can we deal with errors in Zig? (This section is kept as simple as possible to handle the final example program.)

Catching Errors

Errors can be caught, using catch:

  stdout.print("Hello in Stdout\n", .{}) catch |err| {
    // do something with 'err'
  };

You can then deal with the error, as you wish.

Deferring Errors

Errors can also be returned to the caller, to be dealt with at an appropriate top-level point. To do this, we simply call try: if the expression returns without an error, then the block continues. However, if the expression raises an error, then that error is immediately returned to the caller: this acts as a shorthand for catching an error and immediately returning it.

To signal that our function may return an error, the return type is made into a union type, marked by the exclamation mark, '!'. The !void return type above means that main can return an error or nothing.

Example Program: Guess the Number

This is the classic "guess the number" game, where the computer picks a number, you enter a guess, and the computer tells you if you have guessed correctly, are too high or too low.

It's a useful program as it puts together several basic concepts, including loops, conditions, functions and error handling. And also we need to know how to get random numbers, handle user input and convert strings into numbers.

Random Numbers

The following function is used to return a random number of type u32:

fn chooseTarget() u32 {
    const seed = @intCast(u64, std.time.milliTimestamp());
    var rnd = std.rand.DefaultPrng.init(seed);
    return 1 + rnd.random().uintLessThan(u32, 100);
}

The first line creates a seed by obtaining the current time in milliseconds: the time must be cast to u64 to remove the sign. Note that times must be signed, as it's possible for times to exist before the zero epoch, but the random number generator insists on an unsigned number.

The second line is used to create a random number generator, based on the given seed.

And finally, the third line generates a random number: random.uintLessThan returns a number of the specified type (u32), between 0 and 100. We add 1 to get a number in the range 1 to 100, inclusive.

User Input

To read from stdin, the following line is used:

    if (try stdin.readUntilDelimiterOrEof(buf[0..], '\n')) |input_line| {

    }

This reads into input_line the string up to the newline character. Bytes are read into buf, which is preset with up to 10 spaces. Here, we assume the user is inputting a small number. The whole is headed by a try statement, so that the function will exit in case any errors occur. Without errors, the block is called and input_line can be queried.

And yes, this is a new form of syntax for if statements: in Zig, the condition part can either test for true/false, or test for null/not-null, or test for error/not-error. In the latter two cases, the value for the not- part is passed into the variable - in this case, when the condition does not throw an error, the value of reading from stdin is passed into input_line. If present, the else clause can be used to process the error (or null value).

Note that input_line does not have the newline delimiter but, on Windows, it may have the \r delimiter, which must therefore be removed.

Parsing Numbers

The string is parsed into an integer using:

    number = try std.fmt.parseInt(u32, line, 10);

Notice the use of try again, which aborts the function in case the input is not an integer.

Complete Program

The complete program is:

const std = @import("std");

// Displays greeting to stdout
fn printGreeting() !void {
    const stdout = std.io.getStdOut().writer();                             // <1>
    try stdout.print("Guess the Number (from 1 to 100)\n", .{});
}

// Returns a random number from 1 to 100, inclusive
fn chooseTarget() u32 {
    const seed = @intCast(u64, std.time.milliTimestamp());                  // <2>
    var rnd = std.rand.DefaultPrng.init(seed);                              // <3>
    return 1 + rnd.random().uintLessThan(u32, 100);                         // <4>
}

// Return a number from stdin or an error if no number found
fn readNumber() !u32 {
    var number: u32 = 0;
    const stdin = std.io.getStdIn().reader();
    var buf: [10]u8 = undefined;

    if (try stdin.readUntilDelimiterOrEof(buf[0..], '\n')) |input_line| {   // <5>
        var line = input_line;
        // for windows, check if penultimate character is \r
        // if so, remove it
        if (line[line.len - 1] == '\r') {                                   // <6>
            line = line[0..(line.len - 1)];
        }
        number = try std.fmt.parseInt(u32, line, 10);                       // <7>
    }

    return number;
}

// Main function, runs the game
pub fn main() !void {
    const stdout = std.io.getStdOut().writer();
    const target = chooseTarget();
    var guess: u32 = 0;

    try printGreeting();

    while (target != guess) {
        try stdout.print("What is your guess? ", .{});
        guess = readNumber() catch {                                        // <8>
            // not a number, so exit
            try stdout.print("Exiting game\n", .{});
            break;
        };

        if (target == guess) {
            try stdout.print("Correct!\n", .{});
            break;
        } else if (target < guess) {
            try stdout.print("Your guess is too high\n", .{});
        } else {
            try stdout.print("Your guess is too low\n", .{});
        }
    }

    try stdout.print("I chose {d}\n", .{target});
}
  1. Separate function to print greetings - still returns an error, and requires stdout writer to be extracted.
  2. Getting a seed relies on the useful @intCast to safely cast between incompatible int types. (NB: in 0.11.0 change the cast to @as(u64, @intCase(std.time.milliTimestamp())).)
  3. Creates a random number generator with given seed.
  4. uintLessThan returns a number less than 100, from 0 to 99.
  5. Reads from stdin into buf until the given delimiter is reached - platform specific.
  6. Tidy up the extra delimiter in Windows.
  7. The line can then be parsed, returning an error if it's not a number.
  8. Catch error from readNumber, as any non-numbers are a reason to exit.

Save this into a file called "guess-number.zig", and compile it using:

> zig build-exe guess-number.zig

Zig will compile your code and, if there are no errors, create a compiled executable "guess-number.exe", which you can run in the command line.

Running and playing the game should look like:

> .\guess-number.exe
Guess the Number (from 1 to 100)
What is your guess? 50
Your guess is too low
What is your guess? 75
Your guess is too high
What is your guess? 63
Your guess is too high
What is your guess? 56
Your guess is too low
What is your guess? 60
Your guess is too low
What is your guess? 61
Correct!
I chose 61

> .\guess-number.exe
Guess the Number (from 1 to 100)
What is your guess? 50
Your guess is too high
What is your guess? quit
Exiting game
I chose 28

Page from Peter's Scrapbook, output from a VimWiki on 2024-01-29.