深入理解 Rust 所有权机制

引言

变量是程序中用于存储和操作数据的命名实体,其值可以在程序运行期间发生改变。类型是对变量的区分,决定了变量可以存储的数据种类和可以执行的操作。

类型可以分为原生类型和组合(复合)类型:

  • 原生类型(primitive type)是编程语言提供的最基础的数据类型,比如字符、整数、浮点数、布尔值、指针、引用、函数和闭包等;
  • 组合类型(composite type)指由一组原生类型和其他类型组合而成的类型,比如结构体、联合体、枚举、数组、切片、元组、类和接口等。

栈上存放的变量,它的大小在编译期就需要确定,其生命周期在当前调用栈的作用域内,无法跨调用栈引用。就是说,栈上存放的变量是静态的,固定大小,固定生命周期。

堆上存放的变量,它的大小未知或者属于动态伸缩的数据类型,其生命周期从内存分配后开始,到内存释放时结束,因此堆上的变量允许在多个调用栈之间引用。但也导致堆变量的管理非常复杂,手工管理(C/C++等)会引发很多内存安全性问题,而自动管理(Java/Go等)也有性能损耗和其它问题。就是说,堆上存放的变量是动态的,不固定大小,不固定生命周期

堆上分配的内存会被栈上的多个变量引用,究竟什么时候能释放取决于最后一个引用什么时候结束。

对于堆内存多次引用的问题,一般有两种解决方案:

  • C/C++等语言要求手工处理:开发人员写代码时需要高度自律,按照前人总结的最佳实践来操作内存,并且一有不慎就会导致内存安全问题,要么内存泄露,要么使用已释放内存,导致程序崩溃;
  • Java/Go等语言使用GC方式处理:通过定期扫描堆上数据还有没有被栈上变量引用来自动管理内存,虽然不需要开发人员再手工处理内存,但 GC 带来的 STW(Stop The World) 问题让语言的使用场景受限,性能损耗也不小。

这两种解决方案都是从管理引用的角度出发,各有各的弊端,其根本问题在于堆上的内存可以被栈上的变量随意引用

那我们能不能换个角度,限制引用行为本身呢?这个想法打开了新的大门,Rust 就是这样另辟蹊径的。在 Rust 以前,引用是一种随意的、可以隐式产生的、对权限没有界定的行为,比如 C 里到处乱飞的指针、Java 中随处可见的按引用传参,它们可读可写,权限极大,而 Rust 提出了所有权的概念,旨在限制开发人员随意引用的行为

所有权

关于所有权,Rust 给出了如下规则:

  • 一个值只能被一个变量所拥有,这个变量被称为所有者(Each value in Rust has a variable that’s called its owner)
  • 一个值同一时刻只能有一个所有者(There can only be one owner at a time),也就是说不能有两个变量拥有相同的值
  • 当所有者离开作用域,其拥有的值被丢弃(When the owner goes out of scope, the value will be dropped),内存得到释放

说明如下:

  • 这三条规则很好理解,核心就是保证单一所有权,确保内存的安全管理,防止资源的泄漏和重复释放;
  • 第二条规则讲的是所有权转移,是Rust 从 C++ 那里学习和借鉴的 Move 语义,对于变量赋值、参数传递、函数返回等行为,旧的所有者会把值的所有权转移给新的所有者。

Move 语义

看一个简单的示例:

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

字符串字面量(如 "hello")的类型是 &'static str,它是一个字符串切片,指向程序的只读数据段(也称为常量数据区或静态内存)。这些数据在编译时就被包含在程序的二进制文件中,且在程序运行期间一直存在,存储位置固定,内容不可变。

当调用 String::from("hello") 时,发生了以下过程:

  • 创建一个新的 String 实例:在堆上分配足够的内存来存储字符串内容;
  • 将数据拷贝到堆上:将字符串字面量 "hello" 的内容从常量数据区拷贝到堆上新分配的内存空间中;
  • 初始化 String 结构体:String 结构体包含一个指向堆上数据的指针、长度和容量等信息,该结构体也常称作胖指针

因此,s1 是一个拥有其数据所有权的 String 类型,字符串内容存储在堆上。当将 s1 赋值给 s2 时,s1 对堆上数据 "hello" 的所有权被移动到 s2,s1 不再有效(这里发生的是浅拷贝,s2 获得了指向同一堆内存的胖指针,堆上的数据并未被复制)。

因为堆上数据 "hello" 所有权从 s1 转移到了 s2,s1 不能再使用,所以运行该程序时编译器会报错:

bogon:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:4:20
  |
2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 |     let s2 = s1;
  |              -- value moved here
4 |     println!("{}", s1);
  |                    ^^ value borrowed here after move
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
  |
