文章目录
2. Rust 基础入门
2.3. 所有权和借用
2.3.2. 引用与借用(borrowing)
上节中提到,如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。 Rust 能否像其它编程语言一样,使用某个变量的指针或者引用呢?答案是可以。
Rust 通过 借用(Borrowing)
这个概念来达成上述的目的,获取变量的引用,称之为借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来,当使用完毕后,也必须要物归原主。
在Rust编程语言中,引用和借用是密切相关的概念,但它们从字面上来说有细微的区别:
引用:
- 引用是对数据的直接访问而不取得其所有权的方式。在Rust中,引用使用
&
(不可变引用)和&mut
(可变引用)符号来创建。引用本质上是指向数据的指针,但它们是安全的,因为Rust的借用检查器(borrow checker)会在编译时确保引用的使用不会导致数据竞争或悬垂指针。- 引用的创建实际上就是借用行为的一部分,它们本身不拥有数据的所有权,仅提供对数据的访问方式。
借用:
- 借用是一种机制,通过它可以创建对某个值的引用。借用可以是不可变的(
&T
)或可变的(&mut T
)。不可变借用允许你读取数据但不能修改它,而可变借用允许你修改数据。- 借用的核心规则包括:一次只能有一个可变借用或多个不可变借用,可变借用和不可变借用不能同时存在。这些规则帮助防止数据竞争和其他并发错误。
因此,可以说引用是一种语言结构,而借用是Rust内存安全模型的一个原则或规则,用于管理引用的创建和使用。借用确保在编程过程中保持数据的安全性和一致性,而引用则是这些规则下的具体实现工具。
引用与解引用
常规引用是一个指针类型,指向了对象存储的内存地址。在下面代码中,我们创建一个 i32
值的引用 y
,然后使用解引用运算符来解出 y
所使用的值:
fn main() {
let x = 5;
let y = &x;
assert_eq!(5, x);
assert_eq!(5, *y);
}
变量 x
存放了一个 i32
值 5
。y
是 x
的一个引用。可以断言 x
等于 5
。然而,如果希望对 y
的值做出断言,必须使用 *y
来解出引用所指向的值(也就是解引用)。一旦解引用了 y
,就可以访问 y
所指向的整型值并可以与 5
做比较。
相反如果尝试编写 assert_eq!(5, y);
,则会得到如下编译错误:
error[E0277]: can't compare `{integer}` with `&{integer}`
--> src/main.rs:6:5
|
6 | assert_eq!(5, y);
| ^^^^^^^^^^^^^^^^^ no implementation for `{integer} == &{integer}` // 无法比较整数类型和引用类型
|
= help: the trait `std::cmp::PartialEq<&{integer}>` is not implemented for
`{integer}`
不允许比较整数与引用,因为它们是不同的类型。必须使用解引用运算符解出引用所指向的值。
不可变引用(&
)
下面的代码,我们用 s1
的引用作为参数传递给 calculate_length
函数,而不是把 s1
的所有权转移给该函数:
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() // 自动解引用强制
}
能注意到两点:
- 无需像上章一样:先通过函数参数传入所有权,然后再通过函数返回来传出所有权,代码更加简洁
calculate_length
的参数s
类型从String
变为&String
这里,&
符号即是引用,它们允许你使用值,但是不获取所有权,如图所示:
通过 &s1
语法,我们创建了一个指向 s1
的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。
同理,函数 calculate_length
使用 &
来表明参数 s
的类型是一个引用:
fn calculate_length(s: &String) -> usize {
// s 是对 String 的引用
s.len()
} // 这里,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
--> src/main.rs:8:5
|
7 | fn change(some_string: &String) {
| ------- help: consider changing this to be a mutable reference: `&mut String`
------- 帮助:考虑将该参数类型修改为可变的引用: `&mut String`
8 | some_string.push_str(", world");
| ^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
`some_string`是一个`&`类型的引用,因此它指向的数据无法进行修改
正如变量默认不可变一样,引用指向的值默认也是不可变的,没事,来一起看看如何解决这个问题。
可变引用(&mut
)
只需要一个小调整,即可修复上面代码的错误:
// 测试代码
fn main() {
let mut s = String::from("hello");
change(&mut s);
println!("s: {}", s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}
首先,声明 s
是可变类型,其次创建一个可变的引用 &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 同一时间无法对 `s` 进行两次可变借用
--> 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 第一个借用在这里使用
这段代码出错的原因在于,第一个可变借用 r1
必须要持续到最后一次使用的位置 println!
,在 r1
创建和最后一次使用之间,我们又尝试创建第二个可变借用 r2
。
对于新手来说,这个特性绝对是一大拦路虎,也是新人们谈之色变的编译器 borrow checker
特性之一,不过各行各业都一样,限制往往是出于安全的考虑,Rust 也一样。
这种限制的好处就是使 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
// 无法借用可变 `s` 因为它已经被借用了不可变
--> src/main.rs:6:14
|
4 | let r1 = &s; // 没问题
| -- immutable borrow occurs here 不可变借用发生在这里
5 | let r2 = &s; // 没问题
6 | let r3 = &mut s; // 大问题
| ^^^^^^ mutable borrow occurs here 可变借用发生在这里
7 |
8 | println!("{}, {}, and {}", r1, r2, r3);
| -- immutable borrow later used here 不可变借用在这里使用
其实这个也很好理解,正在借用不可变引用的用户,肯定不希望他借用的东西,被另外一个人莫名其妙改变了。多个不可变借用被允许是因为没有人会去试图修改数据,每个人都只读这一份数据而不做修改,因此不用担心数据被污染。
注意,引用的作用域 s
从创建开始,一直持续到它最后一次使用的地方,这个跟变量的作用域有所不同,变量的作用域从创建持续到某一个花括号 }
Rust 的编译器一直在优化,早期的时候,引用的作用域跟变量作用域是一致的,这对日常使用带来了很大的困扰,你必须非常小心的去安排可变、不可变变量的借用,免得无法通过编译,例如以下代码:
// 测试代码
fn main() {
let mut s = String::from("hello");
let r1 = &s;
let r2 = &s;
println!("{} and {}", r1, r2);
// 新编译器中,r1,r2作用域在这里结束
let r3 = &mut s;
println!("{}", r3);
// 新编译器中,r3作用域在这里结束
let r4 = &s;
let r5 = &s;
println!("{} and {}", r4, r5);
// println!("{}", r3); // 如果加了这句,上面r4、r5就不允许不可变引用了,因为r3被可变引用中,作用域还没结束
} // 老编译器中,r1、r2、r3、r4、r5作用域在这里结束
在老版本的编译器中(Rust 1.31 前),将会报错,因为 r1
和 r2
的作用域在花括号 }
处结束,那么 r3
的借用就会触发 无法同时借用可变和不可变 的规则。
但是在新的编译器中,该代码将顺利通过,因为 引用作用域的结束位置从花括号变成最后一次使用的位置,因此 r1
借用和 r2
借用在 println!
后,就结束了,此时 r3
可以顺利借用到可变引用。
NLL(Non-Lexical Lifetimes)(非词法作用域生命周期)
对于这种编译器优化行为,Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(}
)结束前就不再被使用的代码位置。
非词法作用域生命周期(Non-Lexical Lifetimes, NLL)是Rust编程语言中的一项重要特性,它极大地改善了编译器对变量生命周期的管理方式。NLL允许Rust编译器更准确地推断变量(特别是引用)的生命周期,使得代码不仅更安全,同时也更加灵活和易于使用。以下是NLL带来的几个主要好处:
增强编程灵活性:
- 在NLL之前,引用的作用域被限定在整个代码块(如函数体或循环体)中,即使引用在块的早期就不再被使用。NLL允许编译器根据引用的实际使用情况来缩短其生命周期,这意味着开发者可以在同一个代码块中更早地重新借用变量,从而增加代码的灵活性。
简化代码:
- NLL使得一些以前需要手动重构或引入额外作用域来满足借用规则的代码变得更加简单直接。例如,不再需要创建不必要的代码块来人为限制引用的作用域,这使得代码更清晰、更直观。
减少编译错误:
- 通过更智能地管理生命周期,NLL减少了因违反借用规则而引起的编译错误。这对于新手尤其有帮助,因为它降低了学习Rust借用机制的难度。
提高代码安全性:
- 尽管NLL使得引用的生命周期管理更加灵活,但它依然保持了Rust强大的内存安全保证。通过精确跟踪引用的实际使用,NLL帮助防止悬垂引用和数据竞争等问题。
更好的错误信息:
- NLL的引入还改进了编译器的错误信息。由于编译器能更准确地理解引用何时不再被使用,因此它可以提供更具体、更有帮助的错误信息,指导开发者如何修改代码以满足安全规则。
支持更复杂的控制流:
- 在处理复杂的控制流(如多个分支、循环和早期返回)时,NLL允许更多的编程模式而不违反借用规则。这对于开发复杂逻辑和功能而言是非常重要的。
总之,NLL是Rust持续演进中的一部分,它不仅使Rust的内存管理模型更强大,也使得语言更易于学习和使用,同时保持了其无与伦比的内存安全特性。
补充:不能直接把一个 &mut 引用传递到多个线程中
在Rust中,不能直接把一个 &mut
引用传递到多个线程中。这是因为Rust的借用检查器(borrow checker)确保内存安全和数据竞争的防护。让我们来详细解释一下:
-
唯一性和可变借用(Exclusive and Mutable Borrowing):
Rust中的可变借用规则要求对给定资源的可变引用(&mut
)在任意时刻只能有一个。可变借用提供了对资源的独占访问权,这意味着没有其他的借用者可以读取或修改这个资源。 -
跨线程共享(Sharing Across Threads):
当涉及到跨线程共享数据时,Rust非常谨慎。如果试图将&mut
引用传递到另一个线程,Rust的借用检查器将会报错,因为这破坏了可变借用的唯一性规则。 -
解决方案(Solutions for Concurrency):
如果需要在多个线程之间共享可变数据,可以使用一些线程安全的包装器,例如:Mutex<T>
:互斥锁提供了对内部数据的独占访问权。任何线程在访问数据之前都必须先锁定互斥锁,这确保了同一时间只有一个线程可以访问数据。RwLock<T>
:读写锁允许多个线程同时读取数据,或者独占写入。这对于读多写少的场景很有用。Arc<T>
:原子引用计数(Atomic Reference Counting)可以与互斥锁或读写锁一起使用,来在多个线程之间共享和管理资源的所有权。
-
例子:
use std::sync::{ Arc, Mutex}; use std::thread; fn main() { let data = Arc::new(Mutex::new(0)); // 包装数据以供多线程安全访问 let mut handles = vec![]; for _ in 0..10 { let data = Arc::clone(&data); let handle = thread::spawn(move || { let mut data = data.lock().unwrap(); // 锁定数据以进行修改 *data += 1; }); handles.push(handle); } for handle in handles { handle.join().unwrap(); } println!("Result: {}", *data.lock().unwrap()); }
在这个例子中,Mutex
确保了当数据被修改时的线程安全。通过使用 Arc
和 Mutex
,可以安全地在多个线程间共享和修改数据。
悬垂引用(Dangling References)(Rust编译器可以确保引用永远不会变成悬垂状态)
悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。
让我们尝试创建一个悬垂引用,Rust 会抛出一个编译时错误:
// 测试代码
fn main() {
let _reference_to_nothing: &String = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
这里是错误:
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 {
| ~~~~~~~~
错误信息引用了一个我们还未介绍的功能:生命周期(lifetimes)。不过,即使你不理解生命周期,也可以通过错误信息知道这段代码错误的关键信息:
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 返回一个字符串的引用
let s = String::from("hello"); // s 是一个新字符串
&s // 返回字符串 s 的引用
} // 这里 s 离开作用域并被丢弃。其内存被释放。
// 危险!
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放,但是此时我们又尝试去返回它的引用。这意味着这个引用会指向一个无效的 String
,这可不对!
其中一个很好的解决方法是直接返回 String
:
// 测试代码
fn main() {
let s: String = dangle();
println!("s: {}", s);
}
fn dangle() -> String {
let s = String::from("hello");
s // 转移所有权
}
上述代码中的函数
dangle()
相当于转移了所有权。在这个例子中,
dangle()
函数创建了一个String
类型的变量s
,并将其初始化为字符串"hello"
。在函数的最后,通过返回s
,这个String
的所有权被转移到了调用它的地方。这意味着,当dangle()
的返回值被赋给main
函数中的变量s
时,String
的所有权从dangle()
转移到了main
的局部变量s
。这种所有权的转移是安全的,且是 Rust 所鼓励的使用模式,因为它避免了内存复制并确保了数据的唯一所有者,这有助于防止内存泄漏和其他安全问题。这个模式也显示了 Rust 的所有权系统的工作方式,它确保了一段数据在任何时候只有一个所有者,从而管理内存的生命周期并保证内存安全。
这样就没有任何错误了,最终 String
的 所有权被转移给外面的调用者。
借用规则总结
总的来说,借用规则如下: