[ Rust笔记 十一] 内存安全-所有权和移动语义

所有权

“所有权”代表着以下意义:

  1. 每个值在Rust中都有一个变量来管理它,这个变量就是这个值、这块内存的所有者;

  2. 每个值在一个时间点上只有一个管理者;

  3. 当变量所在的作用域结束的时候,变量以及它代表的值将会被销毁。

移动语义

一个变量可以把它拥有的值转移给另外一个变量,称为“所有权转移”​。赋值语句、函数调用、函数返回等,都有可能导致所有权转移。

1
2
3
4
5
6
7
8
9
10
11
fn create() -> String {
let s = String::from("hello");
return s; // 所有权转移,从函数内部移动到外部
}
fn consume(s: String) { // 所有权转移,从函数外部移动到内部
println! ("{}", s);
}
fn main() {
let s = create();
consume(s);
}

所有权转移的步骤分解如下。

  1. main函数调用create函数。

  2. 在调用create函数的时候创建了字符串,在栈上和堆上都分配有内存。局部变量s是这些内存的所有者。

  3. create函数返回的时候,需要将局部变量s移动到函数外面,这个过程就是简单地按字节复制memcpy。

  4. 同理,在调用consume函数的时候,需要将main函数中的局部变量转移到consume函数,这个过程也是简单地按字节复制memcpy。

  5. 当consume函数结束的时候,它并没有把内部的局部变量再转移出来,这种情况下,consume内部局部变量的生命周期就该结束了。这个局部变量s生命周期结束的时候,会自动释放它所拥有的内存,因此字符串也就被释放了。

Rust中所有权转移的重要特点是,它是所有类型的默认语义。这是许多读者一开始不习惯的地方。这里再重复一遍,请大家牢牢记住,Rust中的变量绑定操作,默认是move语义,执行了新的变量绑定后,原来的变量就不能再被使用!一定要记住!

复制语义

默认的move语义是Rust的一个重要设计,但是任何时候需要复制都去调用clone函数会显得非常烦琐。对于一些简单类型,比如整数、bool,让它们在赋值的时候默认采用复制操作会让语言更简单。

比如下面这个程序就可以正常编译通过:

1
2
3
4
5
fn main() {
let v1 : isize = 0;
let v2 = v1;
println! ("{}", v1);
}

编译器并没有阻止v1被使用,这是为什么呢?因为在Rust中有一部分“特殊照顾”的类型,其变量绑定操作是copy语义。

所谓的copy语义,是指在执行变量绑定操作的时候,v2是对v1所属数据的一份复制。v1所管理的这块内存依然存在,并未失效,而v2是新开辟了一块内存,它的内容是从v1管理的内存中复制而来的。和手动调用clone方法效果一样,let v2 = v1;等效于let v2=v1.clone();。

Rust中,在普通变量绑定、函数传参、模式匹配等场景下,凡是实现了std::marker::Copy trait的类型,都会执行copy语义。基本类型,比如数字、字符、bool等,都实现了Copy trait,因此具备copy语义。对于自定义类型,默认是没有实现Copy trait的,但是我们可以手动添上。示例如下

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Foo {
data : i32
}
impl Clone for Foo {
fn clone(&self) -> Foo {
Foo { data : self.data }
}
}
impl Copy for Foo {} //我们要实现Copy trait必须同时实现Clone trait
fn main() {
let v1 = Foo { data : 0 };
let v2 = v1;
println! ("{:? }", v1.data);
}

Rust提供了一个编译器扩展derive attribute,来帮我们写这些代码,

1
2
3
4
5
6
7
8
9
#[derive(Copy, Clone)]
struct Foo {
data : i32
}
fn main() {
let v1 = Foo { data : 0 };
let v2 = v1;
println! ("{:? }", v1.data);
}

Box类型

Box类型是Rust中一种常用的指针类型。它代表“拥有所有权的指针”​。

