当前位置: 代码迷 >> 综合 >> [The RUST Programming Language]Chapter 4. Understanding Ownership (2)
  详细解决方案

[The RUST Programming Language]Chapter 4. Understanding Ownership (2)

热度:58   发布时间:2024-01-25 05:02:18.0

Understanding Ownership

  • References and Borrowing 引用和借用
    • Mutable References 可修改引用
    • Dangling References 摇摆引用
    • The Rules of References 引用的规则
  • The Slice Type 切片类型
    • String Slices String切片
    • String Literals Are Slices 字符串都是切片
    • String Slices as Parameters 字符串切片作为形参
    • Other Slices 其它切片
  • Summary 总结

References and Borrowing 引用和借用

在上一章的例子中,我们不得不使用元组返回调用函数时传入的String,这样才能继续在calculate_length中使用这个String,这是由于String会被移动到calculate_length中。

这里有另外一个例子,我们在定义calculate_length函数时将一个对象的引用作为形参而非直接去获得值的所有权:

fn main() {let s1 = String::from("hello");let len = calculate_length(&s1);println!("The length of '{}' is {}.", s1, len);
}fn calculate_length(s: &String) -> usize {s.len()
}

与之前的例子相比,新的函数最明显的地方在于我们将形参定义和返回值中的元组给删除了;此外,在调用函数时,我们将&s1传入了calculate_length,并且在函数定义中,我们用&String取代了String

这些&符号被称为引用,它能够使你在不占用所有权的情况下,引用某个值。图4-5显示了引用的实现。

图4-5
4-5

&的反向操作被称为反向引用,它通过反向引用的操作符*实现。我们在第8章中会看到一些反向引用的例子,在第15章我们会对反向引用详细讨论。

让我们靠近点瞧下:

let s1 = String::from("hello");let len = calculate_length(&s1);

&s1这个语法让我们创建了一个引用,它引用了s1的值,但不会抢夺掉所有权。因为引用没有所有权,所以在引用离开了它自己的作用域,它并不会去drop掉它所指向的值。

同样的,在函数签名中,我们使用了&去标记形参s的类型是一个引用。看下下面加过料的代码来帮助你理解。

fn calculate_length(s: &String) -> usize { // s 是一个 String 的引用s.len()
} // s 离开了作用域,但因为它没有它所引用的对象的所有权,所以什么事也不会发生

在上面的例子中,变量s的作用域的有效区间与任何其它函数形参的作用域都是一样的,但因为我们没有被引用的数据的所有权,所以当变量s离开函数作用域时,我们并不会去drop掉它。当函数使用引用取代实际数值作为形参时,我们就无须添加返回值来归还所有权,至始至终我们都没有所有权。

我们把这种使用引用作为函数形参的方式称作借用。就像我们现实生活中,如果你借用了某个人的东西,当你用完后你就该还给他。

那么,当我们尝试修改我们借用的东西时,会发生什么呢?试下下面的代码吧:

fn main() {let s = String::from("hello");change(&s);
}fn change(some_string: &String) {some_string.push_str(", world");
}

这段代码无法执行,在编译时会返回像下面的错误:

