22人参与 • 2025-06-26 • rust
所有权是 rust 最独特的特性,对语言的其他部分有着深刻的影响。它使 rust 能够在不需要垃圾收集器的情况下保证内存安全,因此理解所有权是如何工作的很重要。在本文中,我们将讨论所有权以及几个相关的特性:借用、切片,以及 rust 如何在内存中布局数据。
所有权是一组控制 rust 程序如何管理内存的规则。
所有程序在运行时都必须管理它们使用计算机内存的方式。一些语言有垃圾收集,在程序运行时定期查找不再使用的内存;在其他语言中,程序员必须显式地分配和释放内存。rust 使用了第三种方法:内存通过一个所有权系统进行管理,该系统拥有一组编译器检查的规则。如果违反了任何规则,程序将无法编译。
所有权的主要目的是管理堆数据。
跟踪代码的哪些部分正在使用堆上的哪些数据,最小化堆上的重复数据量,以及清理堆上未使用的数据,这样就不会耗尽空间,这些都是所有权可以解决的问题。
首先,让我们看一下所有权规则:
作用域是程序中某项有效的范围。变量从声明它的地方开始有效,直到当前作用域结束。
示例:
{ // s is not valid here, it's not yet declared let s = "hello"; // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no longer valid
这里有两个重要的时间点:
在这一点上,作用域和变量何时有效之间的关系与其他编程语言中的关系类似。
我们已经看到了字符串字面量,其中字符串值被硬编码到程序中。字符串字面值很方便,但它们并不适合我们可能想要使用文本的所有情况。一个原因是它们是不可变的。另一个问题是,当我们编写代码时,不是每个字符串值都是已知的。
对于这些情况,可以创建字符串变量:
let mut s = string::from("hello"); s.push_str(", world!"); // push_str() appends a literal to a string println!("{s}"); // this will print `hello, world!`
这种类型的字符串可以被改变。
为什么字符串可以改变,而字面量不能?不同之处在于这两种类型处理内存的方式。
对于字符串字面值,我们在编译时就知道其内容,因此文本直接硬编码到最终的可执行文件中。这就是字符串字面值快速高效的原因。但这些属性仅来自字符串文字的不变性。不幸的是,我们不能为每个在编译时大小未知且在运行程序时大小可能改变的文本块放入二进制文件中的内存块。
对于 string 类型,为了支持可变的、可增长的文本片段,我们需要在堆上分配一定数量的内存(在编译时未知)来保存内容。这意味着:
第一部分由我们完成,当调用string::from时,它的实现请求它所需的内存。这在编程语言中是非常普遍的。
然而,第二部分是不同的。在带有垃圾收集器(gc)的语言中,gc 跟踪并清理不再使用的内存,我们不需要考虑它。
在大多数没有 gc 的语言中,我们有责任识别内存何时不再被使用,并调用代码显式释放它,就像我们请求它一样。正确地做到这一点历来是一个困难的编程问题。如果我们忘记了,我们就会浪费记忆。如果我们做得太早,就会得到一个无效的变量。如果我们做两次,这也是一个 bug。我们需要恰好配对一个已分配的和一个空闲的。
rust 采用不同的路径:一旦拥有内存的变量超出作用域,内存就会自动返回。
{ let s = string::from("hello"); // s is valid from this point forward // do stuff with s } // this scope is now over, and s is no longer valid
当变量超出作用域时,rust 会为我们调用一个特殊的函数。这个函数被称为 drop,该函数将内存返回给分配器。
注意:在 c++ 中,这种在项目生命周期结束时释放资源的模式有时被称为资源获取即初始化(raii)。
在 rust 中,多个变量可以以不同的方式与相同的数据交互。
示例 1:
let x = 5; let y = x;
我们现在有两个变量,x 和 y,它们都等于 5。因为整数是具有已知的固定大小的简单值,并且这两个 5 值被压入堆栈。
示例 2:
let s1 = string::from("hello"); let s2 = s1;
字符串由三部分组成:指向存储字符串内容的内存的指针、长度和容量。长度是指字符串的内容当前使用了多少内存(以字节为单位)。容量是字符串从分配器接收到的总内存量(以字节为单位)。
这组数据存储在堆栈上,右边是堆中保存内容的内存。
当将 s1 赋值给 s2 时,复制了 string 数据,这意味着复制了堆栈上的指针、长度和容量。但是,不复制指针所指向的堆上的数据。
前面我们说过,当变量超出作用域时,rust 会自动调用 drop 函数并为该变量清理堆内存。但是上图显示两个数据指针都指向同一个位置。这是一个问题:当 s2 和 s1 超出作用域时,它们都将尝试释放相同的内存。这被称为 double free error,是内存安全错误之一。释放内存两次可能会导致内存损坏,这可能会导致安全漏洞。
为了确保内存安全,在 let s2 = s1;
行之后,rust 认为 s1 不再有效。因此,当 s1 超出作用域时,rust 不需要释放任何东西。
在创建 s2 之后尝试使用 s1 会报错:使用无效的引用。
在 c++ 中,你可能听说过浅拷贝和深拷贝这两个术语,那么在不复制数据的情况下复制指针、长度和容量的概念,可以视为浅拷贝。但是因为 rust 会使第一个变量无效,所以它被称为移动(move),而不是浅拷贝。在这个例子中,我们会说 s1 被移动到 s2。
这就解决了我们的问题!只有 s2 有效,当它超出作用域时,它会单独释放内存,这样就完成了。
此外,这还隐含了一个设计选择:rust 永远不会自动创建数据的“深度”副本。因此,就运行时性能而言,任何自动复制都可以被认为是廉价的。
作用域、所有权和通过 drop 函数释放的内存之间的关系也是如此。
当你给一个已经存在的变量赋一个全新的值时,rust 会调用 drop 并立即释放原始值的内存。
示例:
let mut s = string::from("hello"); s = string::from("ahoy"); println!("{s}, world!");
我们首先声明一个变量 s,并将其绑定到一个值为 “hello” 的字符串。然后,我们立即创建一个值为 “ahoy” 的新 string,并将其赋值给 s。此时,原始字符串立即超出了作用域,rust 运行 drop函数立即释放 “hello” 的内存。
rust 提供一个叫 clone 的方法进行深拷贝。
示例:
let s1 = string::from("hello"); let s2 = s1.clone(); println!("s1 = {s1}, s2 = {s2}");
堆数据确实被复制了。
示例:
let x = 5; let y = x; println!("x = {x}, y = {y}");
这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone,但是 x 仍然有效,并且没有移动到 y 中。
原因是,在编译时具有已知大小的整数等类型完全存储在堆栈中,因此可以快速复制实际值。这意味着我们没有理由在创建变量 y 后阻止 x 的有效性。换句话说,这里没有深度复制和浅复制的区别,所以调用 clone 与通常的浅复制没有任何不同,我们可以省略它。
rust 有一个特殊的注释,叫做 copy trait,我们可以把它放在存储在堆栈上的类型上,就像整数一样。如果一个类型实现了 copy 特性,那么使用它的变量不会移动,而是被简单地复制,使它们在赋值给另一个变量后仍然有效。
如果类型或其任何部分实现了 drop 特性,rust 将不允许我们用 copy 注释类型。如果该类型需要在值超出作用域时发生一些特殊的事情,并且向该类型添加 copy 注释,则会得到编译时错误。
一般地,任何一组简单标量值都可以实现 copy,并且不需要分配或某种形式的资源来实现 copy。下面是一些实现 copy 的类型:
将值传递给函数的机制类似于将值赋给变量的机制。将变量传递给函数会移动或复制,就像赋值一样。
示例:
fn main() { let s = string::from("hello"); // s comes into scope takes_ownership(s); // s's value moves into the function... // ... and so is no longer valid here let x = 5; // x comes into scope makes_copy(x); // because i32 implements the copy trait, // x does not move into the function, println!("{}", x); // so it's okay to use x afterward } // here, x goes out of scope, then s. but because s's value was moved, nothing // special happens. fn takes_ownership(some_string: string) { // some_string comes into scope println!("{some_string}"); } // here, some_string goes out of scope and `drop` is called. the backing // memory is freed. fn makes_copy(some_integer: i32) { // some_integer comes into scope println!("{some_integer}"); } // here, some_integer goes out of scope. nothing special happens.
如果我们试图在调用 takes_ownership 之后使用 s, rust 会抛出一个编译时错误。
返回值也可以转移所有权。
示例:
fn main() { let s1 = gives_ownership(); // gives_ownership moves its return // value into s1 let s2 = string::from("hello"); // s2 comes into scope let s3 = takes_and_gives_back(s2); // s2 is moved into // takes_and_gives_back, which also // moves its return value into s3 } // here, s3 goes out of scope and is dropped. s2 was moved, so nothing // happens. s1 goes out of scope and is dropped. fn gives_ownership() -> string { // gives_ownership will move its // return value into the function // that calls it let some_string = string::from("yours"); // some_string comes into scope some_string // some_string is returned and // moves out to the calling // function } // this function takes a string and returns one fn takes_and_gives_back(a_string: string) -> string { // a_string comes into // scope a_string // a_string is returned and moves out to the calling function }
变量的所有权每次都遵循相同的模式:将值赋给另一个变量会移动该变量。当包含堆上数据的变量超出作用域时,除非数据的所有权已移动到另一个变量,否则该值将通过 drop 清除。
rust 允许我们使用元组返回多个值,虽然这是可行的,但是获取所有权并返回每个函数的所有权有点繁琐。
示例:
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(); // len() returns the length of a string (s, length) }
如果我们想让一个函数使用一个值,但不获得所有权,该怎么办?
幸运的是,rust 有一个不用转移所有权就能使用值的特性,叫做引用。
引用类似于指针,因为它是一个地址,我们可以按照它访问存储在该地址的数据。
引用用 & 符号表示。与使用 & 进行引用相反的是解引用,它是通过解引用操作符 * 完成的。
与指针不同,引用保证在其生命周期内指向特定类型的有效值。
示例:
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 is a reference to a string s.len() } // here, s goes out of scope. but because s does not have ownership of what // it refers to, the value is not dropped.
我们称创建引用的操作为借用。&s1 语法允许我们创建一个引用 s,该引用引用 s1 的值,但不拥有该值。因为引用不拥有它,所以当引用停止使用时,它所指向的值不会被删除。同样,函数的定义使用 & 表示形参 s 的类型是引用。
引用也是不可变的。我们不允许修改我们引用过的东西。
示例:
fn main() { let s = string::from("hello"); change(&s); } fn change(some_string: &string) { some_string.push_str(", world"); }
这段代码会报错:error[e0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference。
rust 通过“借用检查器”确保引用的安全性。
变量对其数据有三种权限:
这些权限在运行时并不存在,仅在编译器内部存在。
默认情况下,变量对其数据具有读/拥有的权限,如果变量是可变的,那么它还拥有写权限。
重点:引用可以临时解除这些权限。
修改之前的代码,使其允许我们通过使用可变引用的一些小调整来修改借用值。
fn main() { let mut s = string::from("hello"); change(&mut s); } fn change(some_string: &mut string) { some_string.push_str(", world"); }
用 &mut s 就可以创建一个可变引用。
可变引用有一个很大的限制:如果对一个值有一个可变引用,那么就不能有对该值的其他引用。下面的代码试图创建对 s 的两个可变引用将会失败:
let mut s = string::from("hello"); let r1 = &mut s; let r2 = &mut s; println!("{}, {}", r1, r2);
规定不能同时对同一数据进行多个可变引用的限制,这样做的好处是 rust 可以在编译时防止数据竞争。数据竞争类似于竞争条件,发生在以下三种行为时:
数据竞争会导致未定义的行为,当你试图在运行时追踪它们时,可能很难诊断和修复它们。rust 通过拒绝编译带有数据竞争的代码来防止这个问题!
我们可以使用花括号来创建一个新的作用域,允许多个可变引用,只是不能同时使用:
let mut s = string::from("hello"); { let r1 = &mut s; } // r1 goes out of scope here, so we can make a new reference with no problems. let r2 = &mut s;
rust 对可变引用和不可变引用的组合强制了类似的规则:
示例:
let mut s = string::from("hello"); let r1 = &s; // no problem let r2 = &s; // no problem let r3 = &mut s; // big problem println!("{}, {}, and {}", r1, r2, r3);
报错:error[e0502]: cannot borrow `s` as mutable because it is also borrowed as immutable。
请注意,引用的作用域从它被引入的地方开始,一直持续到最后一次使用该引用的时候。例如,下面这段代码可以编译,因为不可变引用的最后一次使用是在 println!,在引入可变引用之前:
let mut s = string::from("hello"); let r1 = &s; // no problem let r2 = &s; // no problem println!("{r1} and {r2}"); // variables r1 and r2 will not be used after this point let r3 = &mut s; // no problem println!("{r3}");
编译器可以在作用域结束之前的某个点上判断出引用不再被使用。
可变引用提供对数据唯一的且非拥有的访问。
不可变引用是只读的。可变引用在不移动数据的情况下,临时提供可变访问。
对一个可变引用再进行引用,可以暂时剥夺可变引用的写权限,新引用只具有读权限。
在创建了 x 的引用 y 后,x 的写和拥有权限暂时被剥夺,直到 y 的最后一次被使用之后,x 重新获得写和拥有权限。
在使用指针的语言中,很容易通过释放一些内存而保留指向该内存的指针来错误地创建悬空指针。
相比之下,在 rust 中,编译器保证引用永远不会是悬空引用:如果你有对某些数据的引用,编译器将确保数据不会在对数据的引用超出作用域之前超出作用域。
即,数据必须在其所有的引用存在的期间存活。
示例:创建了 s 的引用 s_ref 后, s 的拥有权限暂时被剥夺,于是不能移动或删除,程序在 drop(s) 出报错。
让我们尝试创建一个悬空引用,看看 rust 是如何用编译时错误来防止它们的:
fn main() { let reference_to_nothing = dangle(); } fn dangle() -> &string { let s = string::from("hello"); &s }
报错:error[e0106]: missing lifetime specifier。
这个错误消息涉及到一个我们尚未涉及的特性:生命周期。在这里我们先不讨论这个特性,而是分析 dangle() 函数的错误原因:因为 s 是在 dangle 内部创建的,所以当 dangle 的代码完成时,s 将被释放。但是我们试着返回对它的引用。这意味着这个引用将指向一个无效的字符串,于是发生了错误。
这里的解决方案是直接返回 string,而不是其引用:
fn no_dangle() -> string { let s = string::from("hello"); s }
总结:
接下来,我们来看看另一种类型的引用:切片。
切片允许引用集合中连续的元素序列,而不是整个集合。
切片是一种引用,所以它没有所有权。
这里有一个小编程问题:编写一个函数,它接受一个由空格分隔的单词字符串,并返回它在该字符串中找到的第一个单词的长度。如果函数在字符串中没有找到空格,则整个字符串必须是一个单词,因此应该返回整个字符串的长度。
让我们来看看如何在不使用切片的情况下编写这个函数,以理解切片将解决的问题:
fn first_word(s: &string) -> usize { // convert string to an array of bytes let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return i; } } s.len() }
我们现在有一种方法来找出字符串中第一个单词末尾的索引,但是有一个问题。我们自己返回了一个 usize,但它只是一个独立于 string 的值,所以不能保证它在将来仍然有效。
示例:
fn main() { let mut s = string::from("hello world"); let word = first_word(&s); // word will get the value 5 s.clear(); // this empties the string, making it equal to "" // `word` still has the value `5` here, but `s` no longer has any content // that we could meaningfully use with the value `5`, so `word` is now // totally invalid! }
这个程序编译时没有任何错误,如果在调用 s.clear() 之后使用 word,也不会出现任何错误。因为 word 根本没有连接到 s 的状态,所以 word 仍然包含值 5。我们可以使用这个值 5 和变量 s 来尝试提取出第一个单词,但这将是一个 bug,因为自从我们将 5 保存在 word 中以来,s 的内容已经发生了变化。
word 中的索引与 s 中的数据不同步是危险的。幸运的是,rust 有一个解决方案:字符串切片。
字符串切片是对字符串的一部分的引用,它看起来像这样:
let s = string::from("hello world"); let hello = &s[0..5]; let world = &s[6..11];
指定 [starting_index…ending_index],其中 starting_index 是片中的第一个位置,ending_index 比片中的最后一个位置大 1。
如果想从下标 0 开始,starting_index 可以省略,下面两种切片是一样的:
let s = string::from("hello"); let slice1 = &s[0..2]; let slice2 = &s[..2];
同样,如果切片包含 string 的最后一个字符,ending_index 也可以省略,下面两种切片是一样的:
let s = string::from("hello"); let len = s.len(); let slice1 = &s[3..len]; let slice2 = &s[3..];
掌握了切片后,重写之前的 first_word 函数,这次返回的是字符串而非下标:
fn first_word(s: &string) -> &str { let bytes = s.as_bytes(); for (i, &item) in bytes.iter().enumerate() { if item == b' ' { return &s[0..i]; } } &s[..] }
还记得之前程序中的错误吗?当我们获得了第一个单词末尾的索引,但随后清除了字符串,因此索引无效。这段代码在逻辑上是不正确的,但没有立即显示出任何错误。如果我们一直尝试将第一个单词索引与空字符串一起使用,那么问题就会出现。切片使这个 bug 不可能出现,并让我们更快地知道我们的代码有问题。
示例:
fn main() { let mut s = string::from("hello world"); let word = first_word(&s); s.clear(); // error! println!("the first word is: {word}"); }
报错:error[e0502]: cannot borrow `s` as mutable because it is also borrowed as immutable。
回顾借用规则,如果有对某物的不可变引用,就不能同时使用可变引用。因为 clear 需要截断 string,所以它需要获得一个可变引用。println! 在调用 clear 之后使用 word 中的引用,因此不可变引用在那时必须仍然是活动的。rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,编译失败。
回想一下,我们讨论过将字符串字面值存储在二进制文件中。现在我们知道了切片,我们可以正确地理解字符串字面值:
let s = "hello, world!";
这里 s 的类型是 &str,一个指向二进制数据中特定点的切片。这也是为什么字符串字面值是不可变的,&str 是一个不可变引用。
知道可以取字面量和字符串值的切片后,我们对 first_word 又做了一个改进,那就是它的声明:
fn first_word(s: &str) -> &str {
定义一个函数来接受一个字符串切片而不是一个字符串的引用,将提高函数的灵活性。如果我们有一个字符串切片,我们可以直接传递。如果有一个 string 对象,则可以传递 string 对象的切片或对 string 对象的引用。这种灵活性利用了取消强制转换。
数组也可以有切片:
let a = [1, 2, 3, 4, 5]; let slice = &a[1..3]; assert_eq!(slice, &[2, 3]);
这个切片的类型是 &[i32]。
所有权、借用和切片的概念确保了 rust 程序在编译时的内存安全。rust 语言提供了对内存使用的控制,但是当数据所有者超出范围时,数据所有者会自动清理数据,这意味着不必编写和调试额外的代码来获得这种控制。
到此这篇关于深入理解rust所有权的文章就介绍到这了,更多相关rust所有权内容请搜索代码网以前的文章或继续浏览下面的相关文章希望大家以后多多支持代码网!
您想发表意见!!点此发布评论
版权声明:本文内容由互联网用户贡献,该文观点仅代表作者本人。本站仅提供信息存储服务,不拥有所有权,不承担相关法律责任。 如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 2386932994@qq.com 举报,一经查实将立刻删除。
发表评论