rust-所有权

发布于:2025-07-26 ⋅ 阅读:(20) ⋅ 点赞:(0)

什么是所有权
所有权是一组规则,它决定了 Rust 程序如何管理内存。所有运行中的程序都必须管理它们对计算机内存的使用方式。某些语言使用垃圾回收(GC),在程序运行时定期查找不再使用的内存;另一些语言则要求程序员显式地分配和释放内存。Rust 采用第三种方式:通过一套编译期检查的“所有权系统”来管理内存。一旦违反这些规则,程序就无法通过编译。所有权机制的任何特性都不会在运行时拖慢程序。

对许多开发者来说,所有权是一个全新概念,确实需要一定时间适应。好消息是:随着你对 Rust 及所有权规则愈发熟悉,你会自然而然写出既安全又高效的代码。坚持下去!

理解了所有权,你就掌握了理解 Rust 独特特性的基石。本章将通过一种非常常见的数据结构——字符串(String)——的示例来学习所有权。

栈(Stack)与堆(Heap)
很多高级语言很少要求你关注栈和堆。但在像 Rust 这样的系统编程语言里,值位于栈还是堆,会直接影响语言的行为以及你为何必须做出某些决策。后面讲解所有权时,会结合栈和堆的概念,因此先简要说明。

栈和堆都是可供代码在运行时使用的内存区域,但组织方式不同。

  • 栈按“后进先出”顺序存取值:就像一摞盘子,后放的在上,先取最上面的。往栈里加数据叫“压栈”(push),移除叫“弹栈”(pop)。栈上所有数据的大小必须在编译期已知且固定。
  • 堆则没那么有序:把数据放入堆时,先向内存分配器申请一块足够大的空间,分配器标记该空间为“已用”,并返回指向此位置的指针。这个过程叫“堆分配”(简称分配),压栈不算分配。由于指针大小固定,可以把指针存在栈上;真正访问数据时,必须顺着指针去堆里拿。就像进餐厅时,告诉服务员你们几个人,他找一张空桌领你们过去,后来者问服务员即可找到你们。

压栈比堆分配更快,因为无需寻找空闲位置;栈顶永远是下一个位置。堆分配需要找到足够大的空间,并记录元数据以备后续分配,工作量更大。

访问堆数据也比栈慢,需要一次指针跳转。现代处理器在内存连续时更快。类似地,服务员若逐桌收齐一桌的订单再换下一桌,效率最高;若来回穿插,则慢得多。同理,处理器处理栈上紧密排布的数据更高效。

当函数被调用,传入的值(可能包括指向堆数据的指针)以及局部变量都会压栈;函数结束时,这些值被弹栈。

追踪哪些代码正在使用堆上的哪些数据、减少堆上重复数据、及时清理不再使用的数据以免耗尽内存——这些正是所有权要解决的问题。理解所有权后,你无须时刻惦记栈和堆,但明白“所有权主要用来管理堆数据”有助于理解其设计初衷。

所有权规则
先记住三条核心规则,后面示例会逐一阐释:

  1. Rust 中每个值都有且仅有一个所有者(owner)。
  2. 同一时间只能有一个所有者。
  3. 所有者离开作用域(scope)时,该值被丢弃(drop)。

变量的作用域
我们不再在每个示例中写 fn main() { ... },请自行把代码放进 main 函数。先看变量作用域:作用域指一个项在程序中有效的范围。例如:

{                      // s 尚未声明,不可用
    let s = "hello";   // 从这里开始 s 有效
    // 使用 s
}                      // 作用域结束,s 失效

重点:

  • s 进入作用域时生效。
  • 离开作用域时失效。

这与多数语言类似。接下来引入 String 类型,以进一步说明所有权。

String 类型
为了展示所有权规则,我们需要比第 3 章更复杂的数据类型。之前提到的类型大小已知,可放栈上,作用域结束时弹出,且易于按位复制出独立副本。现在我们想研究存放在堆上的数据,以及 Rust 如何决定何时清理它们——String 是很好的例子。

我们已见过字符串字面量("hello"),其值在编译期已知并直接写入可执行文件,速度快、效率高,但不可变,且无法在编译期确定所有文本(如用户输入)。于是 Rust 提供第二种字符串类型 String,它在堆上管理数据,允许存储编译期大小未知的文本。可用 String::from 由字面量创建:

let s = String::from("hello");

:: 语法把 from 置于 String 命名空间下,第 5 章与第 7 章会再谈。
String 可被修改:

let mut s = String::from("hello");
s.push_str(", world!");
println!("{s}"); // 输出 `hello, world!`

为何 String 可变,而字面量不行?关键在于二者内存处理方式不同。

内存与分配

  • 字面量内容在编译期已知,直接嵌入可执行文件,因此不可变。
  • String 需支持可增长文本,于是:
    1. 在运行时向内存分配器申请未知大小的堆内存。
    2. 用完后需将此内存归还(释放)。

