前述

我初次了解所有权概念时,给我感觉就是它和作用域不是一样的吗,有什么区别吗,可能刚开始对所有权没理解透,后面才开始逐渐理解。它和作用域是有一点区别的,所有权作为 Rust 中最为独特的功能,给 Rust 语言带来十分深远影响,也正是由于所有权,Rust 才能够在没有 GC (垃圾回收) 机制前提下保障内存安全,因此,了解所有权概念及其在 Rust 中的实现方式,是非常重要的。

什么是所有权

所有权是一套规则,决定 Rust 管理内存的方式。Rust 没有 GC,它是通过所有权来管理,在深入了解之前,需要先了解栈和堆的概念。

栈和堆

从物理角度上来看,栈和堆本质上没有区别,都属于内存,你有见过栈内存条和堆内存条吗?

栈和堆都是代码在运行时可以使用的内存空间,只不过是结构和组织方式不同。栈会以你放入值时的顺序来存储它们 (push),并以相反的顺序将值取出 (pop),这就是所谓的后进先出,先进后出。所有存储在栈上的数据必须拥有一个已知且固定的大小,对于无法确定大小的数据,只能将它们存储在堆上。

堆空间管理是较为松散的,当你希望将数据放在堆上,就可以请求特定大小的空间,操作系统会根据请求在堆上找到一块足够大的空间,将它标记为已使用,并把指定这块空间地址的指针返回给你,这个过程为堆分配。指针大小是固定且可以在编译期确定,所以可以将指针存储在栈上。

向栈上 push 数据要比在堆上分配空间更有效率,why,因为操作系统省去了搜索新数据存储位置的工作,对于栈,这个位置永远处于栈的顶部。对于堆,由于多了指针跳转,所以访问堆上的数据要慢于访问栈上的数据,另外,由于缓存的缘故,指令在内存中跳转的次数越多,性能就越差。

许多系统编程语言都需要你记录代码中分配的空间,最小化堆上冗余数据量,并及时清理堆上无用的数据,以避免耗尽空间,这些工作都可以由所有权解决,了解如何使用和管理内存,可以帮助理解所有权存在的意义及其背后工作的原理。

规则

熟记以下所有权规则:

  • Rust 中的每一个值都有一个对应的所有者;

  • 在同一时期内,值有且仅有一个所有者;

  • 当所有者离开自己的作用域时,它持有的值就会被丢弃;

举例

还是实操来举例说明

第一个例子,变量作用域,作用域是一个对象在程序中有效的范围

fn main() {
    let s = "hello";
    println!("s = {s}");
}

hello 的所有者为 s,它是字符串字面量,是不可变的,能在编译期中确定大小,会被硬编码进程序中,这样的好处就是高效。程序运行时会将 hello 放在栈中,当 s 变量离开 } 时,s 变量就失效,hello 被丢弃。

第二个例子,String 类型

当程序想要获取用户输入的数据,此时就不确定大小了。Rust 提供 String 类型,String 类型会在堆上分配存储空间,所以能够处理在编译时未知大小的文本

fn main() {
    let s = String::from("hello");
    println!("s = {s}");
}

hello 的所有者为 s,程序运行时会将 hello 放在堆中,当 s 变量离开 } 时,s 变量就失效,hello 被丢弃。

String::from 函数会请求自己需要的空间,当离开 } 时,会自动调用 drop 这个特殊函数,String 类型的作者可以在这个函数中编写释放内存的代码。在上面这个例子中,这种释放机制看起来还算简单,然而,一旦把它应用在某些复杂的环境中,代码呈现出来的行为往往会出乎我们的意料,特别是当拥有多个指向同一处堆内存的变量时。

移动

Rust 中的多个变量可以以一种独特的方式与同一数据进行交互,举个粟子

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;
    println!("s1 = {s1}, s2 = {s2}");
}

编译时会报错,如果是换作其它语言,以上大概率是行的通,但在 Rust 中这行不通,报错信息

第一行蓝色字提示由于 s1 的类型为 String,此操作发生了移动,然而 String 类型并没有实现 Copy 这个 trait。第二行蓝色字提示值在这已移动,下面的绿色好心帮我们提建议。从解释中可以看出,在执行 s2 = s1 时,hello 的所有者从 s1 变为 s2 (术语为移动,从 s1 移动到 s2),在前面规则提到过,在同一时期内,值有且仅有一个所有者,所以此时 s1 无效,在 println! 宏中尝试输出 s1 的值发生错误。

String 由 3 部分组成:

  • 指向存放字符串内容的指针 ptr;

  • 长度 len;

  • 容量 capacity;

