使用赋值运算符时实际发生了什么?

发布于 2025-01-16 16:41:43 字数 792 浏览 0 评论 0原文

我有这样的代码:

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);

}

可能的输出:

0x7ffe631ffc28
0x7ffe631ffc28
0x7ffe631ffc90

我试图了解执行 let b = p 时会发生什么。我知道,如果 p 拥有原始类型或任何具有复制或克隆特征的类型,则值或结构将被复制到新变量中。在本例中,我没有在 Point 结构中定义任何这些特征,因此我希望 b 应该取得该结构的所有权,并且不应进行任何复制。

pb 怎么可能有不同的内存地址?该结构是否从一个地址移动到另一个地址?它是隐式复制的吗?让 b 拥有创建结构时已分配的数据并因此保持相同的地址难道不应该更有效吗?

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?

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。

评论(1

依 靠 2025-01-23 16:41:43

您正在体验观察者效应:通过获取指向这些字段的指针(发生这种情况)当您使用 {:p} 格式化引用时,您会导致编译器和优化器改变其行为。 您通过测量改变了结果!

获取指向某个对象的指针要求它位于可寻址内存中某处,这意味着编译器无法将 bp 放入 CPU 寄存器中(它更愿意在可能的情况下将内容放入其中,因为寄存器速度很快)。我们甚至还没有进入优化阶段,但我们已经影响了编译器做出的关于数据需要放在哪里的决策——这是限制优化器可以做的一件大事。

现在优化器必须弄清楚是否可以省略该移动。使用指向 bp 的指针可能阻止优化器这样做,但也可能不会。也有可能您只是在没有优化的情况下进行编译。

请注意,即使 PointCopy,如果您删除了所有指针打印,优化器甚至可能会删除副本(如果它可以证明 p) > 要么在副本的另一侧未使用,要么两个值都没有发生变化(这是一个很好的选择,因为两个值都没有声明 mut)。

规则如下:永远不要尝试从代码中确定编译器或优化器对您的代码执行的操作 - 这样做实际上可能会破坏优化器并导致您得出错误的结论。这适用于所有语言,而不仅仅是 Rust。

唯一有效的方法是查看生成的程序集。

那么让我们这样做吧!


我使用您的代码作为起点,编写了两个不同的函数,一个带有移动,一个没有:

#![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();
}

在我们继续查看程序集之前需要指出的一些事情:

  • std::hint::black_box() 是一个实验性函数,其目的是充当优化器的黑匣子。优化器不允许查看这个函数来了解它的作用,因此它无法优化它。如果没有这个,优化器将查看函数的主体并正确地得出结论:它根本没有做任何事情,并将整个事情作为无操作消除。
  • 我们将这两个函数标记为#[inline(never)],以确保优化器不会将这两个函数内联到main()中。这使得它们更容易相互比较。

所以我们应该从中得到两个函数,然后我们可以比较它们的汇编。

但是我们没有得到两个函数。

在生成的程序集中,找不到b()。那么到底发生了什么?让我们看看 main() 做了什么:

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

嗯...你能看一下吗?优化器发现两个函数在语义上是等效的,尽管其中一个函数有额外的移动。因此它决定完全消除 b() 并使其成为 a() 的别名,从而导致两次调用 a()

出于好奇,我更改了 b() 中的文字 f64 值,以防止函数统一,并看到了我期望看到的内容:除了不同的值之外,发出的组件是相同的。编译器忽略了这一举动。

(游乐场 -- 注意您需要手动按“运行”旁边的三点按钮并选择“ASM” 选项。)

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.)

~没有更多了~
我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
原文