Zig: Tiptoeing Around @ptrCast
A variable associates memory with a specific type. The compiler uses this information to generate the correct instructions (or to tell us that our code is invalid). For example, given this code:
const std = @import("std");
const User = struct {
id: u32,
name: []const u8,
};
pub fn main() !void {
const user1 = User{.id = 1, .name = "Leto"};
std.debug.print("{d}\n", .{user1.id});
}
The compiler can use user1's
type, User
, to generate the instructions necessary for reading and writing the id
and name
fields. This is possible because, while different Users
might have different values, they all have the same memory layout. Thus, given a User
, the compiler knows the relative position (relative to the memory referenced by the variable) of each field and can generate the instructions.
While the above user1
is unquestionably a User
, we can use @ptrCast
to create a new variable that points to the same location but as a different type:
const std = @import("std");
const User = struct {
id: u32,
name: []const u8,
};
const Node = struct {
next: ?*Node,
};
pub fn main() !void {
var user1 = User{.id = 1, .name = "Leto"};
const node1: *Node = @ptrCast(&user1);
node1.next = null;
std.debug.print("{}\n", .{node1});
}
This code not only compiles, but it also runs. Compiling and running are two distinct aspects we must consider. The code compiles because we told the compiler it was ok to treat the memory as a *Node
. @ptrCast
isn't changing the memory at runtime, it's forcing the compiler to see the memory as a *Node
. In this case, the code runs because there are some truths we can rely on that make it so the memory used to represent a User
can safely be used to represent a Node
.
Consider the opposite transformation:
const std = @import("std");
const User = struct {
id: u32,
name: []const u8,
};
const Node = struct {
next: ?*Node,
};
pub fn main() !void {
var node1 = Node{.next = null};
const user: *User = @ptrCast(&node1);
std.debug.print("Id: {d}\n", .{user.id});
std.debug.print("Name: {d}\n", .{user.name});
}
Now we're creating a Node
and telling the compiler to see the underlying memory as a User
. Again, this code compiles. But what happens when we try to run it? You'll probably get the same thing I did: Id: 0
followed by a segfault. Why does it work one way but not the other? Consider the size of a Node
and the size of a User
:
const std = @import("std");
pub fn main() !void {
std.debug.print("Node: {d} User: {d}\n", .{@sizeOf(Node), @sizeOf(User)});
}
Assuming you're on a modern platform, you'll likely see: Node: 8 User: 24
. This highlights the power and danger of @ptrCast
: it's obvious that the memory underlying a Node
isn't big enough to represent a whole User
, but @ptrCast
forces the compiler to proceed as though it can.
But size constraints aren't are only issue. Let's go back to our original example and add 2 more lines at the end:
const std = @import("std");
const User = struct {
id: u32,
name: []const u8,
};
const Node = struct {
next: ?*Node,
};
pub fn main() !void {
var user1 = User{.id = 1, .name = "Leto"};
const node1: *Node = @ptrCast(&user1);
node1.next = null;
std.debug.print("{}\n", .{node1});
std.debug.print("{d}\n", .{user1.id}); // added
std.debug.print("{s}\n", .{user1.name}); // added
}
The underlying memory for node1
is more than big enough, but the code still crashes. When we write to user.id
or user1.name
, the compiler enforces correctness: id
must be an u32
and name
must be a []const u8
. Similarly, When we write null
to node1.next
, the code compiles because null
is a valid ?*Node
. But when, at runtime, we try to interpret that null
as a part of a User
, the behavior becomes undefined (i.e. we'll most likely crash).
One last thing worth pointing out is that, unless a structure is declared as packed
, Zig makes no guarantee about its memory layout. In almost all cases, you should not write to memory as one type and read it as another (which is exactly what we've done throughout the post). Unless the struct is packed
or the struct is very simple, you cannot predict how those read/writes will be interpreted by different types sharing the same memory.
In a previous post exploring Zig Interfaces we used @ptrCast
to restore erased type information. In this post, we're doing something a little different: alternating and using two concrete types. In the next post we'll examine a wonderful type in Zig's standard library which safely exploits what we've learned here.