3 |     let s2 = s1.clone();
  |                ++++++++

For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

Copy 语义

在 Move 语义一节的编译错误中,细心的读者发现了`Copy` trait 的提示:

2 |     let s1 = String::from("hello");
  |         -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait

String 类型没有实现 Copy trait,在赋值或者函数调用的时候无法 Copy,于是就按默认使用 Move 语义。而 Move 之后,原先的变量 s1 无法访问,所以出错。

换句话说,当你要移动一个值(比如 let s2 = s1),如果值的类型实现了 Copy trait,就会使用 Copy 语义进行拷贝,否则使用 Move 语义进行转移。

Rust 官方文档页面呈现了标准库中实现了 Copy trait 的所有数据结构:

copy.png

从编译的错误信息来看,String 类型没有实现 Copy 语义(“does not implement the Copy trait”),这与实际是一致的。然而事实是 String 类型不能实现 Copy 语义,因为 String 包含堆上的数据,简单地按位复制会导致多个 String 实例指向同一块内存,这会引发内存安全问题,比如双重释放(double free)。

Rust 的设计原则是确保内存安全, 因此 String 类型被设计为不实现 Copy 语义是合乎情理的。

Copy 语义表示一个类型的值可以通过按位复制的方式进行复制,而不需要额外的资源分配或复杂的逻辑。一般针对类型是简单、轻量级、存储在栈上的数据,并且希望在赋值或传参时自动复制,开销非常小

常见实现 Copy 语义的类型:

  • 基本类型:比如整数(i32、u32)、浮点数(f32、f64)、布尔值(bool)、字符(char)等;
  • 具有 Copy 语义成员的元组 或数组,比如(i32, bool)、[1, 2, 3]。

代码示例:变量 a 和 b 都是 i32 类型的,在 a 赋值给 b 的过程中使用了 Copy 语义

fn main() {
  let a = 42; // i32 类型,实现了 Copy
  let b = a;  // a 被复制到 b,a 仍然可用
  println!("a = {}, b = {}", a, b); // 输出 a = 42, b = 42
}

你可能还有一个疑问:既然 Copy 语义一般仅针对栈上的数据,而栈上的数据在变量赋值、参数传递和函数返回等场景会自动按位进行复制,为什么还要再实现 Copy trait 呢?

事实上,Copy 语义的存在和实现具有更深层次的意义。Rust 引入了所有权系统,以确保内存安全和防止数据竞争,每个值在任何时刻只有一个所有者。默认情况下,赋值或传参会发生所有权的转移,而不是简单的复制。这意味着原变量不再有效,避免了悬挂指针和双重释放的问题。Copy 语义用于标识那些可以安全地按位复制的类型,以便在赋值或传参时隐式的按位复制,而不会发生所有权的转移。开发人员可以明确数据按位复制时哪些类型会被保持所有权,哪些类型会被转移所有权

Clone 语义

在 Move 语义一节的编译错误中,细心的读者发现编译器还有一个帮助提示:如果性能成本可以接受的话,考虑克隆改值(help: consider cloning the value if the performance cost is acceptable)。

我们按照帮助提示修改代码:

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

运行代码,符合预期:

bogon:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.63s
     Running `target/debug/ownership`
hello
hello

当将 s1.clone() 赋值给 s2 时,程序在堆上将数据 "hello" 克隆了一份,即 s2 引用了堆上的数据 "hello"2,那么 s1 对堆上数据 "hello" 的所有权没有改变,同时 s2 对堆上的数据 "hello"2 持有所有权。

可以看到,所有权规则解决了谁真正拥有数据的生杀大权问题,让堆上数据的多重引用不复存在,这是它最大的优势

Clone 语义表示一个类型的值可以通过调用 .clone() 方法来创建一个深度复制的副本。由于可能涉及深拷贝,Clone 操作的开销可能较大,需要谨慎使用

常见实现 Clone 的类型:

  • 标准库中的大多数类型:如 String、Vec<T>、Box<T> 等;
  • 任何需要深度复制的自定义类型。

Borrow 语义

顾名思义,Borrow 语义允许一个值的所有权在不发生转移的情况下可以被其它上下文使用,就好像住酒店或者租房那样,旅客 / 租客只有房间的临时使用权,但没有房间的所有权。

Borrow 语义通过引用语法来实现。在 Rust 中,“借用”和“引用”是一个概念(两者等价),只不过在其他语言中引用的意义和 Rust 不同,所以 Rust 提出了新概念“借用”,便于区分。

在其他语言中,引用是一种别名,你可以简单理解成鲁迅之于周树人,多个引用拥有对值的无差别的访问权限,本质上是共享了所有权。但在 Rust 下,所有的引用都只是借用了“临时使用权”,它并不破坏值的单一所有权约束

