所有权是Rust语言的核心特性,也是Rust从根本上实现内存安全的保证。由于所有权这个特性根植在Rust核心中,所以对Rust语言中的各个部分都有深远的影响。
基本上所有的计算机程序都需要在其运行过程中对内存进行管理。目前流行的大部分编程语言都采用了垃圾回收(GC)的方式来释放已经使用过的内存,但是这种内存的整理方式首先是要消耗掉一些CPU时间的,而且在一些资源用量比较大的程序中,常常也会出现由于GC导致的程序发生明显停顿的情况。还有一部分追求更高性能的编程语言则提供了程序员更加原始的内存操作方式,例如C++的手动内存分配和内存释放。这种相对比较自由的内存管理方式,在程序员经验不足的情况下,常常会带来更多的内存问题,例如内存泄露或者悬垂指针。
为了解决这个问题,Rust采用了一种“简单粗暴”的方式提供了一个非常有效的解决方案(虽然用起来不是那么容易接受)。Rust语言直接操作的是内存区域,变量是持有这一段内存区域的一个代号。当一个变量持有这一段内存区域的时候,就可以说这个变量拥有对于这段内存区域的所有权。
所有权规则
所有权,顾名思义,是一种排他性质的权利,Rust中的所有权也不例外。在Rust中,所有权的规则主要有以下三条:
- 每一个值(value)都必然有一个与其对应的变量(variable),这个变量被称为值的拥有者(owner)。
- 每一个值在同一时间有且只能存在一个拥有者。
- 如果拥有者离开了其作用域(scope),那么其所拥有的值将会被释放(drop)。
在Rust中,一个变量的作用域在最简单的情况下,就是一对大括号的范围。
{ // 左大括号只是标记了一块作用域的开始,并不需要与其他关键字配合
let s = "something"; // 变量s的作用域从这一条语句开始生效
} // 变量s的作用域在这里就结束了
在这个示例中,变量 s
所拥有的值 something
在离开变量 s
的作用域以后,就会被释放掉。
栈和堆
在使用大部分语言编写程序的过程中,一般并不需要过多的考虑栈内存和堆内存的使用。但是在Rust中,区别一个值存放在何处将会有助于提升程序的工作效率。
栈内存和堆内存分别用于存储不同类型的值。栈内存采用LIFO的存取策略,并且栈内存中所存放的内容必须有明确的大小。相比之下,堆内存就没有这么栈内存这么严格的管理方式了。如果程序向堆内存申请空间的时候,堆内存会从堆中寻找空闲的存储空间,之后把这些空闲空间组织在一起交给程序使用。堆内存上的这一段空间通常会通过指针来访问,但是当程序需要更多的内存空间的时候,堆内存会寻找更多的空闲空间来继续分配。
虽然栈内存中内容的访问也是通过指针来完成的,但是栈内存中内容的大小是已知的,所以在栈内存中,所要访问的值占据的内存空间是连续的,所以在栈内存中检索和操作值内容,可以节省大量的寻址时间。但是堆内存中所存放的内容是不连续的,虽然可以存放较大的内容,但是访问的时候就需要花费比较多的寻址时间。
栈内存虽然操作速度快,但是其使用过程中的分配和回收并不受程序员控制,而是由系统来完成的,并且栈内存的大小是十分有限的。一般来说,对于栈内存的操作,主要体现在函数调用上。当调用一个函数时,函数的第一条可执行语句将会首先入栈,其次是函数的参数,然后是函数的局部变量(但是这里并不会包括静态变量,静态变量和全局变量是放在专门的全局区里的)。当一个函数执行完毕以后,这个函数在栈内存中的内容即完全弹出,栈内存顶部会回到调用函数之前的运行位置。
如果函数调用嵌套层数太多,比如层级过深的递归函数调用,就可能会导致栈内存消耗殆尽。
堆内存就几乎完全是程序员自己的领地了,堆内存会使用一个类似于链表的数据结构来跟踪在堆内存中存储的内容。但是堆内存最容易出现的问题就是内存的释放,如果已经不再使用的堆内存没有及时释放或者是超出了程序能够管理的边界,那么就会造成内存泄露,使程序可用的内存数量不断降低。从而使系统也变得越来越不稳定。
在Rust中,一个值在同一时间只能有一个所有者,当一个值没有对应的所有者的时候,这个值也就没有存在的必要了,所以Rust就会将这种值占据的内存回收。Rust的这种所有权规则从根本上避免了内存泄露的出现。
所有权转移
所有权转移在Rust语言程序中是十分常见的,一个最简单的赋值操作就可以把值的所有权移交给一个新的变量。
let s1 = String::from("something");
let s2 = s1;
println!("{}", s2); // 这一句是有效的
println!("{}", s1); // 这一句将会报错
在这个示例中,字符串值 "something"
的所有权就被从变量 s1
转移到了 s2
。在上面的示例中,打印 s2
的语句是成功的,因为此时 s2
是字符串值 "something"
的拥有者,但打印 s1
的语句是失败的,因为字符串值 "something"
的所有权已经被移交给了 s2
,此时 s1
已经不再拥有任何值,所以就会报错。
Rust的这个特性就保证了同一片内存区域在进行多线程访问时,多个线程同时向一个内存区域写的问题。由于每时每刻这一片内存区域只有一个拥有者,所以就不会存在多个线程争抢写入的问题,一个线程必须要首先拥有这片内存区域的所有权,才能够执行全权的访问。
克隆
那如果在程序中的确需要在多个地点同时使用一个值呢?Rust提供了克隆的方法,可以让程序员获得一个内存区域的复制。Rust中的这个克隆是一个内存区域的深拷贝,而且克隆只能应用于存储在堆内存区域的内容。
对于上面的示例,如果想让 s1
和 s2
同时起效,那么就可以利用克隆来对 s1
的内容进行一次深拷贝。
let s1 = String::from("something");
let s2 = s1.clone();
在大多数情况下使用克隆时,一定要牢记:克隆是对一个对象的深拷贝。这个操作可能是非常费时而且代价高昂的。
克隆出的新对象和原对象完全不是同一个对象,两个对象在克隆操作完成之后就分道扬镳了,相互之间不再有任何关联。
复制
在前文中提到过,大小明确的数据类型实例将会被保存在栈内存上,例如整型值。这些类型的值在赋值操作的时候,是不会移交所有权的,而是直接执行克隆操作。例如以下示例。
let a = 5;
let b = a;
在这个示例中,a
保存了一个整型值5,之后利用赋值操作将5这个值赋予了变量 b
。按照之前所有权转移的特性来解释,经过复制操作以后,变量 a
就会因为所有权移交变为无效变量。但是因为变量 a
中保存的是长度明确的整型值,所以Rust会采用复制的方式复制出一个新的整型值5的实例交给变量 b
,所以在经过赋值语句之后,变量 a
和变量 b
都是有效的,
在Rust中,以下基础类型是默认采用复制完成赋值操作的。
- 所有的整型类型,例如
u32
。 - 布尔类型。
- 浮点类型,例如
f64
。 - 字符类型,即
char
,不是字符串。 - 只包括具有
Copy
trait子类型内容的元组,例如(i32, i32)
,但是(i32, String)
就不是。
简单的来说,一个类型如果不拥有分配在堆上的区域,那么它就是采用复制完成赋值操作的。比如字符串就拥有分配在堆上的区域,所以字符串就是采用转移来完成复制的。在Rust中,大部分类型都是采用转移来完成赋值的。
在Rust中存在两个trait,一个名为 Copy
,一个名为 Drop
。我们可以把一个全部由基础类型组成的新类型标记为 Copy
,这样Rust就会将这个新类型的实例放置在栈上,并自动使用复制的方式去操作它,但是如果这个新类型中存在不拥有 Copy
trait的子元素,那么这个新类型也就不能通过实现 Copy
trait来成为一个可以被放置在栈中的对象。
函数调用
在Rust中,函数调用时的参数传递原理也跟赋值操作差不多,也同样会发生所有权的转移,对于默认采用复制完成赋值操作的数据类型,Rust会自动把要传入的值进行复制。下面借用官方的一个示例来说明函数调用时所有权转移的特性。
fn main() {
let s = String::from("something");
take_ownership(s); // 变量s所对应的值的所有权被移入了函数,所以变量s不再有效。
let a = 5;
make_copy(a); // 变量a所对应的值被自动进行了复制,复制后的值的所有权被移入了函数,所以变量a依旧有效。
}
fn take_ownership(some_str: String) {
println!("{}", some_str);
} // 参数some_str的作用域在此处终结,所以其持有的值被释放了。
fn make_copy(some_int: i32) {
println!("{}", some_int);
} // 参数some_int的作用域在此处终结,所以其持有的值被释放了。
函数 take_ownership
传入的是值的所有权,所以字符串值的所有权就从变量 s
转移到了 函数参数 some_str
,当函数参数 some_str
离开其作用域的时候,其所有的值也就自然被释放,而不是归还给变量 s
,所以字符串值 "something"
在执行完函数 take_ownership
之后,实际上是丢失了。但是函数 make_copy
传入的是复制后的值的所有权,函数参数 some_int
实际上拥有一个独立的值的所有权,所以即便是 some_int
离开作用域被释放以后,也不会对变量 a
产生任何影响。
函数返回
前面说到了一个值的所有权被传入函数之后,在函数执行结束时,值会因为具有其所有权的函数参数离开函数作用域而被释放,最终使这个传入函数的值丢失。但是在很多情况下我们依旧会希望继续使用这个值,并不想丢失这个值,那么就可以使用函数返回来再次返回这个值来把所有权重新交回。
例如我们把上面这个示例改变一下,就会产生不一样的效果。
fn main() {
let s = String::from("something");
let s = take_ownership(s); // 变量s所对应的值的所有权被移入了函数,所以变量s不再有效。
// 但是变量s又从函数返回值那里重新获得了值的所有权,所以变量s又变得重新可以访问了。
}
fn take_ownership(some_str: String) -> String {
println!("{}", some_str);
some_str
} // 参数some_str的作用域在此处终结,但是因为它的值被作为函数返回值返回,所以值并没有被释放。
如果需要从函数中返回多个所有权,可以直接在函数中返回一个元组。但是如果这个函数中有多个所有权需要移交,是不是就会十分头疼?因为返回值元组会十分庞大。
共享所有权
虽然Rust的所有权控制非常严格,但是在实际使用过程中依旧会使用到共享所有权的情况。例如一个值会在多个地方使用,但是转移所有权又十分繁琐,亦或者这个值会在多个地方同时使用,如果转移其所有权会导致程序的其他部分无法正常运行。这时就可以使用Rust提供的共享所有权方式来访问需要共享的值,这种共享方式与Python中的对象控制很像,都是采用引用计数的策略来确定被引用的值在何时可以被丢弃。
共享所有权是使用 Rc
和 Arc
两个类来实现的,其中 Arc
用于多线程条件下的所有权共享,可以提供原子性操作。这里需要注意的是,所有共享所有权的值,只能是不可变的,这是因为在Rust不存在既共享又可变的值。Rc
的使用可参考以下示例。
use std::rc::Rc;
fn main() {
let original: Rc<String> = Rc::new("Something".to_string());
let copy1: Rc<String> = original.clone();
let copy2: Rc<String> = original.clone();
assert!(copy1.contains("me"));
}
Rc
引用的共享所有权可以直接使用被引用类型的所有方法,区别只是使用 Rc
类型做了明确的引用说明。
引用
如果总是使用所有权移交的方式来传递程序中所要使用的值,那就太过于繁琐了,我们必须要十分小心的移交和获取每一个值空间的所有权。这对于函数的调用是十分不利的。
不过好在Rust提供了引用的用法,在一定规则下允许创建对于已有具有所有权的内存空间的引用。在Rust中,引用的概念与其他语言中基本相似,都是指代一个指向其他变量的变量。而且与C++的习惯一样,Rust也是使用 &
操作符来表示引用的,例如 &String
表示对字符串类型值的引用。
有了引用,前面那个需要传递所有权的函数示例就可以被改写为一个更加简单的形式。
fn main() {
let s = String::from("something");
do_not_take_ownership(&s); // 这里只需要传递变量的引用
println!("{}", s);
}
fn do_not_take_ownership(some_str: &String) {
println!("{}", some_str);
}
在这个示例中,被调用的函数不需要再获取传入实参的所有权,而直接可以访问传入的内容,仿佛就像是已经获取了所有权一般。在离开函数作用域的时候,持有引用的参数被终结,而因为其持有的只是一个引用而不是所有权,所以不会对持有所有权的变量产生任何影响。
Rust中引用的这种行为就像是现实世界中,借用其他人已有的物品来完成所需要完成的任务,之后再将物品归还的行为。所以在Rust中,使用引用的函数参数被称为借用(borrowing),这是因为使用了引用的函数参数在函数结束的时候,引用会自动解除,就好像是把借来的数据归还了一样。这样一来,在Rust中就没有必要纠结哪些数据类型在函数调用的时候是传值的,哪些是传址的。因为如果不使用引用,那就是传值的,因为所有权发生了移交;如果使用了引用,那就是传址的,因为所有权没有发生移交,函数参数只是借用它一下。
引用规则
在Rust中使用引用,需要遵循以下两条规则:
- 在任何时刻,一个变量只能拥有一个可变引用或者若干个不可变引用,可变引用与不可变引用不能同时出现。
- 引用必须时刻都是有效的。
可变引用
在Rust里,变量默认都是不可变的(immutable),所以引用默认也是不可变的。可变变量都需要使用mut
关键字修饰,以明确说明这个变量是可变变量。可变引用也是一样,也需要使用mut
关键字修饰。具体可见以下示例。 ```rust fn main() { let mut s = String::from(“something”); change(&mut s); let s1 = &mut s; // 这里只是一个示例,实际中不要这么书写,具体可参考引用的规则 }
fn change(some_str: &mut String) { somr_str.push_str(“, everything”); }
对于Rust的引用规则的第一条似乎不是那么容易理解,这可以借用官方的一个简单示例来说明。
```rust
let mut s = String::from("something");
let r1 = &s; // 可以创建可变变量的不可变引用
let r2 = &s; // 可以同时创建多个不可变引用
println!("{} and {}", r1, r2);
// 从这里开始,r1和r2都不再使用了
let r3 = &mut s;
println!("{}", r3); // 在这里访问r3是可以的,因为r1和r2已经被使用过而且不再使用了
请仔细理解Rust的引用规则的第一条,对这条内容的深入理解可以解决绝大部分的借用应用问题。
从函数中返回引用
C++中有一个著名的会造成严重内存问题的用法——悬垂指针。这在Rust中也可以存在,名称是悬垂引用,但是无法通过编译,具体可参考以下示例。
fn main() {
let reference = dangle();
}
fn dangle() -> &String {
let s = String::from("somwthing");
&s
}
示例中的函数 dangle
返回了一个对字符串的引用,而这个引用是引用到了函数的内部变量。这个实例出现的问题是,变量 s
在函数执行结束的时候就被释放了,而返回的引用在函数结束后,将指向一个未知的无效的内存区域,这就会造成潜在的安全问题。不过可以庆幸的是,Rust的编译器可以发现这个潜在的问题,并不会允许它通过编译。
要解决这个问题,最根本是要理解Rust的作用域和所有权的转移。这个示例中需要把函数的局部变量的内容移交给更高作用域,所以在这个函数中,只需要使用返回值,把局部变量的所有权移交出来即可,并不需要使用引用。以下是改正以后可以通过编译的版本。
fn main() {
let reference = dangle();
}
fn dangle() -> String {
let s = String::from("somwthing");
s
}
除了这种解决方法以外,Rust编译器还会建议使用 'static
生命期标记的方法来标记返回的值,但是这种方法在一般条件下并不必要,如果想要了解的话,可以去参考Rust中生命期的相关概念。
闭包
闭包实际上就是匿名函数,现在在各个语言里都十分常见,Rust也不例外。闭包作为函数的轻量版,自然也拥有一些函数的特性,但是闭包还拥有一些独特的特性。
首先,闭包会捕获所在函数的数据,并自动使用引用来借用它们。例如:
fn sort_by_score(students: &mut Vec<Student>, weight: i64) {
students.sort_by_key(|st| -st.score * weight);
}
在这个示例中,闭包捕获了函数参数 weight
,但是闭包的存活时间要短于它外层的函数,所以这个示例可以不出任何问题的通过编译。
那如果闭包中的引用,其存活期超过了外层函数呢?虽然原则上Rust不应该允许这样的情况存在,但是在多线程操作中,这样的情况是很普遍的,所以Rust引入了一个盗用机制。例如以下这个示例是不能正常通过编译的。
use std::thread;
fn start_thread(mut students: Vec<Student>, weight: i64) -> thread::JoinHandle<Vec<Student>> {
let sort_key_fn = |student: &Student| -> i64 { -student.score * weight };
thread::spawn(|| {
students.sort_by_key(sort_key_fn);
students
});
}
在这个示例中,闭包 sort_key_fn
所捕获的 weight
是在一个新的线程中使用的,所以不能保证其存活期短于函数 start_thread
,即 thread::spawn
创建的新线程不能保证自己在 students
和 weight
被销毁之前结束。所以这是一个不安全的使用,Rust不会允许这个示例通过编译。
要解决这个问题,Rust可以将所有权移交一下,这里的移交是使用一个新的关键字 move
来完成的。这个关键字的用途是通知Rust,这个闭包里所使用的变量都不是它本身的,而是需要从其他地方盗用过来。这样一来,上面这个不能通过编译的示例,就需要改成以下这个样子了。
use std::thread;
fn start_thread(mut students: Vec<Student>, weight: i64) -> thread::JoinHandle<Vec<Student>> {
let sort_key_fn = move |student: &Student| -> i64 { -student.score * weight };
thread::spawn(move || {
students.sort_by_key(sort_key_fn);
students
});
}
在修改以后的示例里,闭包 sort_key_fn
获得了变量 weight
的所有权,第二个闭包获得了 students
和 sort_key_fn
的所有权。
如果一个闭包转移了一个可以复制的值,那么这个值会被复制,在创建闭包之后,这个值还是可以继续使用的。如果闭包转移的是一个不可复制的类型,例如 Vec<Student>
,那么在闭包创建之后,这个值就会被转移到闭包里,并且不能再继续访问。对于转移的是不可复制的类型,如果后续还需要继续使用这个值,可以把它克隆到另一个变量中,让闭包盗走它自己引用的那个版本。
装箱
不同类型的对象占内存的大小也是不一样的,但是有时我们需要程序平等的对待所有的值,这是就需要对目标类型对象进行装箱。类型 Box
在创建的时候会在堆上申请一块空间来存放指定类型的值,方法 Box::new()
会在堆上申请空间并返回指向这块堆空间的指针,这样从程序的角度看起来,所有被包装起来的类型值,大小就都是一样的了,因为存储的都是指针。
装箱的另一个用途是如果需要从函数中返回一个浅拷贝的变量时,需要使用堆内存,而不是返回一个指向函数本地变量的引用。所以在Rust中见到 Box
类型的时候,可以直接认为它就是一个指向它携带的数据的指针,但是在使用的时候并不需要进行任何解引用操作。
切片
切片是Rust中比较特殊的一个数据类型,切片不拥有任何所有权,但是可以利用引用提供对于一个序列的局部内容访问能力。在Rust中,使用的最多的应该就是字符串切片,而字符串切片的存在也是让人觉得Rust中字符串类型比较混乱的原因之一。
跟Python语言中的切片一样,Rust中的切片也是需要来源自一个完整的可迭代序列,对于字符串切片来说,就是来自于一个字符串。例如以下示例。
let s = String::from("something");
let s1 = &s[0..4]; // 变量s1的类型就是字符串切片
let s2 = &s[..4]; // 变量s2的内容与变量s1一样,但是是一个全新的切片
let s3 = &s[4..]; // 缺省一端的值,表示从一端开始或者到一端结束。
字符串切片是一个引用,但是只引用了序列中的一部分。切片使用 ..
来定义要截取的区间。字符串切片的类型为 &str
,而不是 &String
。所以我们可以定义一个从字符串对象中寻找子字符串的函数,这样的函数示例如下。
fn first_word(s: &String) -> &str { // 函数不需要获取字符串的所有权,并且返回一个字符串切片
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
示例中的这个函数可以截取字符串中第一个空格前的作用内容,如果字符串中没有空格,那么就会返回全部字符串,但是始终是以字符串切片的形式。注意,这里虽然返回的同样是引用,但是并没有破坏Rust的引用使用规则,所有被返回的切片,都依附于目前还存在的字符串,所以并不会形成悬垂引用。
另外之前在所有的示例中定义字符串的时候,全部都是使用的 String::from
的形式,而不是像其他语言中直接使用 ""
来定义。这是因为直接使用 ""
的字面量形式来定义一个字符串,实际上在Rust里是字符串切片的类型,而不是字符串。所以如果上面的这个示例改变一下定义格式,那么在使用的时候其用法就会发生很大的变化。
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i];
}
}
&s[..]
}
fn main() {
let s1 = "something";
let w1 = first_word(s1);
let s2 = String::from("something");
let w2 = first_word(&s2[..]);
}
除了字符串切片以外,其他的序列类型也可以定义切片,例如整型数组,此时切片的类型标记就是 &[i32]
。