Why does &a == &copied hold for two distinct const bindings, and why does a type annotation change it?
I was experimenting with pointer addresses and came across something I
didn’t expect:
const std = @import("std");
pub fn main() void {
var x: u32 = 10;
const a: *u32 = &x;
const copied = a;
const typed_copy: *u32 = a;
const direct: *u32 = &x;
const p = &x;
const q = &x;
std.debug.print(
"copy={} typed_copy={} direct={} untyped={} target={}\n",
.{
&a == &copied,
&a == &typed_copy,
&a == &direct,
&p == &q,
p == q,
},
);
}
On Zig 0.17.0, x86-64 Linux/WSL, I get:
copy=true typed_copy=false direct=false untyped=true target=true
I understand why the pointer values are equal: they all ultimately point
to x. What I didn’t expect was &a == &copied being true. I thought
copied would have its own address, even though it holds the same pointer
value as a.
What makes this stranger to me is that adding a type annotation changes the
result:
const copied = a; // &a == &copied: true
const typed_copy: *u32 = a; // &a == &typed_copy: false
Both have type *u32 and both point to x. Why would the annotation make
a difference when taking their addresses?
What I’ve tried
The example above gave me the same output in Debug with both -fllvm and
-fno-llvm.
I also ran a related test with a pointer returned from a noinline
identity function and a mutable pointer. They all initially pointed to
x, but their binding addresses compared differently:
&a == &copied true
&a == &direct false
&a == &via_call false
&a == &mutable false
I saw that result in Debug, ReleaseSafe, ReleaseFast and ReleaseSmall with
the default backend.
I tried a similar example in both C and C++:
int x = 10;
int *const a = &x;
int *const copied = a;
/* a == copied: true
&a == &copied: false */
With GCC/G++ 15.2.0 on Linux/WSL, using C17 and C++20, &a == &copied
was false at both -O0 and -O2. I realize Zig doesn’t have to follow the
same rules, but the difference made me curious about what Zig intends here.
Looking at the compiler
I looked at the LLVM IR for a related Debug build with -fllvm. The
address-of expressions for a and copied used the same alloca
operand.
I also used std.zig.Ast.parse and std.zig.AstGen.generate to inspect
the ZIR for the first example. As far as I can tell, a and copied
refer to the same ZIR value, and their address comparison uses the same
ref instruction on both sides. The annotated typed_copy has a
separate as_node and ref. Similarly, p and q refer to the same
ZIR value and use the same ref.
That seems to explain how I got this result in this build. I couldn’t
tell from it what the language promises, though. I also checked the
language reference (Variables, Local Variables, and “Where are the
bytes?”) and couldn’t find a rule about whether distinct local bindings
must have distinct addresses.
I found these commits while looking through the compiler:
- LocalVal and simpler ZIR for semantic analysis
refreuse for address consistency and ZIR dominancerefdeduplication for params,
which treated&a != &afor parameters as a bug
(#22140)
They helped me follow the implementation. They make it clear that repeated
address-of on the same name is meant to be consistent, but I’m still
unsure what identity two different local const bindings are supposed
to have.
Questions
- Is Zig allowed to give two distinct
constbindings the same address? - Is this particular result intentional, an implementation detail, or
possibly a bug? - Is there a design discussion explaining why Zig handles this
differently from the C and C++ examples? - How should I think about the type annotation: does it have any semantic
significance here, or is the difference just an artifact of how this
example is currently lowered?