⚡ Zig Guide LiveUnofficial
✓ Zig 0.17.0-dev.1454+5faa79730On an older Zig?

MultiArrayList

const std = @import("std");
const expect = std.testing.expect;

const Monster = struct {
    hp: u32,
    x: f32,
    y: f32,
};

test "append structs, read fields as plain slices" {
    const gpa = std.testing.allocator;

    var monsters: std.MultiArrayList(Monster) = .empty;
    defer monsters.deinit(gpa);

    try monsters.append(gpa, .{ .hp = 10, .x = 1.0, .y = 2.0 });
    try monsters.append(gpa, .{ .hp = 25, .x = 3.0, .y = 4.0 });
    try monsters.append(gpa, .{ .hp = 40, .x = 5.0, .y = 6.0 });

    // items(.hp) is a real []u32: every hp value, contiguous in memory.
    // A loop that only touches hp never loads x or y into cache.
    var total: u32 = 0;
    for (monsters.items(.hp)) |hp| total += hp;
    try expect(total == 75);
}

test "get and set reassemble the struct" {
    const gpa = std.testing.allocator;

    var monsters: std.MultiArrayList(Monster) = .empty;
    defer monsters.deinit(gpa);
    try monsters.append(gpa, .{ .hp = 10, .x = 1.0, .y = 2.0 });

    const m = monsters.get(0); // gathers one element from all three arrays
    try expect(m.hp == 10 and m.x == 1.0);

    monsters.set(0, .{ .hp = 99, .x = 0.0, .y = 0.0 });
    try expect(monsters.items(.hp)[0] == 99);
}

test "mutate one column in place" {
    const gpa = std.testing.allocator;

    var monsters: std.MultiArrayList(Monster) = .empty;
    defer monsters.deinit(gpa);
    try monsters.append(gpa, .{ .hp = 10, .x = 0, .y = 0 });
    try monsters.append(gpa, .{ .hp = 20, .x = 0, .y = 0 });

    // The slice is live storage, not a copy.
    for (monsters.items(.hp)) |*hp| hp.* += 5;
    try expect(monsters.get(0).hp == 15);
    try expect(monsters.get(1).hp == 25);
}

test "cache the slice when touching several fields" {
    const gpa = std.testing.allocator;

    var monsters: std.MultiArrayList(Monster) = .empty;
    defer monsters.deinit(gpa);
    try monsters.append(gpa, .{ .hp = 1, .x = 3.0, .y = 4.0 });

    // Each items() call recomputes field pointers; slice() does it once.
    const s = monsters.slice();
    const xs = s.items(.x);
    const ys = s.items(.y);
    try expect(xs[0] * xs[0] + ys[0] * ys[0] == 25.0);
}

Same API, transposed storage

MultiArrayList(T) holds the same elements an ArrayList(T) would, but stores each field of T in its own contiguous array. Ten monsters with hp, x, and y become three arrays of ten, not ten structs side by side. You still append whole structs and get whole structs back; the transpose is invisible until you ask for one field.

Why bother

items(.hp) hands you a real []u32: every hp value packed together. A loop that sums hp touches only that array, so the CPU loads no x or y bytes it will not use. When one field is hot and the struct is wide, this is the difference between streaming exactly the bytes you need and dragging the whole struct through cache. It is also what makes bulk field updates vectorize.

The costs

get and set gather or scatter across the separate arrays, so touching a whole element is slightly more work than with a plain ArrayList. Reach for MultiArrayList when you iterate columns far more than you handle whole rows.

When several fields are needed in one pass, call slice() once and pull each items(.field) from it. Each bare items() call recomputes the field pointers; slice() does that work a single time. As with every list here, those slices are invalidated by the next append.