趣味学Rust基础篇(所有权)
什么是所有权?
所有权是 Rust 最核心的概念之一。理解它至关重要,因为所有权机制是 Rust 语言能够保证内存安全,同时又无需垃圾回收器的关键所在。
所有权解决了许多其他编程语言中常见的内存管理难题。例如,在有垃圾回收机制的语言中,性能会受到影响;而在没有垃圾回收机制的语言中(如 C 或 C++),开发者必须手动分配和释放内存,这很容易导致内存泄漏或悬垂指针等错误。
Rust 采用了一套独特的规则来在编译时检查内存的使用情况,从而避免了运行时的开销。这些规则的核心就是所有权系统。如果你违反了这些规则,你的程序将无法编译。在运行时,所有权机制的开销为零。
想象一下,现在你走进了一家非常严格、极其高效的未来图书馆。这家图书馆没有管理员,也没有借阅系统,但每本书具有唯一的标识,每本书的借阅、归还和管理都完美无缺,绝不会丢失,也绝不会发生“这本书到底是谁在看”的纠纷。
这家图书馆的规则,就是 Rust 的所有权(Ownership)系统。它不是靠人管理,而是靠一套自动化的、铁一般的规则。
核心比喻:一本书 = 一块内存
- 一本书:代表程序中的一块数据(比如一个字符串、一个数组)。
- 读者:代表程序中的一个变量。
- 借书规则:就是 Rust 的所有权规则。
Rust 所有权的三大铁律
图书馆有三条铁律,违反任何一条,警报就会响起!
铁律一:每本书(数据),有且只有一个读者(所有者)
在图书馆里,同一时间,一本书只能借给一个人。这本书的“所有权”属于这个读者。
let book = String::from("Rust编程入门"); // 小明借走了这本书
book
是变量(读者)。String::from(...)
是书本身(数据)。- 现在,
book
拥有这本书的所有权。
你不能让两个人同时拥有同一本书的所有权!这会导致混乱。
铁律二:当读者(所有者)离开(作用域结束)图书馆,书必须归还(内存被释放)
当读者看完书,离开了图书馆(变量作用域结束),他必须把书立刻归还给图书馆,图书馆会立刻销毁这本书(释放内存)。
{let book = String::from("Rust编程入门"); // 小明在图书馆里看书
} // 小明离开了图书馆(作用域结束)// 书被自动归还并销毁!内存安全!
这就是 Rust 保证内存安全的关键:没有垃圾回收器,但内存使用完就立刻释放,绝不会泄露!
铁律三:只能转移所有权,不能复制所有权(默认是“移动”)
如果你想把书给别人看,你不能简单地“复印一本”给他(默认不行),你必须把原书交给他,你自己就不再拥有了。
let book = String::from("Rust编程入门"); // 小明借书
let friend_book = book; // 小明把书给了小红// println!("{}", book); // 错!小明已经没有这本书了!
println!("{}", friend_book); // 小红可以看
- 这叫移动(Move)。
book
的所有权被转移给了friend_book
。 book
变成了“无效”,不能再使用,否则编译器会报错。
为什么?因为图书馆规定:一本书只能有一个人拥有。小明把书给了小红,他就不再是所有者了。
如何“共享”阅读?—— 借用(Borrowing)
如果小明想让小红也看看这本书,但又不想把书给她(不想失去所有权),怎么办?
他可以把书借给小红看,这叫借用(Borrowing)。
不可变借用:只能读,不能改
fn main() {let book = String::from("Rust编程入门"); // 小明拥有书// 借给小红看read_book(&book); // &book 表示“借用这本书的引用”println!("{}", book); // 小明还能看,因为他还是所有者!
}fn read_book(borrowed_book: &String) {println!("正在阅读:{}", borrowed_book);
}
&book
:创建一个引用(reference),指向书,但不拿走所有权。borrowed_book: &String
:函数参数接收一个引用。- 小红看完,书自动“归还”,小明依然拥有它。
规则:你可以有多个不可变引用(可以有很多人同时看书),但只要有一个不可变引用存在,你就不能再获得可变引用或修改数据。
可变借用:可以修改,但只能有一个
如果小红想修改书的内容(比如写笔记),她需要可变借用。
fn main() {let mut book = String::from("Rust编程入门"); // 书必须是可变的// 借给小红修改edit_book(&mut book); println!("{}", book); // 输出修改后的内容
}fn edit_book(book: &mut String) {book.push_str(" - 第二版"); // 添加内容
}
&mut book
:创建一个可变引用。book: &mut String
:函数接收可变引用。- 关键规则:同一时间,只能有一个可变引用。这防止了数据竞争(比如两个人同时修改同一行)。
你不能同时有可变引用和不可变引用。就像图书馆规定:如果有人要修改书,其他人必须停止阅读。
现在我们已经大致了解了如何声明变量并给它们赋值,是时候更深入地探讨作用域了。作用域是一个变量在程序中有效的范围。让我们通过一个例子来说明。假设我们有一个变量 s
,我们这样声明它:
let s = "hello";
这个变量从声明它的位置开始有效,一直到当前作用域结束为止。例如,在函数中,变量在进入作用域时有效,在离开作用域时失效:
fn main() {// s 在此处无效,它尚未声明let s = "hello"; // s 从此处开始有效// 在 s 作用域内的代码可以使用 s
} // 此作用域已结束,s 不再有效
换言之,s
从 let s = "hello";
这一行开始进入作用域,并在 }
这一行离开作用域。
String 类型
为了让讨论所有权更有意义,我们需要一个比之前用过的数据类型更复杂的数据类型。之前我们用过的类型,比如整数,都是在栈上分配和直接操作的。为了演示所有权,我们需要一个在堆上分配数据的类型。
我们来看一个叫做 String
的类型。String
类型被管理在堆上,所以它是可变的,并且它的大小在编译时是未知的。为了展示所有权,我们将重点关注 String
与内存管理的交互。
在 Rust 中,字符串字面量(如 "hello"
)是直接硬编码进最终的可执行文件中的。这是高效且快速的,但也是有局限性的:字符串字面量是不可变的。有时,你确实需要一个可以修改或增长的字符串,比如一个用户输入的字符串或一个文件的内容。对于这种场景,Rust 提供了堆分配的字符串类型,即 String
。
String
可以通过 from
函数从字符串字面量创建:
let s = String::from("hello");
这会创建一个包含 hello
的 String
,并将其绑定到变量 s
上。这个 String
被分配在堆上,所以它是可变的:
let mut s = String::from("hello");
s.push_str(", world!"); // push_str() 在字符串后面附加一个字面量
println!("{}", s); // 这会打印 `hello, world!`
所以,String
类型是可变的,而字符串字面量则不是。但 String
的可变性背后,更重要的是它在堆上分配内存来存放内容。这意味着:
- 内存必须在运行时分配。
- 内存一旦使用完毕,必须被释放。
在有垃圾回收(GC)的语言中,GC 会自动跟踪并清理不再使用的内存。在大多数没有 GC 的语言中,程序员必须手动分配和释放内存。Rust 采取了第三种方式:内存在拥有它的变量离开作用域时自动释放。
举个例子:
{let s = String::from("hello"); // s 是有效的// 在 s 作用域内的代码可以使用 s
} // 此作用域已结束,// s 不再有效,`drop` 函数被调用,// 内存被自动释放
当 s
离开作用域时,Rust 会自动调用一个叫做 drop
的特殊函数。这个 drop
函数的作用就是释放 s
所拥有的内存。Rust 在 }
处自动插入了 drop
调用。
注意:在 C++ 中,这种在对象生命周期结束时释放资源的模式有时被称为资源获取即初始化(RAII)。Rust 的
drop
函数在概念上与 C++ 的析构函数类似。
变量与数据交互的方式
现在我们已经大致了解了变量的作用域,我们可以探讨变量和数据在 Rust 中如何交互的三个不同方式:移动(move)、克隆(clone) 和 复制(copy)。
1. 移动(Move)
在 Rust 中,多个变量可以以不同的方式与同一数据交互。让我们看看一个例子:
let x = 5;
let y = x;
这里发生了什么?很简单:将 5
绑定到 x
,然后创建一个 x
的值的副本并将其绑定到 y
。现在我们有了两个变量,x
和 y
,它们都等于 5
。这很简单,因为 5
是一个简单的值,存储在栈上。
现在看看 String
的情况:
let s1 = String::from("hello");
let s2 = s1;
这看起来与上面的代码非常相似,所以我们可能会认为它的运行方式也相同:也就是说,第二行会创建一个 s1
的值的副本并将其绑定到 s2
。但这实际上不是 Rust 在这里所做的事情。
让我们更详细地看看 String
是如何在内存中表示的。String
由三部分组成,可以看作是存储在栈上的一个结构体:一个指向存储字符串内容的内存的指针、一个长度和一个容量。该内存存储在堆上。下图展示了这种关系:
图4-1
图 4-1: String
在内存中的表示,包含一个指向堆上字节的指针、一个长度和一个容量。
长度是当前 String
使用的内存量(以字节为单位)。容量是 String
分配的内存量(以字节为单位),可能大于长度。当 String
被修改时,如果需要更多内存,容量就变得相关起来。这里,s1
的长度是 5 个字节,因为它存储了 5 个 Unicode 字符。容量可能等于长度,也可能更大,具体取决于分配器。
当我们将 s1
赋值给 s2
时,String
的数据被复制,这意味着我们复制了栈上的指针、长度和容量。但我们没有复制指针指向的堆上的数据。下图展示了结果:
图 4-2
图 4-2: 变量 s2
的内存布局,它有一份 s1
指针、长度和容量的拷贝,也指向同一堆上的数据。
如果 s2
和 s1
都拥有堆上数据的所有权,并且当它们离开作用域时都尝试释放相同的内存,就会出现问题。这被称为双重释放(double free)错误,是内存安全错误的一种。释放内存两次可能会导致内存损坏,从而导致安全漏洞。
为了避免这种错误,Rust 在这里采取了一个巧妙的策略:当 s2 = s1
时,Rust 认为 s1
不再有效。因此,当 s1
离开作用域时,Rust 不会尝试释放任何内存。看看在 s2
被创建之后尝试使用 s1
会发生什么:
let s1 = String::from("hello");
let s2 = s1;println!("{}, world!", s1); // 错误!
你会得到一个编译时错误:
$ cargo run--> src/main.rs:5:28|
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 |
5 | println!("{}, world!", 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`.
warning: `owner_ship` (bin "owner_ship") generated 1 warning
error: could not compile `owner_ship` (bin "owner_ship") due to 1 previous error; 1 warning emitted
如果你在其他语言中听说过“浅拷贝(shallow copy)”和“深拷贝(deep copy)”这两个术语,那么复制指针、长度和容量而不复制数据,听起来就像是在做一次浅拷贝。但是,因为 Rust 也使第一个变量(s1
)无效,所以它不被称为浅拷贝,而是被称为移动(move)。在这个例子中,我们说 s1
被移动到了 s2
。因此,实际发生的情况如图 4-3 所示。
图4-3
图 4-3: s1
无效,s2
指向堆上的数据。由于 s1
无效,它不再被认为是堆上数据的所有者。
这解决了我们的问题!只有 s2
是有效的,当它离开作用域时,它会单独负责释放内存,而 s1
不能再被使用。
那么,为什么在第一个使用 5
的例子中不会发生同样的情况?为什么 x
仍然有效?区别在于 String
类型在堆上分配了内存来存储其数据,而像 i32
这样的类型则完全存储在栈上。当 s2 = s1
时,没有堆上的数据被复制,所以 Rust 不需要使 s1
无效。事实上,对于像整数这样已知大小的类型,拷贝是廉价的,通常我们希望在创建变量时进行实际的拷贝。
2. 克隆(Clone)
如果你确实想深度复制 String
数据,使其堆上的内容也被复制,而不仅仅是栈上的数据,你可以使用一个叫做 clone
的通用方法。我们将在第 5 章的“方法语法”一节中更详细地讨论方法,但因为方法在许多编程语言中是一个常见概念,所以你可能已经见过它们了。
这是一个实际使用 clone
的例子:
let s1 = String::from("hello");
let s2 = s1.clone();println!("s1 = {}, s2 = {}", s1, s2);
这段代码可以正常运行,并且显式地表明我们正在进行一个深度拷贝,这比复制栈上的指针、长度和容量要昂贵得多。当出现 clone
调用时,你知道一些任意的代码正在被执行,并且可能涉及大量内存的复制。
3. 复制(Copy)
那么,为什么 i32
类型可以拷贝而 String
不行?区别在于,像整数这样的类型完全存储在栈上,因此拷贝其实际值是快速的。这意味着没有理由在创建变量 y
后使 x
无效。换句话说,x
在被“移动”到 y
之后仍然有效。
Rust 有一个特殊的注解,叫做 Copy
trait,你可以将其放在存储在栈上的类型上(我们将在第 10 章的“trait:定义共享行为”一节中更详细地讨论 trait)。如果一个类型实现了 Copy
trait,那么一个旧的变量在赋值后仍然可用。
Rust 不允许你使用 Copy
注解来标记一个类型,如果这个类型或其任何部分已经实现了 Drop
trait。如果你对一个类型使用了 Copy
注解,就会得到一个编译时错误。
那么,哪些类型是 Copy
的?你可以安全地拷贝的类型包括:
- 所有整数类型,如
u32
。 - 布尔类型,
bool
,其值为true
和false
。 - 浮点数类型,如
f64
。 - 字符类型,
char
。 - 元组,但仅当它们包含的类型也是
Copy
的时候。例如,(i32, i32)
是Copy
的,但(i32, String)
就不是。
所有权与函数
将值传递给函数与给变量赋值的行为类似。向函数传递一个值可能会移动它,或者复制它,这取决于它的类型。下面是一个展示变量所有权如何转移给函数的示例:
fn main() {let s = String::from("hello"); // s 进入作用域takes_ownership(s); // s 的值移动到函数中,// ... 所以在这里 s 不再有效let x = 5; // x 进入作用域makes_copy(x); // x 被移动到函数中,// 但由于 i32 实现了 Copy,所以之后 x 仍然有效
} // 在这里,x 离开作用域并被丢弃。s 也被丢弃,但它已经无效了,// 所以什么也不会发生。fn takes_ownership(some_string: String) { // some_string 进入作用域println!("{}", some_string);
} // 这里,some_string 离开作用域并被 drop 调用。// 占用的内存被释放。fn makes_copy(some_integer: i32) { // some_integer 进入作用域println!("{}", some_integer);
} // 这里,some_integer 离开作用域。因为它是一个栈上值,所以什么也不会发生。
如果你尝试在调用 takes_ownership
之后使用 s
,Rust 会在编译时抛出一个错误。这些静态检查保护我们避免犯错。尝试在 main
中添加使用 s
和 x
的代码,看看你可以在哪里使用它们,以及所有权规则在何处阻止你使用它们。
返回值与作用域
返回值也可以转移所有权。下面是一个返回某个值的函数示例,带有与前面示例类似的注释。
文件名: src/main.rs
fn main() {let s1 = gives_ownership(); // gives_ownership 将其返回值移动到 s1let s2 = String::from("hello"); // s2 进入作用域let s3 = takes_and_gives_back(s2); // s2 被移动到// takes_and_gives_back,// 它也把返回值移动到 s3
} // 在这里,s3 离开作用域并被丢弃。s2 被移动了,所以什么也不会发生。// s1 离开作用域并被丢弃。fn gives_ownership() -> String { // gives_ownership 将把// 其返回值移动到调用它的函数中let some_string = String::from("yours"); // some_string 进入作用域some_string // some_string 被返回并// 移动到调用函数
}// 这个函数接收一个 String 并返回一个
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域a_string // a_string 被返回并移动到调用函数
}
变量的所有权遵循相同的模式:将一个值赋给另一个变量会移动它。当一个包含堆上数据的变量离开作用域时,该值将由 drop
清理,除非该数据的所有权已被转移到另一个变量。
总结
文件类型 | Rust 操作 | 比喻 | 能否继续使用原变量? |
---|---|---|---|
小便签(i32 , bool …) | 复印一份 (Copy ) | 给同事一张复印件 | ✅ |
大合同(String , Vec …) | 转移原件 (Move ) | 把原件交出去,自己没了 | ❌ |
因此:
- 小而简单的类型(整数、布尔、字符等)可以
Copy
,赋值后原变量依然有效。 - 复杂类型(
String
、Vec
、Box
等)会Move
,赋值后原变量失效。 - 只要一个类型或它的成员需要“清理工作”(
Drop
),它就不能是Copy
。