本期想聊聊引用,大部分的资料在谈Rust的引用的时候都会聊到可变引用,不可变引用,所有权等等的概念,其实Rust的几乎所有概念都是正交的,引用本身与可变性是一组正交的概念,所以这里我只想聊聊引用,不涉及其他。
引用的来源
当我们讨论C语言的时候,不可避免的要讨论C语言的指针,Rust的引用是指针的对应,与指针对应的概念是值,例如我们要给函数传递参数的时候,要么传递的是变量的值,要么传递的是变量的指针。
指针的本质是内存的地址,当我们要传递的参数是一片很大的内存的时候,传递值的方式是很低效的,通过传递该片内存的地址给函数,可以极大的提高效率。
指针的高效率同时也带来了很多问题,大部分的内存安全问题都是指针所导致的,Rust的一个设计诉求就是内存安全,同时要保持高效,所以Rust中必须要有指针,但是要保证内存安全,所以才有了引用这一概念。
引用就是指针,是一种附加了很多额外限制的指针。
内存安全的分类
我们碰到的内存安全问题分为好多种,大体上可以分为三类。
第一种是内存地址的错误,也就是指针的值有问题,拿一个错误的内存地址去访问或者修改内存就会导致程序错误。
比如空指针,就是指针的值指向的是内存的地址0x0,这肯定是一个错误的内存地址,访问地址0x0的内容必然会导致错误。
比如野指针,这个是指指针的值没有初始化,这样指针可能指向任意一个内存地址,访问该地址的内存肯定是错误的。
比如悬挂指针,这个是指指针指向的内存已经被释放了,被系统回收了,访问该地址的内存很大的概率是暂时不会报错,程序奔溃在其他莫名其妙的地方,修复此类问题会非常棘手。
比如数组越界,这个问题的本质是数组的访问是通过计算的方式生成一个内存地址,越界的情况是计算出了一个错误的内存地址,访问到了错误的内存,也是错误的。
第二种错误是错误解读内存
比如类型不匹配,内存里保存的是结构A的数据,我们按照结构B的方式去解读内存,读取的数据肯定是错误的。
比如重叠内存访问,两个指针分别按照不同的结构去解读内存,一个写,一个读,数据肯定是错误的。
第三种错误是内存泄漏,分配后的内存没有释放,逐渐累积会导致系统的内存到达极限,无法再有效的分配内存,导致程序奔溃。
Rust的解决方案
Rust通过引用来有效的避免空指针,野指针,悬挂指针等错误,通过切片的方式来避免数组相关的一些错误,通过可变性来规避内存解读的错误。
Rust并没有解决内存泄漏的问题。
我们可以从一个C函数的角度来看Rust引用的设计。
void test(int a) {}
这是一个简单的C函数,参数a是通过传值的方式传递的,因为a的类型是int,只有4个字节,比较小,所以通过传值的方式没有问题,如果参数是一个很大的结构体或者是数组的话,通过传递值的方式就会效率很低,需要通过传递指针的方式。
void test(int *a) {}
对应的Rust函数是:
fn test(a:&i32){}
到这里的时候,就会有几个问题,因为传递的是一个地址,所以需要确保a指向的内存数据必须是有效的。
-
a不可以是空指针,rust中所有的引用都是从一个有效的值借用而来,这样可以保证所有的引用在产生的时点就是有效的,不会是空指针。
-
a不可以是野指针,rust中在语法上也有保证,一个引用变量在赋值之前是不可以被使用的,这样可以保证不会有野指针的出现。
-
a不可以是悬空指针,要做到没有悬空指针,就需要保证当一个变量变成无效的时候(比如free,或者局部变量离开函数域),要么指向它的所有指针都无效,要么就保证没有任何对这个变量的指针的时候这个变量才能消失。在C语言中,一般我们free一段内存后,都会把指向这个内存的指针设置为Null,这样保证不会出现悬空指针,但是指针是可以Copy的,当程序中多处都保存同一指针的时候,free一段内存要去清理所有指向它的指针,很多时候是不现实的。
Rust采纳的方案当存在对一个变量的引用的时候,该变量不可以失效,反过来说的意思就是,引用只能在变量本身存在的时候有效。
比如在C语言中:
int* test(){ int a=1; return &a;}
这个函数在C语言中是符合语法规则的,只是返回的指针是一个悬空指针。
而在Rust中:
fn test()->&i32{let a:i32 = 1;return &a;}
这段代码是无法编译的,Rust会检查引用的有效性,当引用返回的时候,变量a已经失效了,会是悬空指针,所以Rust在编译阶段就会避免该问题的发生。
Rust引用的这一特征就会导致Rust的引用只能在栈上向下传递,不可以向上传递。能够向上传递的只能是值。
如果函数需要返回的值很大不适合通过值的方式传递,怎么办?
Rust的方式把这个值放到堆上去,然后返回拥有这个堆内存的变量。这个过程是无法通过引用的方式实现的,Rust提供了几个实现这个过程的工具,比如Box,Rc,Arc等,Vec其实也是其中的一个,只是更复杂。
所有这些工具的一个共同特征是,它们虽然实际的作用是一个指针,但是变量本身是一个值,不是引用,不会受到引用相关的所有限制。
还有一个问题是返回参数,比如下面这个函数。
int * test(int *a,int *b) { return a; }
这种情况是很常见的,在Rust中对应的代码是:
fn test(a:&i32,b:&i32)->&i32 { return a; }
但是这个函数是无法编译的,一种简单的修复方式是:
fn test<'a>(a:&'a i32,b:&'a i32)->&'a i32 { return a; }
也就是需要加上生命周期标志,告诉Rust该函数的返回值是跟着那个变量值的,这样才能保证返回的这个引用存在时,所引用的最终值不可以无效。
这个函数还可以有另外一个版本:
fn test<'a,'b>(a:&'a i32,b:&'b i32)->&'a i32 { return a; }
这个函数的意思跟上面的函数是不同的,明确说明返回的引用只跟参数a有关系,更参数b没有关系,这样返回值存在的时候,a的最终值不可以失效,但是参数b的最终值可以。
结构体中的引用
比如下面这段代码:
struct A<'a> { a: &'a i32, b: i32,}fn test1<'a>(aa: A<'a>) {}
这段代码可以这样理解:
struct B { b: i32,}fn test2<'a>(a: &'a i32, b: B) {}
结构体就是把两个参数捆在一起传递,拆开以后就会到最简单的模式,会很好理解。
所以Rust中带有引用的结构体也只能沿着栈向下传递,没办法跳出栈向上传递。
&‘static 生命周期
变量保存的地方无非是三个,栈,堆,静态。
栈变量的引用只能沿着栈向下传递。
堆变量虽然是指针,但是已经被Rust包装成值变量了,不涉及引用。
对于静态变量,Rust有一个特殊的生命周期标志,&’static,这一类特殊的生命周期代表在整个程序运行中都会有效,对于这种生命周期的引用,可以直接当成值变量来理解。
总结
个人的理解,rust的引用只能用在沿着栈向下传递的情况,这样就把指针这个概念缩小到一个很小的范围,但是变得特别清晰。写代码的时候经常遇到的情况是大量的数据需要管理,所以可以看到很多的Manager什么的来保存这些数据,在这些数据容器中,基本是很难保存引用的,只能保存值,或者把数据放到堆上去,把Box,Rc,Arc这些胖指针保存到容器中。
rust的所有的代码都是写在普通的函数中,理解一个函数就可以理解所有的函数。