Borrow 语义分为两类:

  • 不可变借用(Immutable Borrow):使用 &,允许读取数据但不允许修改,类似于住酒店,退房时要完好无损
  • 可变借用(Mutable Borrow):使用 &mut,允许读取和修改数据,类似于租房,可以对房屋进行必要的装饰

在 Move 语义一节的编译错误中,细心的读者发现编译器有 borrow 和 borrowed 的错误信息:

Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
error[E0382]: borrow of moved value: `s1`
4 |     println!("{}", s1);
  |                    ^^ value borrowed here after move

你这时或许会有一个疑惑:代码中并没有显式的使用借用(引用&),为何会有这样的错误信息?

Rust 编译器的在这种情况使用“借用”(borrow)一词,有下面几个原因:

  • 使用变量实际上涉及借用:当你在代码中使用一个变量时,实际上是试图借用它的数据;
  • 所有权与借用的关系:如果一个变量的所有权被转移,任何尝试使用该变量的行为都被视为对已被移动所有权数据的借用;
  • 借用检查器(Borrow Checker):Rust 的借用检查器负责确保所有的借用都是安全的,防止数据竞争和悬挂引用,当违反所有权规则时,它会报出类似的错误信息。

我们使用不可变借用来解决之前的编译问题:

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

运行代码,符合预期:

bogon:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.68s
     Running `target/debug/ownership`
hello
hello

假如你想用可变借用来修改 “hello” 成 “hello rust”,首先需要将 s1 声明成可变变量,然后 s2 借用 s1 的可变引用:

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

运行代码,编译器报错:

bogon:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
error[E0502]: cannot borrow `s1` as immutable because it is also borrowed as mutable
 --> src/main.rs:5:20
  |
3 |     let s2 = &mut s1;
  |              ------- mutable borrow occurs here
4 |     s2.push_str(" rust");
5 |     println!("{}", s1);
  |                    ^^ immutable borrow occurs here
6 |     println!("{}", s2);
  |                    -- mutable borrow later used here
  |
  = note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)

For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to 1 previous error

我们分析一下代码:

  • let s2 = &mut s1,创建了对 s1 的可变引用 s2,在这个时刻,s1 被 可变借用;
  • s2.push_str(" rust"), 通过 s2 修改了 s1 的内容,使其变为 "hello rust";
  • println!("{}", s1),尝试使用 s1 进行不可变借用(即读取 s1 的值),由于 s2 是对 s1 的可变借用,并且 s2 仍然在作用域内,Rust 不允许同时存在可变借用和不可变借用,因此编译器报错(cannot borrow `s1` as immutable because it is also borrowed as mutable,即无法将 `s1` 作为不可变借用,因为它已经被可变借用);
  • println!("{}", s2),使用了 s2,这进一步延长了 s2 的作用域。

由此可见,Rust 对可变借用做了严格的约束:在同一个作用域内,只允许一个可变借用或多个不可变借用,但不能同时存在可变借用和不可变借用,类似于读写锁 RWLock

于是乎,我们可以将代码根据可变借用的约束进行修复。

修复方式一:限制可变引用的作用域

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

运行结果,与预期一致:

ogon:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.84s
     Running `target/debug/ownership`
hello rust
hello rust

修复方式二:仅使用可变引用进行操作

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

运行结果,与预期一致:

bogon:ownership zhangxiaolong$ cargo run
   Compiling ownership v0.1.0 (/Users/zhangxiaolong/Desktop/D/rust-workspace/ownership)
    Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.47s
     Running `target/debug/ownership`
hello rust

小结

Rust 所有权 是 Rust 与其他语言的主要区别,也是 Rust 其他知识点的基础。本文通过一个简单的例子深入探讨了 Rust 所有权的概念及其运作机制,包括 Move 语义、Copy 语义、Clone 语义和 Borrow 语义,展示了 Rust 如何通过这些机制确保内存安全及高效管理。所有权规则确保每个值在任何时刻只有一个所有者,防止了内存泄漏和双重释放等常见问题。

掌握 Rust 所有权机制不仅能防止常见的内存错误,还能优化程序性能,减少不必要的内存分配和复制。因此,深入理解并正确应用所有权机制,是成为优秀 Rust 开发者的关键一步。

参考资料

  • 极客时间专栏,《Rust 编程第一课》,陈天
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 206,013评论 6 481
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 88,205评论 2 382
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 152,370评论 0 342
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 55,168评论 1 278
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 64,153评论 5 371
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,954评论 1 283
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,271评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,916评论 0 259
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 43,382评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,877评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,989评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,624评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,209评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,199评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,418评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,401评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,700评论 2 345

推荐阅读更多精彩内容