I can’t think of any other language in my 45 years long career that surprised more than Zig. I can easily say that Zig is not only a new programming language, but it’s a totally new way to write programs, in my opinion. To say it’s merely a language to replace C or C++, it’s a huge understatement.
In this article, I will present the features that I found to be most seductive in the language, and I will also present a brief overview about it. The goal is to present simple features for programmers to quick start in the language. Be aware, though, that many more features are affecting its acceptability in the industry.
Probably the most incredible virtue of Zig compiler is its ability to compile C code. This associated with the ability to cross-compile code to be run in another architecture, different than the machine where it is was originally compiled, is already something quite different and unique. These features alone, completely out-of-the-box, are causing an incredible impact in the industry already. In spite of that what we want to concentrate on is how to program in Zig and why should one choose Zig instead of any other language.
Installing the compiler is quite simple. In Zinglang’s download page one finds the compiler in several formats, depending on the processor or OS:
On Windows 10, for example, one chooses the x86_64 zip file and copy its decompressed content in the desired directory. For example, in “Program Files”. I modified the root directory name to “zig-windows-x86_64” because in this way I can just copy another version of the compiler with no need to modify the path in Path environment variable.
Next, one adds this root directory path to the Path environment variable using Advanced System Properties (clicking on 1-4, pasting path on 5, and clicking “OK” on 6-8):
Notice that after setting the path to Zig’s root directory one can already use the compiler in CLI mode. That’s the recommended way to use it for the sake of simplicity.
It’s recommended to build a “Hello World!” program using the instructions in the “Getting Started” section in this site. An alternative installation procedure is also presented there, including for MacOS and Linux (“Installing manually” is highly recommended). It’s also recommended to check the “Language” Basis section, for a deeper insight of Zig language.
The following sections will give a bird’s eye view of Zig language. Their goal is to instruct the programmer how to quickly start programming in Zig by just knowing a few basic concepts and commands.
Then, a compact view on how to build programs and test modules is given.
Finally, a deeper view on how low level programming can be done in Zig. A more detailed explanation on the examples used is also given.
Normally, variable declarations in Zig have potentially three parts.
The first part contains the accessibility (pub
or nothing),
followed by the word var
or const
, followed by
the variable name. The second part, separated of the first part by a
colon, contains the variable type declaration. The third part is the
initialization of the variable. Only the first and third part are
compulsory in Zig, which is kind of puzzling, coming from Java or C. One
may wonder how the compiler discovers the variable type. The type in
this case is inferred by the initialization.
var sum : usize = 0; // variable declaration with 3 parts
Variables that don’t have their accessibility explicitly
pub
are local to the module, that is, they aren’t
accessible outside the source file it was declared (just like
static
variables in C). It’s not recommended to have
variables declared pub
and it’s recommended to have just
few functions declared pub
in a module to discourage
coupling and encourage cohesion. The pub
functions behave
as the module’s API.
In Zig, a .{
closed with a }
is an
“anonymous struct literal” - an anonymous struct mostly used to
initialize the elements of another structure or to create a new
structure with its elements initialized. A .{ }
is an an
empty anonymous struct literal. The word struct
followed by
a {
and closed with a }
is a struct
declaration. One can initialize a variable with a type, which functions
as an alias to the type. Notice the “test block” allowing to
compile and execute tests without the need of an executable.
Bitfields are declared fields with types having specific sizes in a
packed struct
. Pointers can point to a specific bit field
as shown here:
Zig syntax is clearer than C’s, except that one would think it should
be [0..8]
, but in reality it’s an open interval:
[0..9)
. Advantage in Zig: the type
declaration of i
, its initialization, test, and
incrementation are automatic.
A [_]
defines an array with unknown size. It must be followed
by the type of its elements (here u8
, unsigned
byte) and its initialization between {
and
}
. In this example below, the initialization is saying that
this is an array of unknown size ([_]
) where each element
is of type u8
(unsigned byte) and each element initialized
with zeros ({0} ** 81
). Notice that the size is also
inferred by the repetition factor (81
) of the
initialization ({0}
).
var grid = [_]u8{0} ** 81;
We see in the figure below a test environment with a loop interacting
over the array and adding its elements. The try expect
statement verifies that sum
is correct.
The word byte
is not a type or reserved word in Zig.
Here, it’s a variable to hold each of the array’s elements on each step
of the loop. Notice that a variable declared between two |
with an array between the parenthesis of a for loop is always assumed of
the same type as the array elements.
Notice also, that usize
is the natural unsigned integer
for the platform. That means on 64 bits machines it’s an
u64
, and in 32 bits machines it’s an u32
.
Pointers to arrays can use pointer arithmetic only if the pointer is
explicitly declared as a many-item pointer, here
[*]const i32
. Notice that the array below is
const
, can’t be changed, but that the pointer is
var
.
When attributed the address of an individual array position, a
pointer cannot be updated with pointer arithmetic. In this case,
const
only prevents its value to be changed with another
direct address attribution. To dereference pointers in Zig one uses the
ptr.*
as shown below:
Zig can do many things in compilation time. Let’s initialize an
array, for example. Here, a labelled break is used. The block
is labelled with an :
after its name init
and
then a value is returned from the block with break
.
This syntax may look overwhelming. 0..
means an infinite
range starting with zero. In the for
, variables pt
and
i
are respectively initialized with the address of
initial_value
array and zero. During the loop both are
incremented automatically and the loop stops right after dealing with
the array’s last position. Also notice how to dereference the
pt
pointer: pt.*
.
Also interesting it’s how the array is declared in the labelled
block. The array is called initial_value
and has 10
positions of the type Point
(declared afterwards).
Variables must be initialized in Zig. The way to explicitly not
initialize is with the reserved word undefined
.
Functions in Zig are declared with fn
and are static by
default (cannot be imported in other files) in the file they are
declared, except when fn
is preceded by pub
. A
function can be “inlined.” Function pointers are preceded by
const
and followed by the function prototype.
Structs can have functions. Here, a simple stack is implemented. This
stack is declared and used only inside the module it is defined, and it
accesses and modifies other data structures in the module that are
irrelevant in this example. The stack can have at maximum 81 elements
(as seen in stk
declaration), each one of type
StkNode
. Notice that ++ and –– operators don’t exist in Zig.
The equivalent += and –= should be used instead. The stack pointer is
just an integer used as an index in the stk
array.
Notice that the pointer self
(self
is not a
reserved word, but it’s just a convention) is not passed explicitly as a
parameter as one would normally expect. It is indirectly assumed that it
is a pointer to the instance of the stack the function is been called
from. In the example below, a stack pop would be called with
stack.pop();
. In this case the pointer self
corresponds to a pointer to stack
. This pointer is then
somewhat equivalent to this
in Java or C++.
Function init()
is the stack constructor.
Notice as well that functions pop
and push
are “inlined.”
To build an executable program one needs a main
function, which indicates the program entry point as in Java or C
programs. When this function exists the set of modules can be compiled
to an executable code. A simple program can have a main
function in the same file as the rest of the program. In many cases one
can also insert a main function at the end of a module to create an
executable in order to debug the module independently. Once debugged
this function can be commented out.
In these situations one can use the following command line to compile
program.zig
and to generate an executable program (on
Windows, a program.exe
):
zig build-exe -O ReleaseFast program.zig
This can be put inside a batch file to prevent typos.
This is probably the best feature of Zig as a programming language. This environment is normally used for testing, but it can also be used for prototyping.
A block in Zig is similar to a block in C or Java, that is, some code
between {
and }
. A test block is a
block that starts with test "message" {
and finishes with
}
, where "message"
is a string containing the
message to be displayed when the test is executed (in this case only the
word message).
Test blocks are executed independently from an executable file. The
final executable file does not execute the tests. The test blocks in a
given module.zig
are compiled together with the entire code
in that file and executed by the following command:
zig test module.zig
As a real life example, the test block from module example.zig is shown below:
test " => testing set and print functions" {
set(
"800000000003600000070090200" ++
"050007000000045700000100030" ++
"001000068008500010090000400"
);
std.debug.print("\n" ++
"===================\n" ++
"Input Grid\n" ++
"===================\n",
.{}
);
print();
}
Notice that example.zig
alone has no main
function and, therefore, cannot generate an executable, but its test
block can be executed by using the following command:
zig test example.zig
As the message says, the test block above tests the functions
set
and print
. As the code shows,
set
passes a string of decimal digits as a parameter,
followed by a print statement (which prints a header saying “Input Grid”
), followed itself by a call of the print
function.
The real display in a command tool is the following:
Let’s focus now on the std.debug.print
statement. This
statement is in fact a call to the function print
in
debug.zig
in the standard Zig library std
. The
first parameter is a format string, and the second is an anonymous
struct (preceded by a dot) containing a list of variables to be
displayed using the format string. Since there is no formatting in the
format string, the struct is empty. This is how formatted prints are
done. This one will display in the stderr by default, as shown
above.
This all looks just like in C language, but there is a fundamental difference here. In C, the printf function dynamically interprets the format string in execution time, whereas in Zig it’s possible to deal with the literal string and the list of variables in compilation time. This is a difficult principle to grasp at the start. Many things can be executed in compilation time.
Using a debugger is not usually a straightforward task, except in IDEs that already integrate a debugger (as in Java IDEs such as Eclipse or Intellij IDEA) or in integrated development kits (such as w64devkit for C/C++).
A huge inconvenient in using debuggers in this way is that one must integrate the symbols, which not only bloats the code with information that is not useful to the program, but also requires compiling in Debug mode, which generates executable code that’s notoriously less efficient. Someone with practice in complex systems knows that it can be a very time consuming task.
Zig offers a quite convenient hack in order to avoid these headaches.
This built-in stops a program at the point where a
@breakpoint();
is inserted in the source code when its
executable is run in a debugger. This is actually an useful feature to
debug optimized Zig code without the need of symbols.
All it’s needed is to trace the variables one wants to watch using
std.debug.print
right before @breakpoint();
In
this way one can know what are the values of the variables at that exact
moment.
As a real life example, one generates an executable for module debug_example.zig
which has the has the following main
function:
pub fn main() !void {
set(
"800000000003600000070090200" ++
"050007000000045700000100030" ++
"001000068008500010090000400"
);
}
To be able to double check with the results from example.zig,
the parameter passed to set
function in this
main
is the same string as the one passed to
set
in the test block in example.zig,
but this time one inserts the following code inside set
function:
if (c != 0) {
print();
std.debug.print(
"Current digit {}\nposition in string {}\n" ++
"line {}\ncolumn {}\ncode {b}\n",
.{c, k, i, j, code}
);
@breakpoint();
}
One can then generate the debug_example.exe
executable
with the following build command:
zig build-exe debug_example.zig
Next, one uses a debugger to call debug_example.exe
. In
this case I used gdb
, a debugger for C/C++ included in
w64devkit, but it could be any debugger for executable programs. Once
inside gdb
, one needs to run the program using an
r
command and typing Enter
right after as
shown below. Notice that the program printed the grid with its contents
so far as well as the variables, stopping
at the point expected. Then, by typing c
command followed
by an Enter
one continues tracing the grid contents and
the variables. After that, one can continue by just typing
Enter
(it repeats the last command - c
, in this
case). By continuing typing Enter
until the program finishes,
the values found in the grid correspond to the values shown by the test
block in example.zig
above, since both examples have the same
parameter passed to set
.
With the introduction and the examples given in the previous sections, one can already start programming Zig for writing generic applications. For more advanced programmers, what follows is a more in-depth analysis of some interesting low-level features already used in the examples, but not yet explicitly commented.
The idea of the examples shown is to construct a module that sets (initializes) and displays a 9x9 matrix. This matrix will hold a Sudoku grid, that is, it will only contain elements with decimal digits. The initialization of the grid should guarantee that the grid satisfies the rules of Sudoku game, so it will contain no errors.
At the same time it would be also an excellent opportunity to demonstrate Zig’s low level capabilities, at least the most noticeable ones, and these examples fit this goal quite well.
The whole hypothesis behind these examples is the representation of a grid digit as a bit in the position given by its value. This representation is quite convenient to detect if a digit is already present in the grid or not (these are basic rules of Sudoku grids). In spite of that, this is so encrypted it is only used internally in the module.
With the purpose of having values that are easily understood by
humans, the digits are actually stored in the matrix as standard
u8
integers. Even though the input grid in the examples is
given in string format, the ASCII characters are internally converted to
u8
integers. The digits’ storage in the grid is organized
linearly, line by line, in an array with 81 positions, called
grid
in the examples:
var grid = [_]u8{0} ** 81; // Sudoku grid stored linearly
To verify grid correctness, one needs to access the elements by its respective line and column. In other words, one needs to access the elements as in a matrix. The strategy is to create an array of pointers with 9 positions, each one pointing to the start of each line. Blocks of code cannot in principle return a value, but in Zig they can with labeled breaks:
const matrix = fill9x9: { // matrix array to allow access
var m : [9][*]u8 = undefined; // to grid element as a matrix,
var pt : [*]u8 = &grid; // thus: element = matrix[i][j]
for (0..9) |i| { //
m[i] = pt; // stores pointers of each line
pt += 9; // at each position of matrix
} //
break :fill9x9 m; // initializes matrix with m
};
At the end of the loop, m
is returned outside the block
using a break :fill9x9 m;
command. Notice that
fill9x9
corresponds to the name of a label placed right
before the beginning of the block.
Supposing i
and j
, respectively an
element’s line and column, any element of the grid can be accessed using
this notation:
element = matrix[i][j]
The key concept used here is the replacement of an integer decimal
digit i
by an integer code
such as:
i ∈ [1,9] → code = 2ⁱ⁻¹
i = 0 → code = 0
In other words, the only bit of code
that is set to
1
is the bit at the position i-1
if
i
is between 1
and 9
, otherwise
all bits of code
are zero.
code
for
each digitThe table below shows the correspondence between digits and their binary representation:
code
in
ZigThe value of code
is calculated in the function
set
using a left shift operator only if c
is
not zero:
code = @as(u9,1) << (c-1);
In Zig, constants must have a proper size in order to allow an
operation to be compiled and to attribute the the result of an operation
to a given variable. In this case, code
is declared of type
u9
. That’s another fundamental quality of Zig, to be able
to have variables with arbitrary bit size. As can be
seen in the table above, the maximum value that code
can have is 256,
which requires at least 9 bits to represent. The built-in
@as
allows to cast the 1
constant to type
u9
.
By representing digits as bits one can mirror the entire grid in much simpler ways.
The array lines
mirrors the entire grid by representing
each line with a 9
bits integer, each bit representing a
decimal digit that might be present in the a line:
var lines = [_]u9{0} ** 9;
In this way, by just accessing this array with the line
i
of an element in the 9x9 grid one can know if a given
digit is already present in that line, by just performing a bitwise
and ( &
) with the digit’s
code
, in this way:
lines[i] & code
If the result of the operation above is zero, this means the digit is
not yet present in the line i
. Otherwise we have a
duplicate.
The array columns
mirrors the entire grid by
representing each column with a 9
bits integer:
var columns = [_]u9{0} ** 9;
In this way, by just accessing this array with the column
j
of an element in the 9x9 grid one can know if a given
digit is already present in that column, by just performing a bitwise
and ( &
) with the digit’s
code
, in this way:
columns[i] & code
If the result of the operation above is zero, this means the digit is
not yet present in the column j
. Otherwise it’s a
duplicate.
Let’s suppose an empty Sudoku grid, as it is the case when one is populating the grid with the input string as done in the examples. A new digit inserted at any empty element, must not already exist in the entire line, column or cell containing the new element.
Let’s suppose now this grid, already initialized:
A cell is each one of the nine 3x3 grids delimited by the thick lines. The key knowledge to understand at this point is that each specific element in the 9x9 grid has a unique line, column and cell that contains this element.
For example, the first cell of the grid contains the values: 3, 5, 6, 8, and 9. Therefore, the values: 1, 2, 4 and 7 are missing. Let’s suppose one is willing to place the value 7 in one of the empty places in the first cell. Obviously, one cannot place it in the only empty element of the first line, because 7 is already present in that line. One cannot place it in the only empty place in the first column either since 7 is already in that column. One can only place the 7 in one of the two empty elements of the second line. But one can’t know for sure which one is the good one.
Let’s examine now the second cell, which contains the values: 1, 5, 7, and 9. One can see that the only possible element in this cell where an 8 can be placed is in the first line at the empty position on the right of the value 7.
Arrays lines
and columns
take care of
checking duplicates in lines and in columns. A new array is then needed
to check duplicates in cells.
The array cells
mirrors the entire grid by representing
each cell with a 9
bits integer:
var cells = [_]u9{0} ** 9; // all elements at each cell
Here is where things get more complicated. One cannot access
cells
directly using the line or the column. It would be
easier if one could access cells
as a 3x3 matrix. This can
be done mimicking what has been done for the 9x9 matrix, that is,
filling the array cell
as follows:
const cell = fill3x3: { // cell array to allows access
var m : [3][*]u9 = undefined; // cell elements as a matrix,
var pt : [*]u9 = &cells; // cell[cindx[i]][cindx[j]]
for (0..3) |i| { //
m[i] = pt; // stores pointers of each line
pt += 3; // at each position of cell
} //
break :fill3x3 m; // initializes cell with m
}; //
But now one needs to determine the line and column in
cell
matrix from the line and column of the element in the
original 9x9 grid. One could use integer divisions to divide the line
and column by 3 to obtain the proper indexes, but a division operation
is notoriously slow. Two divisions makes it even worse. One can use the
following array to give the result of the division as follows:
const cindx = [_]usize{ 0,0,0, 1,1,1, 2,2,2 };
In this way, by just accessing this matrix with the line
i
and column j
of an element in the 9x9 grid,
one can know if a given digit is already present in this element’s cell
by just performing a bitwise and ( &
)
with the digit’s code
, in this way:
cell[cindx[i]][cindx[j]] & code
If the result of the operation above is zero, this means the digit is not yet present in the cell. Otherwise it’s a duplicate.
The complete test to verify if an element is duplicated can be done
by composing with a bitwise or ( |
)
all the previous elements in the same line, column and cell, and then
performing a bitwise and ( &
) with
the element’s code
, in this way:
if (((lines[i]|columns[j]|cell[cindx[i]][cindx[j]])&code) != 0) {
unreachable;
}
If the result is zero it’s because the element doesn’t exist yet in
its line, column or cell. If the result is not zero the program stops
because it tries to run the instruction unreacheable
. This
is the simplest way to explicitly indicate an execution error in Zig.
Notice that the actual code in set
function also prints the
details where the error occurs.
For example, replacing the '0'
right after the first
'8'
by a '5'
in the string passed to
set
gives the following error while testing example.zig:
This is because in column 1 there was already a 5 in line 3 as the
error message says. The error is due to a panic caused by reaching an
unreachable code at function set
as indicated.
In function set
, a double for
loop
interacts line by line to copy each new element from the input string
s
into the grid as indicated below (variable k
keeps the index of the new input character in the string
s
):
for ( 0..9 ) |i| {
line = matrix[i];
for ( 0..9 ) |j| {
c = @intCast(s[k]-'0');
if (c != 0) {
code = @as(u9,1) << (c-1);
⋮ // rest of the code
}
line[j] = c;
k+= 1;
}
}
The character is converted to an u4
(variable
c
) by subtracting '0'
from it. If the new
element to be inserted in the grid is not equal to zero (
c != 0
), code
(calculated with a left shift
instruction as indicated) is copied in each of the mirror grids, by
doing a bitwise or ( |=
) with the
corresponding mirror grid, that is:
lines[i] |= code;
columns[j] |= code;
cell[cindx[i]][cindx[j]] |= code;
No test is required to explicitly test if the value of c
is between 1
and 9
because an overflow will
occur at execution time when the shift operation is executed. For
example, replacing the '0'
right after the first
'8'
of the input string by a ':'
in the string
passed to set
gives the following error while testing example.zig:
By substituting the same '0'
by a '/'
a
similar execution error will issued. The program will work only if the
values are between 1
and 9
, that is, if the
input grid contains only decimal digits.
Many Sudoku grids on the web also represent '0'
as a
'.'
. That’s the reason the following line exists in
set
function:
if (s[k] == '.') c = 0;
This will conveniently bypass the shift operation because the value of
c
is zero.
The forced errors shown in the two sections above demonstrate important features of Zig that might have passed inconspicuously. One is Zig’s robustness. In the case of the shift operation no wrong behavior is allowed and the situation is caught at execution time, as has been shown.
One might think that all the efforts in Zig are towards efficiency, but here it’s a typical case where performance was traded for robustness. One can have mixed feelings about this decision, when performance was the ultimate goal. In C, for example, it’s the programmer’s problem if a shift operation loses a bit and this translates in better performance for this specific Assembler instruction.
Another feature demonstrated in the two sections above is the possibility of using the test blocks for prototyping as suggested at the beginning of the article. The possibilities are numerous, even though the application shown was only to debug certain situations in cases of error.
These features alone demonstrate an awesome power, very rare in programming languages, particularly in compiled programing languages.
This is all quite surprising and let one think that many advantages previously found only in interpreted languages are gradually migrating to compiled languages in order to offer more performance. [A reference to Mojo here looks appropriate].
Zig resemblance to interpreted languages is quite striking, particularly with its concept of compile time execution, unfortunately not stressed enough in this article. This is an aspect of Zig that on one hand makes it particularly different and powerful but on the other hand difficult to grasp.
I concentrated more in instructing how to have a quick and easy start with the language, and in simple aspects that make Zig language cool, although there are many other aspects not mentioned here that are also quite impressive.
The examples shown here are simplified versions of a more involved program to solve Sudoku grids, which was also developed in Java and in C. The documentation in this repository explains in detail most of the structures and algorithms used to accomplish that.