1
2
3
4
5
6
7
struct T{
value: i32
}
fn main() {
let p = Box::new(T{value: 1});
println! ("{}", p.value);
}

Box类型永远执行的是move语义,不能是copy语义。对于Rust里面的所有变量,在使用前一定要合理初始化,否则会出现编译错误。对于Box / &T / &mut T这样的类型,合理初始化意味着它一定指向了某个具体的对象,不可能是空。如果用户确实需要“可能为空的”指针,必须使用类型Option<Box>。

Clone VS. Copy

Copy

Copy的全名是std::marker::Copy。请大家注意,std::marker模块里面所有的trait都是特殊的trait。目前稳定的有四个,它们是Copy、Send、Sized、Sync。这几个trait内部都没有方法,它们的唯一任务是给类型打一个“标记”​,表明它符合某种约定—这些约定会影响编译器的静态检查以及代码生成。

Copy这个trait在编译器的眼里代表的是什么意思呢?简单点总结就是,如果一个类型impl了Copy trait,意味着任何时候,我们都可以通过简单的内存复制(在C语言里按字节复制memcpy)实现该类型的复制,并且不会产生任何内存安全问题。

Clone

Clone的全名是std::clone::Clone。它的完整声明如下

1
2
3
4
5
6
pub trait Clone : Sized {
fn clone(&self) -> Self;
fn clone_from(&mut self, source: &Self) {
*self = source.clone()
}
}

对于Box类型,clone执行的是“深复制”​;而对于Rc类型,clone做的事情就是把引用计数值加1。

但是有一条规则需要注意:对于实现了copy的类型,它的clone方法应该跟copy语义相容,等同于按字节复制。

析构函数

所谓“析构函数”​(destructor)​,是与“构造函数”​(constructor)相对应的概念。​“构造函数”是对象被创建的时候调用的函数,​“析构函数”是对象被销毁的时候调用的函数。

Rust中没有统一的“构造函数”这个语法,对象的构造是直接对每个成员进行初始化完成的,我们一般将对象的创建封装到普通静态函数中。

相对于构造函数,析构函数有更重要的作用。它会在对象消亡之前由编译器自动调用,因此特别适合承担对象销毁时释放所拥有的资源的作用

析构函数不仅可以用于管理内存资源,还能用于管理更多的其他资源,如文件、锁、socket等。

在Rust中编写“析构函数”的办法是impl std::ops::Drop。Drop trait的定义如下:

1
2
3
trait Drop {
fn drop(&mut self);
}

Drop trait允许在对象即将消亡之时,自行调用指定代码。我们来写一个自带析构函数的类型。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
use std::ops::Drop;
struct D(i32);
impl Drop for D {
fn drop(&mut self) {
println! ("destruct {}", self.0);
}
}

fn main() {
let _x = D(1);
println! ("construct 1");
{
let _y = D(2);
println! ("construct 2");
println! ("exit inner scope");
}
println! ("exit main function");
}

执行结果

1
2
3
4
5
6
construct 1
construct 2
exit inner scope
destruct 2
exit main function
destruct 1

管理资源

除去那些错误处理的代码以后,整个逻辑实际上相当清晰:首先使用open函数打开文件,然后使用read_to_string方法读取内容,最后关闭文件,这里不需要手动关闭文件,因为在File类型的析构函数中已经自动处理好了关闭文件这件事情。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
use std::fs::File;
use std::io::Read;
fn main() {
let f = File::open("/target/file/path");
if f.is_err() {
println! ("file is not exist.");
return;
}
let mut f = f.unwrap();
let mut content = String::new();
let result = f.read_to_string(&mut content);

if result.is_err() {
println! ("read file error.");
return;
}
println! ("{}", result.unwrap());
}

主动析构

调用标准库中的std::mem::drop函数

1
2
#[inline]
pub fn drop<T>(_x: T) { }

