Adventures in Zig
Zig is a general purpose programming language, it is low level, type safe, and doesn’t allow values to be null, not unlike rust. Where zig differs from rust is mostly in what it lacks: it has no official package manager yet, functions are not first class citizens, and no traits or classes, making most of the code very C like and procedural.
These differences were expected when trying out zig, some others I was not prepared for.
If you are interested in learning zig, I highly recommend ziglearn.org or the official documentation.
No String type
Zig does not have a string type, similar to C, we can treat an array of characters or u8
s as a string.
const str = "Hello there";
std.debug.print("{}\n", .{@TypeOf(arr)}); // *const [11:0]u8
Because the type inference of zig is pretty good, albiet not quite rust level, we can still treat them as strings in our head, except when needing to print them out.
const str = [_]u8{ 72, 79, 87, 68, 89 };
std.debug.print("{any}\n", .{str});
// { 72, 79, 87, 68, 89 }
std.debug.print("{s}\n", .{str});
// HOWDY
Specifying memory allocation
In rust, we are forced to think about memory allocation in an interesting way, by introducing the concept of borrowing and lifetimes, we now think about who owns what, in terms of functions are variables. In zig, you are forced to think about memory allocation explicitly, by providing what type of memory allocation you wish for a data structure to be initialized using.
var list = ArrayList(u8).init(std.heap.page_allocator);
defer list.deinit();
try list.append('H');
try list.append('e');
try list.append('l');
try list.append('l');
try list.append('o');
try list.appendSlice(" World!");
There are a few benefits for this, outside of forcing us to think about what happens when initializing variables. We can specify, for example, that we want to only allocate 8 bytes of memory for any given data structures, that would otherwise rely on dynamic memory allocation, giving us the ability to use the same API, but specify how the memory is treated.
pub fn main() void {
// create allocator
var buffer: [8]u8 = undefined;
var fba = std.heap.FixedBufferAllocator.init(&buffer);
const allocator = fba.allocator();
// initialize ArrayList
var list = ArrayList(u8).init(allocator);
defer list.deinit();
// Attempt to append 9 u8's
for (1..10) |c| {
const cu8 = @truncate(u8, c);
list.append(cu8) catch printError(cu8);
}
// print final created list
std.debug.print("arr items: {any}\n", .{list.items});
}
// print an exception
fn printError(c: u8) void {
std.debug.print("Out of Memory! Cannot append {}\n", .{c});
}
Output
Out of Memory! Cannot append 9
arr items: { 1, 2, 3, 4, 5, 6, 7, 8 }
Yes, this is essentially a more complicated var list = [8]u8{}
, but it is using the ArrayList API and can be done with hashmaps.
I haven’t seen this done in any language before, but I’m not an expert on low level languages so maybe this is borrowed from elsewhere.
Looking at this example leads into the next point.
Java-like errors
There is probably a better way to describe this, but as someone who also writes a fair amount of java this was the way that helped me understand it.
Rust’s Result type
In rust, you have what’s known as a Result<T, E>
where it is unknown when writing the code if a value is an error or not, rust also uses this concept for getting
around null
with Option<T>
but we will go over that later. An easy example of this is when attempting to parse a string into a number;
let four_hundred = "400".parse::<u32>();
four_hundred
is a Result
type, if we wanted to get access to the value we can do any of the following
print!(four_hundred.unrwap());
four_hundred.map(|four_hundred_u32| print!(four_hundred_u32));
match four_hundred {
Ok(n) => print!(n),
Err(..) => {}
}
// any many more
What we can do is pass the Result
value around before we need to .unrwap()
the value and use it, giving us the opportunity to write safe code we know will run without an exception being thrown.
Let’s try this in zig
const fourHundred = std.fmt.parseInt(u32, "400", 10);
Already this code throws an error, so we have to handle it here, sorta.
Like java, if you have code that throws an exception, you can propagate the exception by adding throws Exception
to the end of the function, allowing whoever runs your function to handle it.
We can do this in zig by adding try
before problem expression and then adding a !
to our function signature, even main.
pub fn main() !void {
const fourHundred = try std.fmt.parseInt(u32, "400", 10);
std.debug.print("{}\n", .{fourHundred});
}
What if we want to handle the exception ourselves?
We did this before with catch
.
- SIDE_NOTE: Try and catch typically in java and javascript would be used together, here they are used separately, where
catch
is taking the place fortry {} catch (e) {}
in other languages.
catch
after an expression will call the following expression in place of the expression, it is here we can specify a default or fallback value, or tell the compiler this will never happen with the unreachable
keyword.
pub fn main() void {
const fourHundred =
std.fmt.parseInt(u32, "400", 10) catch 0;
const fourHundred =
std.fmt.parseInt(u32, "400", 10) catch unreachable;
std.debug.print("{}", .{fourHundred});
// or use an if statement
if (std.fmt.parseInt(u32, "400", 10)) |fourHundred| {
std.debug.print("{}", .{fourHundred});
}
}
The reason to use try
and !
over catch unreachable
is the former will dump the specific error that happened in stderr, where unreachable
will remove any information about that.
These are a few things that took me a minute to really grasp in zig, overall I enjoy the language.
I enjoy the javascript like syntax with typesafe and minimal footprint, it’s ability to interact with C libraries without the need for bindings is really attractive, especially for using in existing C codebases, I understand why it exists, it certainly fills a hole in between C and go in a way that isn’t rust. Overall I think I will continue to learn and stick with it.