这 3 部分的数据是存放在栈上,而右边的字符串则存放在堆上。进行 s2 = s1 后,s2 和 s1 共同指向了 hello 字符串所在位置

此时问题来了,当 s2 和 s1 离开作用域时,会相应地去自动调用 drop 函数 2 次,那么会对同一块内存重复释放了 2 次,重复释放内存可能会导致某些正在使用的数据发生损坏,进而产生潜在的隐患,所以 Rust 为确保安全,在执行 s2 = s1 后,将 s1 视作一个失效的变量,这样在 println! 宏中尝试输出就会发生错误 (Rust 在编译期中执行严格检查),Rust 不允许此编译通过。

可以使用 clone 方法避免 s1 变量失效,这样除了会对栈上数据拷贝外,还会对堆内存数据进行拷贝,示例代码

fn main() {
    let s1 = String::from("hello");
    let s2 = s1.clone();
    println!("s1 = {s1}, s2 = {s2}");
}

查看反汇编关键之处

先调用 __rust_alloc 申请一块堆内存,6F68656C6C -> hello 移动到此堆内存中

再将长度 5、堆内存首地址 (rax,地址 0x1F7FB46EFA0)、容量 5 分别入栈,此时的栈结构,结构中每个成员占 8 字节

接着 clone 操作

在 clone 内部,会先分配一块新的堆内存,再调用 memcpy 进行拷贝操作

最后将新的块返回,即 s2 变量 (0x2373D9F5C0)

栈上结构

以上就是 String 类型数据 clone 内部原理,从中可看出并没有发生移动,s1 和 s2 都有效。再看一个粟子

fn main() {
    let x = 5;
    let y = x;
    println!("x = {x}, y = {y}");
}

以上代码编译时不会报错,没用调用 clone,但是 x 在被赋值给 y 后依然有效,且没有发生移动现象。这是为什么?5 是整型数据,整型数据可以在编译期确定大小,并且能够将自己的数据完整地存储在栈上,对于这些值的复制操作永远都是非常快速的,这样就没有必要去考虑上面问题。

那么如何避免发生移动?Rust 提供了一个名为 Copy 的特殊 trait,它可以被用来标注那些完全存储在栈上的数据类型,比如整型,一旦某个类型实现了 Copy 这种 trait,在将它的变量赋值给其它变量时就可以避免发生移动,而是通过复制来创建一个新的实例,并保持新旧两个变量的可用性。

另外如果一个类型本身或这个类型的任意成员实现了 drop 这种 trait,那么 Rust 就不允许其实现 Copy,drop 和 Copy 不能共存,尝试为某个需要在离开作用域时执行特殊指令的类型添加 Copy 注解,会导致编译发生错误。Rust 中以下类型实现了 Copy:

  • 所有的整数类型,如 u32;

  • 仅拥有两种值 (true 和 false) 的布尔类型 bool;

  • 所有浮点类型,如 f64;

  • 字符类型 char;

  • 如果元组中包含的所有字段的类型都实现了 Copy,那么这个元组也实现了 Copy;

函数

在调用函数时,将变量传递给函数会触发移动或复制行为,举个粟子

fn main() {
    let s = String::from("hello");
    takes_ownership(s);
    let x = 5;
    makes_copy(x);
​
    println!("s = {s}");    // 报错
    println!("x = {x}");    // 不报错
}
​
fn takes_ownership(some_string: String) {
    println!("{some_string}");
}
​
fn makes_copy(some_integer: i32) {
    println!("{some_integer}");
}

对于 s,调用 takes_ownership 时发生了移动,外部的 s 失效,尝试输出时报错,而 x 没事,因为 x 为整型,它实现了 Copy,所以在调用完了还可以使用 x。以上代码中的函数无返回值,其实函数在返回值的过程中也会发生所有权转移

fn main() {
    let s1 = gives_ownership();
    let s2 = String::from("hello");
    let s3 = takes_and_gives_back(s2);
​
    println!("s1 = {s1}");  // 不报错
    println!("s2 = {s2}");  // 报错
    println!("s3 = {s3}");  // 不报错
}
​
fn gives_ownership() -> String {
    let some_string = String::from("hello");
    some_string
}
​
fn takes_and_gives_back(a_string: String) -> String {
    a_string
}

hello 的所有者从 s2 变为了 s3,s2 失效,所以 println! 尝试输出 s2 时报错。函数想使用值但又不想失去其所有权,把它作为返回值返回是一种方法,但这种方法太过烦琐,再来看一个粟子

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);
    println!("The length of '{s2}' is {len}.");
}
​
fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

