Zig’s new LinkedList API (it’s time to learn fieldParentPtr) by todsacerdoti
Apr 10, 2025
In a recent, post-Zig 0.14 commit, Zig’s SinglyLinkedList
and DoublyLinkedList
saw significant changes.
The previous version was a generic and, with all the methods removed, looked like:
pub fn SinglyLinkedList(comptime T: type) type {
return struct {
first: ?*Node = null,
pub const Node = struct {
next: ?*Node = null,
data: T,
};
};
}
The new version isn’t generic. Rather, you embed the linked list node with your data. This is known as an intrusive linked list and tends to perform better and require fewer allocations. Except in trivial examples, the data that we store in a linked list is typically stored on the heap. Because an intrusive linked list has the linked list node embedded in the data, it doesn’t need its own allocation. Before we jump into an example, this is what the new structure looks like, again, with all methods removed:
pub const SinglyLinkedList = struct {
first: ?*Node = null,
pub const Node = struct {
next: ?*Node = null,
};
};
Much simpler, and, notice that this has no link or reference to any of our data. Here’s a working example that shows how you’d use it:
const std = @import("std");
const SinglyLinkedList = std.SinglyLinkedList;
pub fn main() !void {
var gpa: std.heap.DebugAllocator(.{}) = .init;
const allocator = gpa.allocator();
var list: SinglyLinkedList = .{};
const user1 = try allocator.create(User);
defer allocator.destroy(user1);
user1.* = .{
.id = 1,
.power = 9000,
.node = .{},
};
list.prepend(&user1.node);
const user2 = try allocator.create(User);
defer allocator.destroy(user2);
user2.* = .{
.id = 2,
.power = 9001,
.node = .{},
};
list.prepend(&user2.node);
var node = list.first;
while (node) |n| {
std.debug.print("{any}n", .{n});
node = n.next;
}
}
const User = struct {
id: i64,
power: u32,
node: SinglyLinkedList.Node,
};
To run this code, you’ll need a nightly release from within the last week. What do you think the output will be? You should see something like:
SinglyLinkedList.Node{ .next = SinglyLinkedList.Node{ .next = null } }
SinglyLinkedList.Node{ .next = null }
We’re only getting the nodes, and, as we can see here and from the above skeleton structure of the new SinglyLinkedList
, there’s nothing about our users. Users have nodes, but there’s seemingly nothing that links a node back to its containing user. Or is there?
In the past, we’ve described how the compiler uses the type information to figure out how to access fields. For example, when we execute user1.power
, the compiler knows that:
id
is +0 bytes from the start of the structure,power
is +8 bytes from the start of the structure (because id is an i64), andpower
is an i32
With this information, the compiler knows how to access power
from user1
(i.e. jump forward 8 bytes, read 4 bytes and treat it as an i32). But if you think about it, that logic is simple to reverse. If we know the address of power
, then the address of user
has to be address_of_power - 8
. We can prove this:
const std = @import("std");
pub fn main() !void {
var user = User{
.id = 1,
.power = 9000,
};
std.debug.print("address of user: {*}n", .{&user});
const address_of_power = &user.power;
std.debug.print("address of power: {*}n", .{address_of_power});
const power_offset = 8;
const also_user: *User = @ptrFromInt(@intFromPtr(address_of_power) - power_offset);
std.debug.print("address of also_user: {*}n", .{also_user});
std.debug.print("also_user: {}n", .{also_user});
}
const User = struct {
id: i64,
power: u32,
};
The magic happens here:
const power_offset = 8;
const also_user: *User = @ptrFromInt(@intFromPtr(address_of_power) - power_offset);
We’re turning the address of our user’s power field, &user.power
into an integer, subtracting 8 (8 bytes, 64 bits), and telling the compiler that it should treat that memory as a *User
. This code will probably work for you, but it isn’t safe. Specifically, unless we’re using a packed or extern struct, Zig makes no guarantees about the layout of a structure. It could put power
BEFORE id
, in which case our power_offset
should be 0. It could add padding after every field. It can do anything it wants. To make this code safer, we use the @offsetOf
builtin to get the actual byte-offset of a field with respect to its struct:
const power_offset = @offsetOf(User, "power");
Back to our linked list, given that we have the address of a node
and we know that it is part of the User
structure, we are able to get the User
from a node. Rather than use the above code though, we’ll use the slightly friendlier @fieldParentPtr
builtin. Our while
loop changes to:
while (node) |n| {
const user: *User = @fieldParentPtr("node", n);
std.debug.print("{any}n", .{user});
node = n.next;
}
We give @fieldParentPtr
the name of the field, a pointer to that field as well as a return type (which is inferred above by the assignment to a *User
variable), and it gives us back the instance that contains that field.
Performance aside, I have mixed feelings about the new API. My initial reaction is that I dislike exposing, what I consider, a complicated builtin like @fieldParentPtr
for something as trivial as using a linked list. However, while @fieldParentPtr
seems esoteric, it’s quite useful and developers should be familiar with it because it can help solve problems which are otherwise problematic.
13 Comments
roetlich
This looks somewhat horrifying. How do I safely write a function that takes a linked list as a parameter?
spiffyk
For better or worse, the Zig community has long been embracing the `@fieldParentPtr` builtin as the primary way to do things like OOP-ish inheritance, so this change feels pretty in-character for the language to me.
steventhedev
This feels like a net negative result. It removes some of the complexity of using generics, but it couples between the data type and the collections it can be indexed by.
What are the benefits of this approach? Is it limited to data alignment, or is it out of a greater desire to remove generics?
DeathArrow
I wonder why should a language implement in its libraries a SingleLinkedList and a DoubleLinkedList.
I do get why you need an array-like list, a dictionary/hashmap, a stack and a queue. I got the impression that linked lists aren't used very often. Or maybe it's a different case with Zig?
ralferoo
I don't use Zig, but one advantage of having a genericised intrusive linked list where the next pointer doesn't have to be the first thing in the structure is when you want to use larger types, such as 128-bit fields. Sticking a pointer at the beginning would mean the compiler would have to insert alignment padding after the pointer or break the default alignment.
mastax
> The new version isn't generic. Rather, you embed the linked list node with your data. This is known as an intrusive linked list and tends to perform better and require fewer allocations.
I don’t get this. In the generic version the data is embedded with the linked list node, so there’s only one allocation per node. ‘T’ isn’t a pointer (or doesn’t have to be) there’s no indirection between data and node.
lightingthedark
Can someone explain how the claim of higher performance works here? In C, which lacks generics, an intrusive list is preferred because otherwise you end up with each node having a void pointer to the data it holds. The previous Zig version was a generic, so the data type could easily be a value type. Given that the compiler is allowed to rearrange the layout of both structs unless data is packed or extern (in which case it almost certainly won't want to have a linked list node in the middle anyway) isn't the resulting type exactly the same in memory unless you intentionally made T a pointer type?
hoseja
Linked lists should be obscure niche data structures for when you absolutely need their unique characteristics, not some front-and-center default.
esjeon
Hmph, is there any rationale against a generic wrapper over it? Exposing the internal details can be irky in higher level application development. It would not hurt to have some inline functions.
MawKKe
Isn't the new one also horribly type-unsafe? Since you can put (parent) objects of any kind into the list without any mechanism to detect which is which?
mgaunard
There are several advantages to intrusive node-based data structures that I haven't seen stated in the article or the comments:
– the same object can move between containers with no allocation and no need for a dedicated complex API
– the same object can be part of multiple containers at once; particularly useful for intrusive binary trees, for indexing data with different criteria.
– the container can be fully polymorphic, no need for all elements to be the same dynamic type.
– no need for complex allocators, you can just store the objects as you see fit.
kajika91
Couldn't we have a function to return the data like this?
budmichstelk
[dead]