0

I have this code:

struct Point {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

fn main() {

    let p = Point {
        x: 1.0,
        y: 2.0,
        z: 3.0,
    };

    println!("{:p}", &p);
    println!("{:p}", &p.x); // To make sure I'm seeing the struct address and not the variable address. </paranoid>
    let b = p;
    println!("{:p}", &b);

}

Possible output:

0x7ffe631ffc28
0x7ffe631ffc28
0x7ffe631ffc90

I'm trying to understand what happens when doing let b = p. I know that, if p holds a primitive type or any type with the Copy or Clone traits, the value or struct is copied into the new variable. In this case, I have not defined any of those traits in the Point structure, so I expected that b should take the ownership of the struct and no copy should be made.

How is it possible p and b to have different memory addresses? Are the struct moved from one address to another? Is it implicitly copied? Shouldn't be more efficient to just make b own the data that has already been allocated when creating the structure, and therefore maintaining the same address?

AlanWik
  • 326
  • 1
  • 10
  • 2
    Does this answer your question? [What is actually moving a variable in Rust?](https://stackoverflow.com/questions/71576752/what-is-actually-moving-a-variable-in-rust) – Chayim Friedman Mar 22 '22 at 22:43
  • 1
    Moving in general does a memcpy, otherwise it'd be impossible to move a value into a struct/Arc/Box/anything else that requires the value to be off the stack. – Colonel Thirty Two Mar 22 '22 at 23:23
  • So, if my understanding is correct, the Copy and Clone traits are only useful when you want to reuse a variable that was assigned previously to another one? The copy is always done implicitly? – AlanWik Mar 23 '22 at 08:33
  • @AlanWik There's a few big differences between the two traits. `Copy` is a marker trait (it has no methods and therefore its behavior can't be customized). `Copy` means the values can be copied-by-blit (e.g. byte-level copying of the value). `Clone`, on the other hand, is a standard trait that requires a `clone()` method. This means the implementation _can_ copy-by-blit but it can also do something else entirely (e.g. `Vec`'s implementation copies the elements to a new allocation). – cdhowie Mar 23 '22 at 17:27
  • @AlanWik `let a = b;` will move `b` to `a` _unless_ the type of `b` is `Copy` and then it performs a copy. There isn't any way to _explicitly request_ a copy, it just happens when you try to move something that's `Copy`. With `Clone` you have to explicitly request a clone by invoking the `.clone()` method. Note also that `Copy` requires `Clone` to be implemented, which means you can bound a generic argument on `Clone` in most cases and not care too much about `Copy`. – cdhowie Mar 23 '22 at 17:29
  • Put it differently, `let b = a;` will copy `a` into `b`, and will invalidate `a` (i.e. disallow further uses of it) unless its type implements `Copy`. – Chayim Friedman Mar 23 '22 at 20:02

1 Answers1

3

You are experiencing the observer effect: by taking a pointer to these fields (which happens when you format a reference with {:p}) you have caused both the compiler and the optimizer to alter their behavior. You changed the outcome by measuring it!

Taking a pointer to something requires that it be in addressable memory somewhere, which means the compiler couldn't put b or p in CPU registers (where it prefers to put stuff when possible, because registers are fast). We haven't even gotten to the optimization stage but we've already affected decisions the compiler has made about where the data needs to be -- that's a big deal that limits what the optimizer can do.

Now the optimizer has to figure out whether the move can be elided. Taking pointers to b and p could prevent the optimizer from doing so, but it may not. It's also possible that you're just compiling without optimizations.

Note that even if Point were Copy, if you removed all of the pointer printing, the optimizer may even elide the copy if it can prove that p is either unused on the other side of the copy, or neither value is mutated (which is a pretty good bet since neither are declared mut).

Here's the rule: don't ever try to determine what the compiler or optimizer does with your code from within that code -- doing so may actually subvert the optimizer and lead you to a wrong conclusion. This applies to every language, not just Rust.

The only valid approach is to look at the generated assembly.

So let's do that!


I used your code as a starting point and wrote two different functions, one with the move and one without:

#![feature(bench_black_box)]

struct Point {
    pub x: f64,
    pub y: f64,
    pub z: f64,
}

#[inline(never)]
fn a() {
    let p = Point {
        x: 1.0,
        y: 2.0,
        z: 3.0,
    };
    
    std::hint::black_box(p);
}

#[inline(never)]
fn b() {
    let p = Point {
        x: 1.0,
        y: 2.0,
        z: 3.0,
    };
    
    let b = p;
    
    std::hint::black_box(b);
}

fn main() {
    a();
    b();
}

A few things to point out before we move on to look at the assembly:

  • std::hint::black_box() is an experimental function whose purpose is to act as a, well, black box to the optimizer. The optimizer is not allowed to look into this function to see what it does, therefore it cannot optimize it away. Without this, the optimizer would look at the body of the function and correctly conclude that it doesn't do anything at all, and eliminate the whole thing as a no-op.
  • We mark the two functions as #[inline(never)] to ensure that the optimizer won't inline both functions into main(). This makes them easier to compare to each other.

So we should get two functions out of this and we can compare their assembly.

But we don't get two functions.

In the generated assembly, b() is nowhere to be found. So what happened instead? Let's look to see what main() does:

    pushq   %rax
    callq   playground::a
    popq    %rax
    jmp     playground::a

Well... would you look at that. The optimizer figured out that two functions are semantically equivalent, despite one of them having an additional move. So it decided to completely eliminate b() and make it an alias for a(), resulting in two calls to a()!

Out of curiosity, I changed the literal f64 values in b() to prevent the functions from being unified and saw what I expected to see: other than the different values, the emitted assembly was identical. The compiler elided the move.

(Playground -- note that you need to manually press the three-dots button next to "run" and select the "ASM" option.)

cdhowie
  • 158,093
  • 24
  • 286
  • 300
  • Nice answer, since I'm a physicist. I think now I understand the Copy/Clone traits. In that case, it is safe to afirm that, passing a struct, without Copy trait, by value to a function will be optimized away so the struct remains in the same memory spot and can be used inside the function? – AlanWik Mar 23 '22 at 08:36
  • @AlanWik Possibly, depending on the calling convention of the function, whether all of the arguments can fit in CPU registers, etc. – cdhowie Mar 23 '22 at 17:24