以上函数想使用 String,但又想在后续中继续使用 String,所以在函数中又将其返回,所有权转移到 s2 上,为了这不必要的麻烦,可以使用引用操作。

引用

修改

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);
    println!("The length of '{s1}' is {len}.");
}
​
fn calculate_length(s: &String) -> usize {
    s.len()
}

在 calculate_length 函数中,s 是个引用,当在主函数调用时,所有权并没发生转移,当函数调用结束时,s 离开作用域并不会销毁它所引用的值,因为 s 没有该值的所有权,hello 不会被丢弃。

当一个函数使用引用而不是值本身作为参数时,便不需要为了归还所有权而特意去返回值,毕竟在这种情况下,我们根本没有取得所有权。

这种方式也被称为借用,在现实生活中,假如一个人拥有某种东西,你可以从他那里把东西借过来,但是当你使用完毕时,就必须将东西还给人家,因为你并不拥有它。

尝试去修改引用的值

fn main() {
    let s = String::from("hello");
    change(&s);
}
​
fn change(s: &String) {
    s.push_str(", world");
}

此段代码编译不会通过,因为引用默认是不可变的,Rust 不允许修改引用的值。

将不可变引用修改为可变引用

fn main() {
    let mut s = String::from("hello");
    change(&mut s);
}
​
fn change(s: &mut String) {
    s.push_str(", world");
}

以上代码能通过编译,可变引用有一个限制,如果持有了某个值的可变引用,就不能再持有这个值的其它引用,以下代码不能通过编译

fn main() {
    let mut s = String::from("hello");
    let r1 = &mut s;
    let r2 = &mut s;
​
    println!("{r1}, {r2}");
}

这种限制好处在于避免数据竞争,数据竞争与竞态条件十分类似,它会在指令满足以下 3 种情况时发生:

  • 两个或两个以上的指针同时访问同一空间;

  • 其中至少有一个指针会向空间中写入数据;

  • 没有同步数据访问的机制;

数据竞争会导致未定义的行为,Rust 不允许此类情况发生。稍微修改一下就可以创建多个可变引用

fn main() {
    let mut s = String::from("hello");
    {
        let r1 = &mut s;
        println!("{r1}");
    }
    
    let r2 = &mut s;
​
    println!("{r2}");
}

再来看另一种情形

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;    // 不报错
    let r2 = &s;    // 不报错
    let r3 = &mut s;// 报错
​
    println!("{r1}, {r2} and {r3}");
}

报错的原因在于不能在持有一个不可变引用的同时创建一个指向相同数据的可变引用。

另外还需要记住一点就是,一个引用的作用域起始于创建它的地方,并持续到最后一次使用它的地方。

fn main() {
    let mut s = String::from("hello");
    let r1 = &s;
    let r2 = &s;
    println!("{r1} and {r2}");
    // 变量 r1 和 r2 将不再被使用
    
    let r3 = &mut s;
​
    println!("{r3}");
}

以上代码能通过编译。

悬垂引用

悬垂引用跟悬垂指针差不多,悬垂指针是指向曾经存在的某处内存地址,但现在该内存已经被释放甚至被重新分配另做其它用途了,在 Rust 中,编译器可以确保引用永远不会进入这种悬垂状态。

例子

fn main() {
    let reference_to_nothing = dangle();
}
​
fn dangle() -> &String {
    let s = String::from("hello");
    &s
}

以上这段代码编译不通过,上述代码尝试创建一个悬垂引用,s 在函数内部创建,在离开时会被销毁,尝试返回一个不存在的指向 s 的引用,会引发错误,Rust 不允许此类操作。

对于引用记住以下两点

  • 在任何给定时间里,要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用;

  • 引用总是有效的;

切片

切片允许我们引用集合中某一段连续的元素序列,而不是整个集合,由于切片也是一种引用,所以它不会持有值的所有权。

fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5];
    let world = &s[6..11];
    println!("{hello}, {world}");
}

切片 ([starting_index..ending_index]) 数据结构在内部存储了一个指向起始位置的引用和一个描述切片长度的字段,这个切片长度的字段等价于 ending_index - starting_index,在上面的示例中,world 是一个指向变量 s 的第 7 个字节并且长度为 5 的切片。

切片有一个小语法糖,如果希望从第一个元素开始时,可以省略 starting_index,如果省略 ending_index,则可以包含 String 中的最后一个字节,也可以省略两者,那就创建了一个指向整个字符串所有字节的切片。