理解 Rust 中的引用和借用

2023年 10月 16日 50.6k 0

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/…

相关文章

JavaScript2024新功能:Object.groupBy、正则表达式v标志
PHP trim 函数对多字节字符的使用和限制
新函数 json_validate() 、randomizer 类扩展…20 个PHP 8.3 新特性全面解析
使用HTMX为WordPress增效:如何在不使用复杂框架的情况下增强平台功能
为React 19做准备:WordPress 6.6用户指南
如何删除WordPress中的所有评论

发布评论