Rust 中的引用和借用概念与指针的使用有一些相似之处。
在 Rust 中,引用 是一种允许访问数据但不拥有其所有权的方式。通过引用,你可以 借用 其他变量的值而不会转移其所有权。
**引用(reference)**像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值
我们将创建一个引用的行为称为 借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它
在 Rust 中,引用分为两种:**可变引用(mutable references)**和 不可变引用(immutable references)。可变引用允许对数据进行修改,而不可变引用只能读取数据,不能修改。这种限制使得 Rust 在编译时可以进行更严格的内存安全检查。
下面是不可变引用的示例:
fn main() {
let s1 = String::from("hello");
// 这里 `&s1` 创建了不可变引用
let len = calculate_length(&s1);
println!("The length of '{}' is {}.", s1, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}
如果尝试修改借用的变量,则会直接报错:
fn main() {
let s = String::from("hello");
change(&s);
}
fn change(some_string: &String) {
some_string.push_str(", world");
}
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
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`
8 | some_string.push_str(", world");
| ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ `some_string` is a `&` reference, so the data it refers to cannot be borrowed as mutable
For more information about this error, try `rustc --explain E0596`.
error: could not compile `ownership` due to previous error
如果需要修改一个借用的值,正确的做法是改为可变引用:
fn main() {
// 这里需要改用 `mut`
let mut s = String::from("hello");
// 创建可变引用
change(&mut s);
}
fn change(some_string: &mut String) {
// 函数签名也需要改为接收可变引用 `some_string: &mut String`
// 这就非常清楚地表明,`change` 函数将改变它所借用的值
some_string.push_str(", world");
}
与传统的指针相比,Rust 的引用有一些附加的安全性保证。其中最重要的是,Rust 在编译时检查引用的有效性,以防止 **悬垂指针(dangling pointers)**和 **数据竞争(data races)**等内存安全问题。
Rust 如何解决 data race 问题
可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s
的可变引用的代码会失败:
let mut s = String::from("hello");
let r1 = &mut s;
let r2 = &mut s;
println!("{}, {}", r1, r2);
错误如下:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/ownership)
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
For more information about this error, try `rustc --explain E0499`.
error: could not compile `ownership` due to previous error
这个报错说这段代码是无效的,因为我们不能在同一时间多次将 s
作为可变变量借用。第一个可变的借入在 r1
中,并且必须持续到在 println!
中使用它,但是在那个可变引用的创建和它的使用之间,我们又尝试在 r2
中创建另一个可变引用,该引用借用与 r1
相同的数据。
这一限制以一种非常小心谨慎的方式允许可变性,防止同一时间对同一数据存在多个可变引用。新 Rustacean 们经常难以适应这一点,因为大部分语言中变量任何时候都是可变的。这个限制的好处是 Rust 可以在编译时就避免数据竞争。**数据竞争(data race)**类似于竞态条件,它可由这三个行为造成:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!
这里补充一下,虽然 Go 语言没有类似内存安全指针,但是 Go 语言提供了内置的 data race 检测,在运行命令的时候加上 -race
参数就行:
$ go test -race mypkg // to test the package
$ go run -race mysrc.go // to run the source file
$ go build -race mycmd // to build the command
$ go install -race mypkg // to install the package
Rust 如何解决指针悬挂问题
在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。
让我们尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:
fn main() {
let reference_to_nothing = dangle();
}
fn dangle() -> &String {
let s = String::from("hello");
&s
}
这里是错误:
$ cargo run
Compiling ownership v0.1.0 (file:///projects/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` due to previous error
因为 s
是在 dangle
函数内创建的,当 dangle
的代码执行完毕后,s
将被释放。不过我们尝试返回它的引用。这意味着这个引用会指向一个无效的 String
,这可不对!Rust 不会允许我们这么做。
Rust 不允许函数返回对局部变量的引用。一种解法是直接返回被引用对象的所有权:
fn no_dangle() -> String {
let s = String::from("hello");
s
}
另一种解法是使用 Box
来在堆上分配内存并返回一个指针:
fn dangle() -> Box {
let s = String::from("hello");
Box::new(s)
}
注意,这里的 Box 是一种智能指针,它允许将数据分配在堆上,并在函数返回后继续保持有效。
这里补充一下,Go 语言允许在函数中返回局部变量引用,这种情况下,函数虽然退出了,但是因为指针的存在,对象的内存不能随着函数结束而回收(避免指针悬挂),因此只能分配在堆上。
type Demo struct {
name string
}
func createDemo(name string) *Demo {
d := new(Demo) // 局部变量 d 逃逸到堆
d.name = name
return d
}
func main() {
demo := createDemo("demo")
fmt.Println(demo)}
}
Go 是通过在编译器里做逃逸分析(escape analysis)来决定一个对象放栈上还是放堆上,不逃逸的对象放栈上,可能逃逸的放堆上。对于 Go 来说,我们可以通过下面指令来看变量是否逃逸:
$ go run -gcflags '-m -l' main.go
-m 会打印出逃逸分析的优化策略,实际上最多总共可以用 4 个 -m,但是信息量较大,一般用 1 个就可以了。 -l 会禁用函数内联,在这里禁用掉内联能更好的观察逃逸情况,减少干扰
总结
最后谈谈个人想法,在 JS 或者 Java 里面,一个函数返回一个对象(引用类型)是很正常的操作,但实际上在 Go 或者 Rust 里面会有大问题。因为 JS 不允许开发者操作指针,这样运行时就可以做一些简单的设定,比如基本类型都用值传递,引用类型都用指针传递;基本类型默认分配在栈内存,引用类型默认分配在堆内存。但是在 Go 语言中,即使基本类型也可以操作指针,因此这种策略行不通了。在 Go 语言中,任何类型(包括引用类型)都会 倾向于 分配在栈内存,因为栈的内存是由编译器自动进行分配和释放的,非常高效,它们随着函数的创建而分配,随着函数的退出而销毁;而对于堆上的内存回收,得通过Go的三色标记法,进行垃圾回收,并且有时还需要加锁来防止多线程冲突。这里会有个问题,比如一个函数返回了一个局部变量的指针,为了防止出现指针悬挂问题,变量不能在函数退出的时候回收,因此编译器的策略是将该变量逃逸到堆内存上(在 Rust 中需要开发者手动用 Box 指针)。
总结一下,在 Rust 中,内存安全是一种编译时的保证,它可以防止常见的内存错误,如悬垂指针、空指针解引用和数据竞争。需要注意的是,实际上 Go 语言也提供了相应的解决方案。个人觉得还是 Go 语言更好,至少不用一上手就了解一大堆概念,比如并发同步原语、内存模型、逃逸分析这些,可以一边用一边学。但是在 Rust 里面就不行,如果这些不了解就没法开发了。
参考:
doc.rust-lang.org/book/ch04-0…
kaisery.github.io/trpl-zh-cn/…