别名

首先,让我们先了解一些重要的注意事项

  • 为了便于讨论,我们将使用最广泛的别名定义。Rust 的定义可能会更严格,以考虑突变和活跃性。

  • 我们将假设单线程、无中断的执行环境。我们还将忽略内存映射硬件等内容。Rust 假设这些情况不会发生,除非您明确告知。有关更多详细信息,请参阅并发章节

综上所述,以下是我们的工作定义:如果变量和指针引用内存的重叠区域,则它们*别名*。

为什么别名很重要

那么我们为什么要关心别名呢?

考虑这个简单的函数

#![allow(unused)]
fn main() {
fn compute(input: &u32, output: &mut u32) {
    if *input > 10 {
        *output = 1;
    }
    if *input > 5 {
        *output *= 2;
    }
    // remember that `output` will be `2` if `input > 10`
}
}

我们*希望*能够将其优化为以下函数

#![allow(unused)]
fn main() {
fn compute(input: &u32, output: &mut u32) {
    let cached_input = *input; // keep `*input` in a register
    if cached_input > 10 {
        // If the input is greater than 10, the previous code would set the output to 1 and then double it,
        // resulting in an output of 2 (because `>10` implies `>5`).
        // Here, we avoid the double assignment and just set it directly to 2.
        *output = 2;
    } else if cached_input > 5 {
        *output *= 2;
    }
}
}

在 Rust 中,这种优化应该是合理的。对于几乎任何其他语言来说,这都是不可能的(除非进行全局分析)。这是因为优化依赖于知道不会发生别名,而大多数语言在这方面都相当宽松。具体来说,我们需要担心使 inputoutput 重叠的函数参数,例如 compute(&x, &mut x)

使用该输入,我们可以得到以下执行结果

                    //  input ==  output == 0xabad1dea
                    // *input == *output == 20
if *input > 10 {    // true  (*input == 20)
    *output = 1;    // also overwrites *input, because they are the same
}
if *input > 5 {     // false (*input == 1)
    *output *= 2;
}
                    // *input == *output == 1

对于此输入,我们优化的函数将产生 *output == 2,因此我们优化的正确性依赖于此输入是不可能的。

在 Rust 中,我们知道此输入应该是不可能的,因为 &mut 不允许被别名。因此,我们可以安全地拒绝其可能性并执行此优化。在大多数其他语言中,此输入是完全可能的,必须予以考虑。

这就是为什么别名分析很重要的原因:它可以让编译器执行有用的优化!一些例子

  • 通过证明没有指针访问值所在的内存,将值保存在寄存器中
  • 通过证明自上次读取后某些内存没有被写入来消除读取操作
  • 通过证明在下次写入之前某些内存从未被读取来消除写入操作
  • 通过证明读取和写入操作彼此不依赖来移动或重新排序它们

这些优化还有助于证明更大优化(如循环向量化、常量传播和死代码消除)的合理性。

在前面的例子中,我们利用了 &mut u32 不能被别名的事实来证明对 *output 的写入不可能影响 *input。这让我们可以将 *input 缓存在寄存器中,从而消除一次读取操作。

通过缓存此读取操作,我们知道 > 10 分支中的写入操作不会影响我们是否采用 > 5 分支,从而让我们在 *input > 10 时还可以消除一次读-修改-写操作(将 *output 加倍)。

关于别名分析,需要记住的关键一点是,写入操作是优化的主要障碍。也就是说,唯一阻止我们将读取操作移动到程序其他部分的原因是我们可能将其与对同一位置的写入操作重新排序。

例如,在以下修改后的函数版本中,我们不关心别名,因为我们将对 *output 的唯一写入操作移到了函数的最后。这让我们可以自由地重新排序在它之前发生的 *input 的读取操作

#![allow(unused)]
fn main() {
fn compute(input: &u32, output: &mut u32) {
    let mut temp = *output;
    if *input > 10 {
        temp = 1;
    }
    if *input > 5 {
        temp *= 2;
    }
    *output = temp;
}
}

我们仍然依赖别名分析来假设 input 不是 temp 的别名,但证明要简单得多:局部变量的值不能被在其声明之前存在的变量别名。这是每种语言都可以自由做出的假设,因此该函数的这个版本可以用任何语言以我们想要的方式进行优化。

这就是为什么 Rust 将使用的“别名”定义可能涉及一些活跃性和突变的概念:如果实际上没有发生对内存的写入操作,我们实际上并不关心是否发生别名。

当然,Rust 的完整别名模型还必须考虑到函数调用(可能会改变我们看不到的内容)、原始指针(本身没有别名要求)和 UnsafeCell(它允许 & 的引用被改变)等因素。