Learning Zig - first hour...
Today I’ve played around with Zig, the new, hip (is it hip?) programming language. I find it pretty neat. I’m going to walk you (and myself) through my first, very short, piece of code.
Below you can see the entirety of it. It basically allocates a 2MB buffer and reads a file into it… Yep, not particularly impressive, but this is a judgment free, learning zone, ok?!
|
|
It starts with adding files to the build process with @import
built-in function. What’s interesting
is that source files are implicitly structs. That’s why a typical syntax for declaring variables
applies. In Zig you can define methods inside a structure definition. That basically puts the
function in a namespace with the name of that struct. So if std
is a struct, the methods accessed
via std
are just functions in that structure namespace.
The const Allocator = std.mem.Allocator
is a declaration. It declares an alias to
std.mem.Allocator
. I find this a bit confusing since it looks like a variable declaration.
I think it makes sense though. It’s just a declaration. Declaration is an information passed to the
compiler which says a thing, called like so, exists (scientific definition!). In Zig declaring
a struct uses a very similar syntax: const Foo = struct {...};
. Anyway, this line doesn’t create
and instance of the Allocator
. It just declares a new name for it.
Let’s jump to the main
function. First of all the type it returns is !void
meaning it can
return a void
or an error. That means that main
can fail. If it can’t you can just change it to
void
. I think this feature comes down to having a valid Error Return Trace - that’s a very
Zig thing. Not sure what’s going on here and I won’t go into it right now (why? because boring).
|
|
In Zig memory allocations are very explicit. Zig doesn’t provide any default memory allocator.
You can access the standard libc allocating functions like malloc
, realloc
, etc. via
std.heap.c_allocator
. In my case I’ve used the std.heap.FixedBufferAllocator
, which gets inited
with a stack buffer. It’s not the best choice for what I’m going for since stack very limited. It
will do for now. I very much appreciate this idea of not providing a default memory allocator. That
works well for C savages like me. We don’t like throwing malloc
s and free
s around. If you
want to learn more read Memory and
Choosing an Allocator sections
of the documentation.
warn
function outputs a message to stderr. Not much here, except maybe for the fact that you
can’t skip the args
argument, meaning that the .{}
has to dangle there.
Time to talk about the function that reads the file, starting with its signature.
|
|
Firstly it takes the Allocator
instance as the first argument. In Zig functions that allocate
memory take an allocator as an argument. That way there are no hidden allocations. The second
argument, []const []const u8
is an const array of const arrays. The return type is an error or a
slice which in this case is a string. Arrays and slices are two very similar concepts. Arrays have
a length known during compile time, while slices length is changing during run time. Let’s look at
an example from the Zig’s documentation.
// Zig has no concept of strings. String literals are arrays of u8, and
// in general the string type is []u8 (slice of u8).
// Here we implicitly cast [5]u8 to []const u8
const hello: []const u8 = "hello";
This code creates an array holding string "hello"
. The hello
variable is a const slice which
points to that array.
Going back to my code…
|
|
First line uses the array of arrays as a filename. That’s because resolve
function either
resolves path with Windows or Posix standard (using /
or \
, etc.). It also takes an
allocator
. Seems like resolve
uses some dynamic memory allocations and thus needs some cleanup.
In Zig you can cleanup in a nice manner by using defer
. You call something that allocates
memory and you immediately call free
prepended with defer
. That means that when the scope ends,
the memory will be freed. It’s a nice quality of life thing.
You can also see the try
statement before calling the std.fs.path.resolve()
. Most of the
functions in the standard library return either an error or a proper result. try
makes it so if
the function returns an error, the entire scope returns with the same error.
The rest of the code in this function:
|
|
File gets opened via the std.fs
function. Stats about the file get fetched. A memory chunk of the
same size as the file size gets allocated, from the stack buffer. This time I’ve used the
errdefer
. That way, if the function errors out anywhere else, the memory gets freed. If it
succeeds the function returns the pointer to the file contents. The last, few lines of code read
the data into the allocated memory. read_result
holds the amount of bytes read from the file.
Function returns the pointer to the contents of the file.
As soon as the function returns…
|
|
The pointer to the file contents gets passed to a main
scope variable. What’s worth pointing out
is that I call the readFile
function with a &[_][]const u8{".", "Box.gltf"}
argument. [_]
means that the compiler should infer the length of the array, which is easy enough. There are two
elements in the array. The second []
is a slice… So in the end this is an array of slices.
I don’t feel comfortable to explain how slices and arrays implicitly map one to another. I hope
I’ll be able to explain it better in the future. Also, the &
symbol is used to pass a address…
even though it’s an array… Zig ain’t C.
Ending with warn
… on a pointer? Well, more precisely on a slice. Yes, you can easily print any
variable since they have a default implementation of format
. You can implement a format
for
your own structs too. Nice!
That was my first hour with Zig. Writing this article has been very helpful to understand the more subtle parts. Let’s do it again soon!