Are you my Parent?
An intrusive, type-erased list gives you a Node.
You need the struct around it.
When the trouble starts
One list, one struct type: no trouble. You know what is inside.
It changes when the list becomes part of a real system:
- A mailbox grows.
- A scheduler gets more job types.
- A dispatcher starts passing different structs through the same list.
Then you pop a Node.
What struct is this Node inside?
The std list does not know.
Parent is Zig's word
Paternitas did not invent it.
- Zig calls the struct that contains a field the field's parent.
@fieldParentPtrgoes from the field to its parent.Jobis the Parent of itsnodefield.
The list gives you a Node. You need its Parent.
NAQ: Why the word Parent?
Zig already uses it, in @fieldParentPtr.
Paternitas only borrows it.
The footgun
You keep a Message and a Job in one std.DoublyLinkedList:
const Message: type = struct {
text: []const u8,
node: std.DoublyLinkedList.Node = .{},
};
const Job: type = struct {
id: u32,
node: std.DoublyLinkedList.Node = .{},
};
var message: Message = .{ .text = "hello" };
var job: Job = .{ .id = 42 };
var list: std.DoublyLinkedList = .{};
list.append(&message.node);
list.append(&job.node);
You pop a Node.
- Is it in a Message, or in a Job?
- The list does not know.
@fieldParentPtrgives you whatever type you ask for.
The right guess works:
const first: *std.DoublyLinkedList.Node = list.popFirst() orelse return error.ListEmpty;
const m: *Message = @fieldParentPtr("node", first);
std.log.info("message: {s}", .{m.*.text});
const second: *std.DoublyLinkedList.Node = list.popFirst() orelse return error.ListEmpty;
const j: *Job = @fieldParentPtr("node", second);
std.log.info("job: {d}", .{j.*.id});
Now guess wrong. The first Node is in a Message. You ask for a Job.
This line is written text, not working code:
- It compiles.
- It runs.
jobpoints into a Message.- A read gets another struct's bytes.
- A write breaks another struct: its mutex, its pointers, its length.
- The crash comes later, somewhere else. Or never: just wrong data.
One wrong guess in a large system costs days of debugging.
NAQ: Why no working code shows it?
The Zig 0.16.0 langref says so, under @fieldParentPtr.
- When the pointer is not that field of the result type, and the result type has ill-defined layout, it is unchecked Illegal Behavior.
- A plain struct, as
Job, has ill-defined layout. - Unchecked means no build mode catches it.
- Illegal Behavior means the optimizer may assume it never happens. So no test can say what it does.
- In practice it subtracts an offset. The program runs on with wrong data, or fails later.
The same with Paternitas
Paternitas gives you an answer.
A wrong type gives null:
/// Asks each Parent type in turn. A Node that no type claims is an error.
fn recoverAndCount(node: *std.DoublyLinkedList.Node, counts: *Counts) !void {
if (TypedMessage.parentFromNode(node)) |m| {
std.log.info("message: {s}", .{m.*.text});
counts.*.messages += 1;
return;
}
if (TypedJob.parentFromNode(node)) |j| {
std.log.info("job: {d}", .{j.*.id});
counts.*.jobs += 1;
return;
}
return error.UnknownParent;
}
Or, when another type is a bug, a panic. The panic message shows both type names:
mustParentFromNode prints the same kind of message.
- Both panic in every build mode.
- No new container. No allocation. No lock.
- Your std list stays your std list.
NAQ: Why a Latin name?
Paternitas is Latin for "fatherhood".
It finds the Parent of a Node.
The motto on the logo, Agnitio paternitatis, is "the recognition of fatherhood".
Name and origin has the rest.
Side by side
| plain std list | with Paternitas | |
|---|---|---|
| a wrong type | compiles, runs, reads garbage | null, or a panic |
| the panic says | nothing | "asked for Job, found Message" |
| where the bug shows | later, somewhere else | at the call that got it wrong |
| release builds | no check | the same check, in every build mode |
| several types in one list | you track the type yourself | you ask: "is this a Message?" |
| the field name | in every @fieldParentPtr("node", n) |
once, in the struct |
| the cost | none | one pointer compare per check |
| allocation for the link | none | none |
| copy of your struct | no | no |
| your list | std | still std, with the same calls |
| your memory | yours | still yours. Nothing is allocated |
| lifetime checking | no | no |
| locking | no | no |
The important row is the first one: a wrong type.
That is what Paternitas is here to fix.
Do you need it?
No, when each list carries one struct type, and you know which one.
- Plain
@fieldParentPtris enough.
Yes, when:
- one list deliberately mixes struct types;
- the code handling the list should not know every struct type;
- the Node comes from somewhere else;
- you do not want to trust every
@fieldParentPtrcall by hand; - a bad cast would turn into a long debugging session.
The last one is a perfectly respectable reason.
Paternitas is for the moment when "I know what this is" becomes "I hope I know what this is".
Intrusive is not the problem.
The unknown type is the problem.
Where this came from
The Ziggit post New LinkedList API footgun started it.