error[E0596]: cannot borrow immutable borrowed content `*some_string` as mutable--> error.rs:8:5|
7 | fn change(some_string: &String) {|                        ------- use `&mut String` here to make mutable
8 |     some_string.push_str(", world");|     ^^^^^^^^^^^ cannot borrow as mutable

就像变量默认是不可修改的一样,引用也是默认无法修改的,我们无法修改我们引用的值。

Mutable References 可修改引用

我们可以通过一个简单的修改,修复之前例子中的错误:

fn main() {let mut s = String::from("hello");change(&mut s);
}fn change(some_string: &mut String) {some_string.push_str(", world");
}

首先我们在s前加入了关键字mut,接着我们必须使用&mut s来创建一个可修改的引用,并且在函数签名中通过some_string: &mut String声明形参只能接收可修改引用。

这样虽然解决了我们之前的问题,但使用可修改引用有一个很大的限制:在一个明确的作用域中,对于同一个明确的变量,它只能有一个可修改引用。譬如像下面的例子,就会执行失败:

let mut s = String::from("hello");let r1 = &mut s;
let r2 = &mut s;println!("{}, {}", r1, r2);

以下是编译器返回的错误信息:

error[E0499]: cannot borrow `s` as mutable more than once at a time--> src/main.rs:5:14|
4 |     let r1 = &mut s;|              ------ first mutable borrow occurs here
5 |     let r2 = &mut s;|              ^^^^^^ second mutable borrow occurs here
6 |
7 |     println!("{}, {}", r1, r2);|                        -- first borrow later used here

这个限制使得你只能通过严格受控的几个形式去对数据做修改,这也是很多新Rustacean感到吃力的地方,因为大部分编程语言都允许你无论任何时候去修改任何你想修改的值。

这种严格限制的一个好处在于Rust可以在编译时防止数据争用的情况发生,数据争用和竞态条件非常相似,当有下面的三种情况时就会触发:

  • 多个指针同时访问相同的数据
  • 至少有一个指针正在尝试往引用对象中写入数据
  • 没有引用任何机制来保证同步访问数据

数据竞争会导致一些莫名其妙的状况发生,即便你在运行时追踪这些问题,也很难分析和修复它们。Rust防患于未然,会导致数据竞争的代码是无法通过编译的。

通常,我们只要添加一组尖括号来创建一个新的作用域,就可以创建多个可修改引用了:

let mut s = String::from("hello");{let r1 = &mut s;} // r1 离开作用域了,所以之后我们再创建一个新的可修改引用就不会发生数据竞争的问题let r2 = &mut s;

拓展下,有一个类似的规则,当同时有不可修改和可修改引用时,同样也会失败:

let mut s = String::from("hello");let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // !!!大问题println!("{}, {}, and {}", r1, r2, r3);

以下是报错信息:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable--> src/main.rs:6:14|
4 |     let r1 = &s; // no problem|              -- immutable borrow occurs here
5 |     let r2 = &s; // no problem
6 |     let r3 = &mut s; // BIG PROBLEM|              ^^^^^^ mutable borrow occurs here
7 |
8 |     println!("{}, {}, and {}", r1, r2, r3);|                                -- immutable borrow later used here

次奥!在有了一个不可修改的引用后,我们甚至不能创建一个可修改的引用。不可修改引用的使用者,肯定是不希望引用的值在之后被修改掉。但是多个不可修改引用是可以并存的,因为它们都是以只读方式去访问数据,谁都不会去修改它。

牢记一点,引用的作用域开始于它们创建的地方,然后会在最后一次用到它们的地方结束。为了方便理解,可以看下下面的例子,这些代码回编译通过,因为不可修改引用最后一次发生使用是在可修改引用创建前:

let mut s = String::from("hello");let r1 = &s; // 没问题
let r2 = &s; // 没问题
println!("{} and {}", r1, r2);
// r1 和 r2 之后不再有地方用到let r3 = &mut s; // 没问题
println!("{}", r3);

不可修改引用r1r2println!后就离开作用域了,这是它们最后被用到的地方,并且是在可修改引用r3前,它们的作用域没有交集,所以这段代码是允许使用的。

尽管借用错误频频发生很令人沮丧,但记住这是因为Rust编译器提前(在编译时而不是运行时)指出了可能发生bug的地方,并明确告诉了你问题发生的地方。这样你就不须要去debug为什么数据不是你所期望的值。

Dangling References 摇摆引用

在涉及指针的编程语言中,摇摆引用是错误重灾区。当一个指针指向的内存地址中保存的是另一个指针时,如果父指针又要去指向其它值时,就会清空内存中保存的子指针数据,这样子指针指向的值就丢失了。作为对比,在Rust中,编译器保证一个引用绝不会被摇摆引用。当你创建了一个引用时,编译器会确保它所引用的值不会比引用本身更早离开作用域。

像下面的代码,我们创建了一个摇摆引用,Rust会阻止执行,并提示一个编译时的错误信息:

fn main() {let reference_to_nothing = dangle();
}fn dangle() -> &String {let s = String::from("hello");&s
}

下面是报错信息:

error[E0106]: missing lifetime specifier--> main.rs:5:16|
5 | fn dangle() -> &String {|                ^ expected lifetime parameter|= help: this function's return type contains a borrowed value, but there isno value for it to be borrowed from= help: consider giving it a 'static lifetime

错误信息中提到了一个我们还没介绍的特性:生命周期。我们会在第10章中再讨论生命周期的话题。不过我们跳过生命周期的部分,剩下的错误信息其实也告诉了你问题的根本原因:

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 返回一个String的引用let s = String::from("hello"); // s 是一个新的String&s // 我们返回了一个String s的引用
} // s 离开了作用域,它被drop了,内存被清空 // 错误发生!

因为s是在dangle中创建的,所以当dangle代码结束时,s会被释放掉。可是我们尝试返回一个s的引用,在代码结束后它引用的是一个无效的String。这可不是什么好事,Rust当然不会让我们这么做。

解决这个问题的途径是直接饭后String

fn no_dangle() -> String {let s = String::from("hello");s
}

这段代码没有任何问题,所有权已经移出,没有什么东西被释放。

The Rules of References 引用的规则

来回顾下本章我们学到的关于引用的知识:

  • 无论什么时候,你都不能对一个值创建多个可修改引用
  • 引用必须确保始终有效

下面,我们将看下另一种特别的引用:切片

The Slice Type 切片类型

另一种不包含所有权的数据类型是slice切片。切片可以让你引用一个集中连续的部分,而不是引用完整的集本身。

编程时常会遇到这样的场景:写一个函数用于返回字符串中的第一个单词,当字符串中找不到空格时,整个字符串都会被当做单词返回。

让我们思考下这个函数签名:

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元素,检查是不是空格,我们把Stringas_bytes方法转化成了一个字节数组:

let bytes = s.as_bytes();

接着我们在这个字节数组上通过iter方法创建了一个迭代器:

for (i, &item) in bytes.iter().enumerate() {

我们会在第13章中再详细讨论迭代器,目前你只要知道iter这个方法能返回集中的每个元素,然后enumerate又将iter结果中的所有元素组合成元组。enumrate产生的元组,第一个元素都是索引,第二个元素是真实值的引用。这样做是为了方便我们找到对应的索引。

因为enumrate返回的是一个元组,我们可以使用模式来结构它。在for循环中,我们指定了一个模式,用i来获取元组的索引,&item来获取元组中的单个字节。因为.iter().enumrate()返回的是真实值的引用,所以在结构模式中我们使用了&符号。

for循环中,我们通过使用字节字符语法来找到空格,当找到空格是,我们就返回它的位置,如果找不到空格,我们就通过s.len()返回字符串的长度:

    if item == b' ' {return i;}
}s.len()

我们现在有方法来找到String中第一个单词结尾的索引了,不过这里有一个问题。我们返回了一个usize值,但这个值只在&String的上下文中才有意思,换句话说,因为这是一个String外孤立的值,没人能保证它在未来还有没有用。看下下面我们使用first_world函数的代码:

fn main() {let mut s = String::from("hello world");let word = first_word(&s); // word 值是5s.clear(); // String被清空为 ""// word 仍然是5,但这里已经没有和5有关的字符串了// word 从语境上看已经是完全没啥用处了
}

这个程序可以编译并不会报错,我们也能在s.clear()后继续使用word变量,word已经不再与s有什么关系,但仍包含着值5。我们可以通过变量s使用5这个值去获取第一个单词,不过这会产生一个bug,因为s的内容在我们将5保存进word后已经发生改变了。

时刻需要担心word的索引与s中的数据不同步,这是很烦人且容易导致错误的。管理这些索引同样也是充满了挑战,如果我们想写一个second_word函数来返回第二个单词,它的函数签名就该像下面这样:

fn second_word(s: &String) -> (usize, usize) {

现在我们须要跟踪单词的起始索引和截止索引,我们现在须要更多的值来保存计算出的结果,但是这些值与字符串本身并没有任何关联。我们得到了三个没有关系的浮动变量,并且需要时刻保持同步。

为了解决这个问题,Rust提出了一中解决方案:String切片。

String Slices String切片

String切片是对于一个String的部分引用,它长介样:

let s = String::from("hello world");let hello = &s[0..5];
let world = &s[6..11];

它看起来和引用整个String值一样,但是多了[0..5]这部分。不同于引用完整的String,它只是引用了部分的String

我们可以通过使用[starting_index..ending_index]这样的方式来创建一个切片,starting_index是切片的第一个索引,ending_index则是切片的最终索引。我们从内部看,切片的数据结构其实存储的是第一个元素的起始地址以及切片长度,切片长度是ending_indexstarting_index的差。let world = &s[6..11];中,world是一个s的切片,它包含了一个指针指向s的第七个字节且长度为5。

图4-6
4-6

在Rust的..范围语法中,如果你想从第一个索引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..];

你也可以省略句点前后的数字,直接来做一个包含完整String的切片:

let s = String::from("hello");let len = s.len();let slice = &s[0..len];
let slice = &s[..];

String切片的索引只在字符串中都是有效的UTF-8字符时才有效。如果你尝试为一个包含多种编码字符的String创建一个切片,你的程序会退出并报错。为了介绍字符串切片,在本节中,我们假设所有例子都是用的有效ASCII编码。处理UTF-8的问题,我们会在第8章再讨论。

现在我们趁热来重写下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[..]
}