第一步由 String::from 完成,与多数语言相同。第二步则不同:

  • 有 GC 的语言由 GC 清理;
  • 无 GC 的语言通常需程序员显式释放,易出错:忘了解放会泄漏,过早释放为悬垂指针,重复释放是 bug。

Rust 的做法:变量离开作用域时自动归还内存。例如:

{
    let s = String::from("hello"); // 申请内存
    // 使用 s
} // 作用域结束,Rust 自动调用 drop 释放内存

C++ 中类似模式叫 RAII(资源获取即初始化)。Rust 的 drop 函数即此思想的体现。

变量与数据:移动(Move)
Rust 中多个变量可与同一数据交互。先看整数示例:

let x = 5;
let y = x;

整数大小固定,直接复制值压栈,于是 xy 均为 5。

再看 String

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

看起来相似,实则不然。如图 4-1 所示,String 由三部分组成(存栈上):指向堆内容的指针、长度、容量;右侧堆上才是真正的字符数据。
4-1
图 4-1:变量 s1 绑定到值为 "hello"String 在内存中的表示

  • length(长度)表示该 String 的内容当前占用的字节数。
  • capacity(容量)表示该 String 从分配器处获得的堆内存总字节数。
    二者有区别,但在本节并不重要,可先忽略容量。

当我们执行 let s2 = s1; 时,复制的是栈上的那三部分数据(指针、长度、容量),而不会复制指针所指向的堆上的实际内容。换句话说,内存中的数据表示如图 4-2 所示。
4-2
图 4-2:变量 s2 复制了 s1 的指针、长度和容量后的内存示意图
(并没有复制堆上的实际数据)

这种表示并不是图 4-3 所展示的情况——图 4-3 表示的是“连堆上的数据也一并深拷贝”后的内存布局。
如果 Rust 真的那样做,当堆上的数据很大时,s2 = s1 这一操作在运行时就会变得非常昂贵。
4-3
图4-3:如果Rust也复制堆数据,s2 = s1可能的另一种行为

我们之前提到,当一个变量超出作用域时,Rust会自动调用drop函数并清理该变量的堆内存。但图4-2显示两个数据指针指向同一个位置。这是一个问题:当s2和s1超出作用域时,它们都会尝试释放相同的内存。这被称为双重释放错误,是我们之前提到的内存安全漏洞之一。释放内存两次可能导致内存损坏,进而可能引发安全漏洞。

为了确保内存安全,在执行let s2 = s1;这行代码后,Rust认为s1不再有效。因此,当s1超出作用域时,Rust不需要释放任何东西。看看在创建s2之后尝试使用s1会发生什么;它不会工作:

这段代码无法编译!

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

println!("{s1}, world!");

你会得到这样的错误,因为Rust阻止你使用无效的引用:

