导读:
Rust号称内存安全,也就是保证不存在以下问题:
- 空指针\引用
- 悬垂指针(dangling pointer):指向无效内存的指针\引用
- 使用未初始化内存
- 缓冲区溢出
- double free
Rust并没有GC系统,那么它如何解决上述问题?通过两个特性:Ownership(所有权)和Lifetime(生命周期)。两者相互配合,形成了Rust独特的内存安全系统。Ownership和lifetime是rust的核心,需要花费时间去琢磨。
笔者在学习rust期间,一直处于半懂不懂的状态。因为笔者认为lifetime这个概念不是rute独有的,它广泛存在于几乎所有编程语言中,指的是变量有效的范围。但lifetime进入到Rust中,似乎跟什么东西混在一起变得似是而非,让人看得云里雾里。笔者阅读了大量资料,终于找到HashRust的一篇文章,它一针见血指出了问题所在:
Part of the reason why lifetimes are so confusing is because in much of Rust writing the word lifetime is loosely used to refer to three different things — the actual lifetimes of variables, lifetime constraints and lifetime annotations. Let’s talk about them one by one.
所以lifetime一词在rust中有三个含义:
- 变量从声明到被回收的范围。
y=&x, y=&mut x
等借用表达式,对变量x施加了 lifetime constraints- 用于帮助编译器进行分析的lifetime annotation
这三者的关系如下:
- 每个变量都有自己的生命周期,也就是变量从声明到被回收这一段范围。
- 借用了对象x,就对变量x施加了生命周期约束,如果x的生命周期不满足这个约束,就会报错,一般是
'x' does not live enough
这类报错。 - 简单情况编译器可以自动推导lifetime constraint,复杂情况下需要开发者手动添加lifetime annotation来表达life constraint。
Ownership
Ownership的具体规则如下:
- 同一时间(范围), 每一个对象最多只能有一个owner, owner对自己拥有的对象有读写权力。
- 当owner被回收,它所拥有的所有对象都被回收
这两点使得我们可以实现RAII来管理内存:
- owner唯一:所以不会出现回收资源后,另一个owner无法使用资源的情况。
- 自动回收拥有的资源,类似c++的析构函数。
为了配合ownership,rust采用了move语义作为默认语义。也就是如果b没有实现Copy trait的话,那么 a = b
这个语句会把b的内容转移给a,b将不能再使用。
对象之间构成了一个树状的own关系。例如Vec,它可以容纳多个类型相同的元素:
fn main(){
let a = 10;
let b = 20;
// c owns a and b now.
// a and b are moved into c,
// without reinitialization, you can't use a or b.
let c = vec![a, b];
}
Borrow & lifetime constraints
只有ownership是不够的,并非所有场景我们都需要ownership。于是Rust增加了借用(borrow)机制。借用这一行为会产生被借用对象的可变引用或不可变引用( &T, &mut T
),允许在不转移ownership的情况下提供读写对象的能力。
大多数编程语言都提供了引用或指针。 当对象A被借用,那么Rust需要关注如下问题:
- 当释放对象时,如何确保外部的引用不会变成dangling pointer?
- 两个可变引用同时修改A
- 一个不可变引用在读A,另一个可变引用在修改A
为了解决上述问题,Rust设计lifetime constraint,用于约束变量的真实生命周期。Rust规定lifetime constraints规则如下:
- 每一个引用都有自己的lifetime。
- 允许存在多个不可变引用,它们的lifetime可以重叠。
- 最多一个可变引用。
- 可变引用和不可变引用的lifetime不能重叠。
- 被借用(可变或者不可变)期间,对象必须一直存活。
- 返回值的lifetime必须与入参的lifetime相关联。
lifetime annotation
局部变量的lifetime是否满足constraint,这很好确定,但是如下情况就比较复杂:
- 结构体内部包含引用
- 函数入参与返回值都包含引用
因为缺乏足够上下文信息,compiler无法有效分析lifetime的合法性,所以需要在上面两种情况下,开发者要用lifetime annotation显式标记约束。在Rust中,生命周期注解是一种特殊的泛型参数。
struct S<'a>{
data: &'a i32,
}
// 'b:'a可以理解为'a是'b的子集
fn proc<'a, 'b:'a>(a: &'a i32, b:&'b i32) -> &'a i32{
if a > b{
a
}else{
b
}
}
要明确的是:lifetime annotation不会改变引用的真实生命周期,它只是一个编译期的静态标记,用于帮助borrow checker检查变量的生命周期是否满足约束,从而拒绝不合理的代码。
我们可以这样理解 &'a obj
:它借用了obj一段范围,这段范围标记为 'a
,这段范围具体为多少是不定的,编译器会根据上下文进行推断和改变它的大小。
下面举例说明lifetime annotation的作用。
例1
#[derive(Debug)]
struct Foo;
impl Foo {
fn mutate_and_share(&mut self) -> &Self { &*self }
fn share(&self) {}
}
fn main() {
let mut foo = Foo;
let loan = foo.mutate_and_share(); // 等价于 Foo::mutate_and_share(&mut foo);
foo.share();
println!("{:?}", loan);
}
从语义上看,这段代码没有安全问题。但是这段代码不能通过编译,原因如下:
- 第5行的方法定义表明形参
&mut self
(第11行的 实参&mut foo
)这个借用的生命周期要足够长,足以覆盖返回值(loan
)的生命周期。 loan
的生命周期为第11-13行。- 所以borrow checker推断,
&mut foo
的借用至少要覆盖11-13行。 - 而在第12行有一个不可变借用
&foo
,不能与&mut foo
共存,因此报错。
如果注释掉第13行,则代码可以编译通过, 因为borrow checker认为loan这个变量没有被使用,创建后即刻回收也可以。因此 &mut foo
的借用只维持在第11行即可,那么就不会与 &foo
重叠了。
此外,将第12行移到 foo.mutate_and_share()
也可以消除重叠,这也是常用的方法。
例2
方法签名中不恰当的生命周期标记会导致编译器扩大借用范围,导致安全的代码无法通过编译。
样例代码如下:
use std::fmt::Debug;
struct Tes<'a, T>{
data: &'a i32,
data2: T,
}
impl<'a, T> Tes<'a, T>{
// 作为对比, 一般写法是: fn mut_borrow(&mut self){}
fn mut_borrow(&'a mut self){}
}
fn main(){
let n = 10;
let mut t1 = Tes{
data: &n,
data2: n,
};
{
Tes::mut_borrow(&mut t1); //等价于 t1.mut_func()
}
Tes::mut_borrow(&mut t1); // 编译失败: 进行了二次可变借用
}
这段代码没有安全问题的, 但这段代码会编译错误,原因在于第9行的方法签名:
- 方法要求借用范围(lifetime constraint)是
'a
, 也就是Tes结构体的成员data的借用范围。这就意味着mut_borrow()
产生的借用要持续到Tes结构体被回收。 - 也就是说,第一个可变借用范围是从20行到23行到Tes被回收,在此范围内无法进行其他借用,因此编译失败。
那么如何修改?两种方法:
fn mut_borrow<'b>(&'b mut self) where 'a:'b{
// 'b是'a的子集
}
// 隐含生命周期
fn mut_borrow>(&mut self){}
Lifetime annotation省略规则
编译器在下面场景可以自动标记lifetime annotation:
- 每一个引用都有lifetime annotation,如果没有显式标记,则认为各自是不同的lifetime。比如
fn func(a: &A, b: &B)
,认为它有两个隐含的lifetime annotation'_a, '_b
。 - 只有一个入参,那么它的生命周期约束被加到所有返回值上。
- 如果函数带有
&self, &mut self
,那么将self的生命周期约束给返回值。
其他
unbounded lifetime
在unsafe代码中,可能产生unbounded lifetime,如:
- 将raw pointer 解引用转换为引用,如
&*raw_ptr
std::mem::transmute()
以及类似方法,可以把一块内存视为另一种类型,如把某个i64
的内存解释为 一个&i32
引用类型。
以上两种情况产生的生命周期约束,导致原对象的生命周期可以被任意推断,可能产生Undefined behavior。
参考
- https://hashrust.com/blog/lifetimes-in-rust/
- https://doc.rust-lang.org/stable/book/ch04-02-references-and-borrowing.html#references-and-borrowing
- https://doc.rust-lang.org/nomicon/ownership.html
- https://blog.logrocket.com/understanding-lifetimes-in-rust/
- https://doc.rust-lang.org/stable/book/ch10-03-lifetime-syntax.html
- https://santiagopastorino.com/how-to-use-rust-non-lexical-lifetimes-on-nightly/