我们通过查找第一个空格出现的位置,获得了单词末尾出现的索引,就像之前做的那样。当我们找到一个空格,我们返回一个字符串切片,使用0作为起始索引,使用空格出现的索引作为结束索引。

现在当我们调用first_world,我们会得到一个与底层值绑定的结果,这个结果值是一个切片,它由开始索引和包含的字节数组成。

这个方法对于second_word函数也是同样适用的:

fn second_word(s: &String) -> &str {

现在我们有了一个直观的API,不容易被混淆,因为编译器会确认对于String的引用是否有效。还记得我们之前代码中的bug中么?我们获得了第一个单词的结尾索引但随后又清空了这个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);
}

下面是错误信息:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable--> src/main.rs:18:5|
16 |     let word = first_word(&s);|                           -- immutable borrow occurs here
17 |
18 |     s.clear(); // error!|     ^^^^^^^^^ mutable borrow occurs here
19 |
20 |     println!("the first word is: {}", word);|                                       ---- immutable borrow later used here

有没有想起我们之前介绍过的借用规则,如果我们有一个不可修改的引用,我们就不能再创建一个可修改引用。因为clear须要修改String,所以它须要一个可修改引用,但Rust不允许这样做,所以编译失败了。Rust使得我们的API变得更加容易使用,并且它也在编译时帮助我们消除了一大类错误。

