Rust 别名
首先,有几点重要声明:
- 以下的讨论将采用最广泛意义上的别名的定义。而Rust的定义可能会更加严格,需要考虑到可变性和生命周期。
- 我们假设程序都是单线程且不会中断的,同时也不会去考虑存储器映射之类的问题。除非特别指定,否则Rust默认这些事情不存在。更多的细节请见并发章节。
基于这些,我们给出定义:当变量和指针表示的内存区域有重叠时,它们互为对方的别名。
为什么别名很重要
为什么我们要关注别名?
看下面这个简单的函数。
fn compute(input: &u32, output: &mut u32) { if *input > 10 { *output = 1; } if *input > 5 { *output *= 2; } }
我们可能会这样优化它:
fn compute(input: &u32, output: &mut u32) { let cached_input = *input; // 将*input放入缓存 if cached_input > 10 { *output = 2; // x > 10 则必然 x > 5,所以直接加倍并立即退出 } else if cached_input > 5 { *output *= 2; } }
在Rust中,这种优化是正确的。但对于其他几乎所有的语言,都是有错误的(除非编译器进行全局分析)。这是因为优化方案成立的前提是不存在别名,而绝大多数语言并不会限制这一点。例子中我们需要特别担心的是传递给input
和output
的参数可能会重合,比如comput(&x, &mut x)
。
对于上面的参数,程序流程会是这样:
// input == output == 0xabad1dea // *input == *output == 20 if *input > 10 { // true (*input == 20) *output = 1; // 同时覆盖了 *input,以为他们是一样的 } *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
操作的顺序:
fn compute(input: &u32, output: &mut u32) { let mut temp = *output; if *input > 10 { temp = 1; } if *input > 5 { temp *= 2; } *output = temp; }
我们仍然需要别名分析来证明temp
不是input
的别名,但是这时的证明过程要简单得多:一个本地别量不可能是在它的声明之前就存在的变量的别名。这是所有编程语言共有的一个前提,所以这一版本的函数可以按照与其他语言相同的方式去优化它。
这也就是Rust可能采用的“别名”定义与生命周期和可变性有关的原因:在没有写内存操作存在的情况下,我们实际上不需要关注是否存在别名。
当然,一个完整的别名模型也要考虑到诸如函数调用(可能改变我们不可见的内容)、裸指针(不存在限制别名的需求),以及UnsafeCell(允许被&
引用的内容可变)。
下一章:Rust 生命周期
Rust在整个生命周期里强制执行生命周期的规则。生命周期说白了就是作用域的名字。每一个引用以及包含引用的数据结构,都要有一个生命周期来指定它保持有效的作用域。在函数体内,Rust通常不需要你显式地给生命周期起名字。这是因 ...