$ cargo run
   Compiling ownership v0.1.0 (file:///projects/ownership)
error[E0382]: borrow of moved value: `s1`
 --> src/main.rs:5:15
  |
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!("{s1}, world!");
  |               ^^^^ 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

如果你在使用其他语言时听说过浅拷贝和深拷贝的概念,那么只复制指针、长度和容量而不复制数据的想法听起来可能像是浅拷贝。但由于Rust还会使第一个变量失效,因此它不被称为浅拷贝,而是被称为移动(move)。在这个例子中,我们会说s1被移动到了s2。因此,实际发生的情况如图4-4所示。
4-4
图4-4:s1失效后内存中的表示

这解决了我们的问题!只有s2是有效的,当它超出作用域时,它将独自释放内存,任务完成。

此外,这里还隐含了一个设计选择:Rust永远不会自动创建数据的“深拷贝”。因此,任何自动拷贝都可以假设在运行时性能方面是廉价的。

作用域与赋值

这一规则的反面也适用于作用域、所有权以及通过drop函数释放内存之间的关系。当你将一个全新的值赋给一个已存在的变量时,Rust会立即调用drop并释放原始值的内存。考虑以下代码,例如:

let mut s = String::from("hello");
s = String::from("ahoy");

println!("{s}, world!");

我们最初声明了一个变量s,并将其绑定到一个值为"hello"的String。然后我们立即创建了一个值为"ahoy"的新String,并将其赋给s。此时,没有任何东西引用堆上的原始值了。
4-5
图 4-5:初始值被完全替换后在内存中的表示。

因此,原始字符串会立即超出作用域。Rust 会调用 drop 函数来释放它的内存。当我们打印最终的值时,它将是“ahoy, world!”。

变量和数据的克隆操作
如果我们确实需要深度复制 String 的堆数据,而不仅仅是栈数据,我们可以使用一个常见的方法,称为 clone。我们将在第 5 章讨论方法的语法,但由于方法是许多编程语言中的常见特性,你可能之前已经见过。

以下是一个 clone 方法的示例:

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

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

这可以正常工作,并明确地表现出图 4-3 中所示的行为,即堆数据确实被复制了。

当你看到对 clone 的调用时,你应该知道正在执行一些任意代码,而这些代码可能代价高昂。它是一个视觉提示,表明正在发生一些不同的事情。

仅在栈上的数据:Copy 特性
我们还没有提到的另一个细节是,使用整数的代码——其中一部分在清单 4-2 中展示过——可以正常工作且有效:

let x = 5;
let y = x;

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

但这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone,但 x 仍然有效,并且没有被移动到 y 中。

原因是像整数这样在编译时已知大小的类型完全存储在栈上,因此实际值的副本可以快速生成。这意味着我们没有理由阻止在创建变量 yx 仍然有效。换句话说,在这里浅拷贝和深拷贝没有区别,因此调用 clone 与通常的浅拷贝没有什么不同,我们可以省略它。

Rust 有一个特殊的注解,称为 Copy 特性,我们可以将其应用于存储在栈上的类型,例如整数(我们将在第 10 章中更多地讨论特性)。如果一个类型实现了 Copy 特性,使用它的变量不会被移动,而是被简单地复制,这使得它们在赋值给另一个变量后仍然有效。

如果类型本身或其任何部分实现了 Drop 特性,Rust 不会允许我们为该类型添加 Copy 注解。如果类型在值超出作用域时需要执行一些特殊操作,而我们为该类型添加了 Copy 注解,那么我们将会得到一个编译时错误。要了解如何为你的类型添加 Copy 注解以实现该特性,请参阅附录 C 中的“可派生特性”。

那么,哪些类型实现了 Copy 特性呢?你可以查看给定类型的文档来确认,但一般来说,任何一组简单的标量值都可以实现 Copy,而任何需要分配内存或是一种资源的类型都不能实现 Copy。以下是一些实现了 Copy 的类型:

  • 所有整数类型,例如 u32
  • 布尔类型 bool,其值为 truefalse
  • 所有浮点数类型,例如 f64
  • 字符类型 char
  • 如果元组只包含也实现了 Copy 的类型,则元组也实现 Copy。例如,(i32, i32) 实现了 Copy,但 (i32, String) 则没有。

所有权和函数
将值传递给函数的机制与将值赋给变量时的机制类似。将变量传递给函数会移动或复制,就像赋值一样。清单 4-3 有一个带有注释的示例,显示了变量进入和超出作用域的位置。

文件名:src/main.rs

fn main() {
    let s = String::from("hello");  // s 进入作用域

    takes_ownership(s);             // s 的值被移动到函数中...
                                    // ...因此在这里不再有效

    let x = 5;                      // x 进入作用域

    makes_copy(x);                  // 因为 i32 实现了 Copy 特性,
                                    // x 没有被移动到函数中,
    println!("{}", x);              // 因此之后仍然可以使用 x

} // 这里,x 超出作用域,然后是 s。但由于 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 超出作用域。没有特殊的事情发生。

清单 4-3:带有所有权和作用域注释的函数

如果我们试图在调用 takes_ownership 之后使用 s,Rust 会在编译时抛出错误。这些静态检查可以保护我们免于犯错。尝试在 main 中添加使用 sx 的代码,看看你可以在哪里使用它们,以及所有权规则阻止你在哪里使用它们。

返回值和作用域
返回值也可以转移所有权。清单 4-4 展示了一个返回某些值的函数的示例,其注释与清单 4-3 中的类似。

文件名:src/main.rs

fn main() {
    let s1 = gives_ownership();        // gives_ownership 将其返回值移动到 s1 中

    let 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 并返回一个 String。
fn takes_and_gives_back(a_string: String) -> String {
    // a_string 进入作用域

    a_string  // a_string 被返回并移动到调用函数中
}

清单 4-4:返回值的所有权转移

变量的所有权每次遵循相同的模式:将值赋给另一个变量会移动它。当包含堆数据的变量超出作用域时,除非数据的所有权被移动到另一个变量,否则值将通过 drop 被清理。

虽然这可以工作,但每次函数都获取所有权然后再返回所有权会有些繁琐。如果我们想让函数使用一个值但不获取所有权怎么办?我们传递的任何东西都需要再次返回,这相当烦人,尤其是当我们还想返回函数体中可能产生的任何数据时。

幸运的是,Rust 允许我们使用元组返回多个值,如清单 4-5 所示。

文件名:src/main.rs

fn main() {
    let s1 = String::from("hello");

    let (s2, len) = calculate_length(s1);

    println!("'{}' 的长度是 {}。", s2, len);
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len(); // len() 返回一个 String 的长度

    (s, length)
}

清单 4-5:返回参数的所有权

但这仍然过于繁琐,对于一个应该很常见的概念来说,工作量太大了。幸运的是,Rust 有一个特性,可以在不转移所有权的情况下使用值,称为引用。


网站公告

今日签到

点亮在社区的每一天
去签到