Zig's dot star syntax (value.*)
Mar 07, 2025
Maybe I'm the only one, but it always takes my little brain a split second to understand what's happening whenever I see, or have to write, something like value.* = .{...}
.
If we take a step back, a variable is just a convenient name for an address on the stack. When this function executes:
fn isOver9000(power: i64) bool {
return power > 9000;
}
Say, with a power
of 593, we could visualize its stack as:
power -> -------------
| 593 |
-------------
If we changed our function to take a pointer to an integer:
fn isOver9000(power: *i64) bool {
return power > 9000;
}
Our power
argument would still be a label for a stack address, but instead of directly containing an number, the stack value would itself be an address. That's the indirection of pointers:
power -> -------------
| 1182145c0 |------------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- |
| 593 | <----------------------
-------------
But this code doen't work: it's trying to compare a comptime_int
(9000
) with an *i64
. We need to make another change to the function:
fn isOver9000(power: *i64) bool {
return power.* > 9000;
}
power.*
is how we dereference a pointer. Dereferencing means to get the value pointed to by a pointer. From our above visualization, you could say that the .*
follows the arrow to get the value, 593
.
This same syntax works for writing as well. The following is valid:
fn isOver9000(power: *i64) bool {
power.* = 9001;
return true;
}
Like before, the dereferencing operator (.*
), "follows" the pointer, but now that it's on the receiving end of an assignment, we write the value into the pointed add memory.
This is all true for more complex types. Let's say we have a User
struct with an id
and a name
:
const User = struct {
id: i32,
name: []const u8,
};
var user = User{
.id = 900,
.name = "Teg"
};
The user
variable is a label for the location of the [start of] the user:
user -> -------------
| 900 |
-------------
| 3 |
-------------
| 3c9414e99 | -----------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- |
| T | <----------------------
-------------
| e |
-------------
| g |
-------------
A slice in Zig, like our []const u8
, is a length (3
) and a pointer to the values. Now, if we were to take the address of user
, via &user
, we introduce a level of indirection. For example, imagine this code:
const std = @import("std");
const User = struct {
id: i32,
name: []const u8,
};
pub fn main() !void {
var user = User{
.id = 900,
.name = "Teg"
};
updateUser(&user);
std.debug.print("{d}\n", .{user.id});
}
fn updateUser(user: *User) void {
user.id += 100000;
}
The user
parameter of our updateUser
function is pointing to the user
on main
's stack:
updateUser
user -> -------------
| 83abcc30 |------------------------
------------- |
|
............. empty space |
............. or other data |
|
main |
user -> ------------- |
| 900 | <----------------------
-------------
| 3 |
-------------
| 3c9414e99 | -----------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- |
| T | <----------------------
-------------
| e |
-------------
| g |
-------------
Because we're referencing main
's user
(rather than a copy), any changes we make will be reflected in main
. But, we aren't limited to operating on fields of user
, we can operate on its entire memory.
Of course, we can create a copy of the id field (assignment are always copies, just an matter of knowing what we're copying):
fn updateUser(user: *User) void {
const id = user.id
}
And now the stack for our function looks like:
user -> -------------
| 83abcc30 |
id -> -------------
| 900 |
-------------
But we can also copy the entire user:
fn updateUser(user: *User) void {
const copy = user.*;
}
Whch gives us something like:
updateUser
user -> -------------
| 83abcc30 |---------------------
copy -> ------------- |
| 900 | |
------------- |
| 3 | |
------------- |
| 3c9414e99 | --------------------|--
------------- | |
| |
............. empty space | |
............. or other data | |
| |
main | |
user -> ------------- | |
| 900 | <------------------- |
------------- |
| 3 | |
------------- |
| 3c9414e99 | -----------------------|
------------- |
|
............. empty space |
............. or other data |
|
------------- |
| T | <----------------------
-------------
| e |
-------------
| g |
-------------
Notice that it didn't create a copy of the value 'Teg'. You could call this copying "shallow": it copied the 900
, the 3
(name length) and the 3c9414e99
(address of the name pointer).
Just like our simpler example above, we can also assign using the dereferencing operator:
fn updateUser(user: *User) void {
user.* = .{
.id = 5,
.name = "Paul",
};
}
This doesn't copy anything; it writes into the address that we were given, the address of the main's user
:
updateUser
user -> -------------
| 83abcc30 |------------------------
------------- |
|
............. empty space |
............. or other data |
|
main | |
user -> ------------- |
| 5 | <----------------------
-------------
| 4 |
-------------
| 9bf4a990 | -----------------------
------------- |
|
............. empty space |
............. or other data |
|
------------- |
| P | <----------------------
-------------
| a |
-------------
| u |
-------------
| l |
-------------
If you're still not fully comfortable with this, and if you haven't done so already, you might be interested in the pointers and stack memory parts of my learning zig series.