Advent of Code 2024 in Zig - Day 1

Inspired by my brother's post about his efforts solving AOC with Rust, I thought I'd share my adventure with Zig.

I decided to choose Zig because I wanted to get into manual-memory-managed-land. I have solved some days in the previous years using Go, Clojure, and Rust, so I thought I'd challenge myself with a new language.

🦀
You may remark that Rust is a manual-memory-managed language, but I beg to differ - Although Rust does not have a garbage collector, it does manage the memory for you automatically. I wanted something more in my control, hence Zig.

Beware, spoilers ahead.

Task 1

distance between lists

Task 1 has us finding differences between two lists. It's a rather simple solution, read each list, and then diff them one-by-one, summing them up.

First things first, a brief project structure:

  • main.zig calls day1.zig
  • day1.zig has three methods:
    • pub fn run() that in turn calls:
    • fn task1(allocator, input) and fn task2(allocator, input), printing their results

Right, boilerplate stuff and beginning of task 1:

const print = std.debug.print;

pub fn run() !void {
    print("========== day 1 ==========\n", .{});
    print("task1:\t{d}\n", .{try task1(std.heap.page_allocator, real)});
    print("task2:\t{d}\n", .{try task2(std.heap.page_allocator, real)});
}

fn task1(allocator: mem.Allocator, input: []const u8) !u32 {
    var arena = std.heap.ArenaAllocator.init(allocator);
    defer arena.deinit();
    var listA = std.ArrayList(i32).init(arena.allocator());
    var listB = std.ArrayList(i32).init(arena.allocator());

    // more stuff
}

First we create an arena allocator from the parent one (that we received as an argument), and make sure to deinit it after the scope exists. We then initialize two lists with this arena's allocator.

👆 The allocator system in Zig is intuitive once you can wrap your head around what they actually do. Will get back to this, but now, let's continue:

// Read lines into two lists
var lines = std.mem.tokenizeScalar(u8, input, '\n');
while (lines.next()) |line| {
    var nums = std.mem.tokenizeScalar(u8, line, ' ');
    try listA.append(try std.fmt.parseInt(i32, nums.next().?, 10));
    try listB.append(try std.fmt.parseInt(i32, nums.next().?, 10));
}

We tokenize the whole input. It's.. like split, but empty strings are skipped. Then we iterate over the lines, and tokenize the individual lines. Even though the input has 3 spaces between the numbers, we don't need to care about them, because empty strings are skipped, so we only get two items - the two numbers.

Then we parse them as numbers and append them to the previously created lists.

moving on, sorting the lists

// Sort the lists
std.mem.sort(i32, listA.items, {}, comptime std.sort.asc(i32));
std.mem.sort(i32, listB.items, {}, comptime std.sort.asc(i32));

It's a draw the rest of the owl situation. I'm happy that it works, can't quite wrap my head around why I need all these stuff. It does the sorting in-place. And then, the distance counting:

var distance: u32 = 0;
for (listA.items, listB.items) |a, b| {
    distance += @abs(a - b);
}

return distance;

So a cool feature in Zig is that you can iterate over multiple slices(/arrays/lists/whatevers) at the same time as long as their lengths are the same (if not, you get a panic at the disco). The above loop does just that - in each iteration, we get the next value from both lists, which we can then use to figure out the distance.

On to task two:

Task 2

frequencies

Very similar to the first task, the difference being that instead of creating a list from the second column, we create a map of number -> how many times they occurred in the column.

So instead of listB, we have:

var frequencies = std.AutoHashMap(u32, u32).init(arena.allocator());

and instead of appending to listB, we do:

const num2 = try std.fmt.parseInt(u32, nums.next().?, 10);
if (frequencies.get(num2)) |exists| {
    try frequencies.put(num2, exists + 1);
} else {
    try frequencies.put(num2, 1);
}

Again a great feature here: in the if predicate we try to get the value from the map, and if it exists, it gets captured in the |exists| variable, which we can use in the truthy-block, where we increment its value by one and put it back. If it is not yet in the map, we put one in there.

Then the summation:

var total: u32 = 0;
for (listA.items) |num| {
    total += num * (frequencies.get(num) orelse 0);
}

return total;

Iterate over listA, and add each to the total multiplied by the frequency we have in the map (orelse 0 if missing).

Learnings and gotchas

So many things went wrong for me. I initially used Zed, then switched to Visual Studio Code because of inlay hints. Probably I could set up Zed to have inlays, but looks like the Zig extension in VSCode is a bit better.. or at least has better defaults.

All this to say that Zig currently lacks a bunch of editor support that other mainstream languages have in abundance. That said, Zig's compiler is super fast and rather clear about what the issues are, so I could iterate on the code quickly.

Error handling

You probably noticed a bunch of try-s there. We need those to do fallible operations. They are a shorthand for "use the value or return the error", like Rust's question mark ? operator.

Allocators

So what are these, exactly? Well it's Zig's way to handle memory allocations. There are a bunch, of which I used 2 3 in my little code adventure.

You saw the std.heap.page_allocator in the run() function, which is basically an "allocate onto the heap" situation. You also saw the std.heap.ArenaAllocator, which is an allocator that can allocate many times, but only gets freed once, in one go. This is useful in situations where the scope of the memory is well defined, i.e.: a game loop (a frame), or a server request-response cycle. Once these are done, whatever memory was allocated can be freed, because the next iteration will not use any of them.. unless data is leaked, which is a perfect segue to the third one I used:

const example =
    \\3   4
    \\4   3
    \\2   5
    \\1   3
    \\3   9
    \\3   3
;
test "day1 task1" {
    const result = try task1(std.testing.allocator, example);
    try expect(result == 11);
}

test "day1 task2" {
    const result = try task2(std.testing.allocator, example);
    try expect(result == 31);
}

You see, std.testing.allocator is a neat one, because it's just like a normal general purpose allocator, but has two caveats:

  1. it reports memory leaks - i.e.: memory that was allocated but not freed
  2. it can only be used in a test scope

It's great, because I did have memory leaks, mostly because I did not yet understand how to use the allocator system. So how does it look when I comment out the arena.deinit() call? Let's call zig build test on it:

error: 'day1.test.day1 task1' leaked: [gpa] (err): memory address 0x10455c000 leaked

.. and then a bunch of info to help us find where it leaked. Pretty neat!

Onwards to day 2!

P.s.: full code in this repository

P.p.s.: I'm using zig version 0.14.0-dev.2079+ba2d00663