所有权

所有权是 Rust 的核心功能,Rust 用所有权系统去管理计算机内存。

所有程序必须去管理其运行时使用计算机内存的方式:

  • 一些语言有垃圾回收机制,如:Go,Java,在运行时不断寻找不使用的内存;

  • 另一些语言中,程序员必须自己手动分配和释放内存,如:C;

  • Rust 则选择了第三种方式:用所有权系统管理内存

    而且这些都是编译器在编译时就已经完成的,不会影响程序的运行效率。

C 的悬空指针

悬空指针(Dangling pointer) 是 C 语言中一个常见内存管理问题:

int* foo(){
    int a;
    a = 5;
    char *c = "xyx";
    return &a;
}

补充知识点

  • 1、局部变量存储在栈中
  • 2、全局变量、静态变量(全局和局部静态变量)存储在静态存储区
  • 3、new申请的内存是在堆中
  • 4、字符串常量也是存储在静态存储区

补充说明:

  • 1、栈中的变量内存会随着定义所在区间的结束自动释放;而对于堆,需要手动free,否则它就一直存在,直到程序结束;
  • 2、对于静态存储区,其中的变量常量在程序运行期间会一直存在,不会释放,且变量常量在其中只有一份拷贝,不会出现相同的变量和常量的不同拷贝。

上面这个函数被调用时返回函数内局部变量 a 的地址 &a,但是局部变量 a 是一个基本类型,存储在栈中,离开作用域时程序会申请的内存被系统回收,造成了悬空指针的问题。

而变量 c 是一个字符常量,存储在静态存储区,在程序运行期间会一直存在,直到程序退出。

知识点:栈和堆

知识点:栈和堆

栈和堆是编程语言的核心数据结构,对于 Rust 这种系统级语言而言尤为重要,值存储在栈还是堆关系着程序的性能。

栈(Stack)

栈是先入后出(FILO),可以类比为叠盘子,增加一个盘子只能从顶部(入栈),取下一个盘子只能从顶部(出栈)。

栈中的所有数据都必须占用已知且固定大小的内存空间。

堆(Heap)

与栈不同,对于大小未知或者可能变化的数据,我们需要将它存储在堆上。

当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在堆的某处找到一块足够大的空位,把它标记为已使用,并返回一个表示该位置地址的指针, 该过程被称为在堆上分配内存(allocating on the heap),有时简称为 “分配”(allocating)。

接着,该指针会被推入中,因为指针的大小是已知且固定的,在后续使用过程中,你将通过栈中的指针,来获取数据在堆上的实际内存位置,进而访问该数据。

性能区别

写入方面:入栈比在堆上分配内存要快,因为入栈时操作系统无需分配新的空间,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配做准备。

读取方面:栈数据往往可以直接存储在 CPU 高速缓存中(高速缓存和内存的访问速度差异在 10 倍以上!),而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存。

因此,处理器处理和分配在栈上数据会比在堆上的数据更加高效。

所有权与堆栈

当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。

因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。

对于其他很多编程语言,你确实无需理解堆栈的原理,但是在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助。

所有权原则

理解了堆栈,接下来看一下关于所有权的规则,首先请谨记以下规则:

  • Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
  • 值在任一时刻有且只有一个所有者(owner)。
  • 当所有者(变量)离开作用域,这个值将被丢弃(drop)。

变量作用域(scope)


#![allow(unused)]
fn main() {
fn var_scope() {
    let var1 = "test"; // 声明,作用域开始
    // ... 使用 var1
}
// 作用域结束
}

转移所有权(move)

fn move_ownership1() {
    let x = 5;
    let y = x;

    println!("x={},y={}", x, y)
}
fn move_ownership2() {
    let s1 = "s111";
    let s2 = s1;

    println!("s1={},s2={}", s1, s2)
}

fn main() {
 move_ownership1();
 move_ownership2();
}

move_ownership1 中,值 5 绑定到变量 x,基本类型存储在栈中;将x 的值拷贝给 y,也存储在栈上。

因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。

这种方式也叫浅拷贝,浅拷贝只发生在栈上的数据。

再看看这段代码:

fn move_ownership3() {
    let s1 = String::from("s111");
    let s2 = s1;

    //println!("s1={},s2={}", s1, s2)
}

fn main() {
 move_ownership3();
}

试试点击运行会抛出错误:error[E0382]: borrow of moved value: s1`,这其实就是 Rust 中的移动(move),s1 的所有权被转移了。

move_ownership2 和 move_ownership3 的代码有什么区别,通过IDE可以看到 s1 的数据类型是不一样的:

move_ownership3 中 s1 持有了通过String::from("s111") 创建的值的所有权,move_ownership2 中只是是引用了存储在二进制中的字符串 "s111",并没有持有所有权。

image-20220327204608263

克隆(clone)

首先,Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。

如果我们确实需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的方法。

let s1 = String::from("hello"); let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2); 这段代码能够正常运行,因此说明 s2 确实完整的复制了 s1 的数据。

如果代码性能无关紧要,例如初始化程序时,或者在某段时间只会执行一次时,你可以使用 clone 来简化编程。但是对于执行较为频繁的代码(热点路径),使用 clone 会极大的降低程序性能,需要小心使用!

拷贝(copy)

浅拷贝只发生在栈上,因此性能很高,在日常编程中,浅拷贝无处不在。

再回到之前看过的例子:


#![allow(unused)]
fn main() {
let x = 5;
let y = x;

println!("x = {}, y = {}", x, y);
}

但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过依然实现了类似深拷贝的效果 —— 没有报所有权的错误。

原因是像整型这样的基本类型在编译时是已知大小的,会被存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效(x、y 都仍然有效)。换句话说,这里没有深浅拷贝的区别,因此这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它(可以理解成在栈上做了深拷贝)。

Rust 有一个叫做 Copy 的特征,可以用在类似整型这样在栈中存储的类型。如果一个类型拥有 Copy 特征,一个旧的变量在被赋值给其他变量后仍然可用。

那么什么类型是可 Copy 的呢?可以查看给定类型的文档来确认,不过作为一个通用的规则: 任何基本类型的组合可以 Copy ,不需要分配内存或某种形式资源的类型是可以 Copy 的。如下是一些 Copy 的类型:

所有整数类型,比如 u32。 布尔类型,bool,它的值是 true 和 false。 所有浮点数类型,比如 f64。 字符类型,char。 元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32) 是 Copy 的,但 (i32, String) 就不是。 不可变引用 &T ,例如转移所有权中的最后一个例子,但是注意: 可变引用 &mut T 是不可以 Copy的