上一篇: 03-常用编程概念
所有权是 Rust 最独特的特性,对语言的其他部分有着深刻的影响。它使 Rust 可以在不需要垃圾回收器的情况下保证内存安全,因此了解所有权的工作原理非常重要。在本章中,我们将讨论所有权以及几个相关特性:借用、分片以及 Rust 如何在内存中布局数据。
1. 什么是所有权
所有权是一套管理 Rust 程序如何管理内存的规则。所有程序在运行时都必须管理它们使用计算机内存的方式。有些语言有垃圾回收功能,可以在程序运行时定期查找不再使用的内存(Java的垃圾回收机制);在其他语言中,程序员必须明确分配和释放内存(C/C++)。Rust 使用的是第三种方法:通过所有权系统管理内存,并由编译器检查一系列规则。如果违反任何规则,程序将无法编译。在程序运行过程中,所有权的所有特性都不会降低程序的运行速度。
由于所有权对许多程序员来说是一个新概念,因此需要一些时间来适应。好在你对 Rust 和所有权系统的规则越有经验,就越容易自然而然地开发出安全高效的代码。
当你理解了所有权,就为理解 Rust 独特的功能打下了坚实的基础。在本章中,你将通过一些示例来学习所有权,这些示例的重点是一种非常常见的数据结构:字符串。
1.1 所有权规则
首先,让我们来看看所有权规则。在我们举例说明时,请牢记这些规则:
①. Rust 中的每个值都有一个所有者。
②. 一次只能有一个所有者。
③. 当所有者超出范围时,该值将被删除。
1.2 变量作用域
既然我们已经掌握了基本的 Rust 语法,我们就不会在示例中包含所有的 fn main() { 代码,所以如果你正在学习,请务必手动将下面的示例放在 main 函数中。因此,我们的示例将更加简洁,让我们专注于实际细节而不是模板代码。
作为所有权的第一个例子,我们来看看一些变量的作用域。作用域是指一个item在程序中有效的范围。以下面的变量为例:
let s = "hello";
变量 s 指的是一个字符串字面量,字符串的值被硬编码到我们程序的文本中。该变量的有效期从声明时开始,直到当前作用域结束。正面显示了一个带有注释的程序,注释中说明了变量 s 的有效位置。
{ // 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
换句话说,这里有两个重要的时间点:
①. s 开始生效。
②. 它在超出范围之前一直有效。
至此,作用域与变量有效时间之间的关系与其他编程语言类似。现在,我们将在此基础上介绍 String 类型。
1.3 String类型
前面介绍的类型大小已知,可以存储在栈中,并在其作用域结束时从栈中弹出,如果代码的另一部分需要在不同的作用域中使用相同的值,则可以快速、简便地复制以创建一个新的、独立的实例。但我们想看看堆上存储的数据,并探索 Rust 如何知道何时清理这些数据, String 类型就是一个很好的例子。
我们将集中讨论 String 中与所有权相关的部分。这些内容也适用于其他复杂数据类型,无论它们是由标准库提供的还是由您创建的。我们将在后面章节更深入地讨论 String 。
我们已经见过字符串字面量,即在程序中硬编码一个字符串值。字符串字面量很方便,但并不适合我们想要使用文本的所有情况。其中一个原因是它们是不可变的。另一个原因是,在我们编写代码时,并不是每个字符串值都是已知的:例如,如果我们想获取用户输入并将其存储起来,该怎么办?针对这些情况,Rust 提供了第二种字符串类型 String 。该类型管理堆上分配的数据,因此可以存储编译时未知的文本。您可以使用 from 函数从字符串字面量创建一个 String ,如下所示:
let s = String::from("hello");
双冒号 :: 操作符允许我们在 String 类型下使用命名为from 函数,而不是使用某种名称,如 string_from 。
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 可以更改内容,而字面量不能更改内容?区别在于这两种类型如何处理内存。
1.4 内存和分配
对于字符串字面量,我们在编译时就知道其内容,因此文本会直接硬编码到最终的可执行文件中。这就是字符串字面量快速高效的原因。但这些特性仅仅来自于字符串字面量的不变性。遗憾的是,我们无法在二进制文件中为每一段文本添加一块内存,因为这些文本在编译时大小未知,而且在程序运行时大小可能会发生变化。
对于 String 类型,为了支持一个可变、可增长的文本片段,我们需要在堆上分配一定量的内存来存放内容,这在编译时是未知的。这意味着:
①. 必须在运行时向内存分配器申请内存。
②. 当我们完成 String 时,我们需要一种将内存返回分配器的方法。
第一部分是由我们完成的:当我们调用 String::from 时,其实现会请求所需的内存。这在编程语言中几乎是通用的。
不过,第二部分有所不同。在有垃圾回收器(GC)的语言中,GC 会跟踪并清理不再使用的内存,我们不需要考虑这个问题。在大多数没有 GC 的语言中,我们有责任识别内存何时不再被使用,并调用代码显式释放内存,就像我们请求内存一样。正确做到这一点历来是编程中的难题。如果我们忘记了,就会浪费内存。如果过早释放,就会产生无效变量。如果我们做了两次,那也是一个错误。我们需要将一个 allocate 与一个 free 配对。
Rust 采用了不同的方法:一旦拥有内存的变量退出作用域,内存就会自动返回。下面示例使用的是 String 而不是字符串字面量:
{
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
有一个自然的时间点,我们可以将 String 所需的内存归还给分配器:当 s 变量退出作用域时。当变量退出作用域时,Rust 会为我们调用一个特殊函数。这个函数被称为 drop , String 的作者可以在这个函数中写入返回内存的代码。Rust 会在结尾大括号处自动调用 drop 。
这种模式对 Rust 代码的编写方式影响深远。现在看来可能很简单,但在更复杂的情况下,当我们想让多个变量使用堆上分配的数据时,代码的行为可能会出乎意料。现在就让我们来探讨其中的一些情况。
1.4.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 。但事实并非如此。
请看下图,了解 String 的内部结构。
String 由三部分组成,如左上图所示:指向存放字符串内容的内存的指针、长度和容量。这组数据存储在栈中。右上图是堆上存放内容的内存。
长度是指 String 的内容当前使用了多少内存(以字节为单位)。容量是 String 从分配器获得的内存总量(以字节为单位)。长度和容量之间的差值很重要,但在此情况下并不重要,所以目前忽略容量即可。
当我们将 s1 赋值给 s2 时,会复制 String 的数据,这意味着我们复制了堆栈中的指针、长度和容量。我们不会复制指针指向的堆上的数据。换句话说,内存中的数据表示如下图所示。
如果 Rust 将堆数据也复制到内存中,那么内存的表示形式就会如下图所示。如果 Rust 这样做,如果堆上的数据很大, s2 = s1 ,运行时的性能可能会非常昂贵。
前面我们说过,当变量退出作用域时,Rust 会自动调用 drop 函数并清理该变量的堆内存。但两个数据指针都指向同一个位置。这是一个问题:当 s2 和 s1 变量退出作用域时,它们都会尝试释放相同的内存。这就是所谓的双重释放错误,也是我们之前提到的内存安全漏洞之一。释放两次内存会导致内存损坏,从而可能导致安全漏洞。
为了确保内存安全,在s2 = s1之后,Rust 认为 s1 不再有效。因此,当 s1 退出作用域时,Rust 不需要释放任何东西。看看在 s2 创建后尝试使用 s1 会发生什么:它不会工作;
fn main() {
let s1 = String::from("rust!");
let s2 = s1;
println!("Hello, {}", s1);
}
你会得到这样如下所示的一个错误,因为 Rust 阻止你使用已失效的引用:
cargo.exe build
Compiling ownership v0.1.0 (E:\rustProj\ownership)
warning: unused variable: `s2`
--> src\main.rs:3:9
|
3 | let s2 = s1;
| ^^ help: if this is intentional, prefix it with an underscore: `_s2`
|
= note: `#[warn(unused_variables)]` on by default
error[E0382]: borrow of moved value: `s1`
--> src\main.rs:5:27
|
2 | let s1 = String::from("rust!");
| -- move occurs because `s1` has type `String`, which does not implement the `Copy` trait
3 | let s2 = s1;
| -- value moved here
4 |
5 | println!("Hello, {}", 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: `ownership` (bin "ownership") generated 1 warning
error: could not compile `ownership` (bin "ownership") due to previous error; 1 warning emitted
如果你在使用其他语言时听说过 "浅复制 "和 "深复制 "这两个术语,那么复制指针、长度和容量而不复制数据的概念听起来可能就像是在进行 "浅复制"。但是,由于 Rust 也会使第一个变量失效,所以它不叫浅层拷贝,而叫移动(Move)。在这个例子中,我们会说 s1 被移动到了 s2 中。因此,实际发生的情况如下图所示:
这就解决了我们的问题!只需 s2 有效,当它超出范围时,它就会释放内存,我们就大功告成了。
此外,这还隐含着一个设计选择:Rust 不会自动创建数据的 "深度 "副本。因此,可以认为任何自动复制在运行时性能方面都是低成本的。
1.4.2 与"Clone"互动的变量和数据
如果我们确实想深度复制 String 的堆数据,而不仅仅是栈数据,我们可以使用一个名为 clone 的常用方法。我们将在后面章节讨论其语法。
下面是 clone 方法的运行示例:
fn main() {
let s1 = String::from("rust!");
let s2 = s1.clone();
println!("Hello, {}", s1);
println!("Hello, {}", s2);
}
该方法运行正常,说明堆数据确实被复制了。
cargo.exe run
Compiling ownership v0.1.0 (E:\rustProj\ownership)
Finished dev [unoptimized + debuginfo] target(s) in 0.32s
Running `target\debug\ownership.exe`
Hello, rust!
Hello, rust!
当你看到对 clone 的调用时,你就知道一些任意代码正在被执行,而且这些代码可能很昂贵。这是一个直观的指示器,表明正在发生一些不同的事情。
1.4.3 栈专用数据:复制
还有一个问题我们还没有谈到。使用下面所示的代码,变量是有效的:
let x = 5;
let y = x;
println!("x = {}, y = {}", x, y);
但这段代码似乎与我们刚刚学到的内容相矛盾:我们没有调用 clone ,但 x 仍然有效,并没有被移入 y 。
原因是,在编译时已知大小的整数等类型完全存储在栈中,因此实际值的拷贝很快就能完成。这就意味着,在创建变量 y 之后,我们没有理由阻止 x 有效。换句话说,这里的深拷贝和浅拷贝没有区别,所以调用 clone 与通常的浅拷贝没有任何区别,我们可以不调用它。
Rust 有一个特殊的注解叫做 :Copy特质,我们可以把它放在像整数一样存储在堆栈中的类型上。如果一个类型实现了 Copy特质,那么使用它的变量就不会移动,而是被微不足道地复制,使得它们在赋值给另一个变量后仍然有效。
如果一个类型或其任何部分实现了 Drop 特质,Rust 不会让我们用 Copy 对该类型进行注解。如果该类型需要在值离开作用域时发生一些特殊情况,而我们在该类型中添加了 Copy 注释,那么就会出现编译时错误。
那么,哪些类型实现了 Copy 特质呢?您可以查看给定类型的文档来确定,以下是一些实现了 Copy 的类型:
①. 所有整数类型,如 u32;
②. 布尔类型 bool ,其值为 true 和 false ;
③. 所有浮点类型,如 f64;
④. 字符类型 char;
⑤. 元组,如果它们只包含也实现 Copy 的类型。例如, (i32, i32) 实现了 Copy ,但 (i32, String) 没有;
1.5 所有权和函数
将数值传递给函数的机制与为变量赋值的机制类似。将变量传递给函数会像赋值一样移动或复制变量。如下示例,一些注释显示了变量进入和退出作用域的位置。
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); // x would move into the function,
// but i32 is Copy, so it's okay to still
// 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 会在编译时抛出错误。这些静态检查可以防止我们犯错。试着在 main 中添加使用 s 和 x 的代码,看看在哪些地方可以使用它们,在哪些地方所有权规则会阻止你这样做。
cargo.exe build
Compiling ownership v0.1.0 (E:\rustProj\ownership)
error[E0382]: borrow of moved value: `s`
--> src\main.rs:8:31
|
2 | let s = String::from("hello");
| - move occurs because `s` has type `String`, which does not implement the `Copy` trait
3 | take_ownership(s);
| - value moved here
...
8 | println!("x:{}, s:{}", x, s);
| ^ value borrowed here after move
|
note: consider changing this parameter type in function `take_ownership` to borrow instead if owning the value isn't necessary
--> src\main.rs:11:32
|
11 | fn take_ownership(some_string: String) {
| -------------- ^^^^^^ this parameter takes ownership of the value
| |
| in this function
= 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 | take_ownership(s.clone());
| ++++++++
For more information about this error, try `rustc --explain E0382`.
error: could not compile `ownership` (bin "ownership") due to previous error
1.6 返回值及作用域
返回值也可以转移所有权。
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 '{}' is {}.", s2, len);
}
fn calculate_length(s: String) -> (String, usize) {
let length = s.len(); // len() returns the length of a String
(s, length)
}
但是,对于一个本应很常见的概念来说,这样做的仪式和工作量都太大了。幸运的是,Rust 有一种使用值而不转移所有权的功能,叫做引用。
2. 引用与借用
上述的元组代码的问题在于,我们必须将 String 返回给调用函数,以便在调用 calculate_length 后仍能使用 String ,因为 String 已被移入 calculate_length 。相反,我们可以提供一个指向 String 值的引用。引用与指针类似,它是一个地址,我们可以根据它访问存储在该地址的数据;该数据为其他变量所有。与指针不同的是,引用可以保证在其生命周期内指向特定类型的有效值。
以下是如何定义和使用 calculate_length 函数,该函数的参数是对象的引用,而不是值的所有权:
fn main() {
let s1 = String::from("hello");
let len = calculate_length(&s1);
println!("length:{}, s1:{}", len, s1);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
首先,请注意变量声明和函数返回值中的所有元组代码都消失了。其次,请注意我们将 &s1 传递到了 calculate_length 中,而且在其定义中,我们使用的是 &String 而不是 String 。&符号代表引用,它们允许你引用某个值而不占有它的所有权。下图描述了这一概念:
让我们仔细看看这里的函数调用:
let s1 = String::from("hello");
let len = calculate_length(&s1);
通过 &s1 语法,我们可以创建一个指向 s1 的值但不拥有它的引用。由于它不拥有该值,因此当引用停止使用时,它所指向的值不会被删除。
同样,函数的签名使用 & 表示参数 s 的类型是引用。让我们添加一些解释性注释:
fn calculate_length(s: &String) -> usize { // s is a reference to a String
s.len()
} // Here, s goes out of scope. But because it does not have ownership of what
// it refers to, it is not dropped.
变量 s 的作用域与任何函数参数的作用域相同,但当 s 停止使用时,引用所指向的值不会丢弃,因为 s 并不拥有所有权。当函数将引用作为参数而不是实际值时,我们不需要返回值来归还所有权,因为我们从未拥有过所有权。
我们将创建引用的行为称为:借用。在现实生活中,如果某人拥有某样东西,你可以向他借用。借完后,你必须还回去。你并不拥有它。
那么,如果我们试图修改借用的东西,会发生什么呢?试试下面的代码。剧透警告:它不工作!
fn main() {
let s1 = String::from("hello");
change(&s1);
println!("s1:{}", s1);
}
fn change(some_string: &String) {
some_string.push_str(", rust!");
}
错误就在这里:
cargo.exe build
Compiling ownership v0.1.0 (E:\rustProj\ownership)
error[E0596]: cannot borrow `*some_string` as mutable, as it is behind a `&` reference
--> src\main.rs:9:5
|
9 | some_string.push_str(", rust!");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
|
help: consider changing this to be a mutable reference
|
8 | fn change(some_string: &mut String) {
| +++
For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` (bin "ownership") due to previous error
正如变量默认是不可变的一样,引用也是不可变的。我们不能修改我们拥有引用的东西。
2.1 可变引用
我们可以修改上述的代码,只需稍作调整,使用可变引用即可修改借用值:
fn main() {
let mut s1 = String::from("hello");
change(&mut s1);
println!("s1:{}", s1);
}
fn change(some_string: &mut String) {
some_string.push_str(", rust!");
}
首先,我们将 s1 改为 mut 。然后,我们用 &mut s1 创建一个可变引用,在此调用 change 函数,并用 some_string: &mut String 更新函数签名以接受可变引用。这就清楚地表明, change 函数将改变它所借用的值。
可变引用有一个很大的限制:如果对某个值有可变引用,就不能对该值有其他引用。试图创建两个对 s1 的可变引用的代码将失败:
fn main() {
let mut s1 = String::from("hello");
let r1 = &mut s1;
let r2 = &mut s2;
println!("{}, {}", r1, r2);
}
错误就在这里:
cargo.exe run
Compiling ownership v0.1.0 (D:\rustProj\ownership)
error[E0499]: cannot borrow `s1` as mutable more than once at a time
--> src\main.rs:5:14
|
4 | let r1 = &mut s1;
| ------- first mutable borrow occurs here
5 | let r2 = &mut s1;
| ^^^^^^^ second mutable borrow occurs here
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here
For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` (bin "ownership") due to previous error
这个错误说明这段代码无效,因为我们不能将 s1 作为可变引用同时借用多次。第一个可变引用在 r1 中,必须持续到在 println! 中使用为止,但在创建该可变引用和使用该引用之间,我们试图在 r2 中创建另一个可变引用,该引用借用了与 r1 相同的数据。
防止同时对同一数据进行多个可变引用的限制允许变异,但变异是在非常受控的情况下进行的。这也是 Rustace 新手比较头疼的问题,因为大多数语言都允许随时变异。这种限制的好处是,Rust 可以在编译时防止数据竞争。数据竞争,会在这三种行为发生时出现:
①. 两个或多个指针同时访问相同的数据。
②. 至少有一个指针被用来写入数据。
③. 没有同步访问数据的机制。
数据竞争会导致未定义的行为,当你试图在运行时跟踪它们时,会很难诊断和修复;Rust 拒绝编译带有数据竞争的代码,从而避免了这个问题!
一如既往,我们可以使用大括号创建一个新的作用域,允许多个可变引用,但不能同时引用:
fn main() {
let mut s1 = String::from("hello");
{
let r1 = &mut s1;
} // r1 goes out of scope here, so we can make a new reference with no problems.
let r2 = &mut s1;
println!("{}", r2);
}
Rust 对组合可变引用和不可变引用执行类似的规则。这段代码会导致错误:
fn main() {
let mut s1 = String::from("hello");
let r1 = &s1; // no problem
let r2 = &s1; // no problem
let r3 = &mut s1; // BIG PROBLEM
println!("{}, {}, and {}", r1, r2, r3);
}
错误就在这里:
cargo.exe run
Compiling ownership v0.1.0 (D:\rustProj\ownership)
error[E0502]: cannot borrow `s1` as mutable because it is also borrowed as immutable
--> src\main.rs:6:14
|
4 | let r1 = &s1; // no problem
| --- immutable borrow occurs here
5 | let r2 = &s1; // no problem
6 | let r3 = &mut s1; // BIG PROBLEM
| ^^^^^^^ mutable borrow occurs here
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to previous error
也就是说,我们也不能在对同一值拥有不可变引用的同时拥有一个可变引用。
不可变引用的用户不会指望值会突然从他们脚下消失!然而,多个不可变引用是允许的,因为正在读取数据的人没有能力影响其他人对数据的读取。
请注意,引用的作用域从引入引用的地方开始,直到最后一次使用引用为止。例如,这段代码可以编译,因为不可变引用的最后一次使用( println! )发生在引入可变引用之前:
fn main() {
let mut s1 = String::from("hello");
let r1 = &s1; // no problem
let r2 = &s1; // no problem
println!("{}, {}", r1, r2);
// // variables r1 and r2 will not be used after this point
let r3 = &mut s1; // no problem
println!("{}", r3);
}
不可变引用 r1 和 r2 的作用域在最后一次使用它们的 println! 之后结束,也就是在创建可变引用 r3 之前结束。这些作用域并不重叠,因此允许使用此代码:编译器可以判断出引用在作用域结束前的某一点不再被使用。
尽管借用错误有时会令人沮丧,但请记住,这是 Rust 编译器在早期(编译时而不是运行时)就指出了潜在的错误,并准确地告诉你问题所在。这样,你就不必去追查为什么你的数据和你想象的不一样了。
2.2 悬而未决的引用
在使用指针的语言中,很容易错误地创建一个悬空指针(dangling pointer),即通过释放一些内存,同时保留指向该内存的指针,来引用内存中可能已经给了其他人的位置。相比之下,在 Rust 中,编译器会保证引用永远不会成为悬空引用:如果你有一个指向某些数据的引用,编译器会确保数据不会在指向该数据的引用退出作用域之前退出。
让我们尝试创建一个悬挂引用,看看 Rust 是如何通过编译时错误来防止它们的:
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
错误信息如下:
cargo.exe build
Compiling ownership v0.1.0 (D:\rustProj\ownership)
error[E0106]: missing lifetime specifier
--> src\main.rs:5:16
|
5 | fn dangle() -> &String {
| ^ expected named lifetime parameter
|
= help: this function's return type contains a borrowed value, but there is no value for it to be borrowed from
help: consider using the `'static` lifetime
|
5 | fn dangle() -> &'static String {
| +++++++
For more information about this error, try `rustc --explain E0106`.
error: could not compile `ownership` (bin "ownership") due to previous error
这条错误信息涉及到我们尚未涉及的一项功能:生命周期。我们将在后面章节详细讨论生命周期。但是,如果不考虑有关生命周期的部分,这条信息确实包含了为什么这段代码会出现问题的关键所在:
this function's return type contains a borrowed value, but there is no value for it to be borrowed from
让我们仔细看看 dangle 代码的每个阶段到底发生了什么:
fn dangle() -> &String { // dangle returns a reference to a String
let s = String::from("hello"); // s is a new String
&s // we return a reference to the String, s
} // Here, s goes out of scope, and is dropped. Its memory goes away.
// Danger!
因为 s 是在 dangle 内部创建的,所以当 dangle 的代码编写完成后, s 将被取消分配。但我们试图返回对它的引用。这意味着该引用将指向一个无效的 String 。这可不行!Rust 不允许我们这样做。
解决办法是直接返回 String :
fn dangle() -> String {
let s = String::from("hello");
s
}
这样做没有任何问题。所有权被移出,没有任何东西被去分配。
3. slices切片类型
切片允许你引用一个集合中连续的元素序列,而不是整个集合。切片是一种引用,因此不具有所有权。
这里有一个编程小问题:编写一个函数,接收一个由空格分隔的单词字符串,并返回在该字符串中找到的第一个单词。如果函数在字符串中找不到空格,那么整个字符串一定是一个单词,所以应该返回整个字符串。
让我们来看看在不使用分片的情况下如何编写这个函数的签名,以了解分片将解决的问题:
fn first_word(s: &String) -> ?
first_word 函数的参数是 &String 。我们不需要所有权,所以这没有问题。但我们应该返回什么呢?我们其实没有办法讨论字符串的一部分。不过,我们可以返回单词末尾用空格表示的索引。让我们试试看,如下所示。
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
由于我们需要逐个元素查看 String ,并检查某个值是否是空格,因此我们将使用 as_bytes 方法将 String 转换为字节数组。
let bytes = s.as_bytes();
接下来,我们使用 iter 方法在字节数组上创建一个迭代器:
for (i, &item) in bytes.iter().enumerate() {
我们将在后面章节详细讨论迭代器。现在,我们知道 iter 是一个返回集合中每个元素的方法,而 enumerate 封装了 iter 的结果,并将每个元素作为元组的一部分返回。 enumerate 返回的元组的第一个元素是索引,第二个元素是元素的引用。这比我们自己计算索引要方便一些。
因为 enumerate 方法返回一个元组,所以我们可以使用模式来重组这个元组。我们将在后面章节详细讨论模式。在 for 循环中,我们指定了一个模式,其中 i 表示元组中的索引, &item 表示元组中的单字节。由于我们从 .iter().enumerate() 获得了元素的引用,因此我们在模式中使用 & 。
在 for 循环中,我们使用字节文字语法搜索代表空格的字节。如果找到空格,则返回位置。否则,我们将使用 s.len() 返回字符串的长度。
if item == b' ' {
return i;
}
}
s.len()
我们现在有办法找出字符串中第一个单词末尾的索引,但有一个问题。我们单独返回一个 usize ,但它只有在 &String 的上下文中才是一个有意义的数字。换句话说,由于它是一个独立于 String 的值,因此无法保证它在未来仍然有效。请看下面的程序,它使用了上述中的 first_word 函数。
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 there's no more string that
// we could meaningfully use the value 5 with. word is now totally invalid!
}
fn first_word(s: &String) -> usize {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return i;
}
}
s.len()
}
如果我们在调用 s.clear() 之后使用 word ,这个程序编译时也不会出现任何错误。因为 word 与 s 的状态完全无关,所以 word 仍然包含值 5 。我们可以使用该值 5 和变量 s 来尝试提取出第一个单词,但这将是一个错误,因为自从我们将 5 保存到 word 后, s 的内容已经发生了变化。
要担心 word 中的索引与 s 中的数据不同步,既繁琐又容易出错!如果我们编写一个 second_word 函数,管理这些索引就会变得更加困难。它的签名应该是这样的:
fn second_word(s: &String) -> (usize, usize) {
现在,我们要跟踪一个起始索引和一个终止索引,还有更多的值是根据特定状态下的数据计算出来的,但与该状态完全无关。我们有三个不相关的变量需要保持同步。
幸运的是,Rust 可以解决这个问题:字符串切片。
3.1 字符串切片
字符串切片是对 String 部分内容的引用,它看起来像这样:
let s = String::from("hello world");
let hello = &s[0..5];
let world = &s[6..11];
hello 不是对整个 String 的引用,而是对 String 的一部分的引用,由额外的 [0..5] 位指定。我们使用括号内的范围创建分片,方法是指定 [starting_index..ending_index] ,其中 starting_index 是分片中的第一个位置, ending_index 是比分片中最后一个位置多一个的位置。在内部,切片数据结构存储切片的起始位置和长度,即 ending_index 减去 starting_index 。因此,在 let world = &s[6..11]; 的情况下, world 将是一个包含指向 s 索引 6 的字节指针的片段,其长度值为 5 。
下图展示了这一点:
使用 Rust 的 .. range 语法,如果您想从索引 0 开始,可以去掉两个句点之前的值。换句话说,这些值是相等的:
let s = String::from("hello");
let slice = &s[0..2];
let slice = &s[..2];
同样,如果您的片段包括 String 的最后一个字节,则可以去掉尾数。这意味着这两个值相等:
let s = String::from("hello");
let len = s.len();
let slice = &s[3..len];
let slice = &s[3..];
也可以去掉两个值,对整个字符串进行切分。所以这两个值是相等的:
let s = String::from("hello");
let len = s.len();
let slice = &s[0..len];
let slice = &s[..];
有了这些信息,让我们重写 first_word 来返回一个切片。表示 "字符串切片 "的类型写为 &str :
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[..]
}
我们将按照上述方法,通过查找首次出现的空格来获取单词结尾的索引。找到空格后,我们将以字符串的起始位置和空格索引作为起始和结束索引,返回一个字符串切片。
现在,当我们调用 first_word 时,会得到一个与底层数据相关联的值。该值由切片起点的引用和切片中元素的数量组成。
对于 second_word 函数来说,返回切片也是可行的:
fn second_word(s: &String) -> &str {
现在,我们有了一个简单明了的 API,而且更难出错,因为编译器会确保对 String 的引用保持有效。当时我们的索引到达了第一个单词的末尾,但随后又清除了字符串,因此我们的索引无效。这段代码在逻辑上是错误的,但并没有立即显示任何错误。如果我们继续尝试在清空字符串的情况下使用第一个单词的索引,问题就会在稍后出现。而切片则不会出现这种错误,并能让我们更早地知道代码出现了问题。使用 first_word 的片段版本会出现编译时错误:
fn main() {
let mut s = String::from("hello world");
let word = first_word(&s);
s.clear(); // error!
println!("the first word is:{}", 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[..]
}
下面是编译器错误:
argo.exe build
Compiling ownership v0.1.0 (D:\rustProj\ownership)
error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
--> src\main.rs:6:5
|
4 | let word = first_word(&s);
| -- immutable borrow occurs here
5 |
6 | s.clear();
| ^^^^^^^^^ mutable borrow occurs here
7 |
8 | println!("the first word is:{}", word);
| ---- immutable borrow later used here
For more information about this error, try `rustc --explain E0502`.
error: could not compile `ownership` (bin "ownership") due to previous error
从借用规则中可以忆及,如果我们有一个不可变的引用,我们就不能同时获取一个可变的引用。因为 clear 需要截断 String ,所以它需要获取一个可变引用。在调用 clear 之后的 println! 会使用 word 中的引用,因此不可变引用在此时必须仍然有效。Rust 不允许 clear 中的可变引用和 word 中的不可变引用同时存在,因此编译失败。Rust 不仅使我们的应用程序接口更易于使用,而且还消除了编译时的一整类错误!
3.2 作为切片的字符串字面量
回想一下,我们说过字符串字面量存储在二进制文件中。现在我们知道了分片,就能正确理解字符串字面量了:
let s = "Hello, world!";
s 的类型是 &str :它是指向二进制文件中特定点的片段。这也是字符串文字不可变的原因; &str 是不可变的引用。
3.3 字符串切片作为参数
了解到可以对字面量和 String 值进行分片后,我们就可以对 first_word 进行进一步改进,这就是它的签名:
fn first_word(s: &String) -> &str {
更有经验的 Rustacean 会改写签名,因为它允许我们在 &String 值和 &str 值上使用相同的函数。
fn first_word(s: &str) -> &str {
如果我们有一个字符串片段,我们可以直接传递它。如果有 String ,我们可以传递 String 的片段或 String 的引用。我们将在后面章节 "函数和方法的隐式转换 "一节中介绍这一功能。
定义一个函数来获取字符串切片,而不是 String 的引用,这使得我们的应用程序接口更通用、更有用,而不会丢失任何功能:
fn main() {
let my_string = String::from("hello world");
// `first_word` works on slices of `String`s, whether partial or whole
let word = first_word(&my_string[0..6]);
let word = first_word(&my_string[..]);
// `first_word` also works on references to `String`s, which are equivalent
// to whole slices of `String`s
let word = first_word(&my_string);
}
fn first_word(s: &str) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' {
return &s[0..i]
}
}
&s[..]
}
3.4 其他切片
如你所想,字符串切片是专门针对字符串的。但还有一种更通用的切片类型。请看这个数组:
let a = [1, 2, 3, 4, 5];
就像我们可能想引用字符串的一部分一样,我们也可能想引用数组的一部分。我们可以这样做:
let a = [1, 2, 3, 4, 5];
let slice = &a[1..3];
assert_eq!(slice, &[2, 3]);
该分片的类型是 &[i32] 。它的工作方式与字符串切片相同,都是存储第一个元素的引用和长度。
下一篇:使用结构体构建相关数据;