zig-arraylist-hashmap

ArrayList and HashMap in Zig 0.16 with Allocators

  • 4 min

An Allocator is the piece that allows a structure to request dynamic memory when it doesn’t know in advance how much it will grow.

So far we’ve seen allocators quite directly: request memory, use it, and free it. But where they truly become everyday tools is in the dynamic structures of the standard library, such as ArrayList and HashMap.

In Zig, many structures do not hide the memory they use. If a collection can grow, there will normally be an allocator nearby. It’s Zig’s way of making it clear that this operation may allocate memory.

Why a Collection Needs Memory

A normal array ([N]T) has a fixed size. The compiler knows how many elements there are and reserves that space.

const numbers = [_]u8{ 10, 20, 30 };
Copied!

That’s fine if we know the size in advance. But often we don’t:

  • We read lines from a file.
  • We receive data over the network.
  • We are accumulating results.
  • We build a list of errors, tokens, or entities.

That’s when we need a structure that can grow. And if something can grow, someone has to give it new memory.

That someone is the Allocator.

ArrayList

An ArrayList is a dynamic list. Conceptually, it’s similar to a C++ vector or a list in other languages, but with Zig’s philosophy: memory is not hidden.

const std = @import("std");

pub fn main() !void {
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(allocator);

    try list.append(allocator, 10);
    try list.append(allocator, 20);
    try list.append(allocator, 30);

    for (list.items) |value| {
        std.debug.print("{d}\n", .{value});
    }
}
Copied!

Three important details:

  1. We create the list with .empty.
  2. Each operation that can grow receives the allocator.
  3. We free the memory with deinit(allocator).

The list exposes its elements through list.items, which is a slice ([]T). This is very convenient because we can iterate over it like any other slice.

If you save list.items and then do more append operations, the list may reallocate memory. In that case, the previous slice becomes invalid. The same applies to pointers obtained from its elements.

Capacity and Reallocations

An ArrayList does not request memory element by element. It normally reserves a block with a certain capacity and fills it.

If it runs out of space, it requests a larger block, copies the data, and frees the previous block. This is called reallocating memory.

If you roughly know how many elements you’ll store, you can reserve capacity before starting:

var list: std.ArrayList(u32) = .empty;
defer list.deinit(allocator);

try list.ensureTotalCapacity(allocator, 1000);

for (0..1000) |i| {
    try list.append(allocator, @intCast(i));
}
Copied!

This avoids many intermediate reallocations. It’s not always necessary, but in performance-sensitive code it can make a difference.

HashMap

A HashMap is a key-value table. That is, a structure where we store a value associated with a key.

For example:

“temperature” -> 23 “humidity” -> 61 “pressure” -> 1013

In Zig, one of the most common variants for text keys is std.StringHashMap.

const std = @import("std");

pub fn main() !void {
    var debug_allocator = std.heap.DebugAllocator(.{}){};
    defer _ = debug_allocator.deinit();
    const allocator = debug_allocator.allocator();

    var sensors = std.StringHashMap(u32).init(allocator);
    defer sensors.deinit();

    try sensors.put("temperature", 23);
    try sensors.put("humidity", 61);
    try sensors.put("pressure", 1013);

    if (sensors.get("humidity")) |value| {
        std.debug.print("Humidity: {d}%\n", .{value});
    }
}
Copied!

Here too there is dynamic memory. The map needs to reserve space for its internal buckets, and it can grow as we insert more elements.

Iterating Over a HashMap

To iterate over a HashMap, we use an iterator.

var it = sensors.iterator();
while (it.next()) |entry| {
    std.debug.print("{s}: {d}\n", .{ entry.key_ptr.*, entry.value_ptr.* });
}
Copied!

Each entry gives us pointers to the key and value. That’s why we access them with .*.

It may seem a bit more explicit than in other languages, but it’s consistent with Zig: if you’re touching indirect memory, it shows in the code.

ArrayList vs HashMap

Although both structures use allocators, they serve different purposes.

StructureTypical UseAccess
ArrayList(T)Ordered list of elementsBy index
StringHashMap(T)Dictionary with text keysBy key
AutoHashMap(K, V)Dictionary for keys compatible with autoHash, like integers or enums.By key

If you just need to accumulate elements, use ArrayList. If you need to look up by a key, use a HashMap.

StringHashMap does not copy the keys when inserting them. The text used as a key must remain alive as long as it stays inside the map. The literals in the example last for the entire program; for temporary text, you’ll need to duplicate it and free that copy when appropriate.