共计 3960 个字符,预计需要花费 10 分钟才能阅读完成。
所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。目前主要有以下三种流派:
- 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表就是:Java、Go;
- 手动管理内存的分配和释放,在程序中,通过函数调用的方式来申请和释放内存,典型代表:C/C++
- 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查,Rust就是使用这种方式。
1. Stack And Heap
Stack-栈
栈按照先进后出的顺序存取值。基于这种实现方式,栈中的所有数据都必须占用已知且固定大小的内存空间,假设数据大小是未知的,那么在取出数据时,就无法从栈中得到我们想要的数据。
Heap-堆
对于大小未知或者可能变化的数据,一般是存储在堆上的。每当向堆中放入数据时,就需要请求一定大小的内存空间,操作系统会在堆中的某处找到一块足够大的空间将其标记为已使用,并返回一个表示该位置地址的指针,整个过程称为在堆上分配内存。
当分配完成后,该空间的指针就会入栈,因为指针的大小是已知且固定的,所以在后续使用的过程中,会通过栈中的指针来获取在堆中的实际内存位置,从而访问到数据。
性能区别:一般情况下,在栈上分配内存要比在堆上分配内存快得多。因为入栈无需进行函数调用,只需将数据放到栈顶即可,而在堆上分配内存,则需要让操作系统先找到一块足够的内存空间,然后进行记录为下一次分配做准备,如果当前内存页不足,还需要进行系统调用申请更多的内存。
当代码调用一个函数时,传递给函数的参数依次被压入栈中,当函数调用结束时,这些值将根据栈的规则弹出。因为堆上的数据缺乏组织,所以跟踪这些数据何时分配和释放就显得非常重要,否则堆上的数据将产生内存泄漏(数据无法回收)。
2. 所有权原则
- Rust中每一个值都被一个变量所拥有,该变量称为该值的所有者;
- 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者;
- 当所有者(也就是变量)离开作用域范围时,这个值将会被丢弃;
例如下述
{ // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的
// 使用 s
} // 此作用域已结束,s不再有效
2.1 变量绑定背后的数据交换
转移所有权
{ // 作用域开始
let x = 5;
let y = x;
} // 作用域结束
上述代码中所有权并没有发生转移,也就是说:x拥有5,y也拥有5。原因如下:
- 在Rust中,基本数据类型是固定大小的简单值,对于这种简单值,在赋值时是通过自动拷贝的方式赋值的;
- 这种自动拷贝的方式赋的值都被存在栈中,无需在堆上分配内存;
- 由于赋值过程发生在栈中,因此并不需要所有权转移;
{ // 作用域开始
let s1 = String::from("hello");
let s2 = s1;
} // 作用域结束
上述代码中s1的所有权发生了转移,将其转移给了s2,原因如下:
- String不是基本数据类型,其内存分配发生在堆中,当在堆中找到合适大小的空间后,会返回一个堆指针,存储在栈中的数据就包括:堆指针、字符串长度、字符串容量。其中字符串长度是实际字符串的长度大小,容量是堆分配的空间大小;
- 如果s1和s2都指向同一位置,那么当这两个变量离开作用域后,都会对同一位置进行清理,这就会造成二次释放,二次释放同一内存空间会导致内存污染,甚至导致一些隐藏的安全漏洞。
- Rust为了避免二次释放解决上面的问题,就会进行所有权转移:当s1被赋予给s2后,Rust认为s1不再有效,因此无需等待s1离开作用域才清理内存空间,而是把数据的所有权从s1手上转移给了s2,之后s1不再有效.
- 这样,s1因为是无效的,s2是有效的,当s1,s2离开作用域时,就只有s2会释放内存。
当我们在s1转移所有权后再次使用s1,那么就会报错:
它提示我们使用了一个移动(move)的值。
对于了解深拷贝和浅拷贝概念的人来说,应该很容易想到使用深拷贝实现s1和s2都拥有字面量为"xxx"的数据。在Rust中,所有自动复制的行为都是基于浅拷贝实现的,深拷贝都需要我们手动实现。在上面的代码中,s2=s1
就是一个浅拷贝行为,这就导致s1和s2都指向同一个堆内存,因此发生所有权转移。
当我们使用深拷贝时,就可以避免所有权转移。在String类型中,可以使用clone()
方法得到一个深拷贝的数据。
fn main() {
let s1 = String::from("Hello World");
let s2 = s1.clone();
print!("{}", s1);
}
深拷贝不再仅仅只是将栈上的数据拷贝一份,还会把堆内存中的数据拷贝一份。clone
方法会有一定的性能损耗,在其它语言中基本也是这样。这也就是为什么Rust中所有的拷贝行为都是基于浅拷贝而不是深拷贝。
当然,我们还可以对字符串的引用进行浅拷贝:
fn main() {
let s1: &str = "Hello Wolrd";
let _s2 = s1;
print!("{}", s1);
}
在上面的代码中,s1只是引用了存储在二进制中的字符串"Hello Wolrd",并没有持有所有权。当执行s2=s1
时,也只是将引用进行了拷贝,s1和s2都引用了同一个字符串。
3. 函数传值与返回
fn main() {
let s1 = String::from("Hello Wolrd");
move_value(s1);
println!("s1:{}", s1);
}
fn move_value(str: String) {
let s2 = str;
println!("s2:{}", s2);
}
上述代码在执行时会报错,提示我们使用了一个move值。根据所有权原则,不难发现s1在调用move_value
后s1的所有权转移给了s2,s1失效,move_value执行完毕后,s2离开作用域被丢弃。也就是说,我们的s1在传入move_value函数,并且执行完毕后,s1的值失效了。为了解决这个问题,我们可以选择函数返回值将原来的值返回出来。代码如下:
fn main() {
let s1 = String::from("Hello Wolrd");
let s1 = move_value(s1);
println!("s1:{}", s1);
}
fn move_value(str: String) -> String {
let s2 = str;
println!("s2:{}", s2);
s2
}
Rust中所有权机制非常强大,避免了内存的不安全性,但是也带来了一个新麻烦: 总是把一个值传来传去来使用它。 传入一个函数,很可能还要从该函数传出去,结果就是语言表达变得非常啰嗦,幸运的是,Rust 提供了新功能解决这个问题,那就是借用。
4. 借用
获取变量的引用就是借用。Rust正是通过借用避免因为所有权机制造成值被传来传去。
1. 引用
常规引用就是一个指针类型,指向了对象存储的内存地址。和C/C++中的语法很像,可以是&
去取地址,*
去获取指针所执行的数据。
fn main() {
let x = 5;
let y = &x;
println!("{}", *y);
}
基于此,我们可以使用下述代码来简化move_value
函数:
fn main() {
let s1 = String::from("Hello Wolrd");
move_value(&s1);
println!("s1:{}", s1);
}
fn move_value(str: &String) {
let s2 = str;
println!("s2:{}", *s2);
}
但是,正如变量默认不可变的一样,引用指向的变量也是默认不可变的,因此当我在上述代码中尝试对s2指向的数据进行修改,就会报错:
因此要想让引用的变量数据可变,也需要使用mut
关键字:
fn main() {
let mut s1 = String::from("Hello Wolrd");
move_value(&mut s1);
println!("s1:{}", s1); // s1:Hello Wolrd!
}
fn move_value(str: &mut String) {
let s2 = str;
s2.push_str("!");
println!("s2:{}", *s2); // s2:Hello Wolrd!
}
需要注意的是,Rust中一个作用域中只能存在一个对同一数据的可变引用
。当出现多个对同一数据的可变引用是,编译器就会报错。代码如下:
fn main() {
let mut s = String::from("Hello Wolrd");
let s1 = &mut s;
let s2 = &mut s;
println!("s1:{},s2:{}", s1, s2)
}
这一限制的好处就是可以让Rust在编译期就避免数据竞争,数据竞争可由以下行为造成:
- 两个或两个以上的指针同时访问同一数据;
- 至少有一个指针被用于写入数据;
- 没有同步数据访问的机制;
数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。而 Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
除此之外,还有一个需要注意的点就是:可变引用与不可变引用不同同时存在。
fn main() {
let s = String::from("Hello Wolrd");
let s1 = s;
let s2 = &mut s;
println!("s1:{},s2:{}", s1, s2)
}
这点也好理解,毕竟s默认是不可变的,怎么会允许你通过引用去让它改变呢。
总结
- 所有权原则
- 每一个值都被一个变量拥有,该变量称为该值的所有者;
- 一个值同时只能被一个变量所拥有,一个值只能拥有一个所有者;
- 所有者离开作用域时,该值将会被丢弃
- 借用规则:
- 同一时刻,你要么只能拥有一个可变引用,要么拥有任意多个不可变引用;
- 引用必须总是有效的;