资源的所有权和租借
Rust通过一个成熟的租借系统而不是GC来达到内存安全的目的。对于和种资源(栈内存,堆内存,文件句柄等),都确定只有一个拥有者来确保其正确的解构(如果资源需要解构的话)。你可以利用&或者&mut创建对资源新的绑定,我们把这种绑定叫做租借。编译器会保证所有的所有者和租借都正常工作。
在我们进入租借系统的讨论前,我们还知道,Rust会处理复制和所有权移动。基本上,在赋值和函数调用里面:
长话短说,也就是原生基本类型=>复制,非原生基本类型=>转移所有权。
Rust的复制跟C一样,所有原生基本类型是字节复制(浅内存拷贝)而不是语义性的深拷贝。
转移了所有权之后,资源就被下一个拥有者拥有
当资源的所有权不在的时候,Rust会立刻释放该资源。也就是说,当:
拥有者有一些特权,它可以:
拥有者同时也有一些限制:
租借者也有一些特权。除了能够访问和修改被租借的资源,租借者还能够共享租借:
谈得够多的了,让我们来看些代码。在下面的例子中,我们将会使用一个不可复制的结构体Foo(包含了一个在Box里面的值,也就是说分配在堆里),使用不可复制资源会让操作变得更加受限,有利于学习。
下面每个例子,我们都提供一个"作用域图"来说明所有者的作用域,对于租借也是一样。第一行的大括号对应代码里面的大括号。
struct Foo {
f: Box,
}
fn main() {
let mut a = Foo { f: Box::new(10) };
// mutable borrow
let x = &mut a;
// 错误: cannot borrow `a.f` as immutable because `a` is also borrowed as mutable
println!("{}", a.f);
}
{ a x * }
拥有者 a |-----|
租借者 x |---| x = &mut a
访问 a.f | error
这违背了拥有者的限制第二条。如果我们将let x = &mut a;放在一个嵌套的代码块里,租借在println!之前就结束了,那么代码可以通过编译运行:
struct Foo {
f: Box,
}
fn main() {
let mut a = Foo { f: Box::new(10) };
// mutable borrow
{
let x = &mut a;
}
println!("{}", a.f);
}
{ a { x } * }
拥有者 a |---------|
租借者 x |-| x = &mut a
访问 a.f | OK
fn main() {
let mut a = Foo { f: Box::new(10) };
// mutable borrow
let x = &mut a;
// move the mutable borrow to new borrower y
let y = x;
// error: use of moved value: `x.f`
println!("{}", x.f);
}
{ a x y * }
拥有者 a |-------|
租借者 x |-| x = &mut a
租借者 y |---| y = x
访问 x.f | error
在转移之后,原先的租借者x不能再继续访问租借的资源。
当我们开始传递引用(&和&mut)时,事情开始变得有趣了。这也是经常让很多Rust初学者迷惑的地方。
在整个租借过程中,非常重要的一点是知道一个租借者的租借开始和结束的地方。在这里,我们用"租借域"来描述一个租借可用的作用域。注意我们这里的定义和Rust定义的生命周期是有点区别的,后面详说。
首先,让我们记住&等于一个租借,而&mut相当于一个可变租借。
另外,当一个&出现在任何结构体(在其字段中出现),或者函数/闭包(在其返回类型或者参数里面),这个结构体/函数/装饰就是一个租借者,并且要遵守所有的租借规则。
最后,对于所有租借,只有唯一一个拥有者,和一个(可变租借)或者多个(不可变租借)租借。
关于租借域的说明:
首先,一个租借域: “是租借可用的作用域 ”租借域不同于最初租借所在的语法作用域,因为租借者可以扩展其租借域(见下文)
另外,一个租借者可以在赋值或者函数调用中通过复制(不可变租借)或者转移(可变租借)来扩展租借域。租借接收者(可以是一个新的绑定,结构体,函数或者闭包)就变成了新的租借者。
最后,租借域是所有租借者语法作用域的并集,并且被租借的资源必须在整个租借域中都可用。
最后,我们有这样一条租借准则:
资源作用域 >= 租借域 = 所有租借者的语法作用域
让我们来看关于租借域扩展的例子,结构体Foo和上面的例子一样:
fn main() {
let mut a = Foo { f: Box::new(10) };
let y: &Foo;
if false {
//租借
let x = &a;
//和y共享租借,因此扩展了租借域
y = x;
}
// 错误: cannot assign to `a.f` because it is borrowed
a.f = Box::new(1);
}
{ a { x y } * }
资源 a |-----------|
拥有者 x |---| x = &a
租借者 y |-----| y = x
租借域 |=======|
修改 a.f | error
即使租借发生在if代码块里面,并且x超出其语法作用域,它通过赋值y = x; 扩展了租借域。所以存在两个租借者x和y。依照租借准则,租借域是租借者x和租借者y的语法作用域并集,所以其范围是从let x = &a; 到main函数的结束(注意绑定y在y=x之前,并不是一个租借者)。
你可能已经注意到if代码块永远不会被执行到,因为它的条件是false,但是编译器还是阻止了a访问它的资源。这是因为租借检查是发生在编译阶段,而不是在代码运行时。
到现在为止,我们只是关注对单一资源的租借。而一个租借者可能租借多个资源吗?当然可以!例如,一个函数可能接收多个引用,并根据条件返回其中一个:
fn max(x: &Foo, y: &Foo) -> &Foo
main函数返回一个&引用,因此它是一个租借者。返回的结果是两个参数其中的一个,所以它租借着两个资源。
当有多个&引用作为输入时,我们需要明确利用"命名生命周期"来明确其关系。在这里我们同样称呼为"命名租借域"。
上面的代码是无法通过编译的,因为我们没有明确两个租借者的关系。比如,我们没有说明,哪个租借者是在哪个租借域里面。下面的实现才是合法的:
fn max<'a>(x: &'a Foo, y: &'a Foo) -> &'a Foo {
if x.f > y.f { x } else { y }
}
(所有的资源和租借者都包含在命名租借域'a中)
max( { } )
资源 *x <-------------->
资源 *y <-------------->
租借域 'a <==============>
租借者 x |___|
租借者 y |___|
返回值 |___| pass to the caller
在这个函数中,我们有一个租借域'a和三个租借者:两个输入参数,和函数返回。上面提到的租借准则依旧适用。并且所有租借资源必须遵从该租借准则。让我们看下面的例子。
在下面的代码中,我们用上面的max函数来挑出a和b中间大的那个返回:
fn main() {
let a = Foo { f: Box::new(10) };
let y: &Foo;
if false {
let b = Foo{ f: Box::new(12) };
let x = max(&a, &b);
// error: `b` does not live long enough
y = x; //这句注释可通过编译运行
}
}
{ a { b x ( ) y } }
资源 a |----------------| pass
资源 b |----------| fail
租借域 |==========|
临时租借者 |-| &a
临时租借者 |-| &b
租借者 x |----------| x = max(&a, &b)
租借者 y |---| y = x
在let x = max(&a, &b);之前,&a和&b是临时引用,只有当前语句中有效。而第三个租借者x租借了这两个资源(无论a还是b,都被它借去了),直到if代码块结束。所以整个租借域是从let x = max(&a,&b);直到if代码块结束。无论资源a还是b,在这个租借域中都有效,因此满足了租借准则。
当编译到y = x;时,y成为第四个租借者,并将租借域扩展到main代码块结束,而资源b在if语句块结束时就失效了,导致无法通过租借准则的检查。
相对于函数和闭包,一个结构体也可以通过将引用存储在字段上租借多个资源,来租借多个资源。我们将会通过几个例子来看它是怎么遵守租借准则的。接下来,让我们改用下面的Link结构体来保存一个引用(一个不可变租借)来说明:
struct Link<'a> {
link: &'a Foo,
}
即使只有一个字段,Link结构体也可以租借多个资源:
fn main() {
let a = Foo { f: Box::new(12) };
let mut x = Link { link: &a };
if false {
let b = Foo { f: Box::new(10) };
// error: `b` does not live long enough
x.link = &b; //该行注释可通过编译运行
}
}
{ a x { b * } }
资源 a |-----------| pass
资源 b |---| fail
租借域 |=========|
租借者 x |---------| x.link = &a
租借者 x |---| x.link = &b
在上面这个例子中,租借者x从拥有者a处租借资源,所以租借域是直到main代码块结束。而在x.link = &b;语句,x想要去从拥有者b处租借资源,就会因为通过不了租借准则而失败。
一个没有返回值的函数也能够通过输入函数扩展租借域。例如,函数store_foo接收一个Link的可变引用,将在Link里面存储Foo的一个引用(不可变租借):
fn store_foo<'a>(x: &mut Link<'a>, y: &'a Foo) {
x.link = y;
}
在下面的代码中,a拥有的被租借的资源; Link结构体是其租借者,而Link租借体又被x可变引用着(也就是说*x是租借者),租借域是直到main代码块结束。
fn main() {
let a = Foo { f: Box::new(12) };
let x = &mut Link { link: &a };
if false {
let b = Foo { f: Box::new(10) };
store_foo(x, &b)
}
}
{ a x { b * } }
资源 a |-----------| pass
资源 b |---| fail
租借域 |=========|
租借者 *x |---------| x.link = &a
租借者 *x |---| x.link = &b
当程序编译到store_foo(x, &b);时,函数会试图去存储&b到x.link,导致资源b成为另一个被租借的资源,并且在租借准则中检查失败。因为b的语法作用域没有覆盖整个租借域。
在一个函数中拥有多个命名的租借域是可能是。例如:
fn superstore_foo<'a, 'b>(x: &mut Link<'a>, y: &'a Foo,
x2: &mut Link<'b>, y2: &'b Foo) {
x.link = y;
x2.link = y2;
}
在这个函数中,涉及到两个不同的租借域。每个租借域各自需要遵守租借准则(各自进行租借准则检查)。
最后,我想要解析下为什么我认为在Rust的租借系统中用到的名词"生命周期"会让人迷惑(所以在这篇文章中我都回避使用它)。
当我们谈论租借时,涉及到几种不同的"生命周期": * A: 资源拥有者的"生命周期"(或者拥有/被租借的资源) * B: 整个租借过程的"生命周期",例如,从第一个租借到最后一个失效 * C: 一个单独的租借者或者租借的指针的"生命周期"
当一个人说"生命周期"时,他可能指的是上面任何一个。如果涉及到多个资源和租借,事情将会变得更加复杂。例如,"命名生命周期"在函数或者结构体定义中指的是什么?A,B,还是C?
在上面max函数中:
fn max<'a>(x: &'a Foo, y: &'a Foo) -> &'a Foo {
if x.f > y.f { x } else { y }
}
在这里生命周期'a指的是什么?它肯定不是A,因为这两个资源拥有者拥有不同的生命周期。它也不可能是C,因为这里有三个租借者: x,y和返回值。并且它们也各自有着不同的生命周期。那么在这里是指B吗?也许吧。而且租借域本身就不是一个具体的实体,它又怎么会有"生命周期"呢?把它称作生命周期是会让人迷惑的。
整个所有权/租借概念本身已经是够复杂的了。而用"生命周期"这样的名词,只会让事情变得更加难以理解。所以我们用"租借域"来明确表达这个概念。
P.S. 利用上面定义的A, B和C,租借准则可以表达为:
A >= B = C1 U C2 U … U C
尽管Rust的租借和所有权概念会花费你相当多的时间去掌握,但是这是非常有意思的学习过程。Rust要达到内存安全的目标而不用GC,到目前还做得挺好的。有人说,学习Haskell会改变你编程的思维,我认为,Rust也是一样,非常值得我们花时间去学习的。