Home

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中的xadd_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就不用赋返回值了,因为它不需要返回任何东西.这也是由于我们没有传递所有权,而仅仅是借用了所有权而已.

生命期

使用引用把资源借出给其他人可能会非常复杂.比如如下的操作:

  1. 我取得了某资源句柄
  2. 我把它借给你
  3. 我决定不使用该句柄了,然后是释放掉它,但你还拥有这个资源的引用
  4. 你决定使用该资源引用

额!你的引用指向的是非法的资源.当资源是内存时,这称为悬空指针(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出了作用域

额!可以看到,fy的作用域小于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条规则:

否则,输出生命期的消除就是一个错误,无法通过编译.

例子

下面就是一些生命期消除的例子,和一些解释:

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>;

相关资源

待续