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
This section covers the output of information using a print
statement, as
well as simple expressions and storing values in variables.
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> }
-
Imports the
print
function that we will be using from the standard library. -
Creates a public
main
function, which is where our program starts. The return type isvoid
, meaning the function does not return anything. - 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:
-
\n
is used to indicate a new line -
{s}
is the placeholder for a string -
{c}
for a character -
{d}
for numbers, integer or floating point -
{e}
for floating point numbers we want to print out using mathematical notation
Zig supports different types of values, as we'll find as we go deeper into the language.
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
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}); }
-
Notice that
print
is an example of a constant variable! -
Create the constant
x
referring to the value 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:
-
u32
- an unsigned 32-bit integer. -
i32
- a signed 32-bit integer. -
u64
- an unsigned 64-bit integer. -
i64
- a signed 64-bit integer. -
f64
- a 64-bit floating point number. -
bool
- true or false.
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> }
-
Declare "x" to be a
var
of typeu32
. - We can now update the value of "x".
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 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:
- variable scope - when variables can be used and which variables can be defined
- labelled blocks - naming blocks, for precise return points and possible return values
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> }
- Opens the block for the function definition.
-
i
is defined within this outer block. - Opens a new "inner" block, within the outer one.
-
j
is defined within this inner block. -
i
is accessible within the inner block. -
Changes to
i
are reflected in the outer block.
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}); }
-
Starts a labelled block whose value will be used to initialise
j
. - Returns a value from this labelled block.
Conditional expressions are used to select a block of code to execute.
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}); }
-
Simple
if
statement, without an else-block: notice in this case, nothing will be evaulated. - Again, a simple if statement, using an else-block.
-
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 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 }); }
-
The
switch
expression can be used to determine a value in a variable assignment. -
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 }); }
- Check if value is within a range (inclusive of end-points).
- Check if value is within a listed set of values.
-
Note the labelled block,
blk:
-
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 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.)
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> } }
-
The condition is that
i
is less than or equal to 10. -
This is what we do on each iteration through the loop - print the value of
i
. -
We need to update
i
on each iteration, so ultimately the loop condition will befalse
.
This example prints the numbers from 1 to 10.
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> } }
-
The condition is that
i
is less than or equal to 10, and the update step incrementsi
. -
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.
Two keywords are used within loops:
-
break
is used to exit a loop, without waiting for the loop condition to becomefalse
-
continue
is used to immediately move to the next step in the loop
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}); } }
-
Skips the rest of the loop body if
i
is 1. -
Breaks out of the loop if
i
is 7.
When run, this version only prints the numbers from 2 to 6.
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> }
-
The
blk:
label is assigned to the outerwhile
. -
If the end condition is reached, break out of the outer loop, returning value
true
. -
In case the loop did not finish before, return a default value of
false
. -
Note the string substitution is merely
{}
asanswer
is a boolean.
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.
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:
-
The return type from
main
becomes!void
instead of justvoid
. -
The line calling the
print
statement is prefixed withtry
.
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.)
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.
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.
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.
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.
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.
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.
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}); }
-
Separate function to print greetings - still returns an error, and requires
stdout
writer to be extracted. -
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()))
.) - Creates a random number generator with given seed.
-
uintLessThan
returns a number less than 100, from 0 to 99. -
Reads from
stdin
intobuf
until the given delimiter is reached - platform specific. - Tidy up the extra delimiter in Windows.
-
The
line
can then be parsed, returning an error if it's not a number. -
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