首页 > 代码库 > Rust: move和borrow

Rust: move和borrow

感觉Rust官方的学习文档里关于ownship,borrow和lifetime介绍的太简略了,无法真正理解这些语法设计的原因以及如何使用(特别是lifetime)。所以找了一些相关的blog来看,总结一下,以备以后参考。

起因

Rust想要解决的问题是在无GC的情况下安全地管理资源。这点并不容易实现,但不是一点思路都没有。比如,有一个Java程序:

public void foo() {
    byte[] a = new byte[10000000]; 
   a = null;
  byte[] c = new byte[10000];

}

上边的代码有两处位置是我们可以明确告诉编译器可以释放之前分配的两个数组所占的内存的:

  • a = null  此处,之前a指向的数组不能再被访问,所以它的内存可以被回收了
  • foo方法的结尾 }. 此时,数组c的内存可以被释放。

但是,实际情况会比这更复杂。比如,我们可能在foo方法中把a数组传递给foo的本地作用域之外的数组结构,这样, a就不应该在foo的结尾处被释放了。对于Java,在运行时通过GC的方式回收资源是唯一可行的方式,Java的语法并没有提供足够多的线索使得编译器可以知道内存释放的时机。但这样并不是没有好处,因为如果要添加有利于编译器的标记,就只能由程序员来做,这样无疑会降低程序开发的效率。

在Rust语言里,程序员需要思考资源的使用情况,并提供有关的信息给编译器,以使得编译器在编译时检查资源访问的冲突、以及由编译器来决定资源释放的时机。于是,Rust有了下面三个主流语言没有的语法:

  • ownship
  • borrowing
  • lifetime

下面来概述一下为什么需要这三个语法,它们分别负责解决什么问题。

ownship

首先,如果由编译器来决定什么时候资源应该被销毁,那么编译器依据的规则必须是一个很简单的、不由运行时逻辑决定的规则,比如,Reference Counting这种规则是不能在编译时用来检查的。Rust选择通过scope/stack的方式来决定资源的生命周期。当一个变量离开了它的作用域,它所拥有的资源会被释放。但是如果允许一个资源被多个变量拥有,那么编译器就又得通过非常复杂的方式来决定资源释放的时机、甚至不可能做到这点。所以Rust规定任何资源只能在一个所有者(owner)。这样编译器只用检查资源的owner的作用域,就可以决定资源的释放时机。

move

如果资源在被绑定到它的owner以后,这种“所有权”无法转移,会是非常不灵活的。最重要的情况是我们无法由一个函数来决定其参数绑定的资源的释放。所以,需要“move"语法,来转移资源的所有权。

borrow

如果只能通过move才能使得我们通过函数参数或者非owner的其它变量来访问资源,也会有很多不方便之处:

  • 无法共享一个资源给多个对象
  • 在调用一个函数之后,被move给它的参数的资源之前绑定变量就不能再被使用。很多时候,我们并不想这么做,而只是通用一个函数修改/读取这个资源,在此之后,还想继续使用它之前绑定到的变量。

所以,需要有一个语法允许owner把自己的拥有权“借出”,这个语法就是"borrow"。

但是,这种“借出”比move语法要灵活,比如允许多个变量都能引用到一个资源,但这样就面临着读写冲突的问题。所以borrow分了两种:immutable borrow和mutable borrow,并且编译器对于一个作用域里这两种borrow的数量进行限制,从而避免读写的冲突。

borrow实际上创建了到原始资源的reference,它是一种指针。

比较特殊的是mutable borrow,即&mut,它可以把owner绑定到新的资源。在通过mutable borrow改变owner绑定的目标时,会触发owner最初绑定资源的释放。

 

lifetime

如果资源(a)的生命周期比引用(b)的短,即在b失效之前,a已经不能再访问了,那么,编译器应该禁止让b引用a,否则会产生“use after free error”。有时候,a和b的这种关系比较容易编译器发现,比如

let a;
{
   let b = 1;
   a = &b
}
println!("{}",a);

但有时候, 这种关系是编译器发现不了的。比如,a是一个函数的返回值,它的生命周期可能比引用b的要短,也可能是一个常量。编译器不去执行函数的逻辑,就无法确定a的生命周期,因此它就无法判断是否使用b来引用a是安全的。所以,Rust需要一些额外的标记,来告诉编译器什么情况下“reference”是可安全访问的。

实际上,Rust中每个reference的类型可以认为是一个“复合类型”,lifetime是其中的一部分。不过,程序员无法具体地描述一个reference的lifetime,比如,你无法说"a的生命周期是从第5行到第8行”。"lifetime"的值,最初肯定是由编译器写入的。程序员只能通过‘a这种标记来引用已有的lifetime值,来在程序员告诉编译器一些跟lifetime有关的逻辑。

 

ownship

 

Variable bindings have a property in Rust: they ‘have ownership’ of what they’re bound to. This means that when a binding goes out of scope, Rust will free the bound resources. For example:

fn foo() {
    let v = vec![1, 2, 3];
}

