所有权无疑是 Rust 的核心概念。是编写所有代码都需要注意的内存模型。
大多数 native 语言需要手动分配和释放内存(如 C、C++),性能很高,但容易出错,编写出的代码很难维护,需要程序员有较高水平;有一些语言使用垃圾收集(比如 Java、Kotlin、C#),这降低了门槛,但会损失许多性能。
Rust 的答案截然不同,Rust 使用所有权机制,在编译期就可以检查内存分配,减少程序员的心智负担,但运行时仍然很快。
堆和栈
不妨回忆一下这两个概念。
堆(Heap)和栈(Stack)都是在运行时可用的内存,但是结构不同。
栈是一个 Queue 结构,只能操作头部。增加数据被称作进栈,移出数据被称作出栈。需要以入栈相反的顺序出栈,这被称为先进后出。栈是紧密的,其中的所有数据都必须占用已知且固定的内存大小。
堆是缺乏组织的。在堆上分配内存(allocating on the heap)只需要找一块足够大的空位,并将其标记为已使用。堆上的数据通常需要在栈上保存一个指针(Pointer),使用时要指针找到对应的堆内存。
在栈上分配内存比堆上快,因为没有寻找的过程,直接操作头部即可。堆分配需要额外的工作,即需要找到能够存放数据的内存空间,并记录以供下次操作。
访问栈也比访问堆快。因为堆需要经过一层指针转发。
在函数调用时,传递给函数的值(包括指针)和函数局部变量都被压入栈。函数结束时,这些值被移出栈。
所有权
任何时候,Rust 中的变量都有且仅有一个所有者(owner)。当所有者离开作用域时,该值将会被丢弃。
{ // 作用域开始
let s = "hello"; // s 有效
} // 作用域结束, s 被消毁
这与其他语言类似。
String 类型
为了介绍所有权,我们需要一个不能分配在栈上,只能分配到堆上的
String
类型。(详细的介绍在以后进行)
let str = String::from("Hello"); // 初始化
str.push_str(", world!") // 添加字符
println!("{}", str); // 将打印 `Hello, world!`
::
是命名空间访问符号,用于访问 String
下的
from
静态方法。
这和字符串字面量的类型 &str
的区别在于。String
在运行时可修改,编译时大小不可知,存储在堆上。
内存与分配
字符串字面量在编译时可知,所以可以直接硬编码进可执行文件中。然而我们不能将可能变化的数据硬编码。
String
类型为了可变、可修改的特性,需要在堆上分配可变长的数据结构。意味着:
- 必须在运行时请求内存
- 处理后应当将内存释放
第一部分很简单:调用 String::from
时请求即可。
然而第二部分就会出现不同的路线了。在 GC 语言中,GC 会帮我们清除不再使用的内存(这部分可以参见我的 JVM 学习笔记)。在大部分无 GC 的语言中,需要程序员手动识别不再使用的内存,并调用代码显式释放。如 C:
#include <stdlib.h>
#include <stdio.h>
int main() {
int *mem = malloc(sizeof(int) * 4);
[0] = 10;
mem[1] = 2;
memfor (int i = 0; i < 4; i++) {
("%d, ", mem[i]);
printf}
(mem);
free= NULL;
mem }
手动分配内存需要很多开发精力,一次创建需要严格对应一次释放,没有释放会造成内存泄漏,重复释放更会导致不可预测的运行时错误,甚至是让程序崩溃。——JVM学习笔记02
Rust 采取不同的策略:内存在变量离开作用域后被自动释放。如:
{
let s = String::from("hello"); // 从此处起,s 是有效的
// 使用 s...
} // 作用域结束
// s 不再有效
在作用域结束释放内存是很自然的。Rust 此时自动调用一个特殊的函数,名为
drop
。并调用相应的,String
释放内存的代码。
这种模式虽然简单,但影响深远,下面我们将探索在复杂场景下的机制。
变量与数据交互的方式
移动
先看看分配在栈上的对象:
let x = 5;
let y = x;
println!("x: {} y: {}", x, y); // x: 5 y: 5
= 10;
y println!("x: {} y: {}", x, y); // x: 5 y: 10
嗯,很符合预期,成功输出了 x
和
y
。x
和 y
都在栈中,两个值都为
5
。修改 y
不会影响 x
。
再来看看 String
(分配在堆上的对象):
let s1 = String::from("hello");
let s2 = s1; // s1 move to s2
println!("{}", s1);
Rust
报了一个编译时错误:borrow of moved value: s1
。
看起来和上面的代码类似,但行为并不一样。我们可能期待第二行的赋值语句生成
s1
的拷贝并绑定到 s2
上,但事实并非如此。拷贝的仅仅是指针和基本信息。
可以看到,Rust 并没有拷贝堆上的数据,仅仅只拷贝了栈上的数据。如果 Rust 在此时默认拷贝堆上的数据,在数据较大时会造成非常大的开销。
之前提到过,在变量离开作用域时,Rust 会自动调用 drop
并清理内存。不过,上图展示了两个指针指向了同一个位置,这就造成了一个问题:它们在离开作用域时,会尝试释放相同的内存,也叫二次释放。
为了避免这个问题,保证内存安全,在 let s2 = s1
后,Rust
便令 s1
无效。后面再次使用
s1
,将造成编译时异常。就是之前我们遇到的情况。
在其他语言中,有类似浅拷贝(shallow copy)、深拷贝(deep copy)的术语。上面的行为类似浅拷贝,但 Rust 同时令原变量无效了,所以称其为移动(move),而非浅拷贝。
移动方式适用于实现了 Drop
trait(可以简单理解为接口)的数据结构,大多数情况不需要手动实现。
克隆
如果我们切实需要深拷贝,也就是复制一份堆上的数据。我们可以使用
clone()
函数。
let s1 = String::from("hello");
let s2 = s1.clone();
println!("{s1}, {s2}");
克隆方式适用于实现了 Clone
trait 的数据结构。
拷贝
其实之前已经提及,如整数这种类型,就是实现了 Copy
trait
的。它们只存储在栈上。
Copy
和 Drop
互斥,因为 Drop
意味着在堆上储存了数据。以下数据类型实现了 Copy
:
- 整数类型
iXX
uXX
,浮点类型fXX
- 布尔类型
bool
- 字符
char
- 所有成员都实现了
Copy
的元组,如(i32, i32)
,但(i32, String)
就没有。
对实现了 Copy
的类型调用 clone()
与直接赋值没有区别,都是在栈上分配。
let x = 32;
let y = x;
let y_clone = y.clone();
函数和作用域
将值传递给函数与给变量赋值类似,没有什么特别的。可以把函数的形参看作一种赋值(夺去所有权)。返回看作给予所有权。
fn gives_ownership() -> String {
let some_string = String::from("hello");
return some_string;
}
fn takes_and_gives_back(string: String) -> String {
return string;
}
fn main() {
let s1 = gives_ownership();
let s2 = String::from("hello");
let s3 = takes_and_gives_back(s2);
} // s1, s3 被 drop, s2 已经移动去了 s3
我们可以使用函数返回多个值,来达到计算的效果:
fn calculate_len(s: String) -> (String, usize) {
let len = s.len();
return (s, len);
}
fn main() {
let s1 = String::from("hello");
let (s2, len) = calculate_len(s1);
}
然而这未免有点太啰嗦了,返回 s2
仅仅是为了来回转移所有权。为何不更简单点?所以我们有了——
引用和借用
我们可以将函数的参数改为引用:
fn calculate_len(s: &String) -> usize {
return s.len();
}
fn main() {
let s1 = String::from("hello");
let len = calculate_len(s1);
println!("The length of '{}' is {}.", s1, len);
}
&
符号即为引用符号,允许你使用值但不获得其所有权,即借用(borrowing)。
与之对应的操作是解引用(dereferencing),使用
*
。这在后面讲解。
正如变量默认不可变,我们在默认情况下,也无法修改借用的变量。
可变引用
fn main() {
let mut s = String::from("hello");
&mut s);
change(}
fn change(some_string: &mut String) {
.push_str(", world");
some_string}
可以看到,我们在声明处和调用处都必须显式声明 &mut
传递可变引用。
可变引用带来一项很大的限制:在同一时间,对特定数据的可变引用至多有 1 个。
例如,以下的代码将会引发编译时错误:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
这段代码造成异常的可能性很高。为了避免数据竞争造成运行时错误,Rust 将问题提前到了编译期。造成数据竞争的 3 个条件,缺一不可,Rust 的判断机制也基于以下规则:
- 多个指针访问同一数据
- 至少一个指针用于写数据
- 没有同步机制
Rust 允许在不同作用域中拥有多个可变引用,但不能同时拥有:
let mut s = String::from("hello");
{
let r1 = &mut s;
}
let r2 = &mut s;
Rust 允许同时存在多个不变引用(同时读不会造成问题),但只要有一个可变指针,则不允许通过编译:
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题
println!("{}, {}, and {}", r1, r2, r3);
请注意,Rust 可以检测隐式的作用域,亦称非词法作用域生命周期(NLL, Non-Lexical Lifetimes):
let mut s = String::from("hello");
let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{} and {}", r1, r2);
// 此位置之后 r1 和 r2 不再使用
let r3 = &mut s; // 没问题
println!("{}", r3);
相当于自动为你添加了大括号。
悬挂引用
fn dangle() -> &string {
let s = String::from("hello");
&s;
retunr }
将引用作为返回值是非常疯狂的一种行为。在 s
退出作用域时,它被清理了,但指向它的指针却没有,这称为悬挂引用。所以
Rust 又在编译期阶段阻止了这一行为。
复习一下,引用的规则是:
- 在任意时间,要么有一个可变引用,要么有多个不变引用
- 引用必须总是有效的
如果你不记得了,不要害怕,Rust 编译器会提醒你。
切片
切片(slice)的目的是为了引用集合中的元素序列,而不引用整个集合。相比直接使用索引的方式,提供了额外的编译时安全保障。
例如尝试找字符串的第一个单词:
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
return s.len();
}
上述代码的具体实现不重要。无非就是找到第一个单词,并返回索引。
不妨查看以下用例:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s); // word == 5
.clear(); // 清空字符串, 使其为 ""
s// word 在此仍然为 5
// 也就是说, word 的意义不再有效, 后续使用该值可能会造成意外的后果
}
如果我们要编写一个签名如这样的函数,索引问题将会更加疯狂:
fn second_word(s: &String) -> (usize, usize)
切片解决了这一问题,它的基本语法如下:
let s = String::from("Hello World");
let hello = &s[0..5];
let world = &s[6..11];
// 和之前说的一样, 也可以使用 ..= 表示闭区间
// 可以省略 0 和 length, 如:
let len = s.len()
let hello2 = &s[..5];
let world2 = &s[6..];
let whole1 = &s[0..len]
let whole2 = &s[..]
注意,如果字符串 slice range 不在有效的 UTF-8 字符边界内,程序将会崩溃。这里假设为 ASCII 字符集。
使用切片重写的方法:
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];
}
}
return &s[..];
}
类似的,second_word
也可以改写为切片:
fn second_word(s: &String) -> &str
这样,我们在使用时,就无需自己思考索引的有效性了:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
.clear(); // 编译时错误!
sprintln!("the first word is: {}", word);
}
字面量类型
字符串字面量的类型就是
&str
,是指向二进制程序特定位置的
slice
。所以字符串字面量不可变。
let s: &str = "Hello, World!";
字符串 slice 作为参数
将字符串 slice &str
作为参数是最佳实践。
因为可以同时接收字符串字面量、&str
、&String
。
let str1 = String::from("hello, world!");
let word1 = first_word(&str1[..]);
let word2 = first_word(&str1);
let literal = "hello world!";
let word3 = first_word(literal[0..6]);
let word4 = first_word(literal[..]);
let word5 = first_word(literal);
其他类型的 slice 类似,就不再赘述了。
以上,就是 Rust 内存管理相关的概念。