Path for a reference to a vec's item?

In this code:

fn main() {
    let a: Vec<i32> = vec![1,2,3];
    let b: &i32 = &a[2];
    println!("{a:?}, {b}");
}

Should I think of b as path number 2 in the image below, or paths number 1a and 1b (although in this case I'm unsure how it directs to 1b unless it remembers the offset, somehow ?

If you want to edit the image maybe this can be forked from the source.

The address stored in b points directly to the heap-stored data, which I believe you mean by path 2, right?

You can print the addresses of references using the p formatter by the way:

fn main() {
    let a: Vec<i32> = vec![1,2,3];
    
    let b: &i32 = &a[2];
    let heap_addr_of_data_of_a: &[i32] = &a;
    let addr_of_a_on_stack: &Vec<i32> = &a;
    
    println!("{addr_of_a_on_stack:p}, {heap_addr_of_data_of_a:p}, {b:p}");
}

Playground.

2 Likes

Neat, thanks, but there are 8 bytes between b and start of data in a?

0x7ffe34f615e8, 0x64ce6f17ab10, 0x64ce6f17ab18

PD: sorry, i thought it was a[1].

from my test this is the same as &a[0], right? It's just handier to me since the type is only &i32 ?

heap_addr_of_data_of_a is a reference to the whole slice on the heap. &a[0] is a reference to the first element of the slice. The latter can only access the first element, the former allows you to inspect all elements of the slice.

1 Like

In the reply you write:

let heap_addr_of_data_a: &[i32] = &a;

Is that considered the most idiomatic way, or should one use &a[..] ?

Both are semantically equivalent. &a is shorter, but &a[..] a bit more expressive. I wouldn't consider one to be more idiomatic than the other, but that's just my opinion.

1 Like

Keep in mind that although it only "actually" stores the address of the item when running (with the usual "behaves as-if it follows the rules" caveats once you enable optimizations), the borrow is still a reborrow of the implicit borrow of the Vec when you constructed it, and therefore it prevents simultaneous mutable references to any other item (or the Vec itself, eg with a push()) as a compile-time check.

I don't understand what you mean, sorry. But if you rephrase or add a simple snippet I may.

Basically, the way you originally described it is essentially how it does work in Rust, but only at compile time.

When you write something like:

let b: &i32 = &a[2];

First it creates a reference to a implicitly when performing the [2] (it also does the same thing when calling methods like a.push(4)). It then gets the item through that reference, and "reborrows" through the reference to a to create the reference you store to b, that is it's a reference "through" the reference to a, keeping it "alive" as long as the reference in b stays alive.

In practice this means if you then try to modify a, then use b (meaning it must be kept alive until then) Rust will complain:

a.push(4);
println!("{a:?}, {b}");
   Compiling playground v0.0.1 (/playground)
error[E0502]: cannot borrow `a` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let b: &i32 = &a[2];
  |                    - immutable borrow occurs here
4 |     a.push(4);
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{a:?}, {b}");
  |                      --- immutable borrow later used here

This is needed in this case because when you add an item to a, it might need to get more memory to store the items, moving them to a new location, which would move the item b refers to somewhere else.

In some languages, using b might just crash your program, in others you pay a runtime cost to ensure it stays up to date, Rust chose to instead make that a compile error.

I mention this mostly because your original description was actually pretty accurate, just at the wrong time; so when you run into this behavior it would likely be quite confusing.

1 Like

That's interesting, so in code, your first part would be:

let b: &i32 = &a[2];

acts as (approximately)

let b: &i32 = &( ( **(&a) )[2] );

where the &a is first created as a new reference in the stack, then we follow the pointer back to variable b, then another pointer to the data (together make the **) then we access [2], then we create a reference?

So when you borrow an element of a (not copying, but using &), all a is now blocked from modifying it.

However, I feel that at the same time one needs to think of it using path 2 because the reference ends up pointing to that value directly?

Bear in mind:

I think the point Simon is making is that the borrow checker treats the whole vector in a as being immutably referenced while the reference to &a[2] is live. This relationship (which only exists at compile time) looks more like the 1a, 1b path in your graphic. It doesn't mean any additional pointers are created at run time.

1 Like

that's very clear, thanks a lot!

I think the reason why I said more idiomatic was that to my untrained eye &a is a reference to the variable a in the stack, I'm not to its data on the heap.

I find slightly confusing that the result of &a depends on the type assigned to the left although I understand that I will have to get used to it!

This is called deref coercion in case you were wondering.

1 Like

Thanks, I think it's time to read it =)

This topic was automatically closed 90 days after the last reply. We invite you to open a new topic if you have further questions or comments.