所有权

所有权(Ownership)

所有运行的程序都必须管理其使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时不断地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。在运行时,所有权系统的任何功能都不会减慢程序。对于一般的编程语言,通常会先声明一个变量,然后初始化它。例如在 C 语言中:

int* foo() {
    int a;          // 变量a的作用域开始
    a = 100;
    char *c = "xyz";   // 变量c的作用域开始
    return &a;
}                   // 变量a和c的作用域结束

变量 a 和 c 都是局部变量,函数结束后将局部变量 a 的地址返回,但局部变量 a 存在栈中,在离开作用域后,局部变量所申请的栈上内存都会被系统回收,从而造成了 Dangling Pointer 的问题。这是一个非常典型的内存安全问题。很多编程语言都存在类似这样的内存安全问题。再来看变量 c,c 的值是常量字符串,存储于常量区,可能这个函数我们只调用了一次,我们可能不再想使用这个字符串,但 xyz 只有当整个程序结束后系统才能回收这片内存。

首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:

  • Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
  • 值在任一时刻有且只有一个所有者。
  • 当所有者(变量)离开作用域,这个值将被丢弃。

绑定(Binding)

首先必须强调下,准确地说 Rust 中并没有变量这一概念,而应该称为标识符,目标资源(内存,存放 value)绑定到这个标识符:

{
    let x: i32;       // 标识符x, 没有绑定任何资源
    let y: i32 = 100; // 标识符y,绑定资源100
}

在如下的 Rust 代码中:

{
    let a: i32;
    println!("{}", a);
}

上面定义了一个 i32 类型的标识符 a,如果你直接 println!,你会收到一个 error 报错:

error: use of possibly uninitialized variable: a

这是因为 Rust 并不会像其他语言一样可以为变量默认初始化值,Rust 明确规定变量的初始值必须由程序员自己决定。

{
    let a: i32;
    a = 100; //必须初始化a
    println!("{}", a);
}

其实,let 关键字并不只是声明变量的意思,它还有一层特殊且重要的概念:绑定。通俗的讲,let 关键字可以把一个标识符和一段内存区域做“绑定”,绑定后,这段内存就被这个标识符所拥有,这个标识符也成为这段内存的唯一所有者。所以,a = 100 发生了这么几个动作,首先在栈内存上分配一个 i32 的资源,并填充值 100,随后,把这个资源与 a 做绑定,让 a 成为资源的所有者(Owner)。

作用域

像 C 语言一样,Rust 通过 {} 大括号定义作用域,作用域是一个项(item)在程序中有效的范围。假设有这样一个变量:

{
    {
        let a: i32 = 100;
    }
    println!("{}", a);
}

编译后会得到如下 error 错误:

b.rs:3:20: 3:21 error: unresolved name a [E0425] b.rs:3 println!("{}", a);

像 C 语言一样,在局部变量离开作用域后,变量随即会被销毁;但不同是,Rust 会连同变量绑定的内存,不管是否为常量字符串,连同所有者变量一起被销毁释放。所以上面的例子,a 销毁后再次访问 a 就会提示无法找到变量 a 的错误。这些所有的一切都是在编译过程中完成的。

内存与分配

字符串字面值,即被硬编码进程序里的字符串值;字符串字面值是很方便的,不过它们并不适合使用文本的每一种场景。原因之一就是它们是不可变的。另一个原因是并非所有字符串的值都能在编写代码时就知道。而 String 类型被分配到堆上,所以能够存储在编译时未知大小的文本。可以使用 from 函数基于字符串字面值来创建 String,如下:

let s = String::from("hello");

let mut s = String::from("hello");

s.push_str(", world!"); // push_str() 在字符串后追加字面值

println!("{}", s); // 将打印 `hello, world!`

就字符串字面值来说,我们在编译时就知道其内容,所以文本被直接硬编码进最终的可执行文件中。这使得字符串字面值快速且高效。不过这些特性都只得益于字符串字面值的不可变性。不幸的是,我们不能为了每一个在编译时大小未知的文本而将一块内存放入二进制文件中,并且它的大小还可能随着程序运行而改变。

对于 String 类型,为了支持一个可变,可增长的文本片段,需要在堆上分配一块在编译时未知大小的内存来存放内容。这意味着:

  • 必须在运行时向操作系统请求内存。
  • 需要一个当我们处理完 String 时将内存返回给操作系统的方法。

第一部分由我们完成:当调用 String::from 时,它的实现 (implementation) 请求其所需的内存。第二部分实现起来就各有区别了。在有 垃圾回收(garbage collector,GC)的语言中,GC 记录并清除不再使用的内存,而我们并不需要关心它。没有 GC 的话,识别出不再使用的内存并调用代码显式释放就是我们的责任了,跟请求内存的时候一样。从历史的角度上说正确处理内存回收曾经是一个困难的编程问题。如果忘记回收了会浪费内存。如果过早回收了,将会出现无效变量。如果重复回收,这也是个 bug。我们需要精确的为一个 allocate 配对一个 free。

