Rust 3.5 所有权
31 Jan 2015 by LelouchHe
这章介绍Rust所有权系统.这是Rust最独特强大的功能,也是每个Rust开发者需要掌握的.所有权是Rust保证内存安全的方法.所有权系统有一些特定的概念: 所有权(ownership), 借用(borrow)和生命期(lifetime).我们依次介绍.
Meta
讨论细节之前,先看2个关于所有权系统的需要注意的点.
Rust着眼于安全和速度.它使用很多无代价的抽象(zero-cost abstraction)来完成该目标,这意味着,在Rust中,为了让一切运行,抽象的代价要尽量的小.所有权系统就是这样一个无代价抽象的例子.本章讨论的所有分析都在编译期(compile time)完成,运行时是没有任何额外代价的.
但这个系统也有一定的代价:学习的代价(learning curve).很多初学者都有过一段我们称之为”和借用检查器(borrow checker)博弈”的经历,他们觉得代码没问题,但就是无法通过编译.这一般是因为程序员对于所有权系统的理解和Rust的实现不太一致.你也许也有过类似的经历.但这是好消息,有经验的Rust开发者都说,一旦他们熟悉了所有权系统的相关规则,就越来越无需同借用检查器博弈了.
记住这点,我们来学习所有权.
所有权
所有权的核心是资源(resource).本章中我们主要讨论一种特定的资源: 内存.资源的概念很广泛,比如文件句柄之类的,不过为了让讨论更加具体,我们此处仅讨论内存资源.
当程序分配了一些内存之后,就需要释放内存.如果有一个函数,foo
,分配了4字节内存,然后没有释放它.这样就造成了内存泄漏(leak memory),因为每次调用foo
,都会分配4字节内存.最终,调用足够次数的foo
之后,系统内存就会耗尽.这样很不好,所以我们需要一种方法来释放这4个字节.同样重要的是不要重复的多次释放内存.通常来说,重复多次释放内存会带来一些问题.换句话说,内存一旦分配,我们需要保证只会释放这块内存一次.太多不好,太少也不好.次数必须刚刚好才行.
分配内存上还有一个细节.每次请求一些内存,得到的是内存的句柄(handle).我们通过这些句柄(当使用内存时,通常叫做指针pointer)来操作分配好的内存.只要有这些句柄,我们就能操作这些内存.一旦使用完毕,就无法操作这些内存了,因为没有句柄,我们什么都干不了.
一般的系统编程语言需要你自己来管理内存的分配释放,以及相关的句柄.比如,如果我们要使用堆上的内存,在C语言中,可以这样:
{
int *x = malloc(sizeof (int));
// 操作内存
*x = 5;
free(x);
}
调用malloc
来分配内存.调用free
来释放内存.还有一些要分配的内存大小的记录.
Rust把分配内存(还有其他资源)的这两方面组合成了一个称为所有权的概念.当需要一些内存时,我们会得到一个拥有的句柄(owning handle).一旦这个句柄出了作用域,Rust就知道你再也无法操作这块内存,所以就会自动把这块内存释放掉.以上的C代码相当于:
{
let x = Box::new(5);
}
Box::new
创建了一个Box<T>
(这里是Box<i32>
)变量,在堆上分配了一块可以容纳i32
值的内存.但这个内存在哪里释放呢?以前提到过,分配一次内存,就需要释放一次内存.Rust替你自动处理这些.它知道句柄x
引用并拥有这个Box<T>
变量.Rust也知道在代码块结束后,x
就出了作用域,所以就在代码块结束之前,插入了释放该内存的代码.因为这是编译器做的,所以不可能有遗漏.针对每一次内存分配,都只有一次内存的释放.
这个很直观,但当把这个Box<T>
作为参数传给要给函数时,会发生什么?看下如下代码:
fn main() {
let x = Box::new(5);
add_one(x);
}
fn add_one(mut num: Box<i32>) {
*num += 1;
}
以上代码可以运行,但不是如我们所愿.比如,我们加一句代码,输出x
的值:
fn main() {
let x = Box::new(5);
add_one(x);
println!("{}", x);
}
fn add_one(mut num: Box<i32>) {
*num += 1;
}
这个无法通过编译,有如下错误:
error: use of moved value: 'x'
记住,每次分配只能有一次释放.当我们把x
传给add_one
后,就有了2个指向这块内存的句柄:main
中的x
和add_one
中的num
.如果每个句柄除了作用域都释放内存的话,这块内存就分配了一次,释放了两次,这是错误的.所以当调用add_one
时,Rust把num
定义为了这块内存的拥有者(owner).所以,当我们把所有权给了num
之后,x
就无效了.x
的值就从x
移动(move)到了num
中.因此就出现了上面的错误:使用了已经被移动过的值x
.
为了修复这个,可以让add_one
在使用完该Box<T>
之后,把这个的所有权再返回来:
fn main() {
let x = Box::new(5);
let y = add_one(x);
println!("{}", y);
}
fn add_one(mut num: Box<i32>) -> Box<i32> {
*num += 1;
num
}
这样就能通过编译,正确运行了.add_one
返回一个Box<T>
,这样所有权就回到了main
中的y
那里.在函数返回之前,我们临时的拥有该内存的所有权.这种模式很常见,所以Rust为这种临时借用某句柄拥有资源的场景,引入了一个新的概念,称为借用,通过引用(reference)来实现,即&
操作符.
借用
现在add_one
代码如下:
fn add_one(mut num: Box<i32>) -> Box<i32> {
*num += 1;
num
}
函数参数是Box
,连同其所有权一起接收了.然后又把所有权返回.
现实生活中,你可以把你拥有的东西临时让别人用一段时间.你还是拥有该物的所有权,仅仅是让他们用一会儿而已.我们称之为借出(lend)给某人,而对方则是从你这里借用.
Rust的所有权系统也允许拥有着临时借出句柄给别人.这也称为借用.下面这个版本的add_one
就是借用了参数的所有权:
fn add_one(num: &mut i32) {
*num += 1;
}
这个函数从调用者那里借用了i32
的所有权,增加了1.当函数结束之后,num
出了作用域,借用就结束了.
main
函数也要做些修改:
fn main() {
let mut x = 5;
add_one(&mut x);
println!("{}", x);
}
fn add_one(num: &mut i32) {
*num += 1;
}
add_one
就不用赋返回值了,因为它不需要返回任何东西.这也是由于我们没有传递所有权,而仅仅是借用了所有权而已.
生命期
使用引用把资源借出给其他人可能会非常复杂.比如如下的操作:
- 我取得了某资源句柄
- 我把它借给你
- 我决定不使用该句柄了,然后是释放掉它,但你还拥有这个资源的引用
- 你决定使用该资源引用
额!你的引用指向的是非法的资源.当资源是内存时,这称为悬空指针(dangling pointer),或者释放后使用(use after free).
为了解决这个,需要确保第4步永远不在第3步之后发生.Rust的所有权系统通过生命期来保证这一点,生命期内,引用永远是合法的.
还记得借用i32
的那个函数么?代码如下:
fn add_one(num: &mut i32) {
*num += 1;
}
Rust有一个称为生命期消除(lifetime elision)的功能,在某些场景下,你不用把生命期显式的标注出来.此处就是场景之一.其他场景后面详述.如果没有生命期消除,需要写成这样:
fn add<'a>(num: &'a mut i32) {
*num += 1;
}
'a
就称为生命期.大多数生命期的名称很短,像'a
,'b
,'c
一样,但使用一个描述性更强的名字有时更有用些.再深入了解下细节:
fn add_one<'a>(...)
这里声明(declare)了一个生命期.此处表示add_one
有一个生命期'a
.如果有2个声明期的话,像这样:
fn add_two<'a, 'b>(...)
然后在参数列表中,就可以使用已经声明的生命期了:
...(num: &'a mut i32)
&mut i32
和&'a mut i32
是完全一样的,仅仅在&
和mut
之间插入了声明过的生命期而已.我们称&mut i32
为”指向i32的可变引用”,称&'a mut i32
为”生命期为’a的指向i32的可变引用”.
生命期为什么重要?比如如下代码:
struct Foo<'a> {
x: &'a i32,
}
fn main() {
let y = &5; // let _y = 5; let y = &y;
let f = Foo { x: y };
println!("{}", f.x);
}
可以看到,struct
也可以有生命期,类似函数,:
struct Foo<'a> {
声明了一个生命期,然后
x: &'a i32,
使用了该生命期.这里为什么使用生命期?我们需要保证任何Foo
的引用不会比其中的i32
引用的存活的更久.
思考作用域
一种思考生命期的方式是把引用的作用域可视化.比如:
fn main() {
let y = &5; // -+ y进入作用域
// |
// |
// stuff // |
// |
// -+ y出了作用域
}
把Foo
加进去:
struct Foo<'a> {
x: &'a i32,
}
fn main() {
let y = &5; // -+ y进入作用域
let f = Foo { x: y }; // -+ f进入作用域
// stuff // |
// |
// -+ f和y出了作用域
}
f
存在于y
的作用域内,所以一切正常.如果不是这个情况呢?下面这种就不行:
struct Foo<'a> {
x: &'a i32,
}
fn main() {
let x; // -+ x进入作用域
{ // |
let y = &5; // ---+ y进入作用域
let f = Foo { x: y }; // ---+ f进入作用域
x = &f.x; // | | 错误
} // ---+ f和y出了作用域
println!("{}", x); // |
} // -+ x出了作用域
额!可以看到,f
和y
的作用域小于x
.但当x = &f.x
时,x
指向了一个马上就要出作用域的变量.
有名字的生命期(names lifetime)给了这些生命期一个名字.命名是讨论的第一步.
‘static
static
是一个特殊的生命期名称.它表示某变量的生命期和整个程序的生命期一样长.大多数Rust程序员第一次见'static
是在处理字符串:
let x: &'static str = "Hello, world.";
字符串字面值的类型是&'static str
,因为这种引用是永远有效的:它们存在于程序二进制文件的数据段中.另一个例子是全局变量:
static Foo: i32 = 5;
let x: &'static i32 = &Foo;
这里把一个i32
值添加到数据段中,x
是指向它的引用.
共享所有权
目前为止,我们都假定每个句柄都只有一个拥有者.但有时这样不行.比如汽车.汽车有4个轮子.我们想让每个轮子都知道自己是哪个汽车的.但下面这样不行:
struct Car {
name: String
}
struct Wheel {
size: i32,
owner: Car,
}
fn main() {
let car = Car { name: "Delorean".to_string() };
for _ in range(0, 4) {
Wheel { size: 360,owner: car };
}
}
我们想让Wheel
和对应的Car
联系起来.但编译器知道在第二次迭代时,有一个问题:
error: use of moved value: 'car'
我们需要把多个Wheel
指向同一个Car
.同样也不能使用Box<T>
,因为这个也只有一个拥有者.我们呢可以使用Rc<T>
:
use std::rc::Rc;
struct Car {
name: String
}
struct Wheel {
size: i32,
owner: Rc<Car>,
}
fn main() {
let car = Car { name: "Delorean".to_string() };
let car_owner = Rc::new(car);
for _ in range(0, 4) {
Wheel { size: 360,owner: car_owner.clone() };
}
}
把Car
封装在Rc<T>
中,这样得到了Rc<Car>
,然后使用clone()
取得新的引用.我们也把Wheel
中的Car
替换成Rc<Car>
.
这就是最简单的多拥有者的实现.比如,还可以使用Arc<T>
,这个是Rc<T>
的多线程版本,增加一些额外的对引用计数的原子操作.
生命期消除
早先,我们提到了Rust提供的一个称作生命期消除(lifetime elision)的功能,可以在某些场景下,不用显式标注生命期.所有的引用都有一个生命期,如果你省略了生命期(比如&T
,而不是&'a T
),Rust会通过3条规则来决定这些引用的生命期.
讨论生命期消除时,我们引入2个新术语,输入生命期(input lifetime)和输出生命期(output lifetime).输入生命期是和函数参数关联的生命期,输出生命期则是和函数返回值关联的生命期.比如,下面的函数有输入生命期:
fn foo<'a>(bar: &'a str)
而下面则有输出生命期:
fn foo<'a>() -> &'a str
下面则2个都有:
fn foo<'a>(bar: &'a str) -> &'a str
下面就是3条规则:
- 每个函数参数都有自己独立的生命期
- 如果只有一个生命期,显式或者消除的,该函数的返回值的生命期和这个一致
- 如果有多个输入生命期,而且其中之一是
&self
或&mut self
,那么函数返回值的生命期和self
一致
否则,输出生命期的消除就是一个错误,无法通过编译.
例子
下面就是一些生命期消除的例子,和一些解释:
fn print(s: &str);
fn print<'a>(s: &'a str);
fn debug(lvl: u32, s: &str);
fn debug<'a>(lvl: u32, s: &'a str);
// 上面,'lvl'没有生命期,因为它不是引用
// 只有引用(包括带引用的struct)才需要生命期
fn substr(s: &str, until: u32) -> &str;
fn substr<'a>(s: &'a str, until: u32) -> &'a str;
fn get_str() -> &str; // 错误,因为没有输入
fn frob(s: &str, t: &str) -> &str; // 错误,因为有2个输入
fn fron<'a, 'b>(s: &'a str, t: &'b str) -> &str; // 有2个生命期,所以输出生命期无法确定
fn get_mut(&mut self) -> &mut T;
fn get_mut<'a>(&'a mut self) -> &'a mut T;
fn args<T: ToCStr>(&mut self, args: &[T]) -> &mut Command;
fn args<'a, 'b, T: TOCStr>(&'a mut self, args; &'b [T]) -> &'a mut Command;
fn new(buf: &mut [u8]) -> BufWriter;
fn new<'a>(buf: &'a mut [u8]) -> BufWriter<'a>;
相关资源
待续