在进入正题之前,大家先回忆下一般的编程语言知识。 对于一般的编程语言,通常会先声明一个变量,然后初始化它。 例如在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
只有当整个程序结束后系统才能回收这片内存,这点让程序员是不是也很无奈?
备注:对于
xyz
,可根据实际情况,通过堆的方式,手动管理(申请和释放)内存。
所以,内存安全和内存管理通常是程序员眼中的两大头疼问题。令人兴奋的是,Rust却不再让你担心内存安全问题,也不用再操心内存管理的麻烦,那Rust是如何做到这一点的?请往下看。
重要:首先必须强调下,准确地说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通过{}
大括号定义作用域:
{
{
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
的错误。这些所有的一切都是在编译过程中完成的。
先看如下代码:
{
let a: String = String::from("xyz");
let b = a;
println!("{}", a);
}
编译后会得到如下的报错:
c.rs:4:20: 4:21 error: use of moved value:
a
[E0382] c.rs:4 println!("{}", a);
错误的意思是在println
中访问了被moved
的变量a
。那为什么会有这种报错呢?具体含义是什么?
在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
一次。
通过这个机制,就保证了内存安全。是不是觉得很强大?
有读者仿照“move”小节中的例子写了下面一个例子,然后说“a被move后是可以访问的”:
let a: i32 = 100;
let b = a;
println!("{}", a);
编译确实可以通过,输出为100
。这是为什么呢,是不是跟move小节里的结论相悖了?
其实不然,这其实是根据变量类型是否实现Copy
特性决定的。对于实现Copy
特性的变量,在move时会拷贝资源到新内存区域,并把新内存区域的资源binding
为b
。
Before move:
a <=> 内存(地址:A,内容:100)
After move:
a <=> 内存(地址:A,内容:100)
b <=> 内存(地址:B,内容:100)
move前后的a
和b
对应资源内存的地址不同。
在Rust中,基本数据类型(Primitive Types)均实现了Copy特性,包括i8, i16, i32, i64, usize, u8, u16, u32, u64, f32, f64, (), bool, char等等。其他支持Copy的数据类型可以参考官方文档的Copy章节。
前面例子中move String和i32用法的差异,其实和很多面向对象编程语言中“浅拷贝”和“深拷贝”的区别类似。对于基本数据类型来说,“深拷贝”和“浅拷贝“产生的效果相同。对于引用对象类型来说,”浅拷贝“更像仅仅拷贝了对象的内存地址。
如果我们想实现对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对应的资源值相同,但是内存地址并不一样。
通过上面,我们已经已经了解了变量声明、值绑定、以及移动move语义等等相关知识,但是还没有进行过修改变量值这么简单的操作,在其他语言中看似简单到不值得一提的事却在Rust中暗藏玄机。 按照其他编程语言思维,修改一个变量的值:
let a: i32 = 100;
a = 200;
很抱歉,这么简单的操作依然还会报错:
error: re-assignment of immutable variable
a
[E0384] :3 a = 200;
不能对不可变绑定赋值。如果要修改值,必须用关键字mut声明绑定为可变的:
let mut a: i32 = 100; // 通过关键字mut声明a是可变的
a = 200;
想到“不可变”我们第一时间想到了const
常量,但不可变绑定与const
常量是完全不同的两种概念;首先,“不可变”准确地应该称为“不可变绑定”,是用来约束绑定行为的,“不可变绑定”后不能通过原“所有者”更改资源内容。
例如:
let a = vec![1, 2, 3]; //不可变绑定, a <=> 内存区域A(1,2,3)
let mut a = a; //可变绑定, a <=> 内存区域A(1,2,3), 注意此a已非上句a,只是名字一样而已
a.push(4);
println!("{:?}", a); //打印:[1, 2, 3, 4]
“可变绑定”后,目标内存还是同一块,只不过,可以通过新绑定的a去修改这片内存了。
let mut a: &str = "abc"; //可变绑定, a <=> 内存区域A("abc")
a = "xyz"; //绑定到另一内存区域, a <=> 内存区域B("xyz")
println!("{:?}", a); //打印:"xyz"
上面这种情况不要混淆了,a = "xyz"
表示a
绑定目标资源发生了变化。
其实,Rust中也有const常量,常量不存在“绑定”之说,和其他语言的常量含义相同:
const PI:f32 = 3.14;
可变性的目的就是严格区分绑定的可变性,以便编译器可以更好的优化,也提高了内存安全性。
在前面的小节有简单了解Copy特性,接下来我们来深入了解下这个特性。 Copy特性定义在标准库std::marker::Copy中:
pub trait Copy: Clone { }
一旦一种类型实现了Copy特性,这就意味着这种类型可以通过的简单的位(bits)拷贝实现拷贝。从前面知识我们知道“绑定”存在move语义(所有权转移),但是,一旦这种类型实现了Copy特性,会先拷贝内容到新内存区域,然后把新内存区域和这个标识符做绑定。
哪些情况下我们自定义的类型(如某个Struct等)可以实现Copy特性? 只要这种类型的属性类型都实现了Copy特性,那么这个类型就可以实现Copy特性。 例如:
struct Foo { //可实现Copy特性
a: i32,
b: bool,
}
struct Bar { //不可实现Copy特性
l: Vec<i32>,
}
因为Foo
的属性a
和b
的类型i32
和bool
均实现了Copy
特性,所以Foo
也是可以实现Copy特性的。但对于Bar
来说,它的属性l
是Vec<T>
类型,这种类型并没有实现Copy
特性,所以Bar
也是无法实现Copy
特性的。
那么我们如何来实现Copy
特性呢?
有两种方式可以实现。
-
通过
derive
让Rust编译器自动实现#[derive(Copy, Clone)] struct Foo { a: i32, b: bool, }
编译器会自动检查
Foo
的所有属性是否实现了Copy
特性,一旦检查通过,便会为Foo
自动实现Copy
特性。 -
手动实现
Clone
和Copy
trait#[derive(Debug)] struct Foo { a: i32, b: bool, } impl Copy for Foo {} impl Clone for Foo { fn clone(&self) -> Foo { Foo{a: self.a, b: self.b} } } fn main() { let x = Foo{ a: 100, b: true}; let mut y = x; y.b = false; println!("{:?}", x); //打印:Foo { a: 100, b: true } println!("{:?}", y); //打印:Foo { a: 100, b: false } }
从结果我们发现
let mut y = x
后,x
并没有因为所有权move
而出现不可访问错误。 因为Foo
继承了Copy
特性和Clone
特性,所以例子中我们实现了这两个特性。
我们从前面的小节了解到,let
绑定会发生所有权转移的情况,但ownership
转移却因为资源类型是否实现Copy
特性而行为不同:
let x: T = something;
let y = x;
- 类型
T
没有实现Copy
特性:x
所有权转移到y
。 - 类型
T
实现了Copy
特性:拷贝x
所绑定的资源
为新资源
,并把新资源
的所有权绑定给y
,x
依然拥有原资源的所有权。
move关键字常用在闭包中,强制闭包获取所有权。
例子1:
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
注意: 例子1是比较特别的,使不使用 move 对结果都没什么影响,因为x
绑定的资源是i32
类型,属于 primitive type
,实现了 Copy trait
,所以在闭包使用 move
的时候,是先 copy 了x
,在 move 的时候是 move 了这份 clone 的 x
,所以后面的 println!
引用 x
的时候没有报错。
例子2:
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
才可以成功访问并打印我们期待的内容。
关于“Borrowing借用”知识我们会在下一个大节中详细讲解。