Rust 采取了一个不同的策略:内存在拥有它的变量离开作用域后就被自动释放。下面是示例 4-1 中作用域例子的一个使用 String 而不是字符串字面值的版本:

{
    let s = String::from("hello"); // 从此处起,s 是有效的

    // 使用 s
}                                  // 此作用域已结束,
                                   // s 不再有效

这是一个将 String 需要的内存返回给操作系统的很自然的位置:当 s 离开作用域的时候。当变量离开作用域,Rust 为我们调用一个特殊的函数。这个函数叫做 drop,在这里 String 的作者可以放置释放内存的代码。Rust 在结尾的 } 处自动调用 drop。在 C++ 中,这种 item 在生命周期结束时释放资源的模式有时被称作 资源获取即初始化(Resource Acquisition Is Initialization (RAII))。如果你使用过 RAII 模式的话应该对 Rust 的 drop 函数并不陌生。这个模式对编写 Rust 代码的方式有着深远的影响。现在它看起来很简单,不过在更复杂的场景下代码的行为可能是不可预测的,比如当有多个变量使用在堆上分配的内存时。

移动语义(move)

Rust 中的多个变量可以采用一种独特的方式与同一数据交互。

let x = 5;
let y = x;

我们大致可以猜到这在干什么:”将 5 绑定到 x;接着生成一个值 x 的拷贝并绑定到 y“。现在有了两个变量,x 和 y,都等于 5。这也正是事实上发生了的,因为整数是有已知固定大小的简单值,所以这两个 5 被放入了栈中。

所有权转移

在 Rust 中,和“绑定”概念相辅相成的另一个机制就是“转移 move 所有权”,意思是可以把资源的所有权(ownership)从一个绑定转移(move)成另一个绑定,这个操作同样通过 let 关键字完成,和绑定不同的是,= 两边的左值和右值均为两个标识符:

语法:
    let 标识符A = 标识符B;  // 把“B”绑定资源的所有权转移给“A”

move 前后的内存示意如下:

Before move: a <=> 内存(地址:A,内容:“xyz”) After move: a b <=> 内存(地址:A,内容:“xyz”)

被 move 的变量不可以继续被使用。否则提示错误 error: use of moved value。这里有些人可能会疑问,move 后,如果变量 A 和变量 B 离开作用域,所对应的内存会不会造成“Double Free”的问题?答案是否定的,Rust 规定,只有资源的所有者销毁后才释放内存,而无论这个资源是否被多次move,同一时刻只有一个owner,所以该资源的内存也只会被free一次。通过这个机制,就保证了内存安全。

let 绑定会发生所有权转移的情况,但 ownership 转移却因为资源类型是否实现 Copy 特性而行为不同:

let x: T = something;
let y = x;
  • 类型T没有实现Copy特性:x所有权转移到y
  • 类型T实现了Copy特性:拷贝x所绑定的资源新资源,并把新资源的所有权绑定给yx依然拥有原资源的所有权。

譬如如下的代码运行就会抛出异常:

{
    let s1: String = String::from("hello");
    let s2 = s1;
    println!("{}", s1);
}

编译后会得到如下的报错:

c.rs:4:20: 4:21 error: use of moved value: s1 [E0382] c.rs:4 println!("{}", s1);

错误的意思是在 println 中访问了被 moved 的变量 s1。String 由三部分组成,如图左侧所示:一个指向存放字符串内容内存的指针,一个长度,和一个容量。这一组数据存储在栈上。右侧则是堆上存放内容的内存部分。

将值 &ldquo;hello&rdquo; 绑定给 s1 的 String 在内存中的表现形式

长度表示 String 的内容当前使用了多少字节的内存。容量是 String 从操作系统总共获取了多少字节的内存。当我们将 s1 赋值给 s2,String 的数据被复制了,这意味着我们从栈上拷贝了它的指针、长度和容量。我们并没有复制指针指向的堆上数据。换句话说,内存中数据的表现如下图所示。

变量 s2 的内存表现,它有一份 s1 指针、长度和容量的拷贝

如果 Rust 也拷贝了堆上的数据,那么内存看起来就是这样的。如果 Rust 这么做了,那么操作 s2 = s1 在堆上数据比较大的时候会对运行时性能造成非常大的影响。

另一个 s2 = s1 时可能的内存表现,如果 Rust 同时也拷贝了堆上的数据的话

之前我们提到过当变量离开作用域后,Rust 自动调用 drop 函数并清理变量的堆内存。不过上图展示了两个数据指针指向了同一位置。这就有了一个问题:当 s2 和 s1 离开作用域,他们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。为了确保内存安全,这种场景下 Rust 的处理有另一个细节值得注意。与其尝试拷贝被分配的内存,Rust 则认为 s1 不再有效,因此 Rust 不需要在 s1 离开作用域后清理任何东西。因此上文中的代码无法运行,就是因为 s1 已经被清理了。

如果你在其他语言中听说过术语 浅拷贝(shallow copy)和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 移动(move),而不是浅拷贝。上面的例子可以解读为 s1 被 移动 到了 s2 中。