重点是当一个变量离开它的作用域时,Rust会释放它所绑定的资源。而这个决定了资源生命周期的变量就是这个资源的owner. 

 

 

Rust会确保一个资源只有一个owner。这个看起来跟读写冲突有关,可以看下The details。而且只有一个owner,编译器显然也更容易确定资源释放的时机。

 

但是,如果这种资源“所有权"不能转移,就会存在很多问题。比如,我们很多情况下想要将资源的所有权交由一个函数处理。

这种逻辑,由Rust的"move"语法来搞定。

Move semantics

 

move的特点就是在move之后,原来的变量就不可用了。因为函数就像是一个黑盒,如果把所有权转交给函数,那么无法确保函数返回后之前的变量还能够使用。

move的这个特点,有两种典型的例子可以展示:

let v = vec![1, 2, 3];

let v2 = v;

println!("v[0] is: {}", v[0]);

这种情况下,把vector的所有权move给v2之后,就不可以再访问v了。所以编译时会报错

error: use of moved value: `v`
println!("v[0] is: {}", v[0]);

第二种是把资源move给函数

fn take(v: Vec<i32>) {
    // what happens here isn’t important.
}

let v = vec![1, 2, 3];

take(v);

println!("v[0] is: {}", v[0]);

也会报跟上面一样的错误。

 

Borrowing

如果只能通过资源所绑定到的变量来访问它,会有很多不方便的地方,比如并行地去读取一个变量的值。而且,如果只想“借用”一下某个变量绑定的资源,在借用完成以后,不想释放这个资源,而是把所有权“交还”给原来的变量,那么用move语法就有些不方便。比如Rust文档里的这个例子:

fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) {
    // do stuff with v1 and v2

    // hand back ownership, and the result of our function
    (v1, v2, 42)
}

let v1 = vec![1, 2, 3];
let v2 = vec![1, 2, 3];

let (v1, v2, answer) = foo(v1, v2);

这里,foo函数结束时并不想释放v1、v2变量绑定的资源,而是想继续使用他们。如果只有move语法,就只能用函数返回值的方式返回所有权。

borrow语法,可以使这种情况更简单。但是,它本身也会带来新的复杂性。

上面的例子,用borrow语法,可以这么做:

fn foo(v1: &Vec<i32>, v2: &Vec<i32>) -> i32 {
    // do stuff with v1 and v2

    // return the answer
    42
}

let v1 = vec![1, 2, 3];
let v2 = vec![1, 2, 3];

let answer = foo(&v1, &v2);

// we can use v1 and v2 here!

Instead of taking Vec<i32>s as our arguments, we take a reference: &Vec<i32>. And instead of passing v1 and v2 directly, we pass &v1 and &v2. We call the &T type a ‘reference’, and rather than owning the resource, it borrows ownership. A binding that borrows something does not deallocate the resource when it goes out of scope. This means that after the call to foo(), we can use our original bindings again.

 所以,borrow实际上就是生成了对资源的引用,这种引用的作用域并不和资源的生命周期挂钩,这点和binding有本质的不同。

下面,要明确的就是“borrow"的范围,就是从什么时候开始borrow,到什么时候borrow结束。

看下面的例子:

let mut x = 5;
{
    let y = &mut x; //borrow开始
    *y += 1;
} //borrow结束
println!("{}", x);

之所以要确定borrow的范围,是因为borrow语法有一些跟作用域要关的要求:

  • First, any borrow must last for a scope no greater than that of the owner.
  • Second, you may have one or the other of these two kinds of borrows, but not both at the same time:
    •   one or more references (&T) to a resource,
    •   exactly one mutable reference (&mut T).

 第一,当owner无法访问了,那么borrow一定不能再访问。

 第二,下面两种情况只能存在一种:

  • 对资源的一个或多个不可变的引用(&T)
  • 对资源的唯一一个可变的引用(&mut T), 也就是说不能同时有多个可变引用。

第二个限制,是为了防止读写冲突。特别是一个mutable borrowing,可能会使得对同一个资源的immutable borrowing访问错误的地址,当然也可能会使得其它的mutable borrowing访问错误的地址。

比如:

fn main() {
    let mut x = 5;

    let y = &mut x;    // -+ &mut borrow of x starts here
                       //  |
    *y += 1;           //  |
                       //  |
    println!("{}", x); // -+ - try to borrow x here
}                      // -+ &mut borrow of x ends here
      

上边的这段代码,编译时就会报错:“cannot borrow `x` as immutable because it is also borrowed as mutable"

而第一个限制是很容易理解的,毕竟如果owner都访问不了了,那么reference当然就不能用了。下面是一个例子:

let y: &i32;
{
    let x = 5;
    y = &x;
}

println!("{}", y);

在x的作用域结束后,对它的borrow y还可以访问,所以,以上的代码不会通过编译。

 

参考文档

The Rust Programming Language

Explore the ownership system in Rust

Rust Borrow and Lifetimes

Lifetime Parameters in Rust

Rust Lifetimes

Rust: move和borrow