String Literals Are Slices 字符串都是切片

我们之前说过,字符串中的字符是以二进制形式存储的。我们现在已经知道了什么是切片,就能够更加理解字符串:

let s = "Hello, world!";

在这里s的类型是&str:它是一个指向明确二进制数据的切片。这也是为什么字符串是无法修改的,因为&str是不可修改引用。

String Slices as Parameters 字符串切片作为形参

掌握了给字符串和String创建切片的方法后,我们就能更进一步优化first_word函数,下面是它当前的函数签名:

fn first_word(s: &String) -> &str {

一个Rustacean老司机会选择以下面的方式替代上面的代码,因为它能让我们同时处理&String&str值:

fn first_word(s: &str) -> &str {

如果我们有一个字符串切片,我们可以直接把它传入我们的函数。如果我们有一个String,我们可以传入一个完整的String切片。创建一个函数使用字符串切片而非String的引用,可以是的我们的API更加通用且不会失去任何功能:

fn main() {let my_string = String::from("hello world");// first_word 可以处理 `String`s 的完整切片let word = first_word(&my_string[..]);let my_string_literal = "hello world";// first_word 对字符串切片也同样有效let word = first_word(&my_string_literal[..]);// 因为字符串本身就是字符串切片,所以即便不使用切片语法也是没问题的let word = first_word(my_string_literal);
}

Other Slices 其它切片

String切片,就像你所想的那样,只是针对字符串的。当然我们还有一些更加常见的切片。考虑下下面的数组:

let a = [1, 2, 3, 4, 5];

就像我们想引用一部分字符串,我们可能想引用一个数组的一部分,我们可以像下面这样做:

let a = [1, 2, 3, 4, 5];let slice = &a[1..3];

这个切片的类型是&[i32],它和字符串切片的使用方式是一样的,存储对第一个元素的引用以及长度。你以同样的方式为所有其它的集创建索引,在第8章中我们将继续详细讨论集的概念,并且介绍向量。

Summary 总结

所有权、借用和切片的概念确保了Rust程序在运行时的内存安全。Rust不仅给了我们像其它编程语言一样的途径去控制我们内存的使用,同时也借助数据所有权在所有人离开作用域时自动释放数据,你不须要编写额外的代码来做内存释放。

所有权对Rust的运作产生了很多影响,我们会在接下来的学习过程中继续讨论这些概念。接下来的第5章,我们将会看下如何将一些数据通过struct结构来组合到一起。

  相关解决方案