home

Zig: Tiptoeing Around @ptrCast

Dec 11, 2023

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.