Why do &a and &copied have the same address in this example, and why does a type annotation change the result?

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:

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

  1. Is Zig allowed to give two distinct const bindings the same address?
  2. Is this particular result intentional, an implementation detail, or
    possibly a bug?
  3. Is there a design discussion explaining why Zig handles this
    differently from the C and C++ examples?
  4. 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?

In Zig, if you don’t have a type signature, it’s a compile time optimation. So the const copied = a is not on a stack so it doesn’t have its own address. So the copied has the same address as a. The compiler finds out that they are identical, so it optimizes the copied as an alias of the a.

If you use a type signature, it forces to declare a variable for runtime using, and it will have its own address on stack. So the typed_copy has its own address to hold the address of a.

Similar story is

var i = 0;
while (i < my_slice.len) { my_slice[i] = 0; i += 1}

This won’t work, and the compiler will tell you the reason: the i is a compile time integer. The i is not on a stack, so it cannot be added and assigned later in runtime, even if from the codes you can assume it must be usize, but it doesn’t have an address to use in runtime…

1 Like

Thanks! I think I follow the comptime_int example. One thing I’m still unsure about: in my example, copied is inferred as *u32 from a, so does the lack of an explicit type annotation itself make its value comptime-known?

Also, I can take &copied, and in the LLVM IR I inspected it used the same alloca as &a. Did you mean that copied doesn’t necessarily get a separate stack slot? I’m still trying to understand whether the equal addresses are intended language behavior or a consequence of the current lowering.

Yes, if you don’t put a type signature, it’s a comptime known aliasing, instead of variable declaration.

I guess it’s a language behavior to optimize the speed and stack memory usage.

But I cannot say this on behalf of the authors. I am just a random on this forum.

1 Like

In general, Zig (and also most other compiled languages) tries to do as much as possible at compile time when optimizing code. This is based on the assumption that the produced binary is run more often than it is compiled.

This happens by a compiler proving that the optimization it wants to do has no side effect. So if a compiler can prove that the results doesn’t change, it will try to do it at compile time. This is the only guarantee programmers really get(aside from other things that they have specified, like alignment, struct layout and so on).

In this case this case this applies to copied because it’s an immutable copy. If it were declared as var copied = a; the compiler couldn’t make their addresses equal.

This is not correct. Omitting a type signature does not turn an expression into a compile-time optimization(for that we have comptime). It only invokes type inference.

When you write const i = 1; the variable i has type comptime_int only because unconstrained integer literals default to it. Here, a is initialized with &x where x is a runtime variable. Therefore, a is a runtime pointer of type *u32 and const copied = a; simply infers that exact runtime type. You can see this by using;

@compileLog(a, copied, typed_copy);
// which outputs
@as(*const u32, [runtime value]), @as(*const u32, [runtime value]), @as(*const u32, [runtime value])

Why the type annotation changes the address

I’m not that well versed in the Zig compiler internals but afaik the following is happening.

The difference between copied and typed_copy comes down to how AstGen lowers them.
The first is an untyped alias for which AstGen does not emit a new evaluation node. When semantic analysis lowers &a and &copied, the deduplication logic sees identical instruction nodes and reuses the same stack slot.
The second is a typed binding for which the AstGen inserts an explicit coercion node even though the types are identical. This means that everything following that also treats them as different. In theory this could also be caught and deduplicated but it’s likely not happening often in a real codebase, which is why it’s likely not implemented.

4 Likes

Thanks, this helps! I tried @compileLog on my original example and got *u32, [runtime value] for all three pointers. That makes the distinction between inferring the type and knowing the pointer value at compile time much clearer.

The AstGen explanation also matches what I saw in the ZIR: a and copied were associated with the same operand, while typed_copy had a separate as_node.

What I’m still unsure about is whether Zig intends to allow two distinct const bindings to have the same address, or whether this result is just a consequence of the current implementation. Do you know if there’s a language rule or discussion about that?

I don’t think Zig intends to have any particular behaviour for variables and their locations, only guarantees that it is a valid location and alignment.

Granted that isn’t said explicitly, I am infering that it is the same minimum guarantees as typical in other parts of the language e.g size and layout of struct.

The purpose is to give much leeway to the compiler to optimise and provide debug features while also keeping the language simple.

1 Like

Thanks, that helps! I’m digging into this because I’m learning how garbage collectors find roots, especially when scanning the stack.

It got me thinking that a collector can’t assume every source-level binding has its own stack slot. With a moving GC, there’s also the question of how references stay valid after an object moves.

Lots for me to learn here. Thanks again for sharing your thoughts!

If by binding you mean GC allocation, it will be in memory somewhere as it is necessarily runtime known, though not necessarily on the stack…

That’s the neat part, they don’t :D! not without more indirection at least.

1 Like

Ah, I meant the source-level bindings holding references to the GC allocation, rather than the allocation itself.

And yes, the moving GC part is exactly what got me curious. If the object moves, something needs to make sure the program uses its new location.

I also tried a similar example in Rust:

use std::cell::Cell;
use std::ptr;

fn main() {
    let x = Cell::new(10_u32);
    let a = &x;
    let copied = a;

    println!("same target: {}", ptr::eq(a, copied));
    println!("same binding address: {}", ptr::eq(&a, &copied));

    copied.set(90);
    println!("x={}, a.get()={}", x.get(), a.get());
}

With rustc 1.99.0 on Windows, I got the same result with opt-level=0
and opt-level=2:

same target: true
same binding address: false
x=90, a.get()=90

I used Cell so I could change the value through a shared reference.
I know this isn’t exactly equivalent to the C and Zig examples, but I
thought it was interesting to compare the results.