s1 无效之后的内存表现

这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制可以被认为对运行时性能影响较小。

move 关键字

move 关键字常用在闭包中,强制闭包获取所有权。

fn main() {
    let x: i32 = 100;
    let some_closure = move |i: i32| i + x;
    let y = some_closure(2);
    println!("x={}, y={}", x, y);
}

// 结果:x=100, y=102

上例中使不使用 move 对结果都没什么影响,因为 x 绑定的资源是 i32 类型,属于 primitive type,实现了 Copy trait,所以在闭包使用 move 的时候,是先 copy 了 x,在 move 的时候是 move 了这份 clone 的 x,所以后面的 println!引用 x 的时候没有报错。

fn main() {
    let mut x: String = String::from("abc");
    let mut some_closure = move |c: char| x.push(c);
    let y = some_closure('d');
    println!("x={:?}", x);
}

报错:error: use of moved value: x [E0382]
:5 println!("x={:?}", x);

这是因为 move 关键字,会把闭包中的外部变量的所有权 move 到包体内,发生了所有权转移的问题,所以 println 访问 x 会如上错误。如果我们去掉 println 就可以编译通过。那么,如果我们想在包体外依然访问 x,即 x 不失去所有权,怎么办?

fn main() {
	let mut x: String = String::from("abc");
	{
    	let mut some_closure = |c: char| x.push(c);
	    some_closure('d');
	}
	println!("x={:?}", x);  //成功打印:x="abcd"
}

我们只是去掉了 move,去掉 move 后,包体内就会对 x 进行了可变借用,而不是剥夺 x 的所有权,细心的同学还注意到我们在前后还加了 {} 大括号作用域,是为了作用域结束后让可变借用失效,这样 println 才可以成功访问并打印我们期待的内容。

克隆(Clone)

如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。这是一个实际使用 clone 方法的例子:

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上。对于实现 Copy 特性的变量,在 move 时会拷贝资源到新内存区域,并把新内存区域的资源 binding 为 b:

Before move:
a <=> 内存(地址:A,内容:100)
After move:
a <=> 内存(地址:A,内容:100)
b <=> 内存(地址:B,内容:100)

Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 注解,将会出现一个编译时错误。move 前后的 a 和 b 对应资源内存的地址不同。在 Rust 中,基本数据类型(Primitive Types)均实现了 Copy 特性,包括 i8, i16, i32, i64, usize, u8, u16, u32, u64, f32, f64, (), bool, char 等等。

浅拷贝与深拷贝

很多面向对象编程语言中“浅拷贝”和“深拷贝”的区别类似。对于基本数据类型来说,“深拷贝”和“浅拷贝“产生的效果相同。对于引用对象类型来说,”浅拷贝“更像仅仅拷贝了对象的内存地址。如果我们想实现对String的”深拷贝“怎么办? 可以直接调用String的 Clone 特性实现对内存的值拷贝而不是简单的地址拷贝。

{
    let a: String = String::from("xyz");
    let b = a.clone();  // <-注意此处的clone
    println!("{}", a);
}

这个时候可以编译通过,并且成功打印"xyz"。clone 后的效果等同如下:

Before move: a <=> 内存(地址:A,内容:“xyz”) After move: a <=> 内存(地址:A,内容:“xyz”) b <=> 内存(地址:B,内容:“xyz”)

注意,然后 a 和 b 对应的资源值相同,但是内存地址并不一样。

所有权与函数

将值传递给函数在语义上与给变量赋值相似。向函数传递值可能会移动或者复制,就像赋值语句一样。

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值移动到函数里 ...
                                    // ... 所以到这里不再有效

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // x 应该移动函数里,
                                    // 但 i32 是 Copy 的,所以在后面可继续使用 x

} // 这里, x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
  // 所以不会有特殊操作

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!("{}", some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

fn makes_copy(some_integer: i32) { // some_integer 进入作用域
    println!("{}", some_integer);
} // 这里,some_integer 移出作用域。不会有特殊操作

当尝试在调用 takes_ownership 后使用 s 时,Rust 会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在 main 函数中添加使用 s 和 x 的代码来看看哪里能使用他们,以及所有权规则会在哪里阻止我们这么做。

返回值与作用域

返回值也可以转移所有权。

fn main() {
    let s1 = gives_ownership();         // gives_ownership 将返回值
                                        // 移给 s1

    let s2 = String::from("hello");     // s2 进入作用域

    let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                        // takes_and_gives_back 中,
                                        // 它也将返回值移给 s3
} // 这里, s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
  // 所以什么也不会发生。s1 移出作用域并被丢弃

fn gives_ownership() -> String {             // gives_ownership 将返回值移动给
                                             // 调用它的函数

    let some_string = String::from("hello"); // some_string 进入作用域.

    some_string                              // 返回 some_string 并移出给调用的函数
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域

    a_string  // 返回 a_string 并移出给调用的函数
}

变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。我们可以使用元组来返回多个值:

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("The length of '{}' is {}.", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回字符串的长度

    (s, length)
}
上一页
下一页