drop函数的关键在于使用move语义把参数传进来,使得变量的所有权从调用方移动到drop函数体内,参数类型一定要是T,而不是&T或者其他引用类型。

函数体本身其实根本不重要,重要的是把变量的所有权move进入这个函数体中,函数调用结束的时候该变量的生命周期结束,变量的析构函数会自动调用,管理的内存空间也会自然释放。

对于Copy类型的变量,对它调用std::mem::drop函数是没有意义的。下面以整数类型作为示例来说明:

1
2
3
4
5
6
7
use std::mem::drop;
fn main() {
let x = 1_i32;
println! ("before drop {}", x);
drop(x);
println! ("after drop {}", x);
}

变量遮蔽(Shadowing)不会导致变量生命周期提前结束,它不等同于drop。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
use std::ops::Drop;
struct D(i32);
impl Drop for D {
fn drop(&mut self) {
println! ("destructor for {}", self.0);
}
}
fn main() {
let x = D(1);
println! ("construct first variable");
let x = D(2);
println! ("construct second variable");
}

输出

1
2
3
4
construct first variable
construct second variable
destructor for 2
destructor for 1

这里函数调用的顺序为:先创建第一个x,再创建第二个x,退出函数的时候,先析构第二个x,再析构第一个x。由此可见,在第二个x出现的时候,虽然将第一个x遮蔽起来了,但是第一个x的生命周期并未结束,它依然存在,直到函数退出。这也说明了,虽然这两个变量绑定了同一个名字,但在编译器内部依然将它们视为两个不同的变

另外还有一个小问题需要提醒读者注意,那就是下划线这个特殊符号。请注意:如果你用下划线来绑定一个变量,那么这个变量会当场执行析构,而不是等到当前语句块结束的时候再执行。下划线是特殊符号,不是普通标识符。示例如下:

1
2
3
4
5
6
7
8
9
10
11
12
use std::ops::Drop;
struct D(i32);
impl Drop for D {
fn drop(&mut self) {
println! ("destructor for {}", self.0);
}
}
fn main() {
let _x = D(1);
let _ = D(2);
let _y = D(3);
}

输出

1
2
3
destructor for 2
destructor for 3
destructor for 1

最后,请大家注意区分,
std::mem::drop()函数和std::ops::Drop::drop()方法。

1)
std::mem::drop()函数是一个独立的函数,不是某个类型的成员方法,它由程序员主动调用,作用是使变量的生命周期提前结束;
std::ops::Drop::drop()方法是一个trait中定义的方法,当变量的生命周期结束的时候,编译器会自动调用,手动调用是不允许的。

2)
std::mem::drop(_x: T)的参数类型是T,采用的是move语义;
std::ops::Drop::drop(&mut self)的参数类型是&mut Self,采用的是可变借用。在析构函数调用过程中,我们还有机会读取或者修改此对象的属性

Drop VS. Copy

要想实现Copy trait,类型必须满足一定条件。这个条件就是:如果一个类型可以使用memcpy的方式执行复制操作,且没有内存安全问题,那么它才能被允许实现Copy trait。反过来,所有满足Copy trait的类型,在需要执行move语义的时候,使用memcpy复制一份副本,不删除原件是完全不会产生安全问题的。

本节中需要强调的是,带有析构函数的类型都是不能满足Copy语义的。因为我们不能保证,对于带析构函数的类型,使用memcpy复制一个副本一定不会有内存安全问题。所以对于这种情况,编译器是直接禁止的。

报错代码

1
2
3
4
5
6
7
8

use std::ops::Drop;
struct T;
impl Drop for T {
fn drop(&mut self){}
}
impl Copy for T {}
fn main() {}

析构标记

首先判断一个变量是否可能会在多个不同的路径上发生析构,如果是这样,那么它会在当前函数调用栈中自动插入一个bool类型的标记,用于标记该对象的析构函数是否已经被调用,生成的代码逻辑像下面这样: