深入了解Rust中引用与借用的用法

 更新时间:2022年11月03日 12:28:27   作者:古明地觉  
这篇文章主要为大家详细介绍了Rust语言中引用与借用的使用,文中的示例代码讲解详细,具有一定的借鉴价值,需要的小伙伴可以了解一下

楔子

好久没更新 Rust 了,上一篇文章中我们介绍了 Rust 的所有权,并且最后定义了一个 get_length 函数,但调用时会导致 String 移动到函数体内部,而我们又希望在调用完毕后能继续使用该 String,所以不得不使用元组将 String 也作为元素一块返回。

// 该函数计算一个字符串的长度
fn get_length(s: String) -> (String, usize) {
    // 因为这里的 s 会获取变量的所有权
    // 而一旦获取,那么调用方就不能再使用了
    // 所以我们除了要返回计算的长度之外
    // 还要返回这个字符串本身,也就是将所有权再交回去
    let length = s.len();
    (s, length)
}


fn main() {
    let s = String::from("古明地觉");

    // 接收长度的同时,还要接收字符串本身
    // 将所有权重新 "夺" 回来
    let (s, length) = get_length(s);
    println!("s = {}, length = {}", s, length); 
    /*
    s = 古明地觉, length = 12
    */
}

但这种写法很笨拙,下面我们将 get_length 函数重新定义,并学习 Rust 的引用。

什么是引用

新的函数签名使用了 String 的引用作为参数,而没有直接转移所有权。

fn get_length(s: &String) -> usize {
    s.len()
}

fn main() {
    let s1 = String::from("hello");
    let length = get_length(&s1);
    println!("s1 = {}, length = {}", s1, length); 
    // s1 = hello, length = 5
}

首先需要注意的是,变量声明以及函数返回值中的那些元组代码都消失了。其次在调用 get_length 函数时使用了 &s1 作为参数,并且在函数的定义中,我们使用 &String 替代了 String。而 & 代表的就是引用语义,它允许我们在不获取所有权的前提下使用值。

既然有引用,那么自然就有解引用,它使用 * 作为运算符,含义和引用相反,我们会在后续详细地介绍。

现在,让我们仔细观察一下这个函数的调用过程:

let s1 = String::from("hello");
let length = get_length(&s1);

这里的 &s1 允许我们在不转移所有权的前提下,创建一个指向 s1 值的引用,由于引用不持有值的所有权,所以当引用离开当前作用域时,它指向的值也不会被丢弃。同理,函数签名中的 & 用来表明参数 s 的类型是一个引用。

           // s 是一个指向 String 的引用
fn get_length(s: &String) -> usize { 
    s.len()
}  // 到这里 s 离开作用域
   // 但由于它并不持有自己指向值的所有权
   // 所以最终不会发生任何事情

此处变量 s 的有效作用域与其它任何函数参数一样,但唯一不同的是,它不会在离开自己的作用域时销毁其指向的数据,因为它并不拥有该数据的所有权。当一个函数使用引用而不是值本身作为参数时,我们便不需要为了归还所有权而特意去将值返回,毕竟在这种情况下,我们根本没有取得所有权。

而将引用传递给函数参数的这一过程被称为借用(borrowing),在现实生活中,假如一个人拥有某件东西,你可以从他那里把东西借过来。但是当你使用完毕时,还必须将东西还回去。

Rust 的变量也是如此,如果一个值属于该变量,那么该变量离开作用域时会销毁对应的值,就好比东西你不想要了,你可以将它扔掉,因为东西是你的。但如果是借用的话,变量在离开作用域时,这个值并不会被销毁,就好比东西你不想要了,但这个东西并不属于你,因此你要将它还回去,并且这个东西还在。

至于后续这个东西是否会被扔掉、何时被扔掉,就看它真正的主人是否还需要它,如果不需要了,东西的主人是有权利销毁的,因为这东西是他的。当然,他也可以将东西送给别人,此时就相当于发生了所有权的转移,转移之后这东西跟他也没关系了。

然后问题来了,如果我们尝试修改借用的值会怎么样呢?相信你能猜到,肯定是不允许的,还是拿借东西举例子,东西既然是借的,就说明你只有使用权,而没有修改它的权利。

fn change_string(s: &String) {
    s.push_str(" world");
}

fn main() {
    let s1 = String::from("hello");
    change_string(&s1);
}

执行这段代码会出现编译错误:

与变量类似,引用是默认不可变的,Rust 不允许我们去修改引用指向的值。

可变引用

我们可以通过一个小小的调整来修复上面的示例中出现的编译错误:

fn change_string(s: &mut String) {
    s.push_str(" world");
}

fn main() {
    let mut s1 = String::from("hello");
    change_string(&mut s1);
}

首先我们需要将变量 s1 声明为 mut,即可变的,也就是东西的主人能够允许它的东西发生变化。其次,要使用 &mut s1 来给函数传入一个可变引用,意思就是东西的主人在将东西借给别人时专门强调了,自己的东西允许修改,不然别人不知道啊。

所以这里如果不传递可变引用的话,即使 s1 是可变的,函数 change_string 里面也不能对值进行修改。因此调用函数的时候要传递可变引用,当然函数参数接收的也要是一个可变引用,因为类型要匹配。

另外,除了将引用作为参数传递之外,还可以赋值给一个变量,因为作为函数参数和赋值给一个变量是等价的。

fn main() {
    let mut s1 = String::from("hello");
    // 可变引用指的是,引用指向的值可以修改
    // 所以要注意这里的写法,不要写成了 let mut s2: &String
    // 这表示 s2 是个不可变引用,但 s2 本身是可变的
    // 可变引用是一个整体,所以 &mut String 要整体作为 s2 的类型
    let s2: &mut String = &mut s1;
    // 当然啦,此时 s2 引用的值可变,但 s2 本身不可变
    // 如果希望 s2 还能接收其它字符串的可变引用,那么应该这么声明
    // let mut s2: &mut String = &mut s1;
    // 此时表示 s2 是个可变引用,它引用的值可以修改
    // 并且 s2 本身也是可变的。或者还有更简单的写法:
    // 直接写成 let mut s2 = &mut s1 也行,因为 Rust 会做类型推断
   
    s2.push_str(" world");
    println!("{}", s1);  // hello world
}

此外要注意:当变量声明为不可变时,只能创建不可变引用。

fn main() {
    let s1 = String::from("hello");
    let s2: &mut String = &mut s1;
    println!("{}", s2); 
}

代码中的 s1 不可变,但却创建了可变引用,于是报错。

因为 s1 是不可变的,就意味着数据(包括栈内存、堆内存)不可以修改,所以此时不能创建可变引用,否则就意味着值是可以修改的,于是就矛盾了。因此当变量声明为不可变时,不可以将可变引用赋值给其它变量。

但当变量声明为可变时,既可以创建可变引用,也可以创建不可变引用。如果是可变引用,那么允许通过引用修改值;如果是不可变引用,那么不允许通过引用修改值。

fn main() {
    // 变量可变
    let mut s1 = String::from("hello");
    // 可以通过 &s1 创建不可变引用
    // 也可以通过 &mut s1 创建可变引用
    // 但前者不可以修改值,后者可以
}

另外可变引用有一个很大的限制:对于特定作用域中的特定数据来说,一次只能声明一个可变引用,否则会导致编译错误。

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &mut s1;
    let s3 = &mut s1;
    s2.push_str("xx");
    s3.push_str("yy");
    println!("{}", s1);
}

我们将 s1 的可变引用给了 s2 之后又给了 s3,而这是非法的。

但 Rust 做了一个 "容忍" 操作,那就是声明多个引用之后,如果都不使用的话,那么也不会出现错误。

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &mut s1;
    let s3 = &mut s1;
    println!("{}", s1);  // hello
}

以上这段代码可以顺利执行,虽然声明了多个可变引用,但我们没有使用,所以 Rust 编译器就大发慈悲 "饶" 了我们。但只要对任意某个引用执行了任意某个操作,那么 Rust 就不会再手下留情了,比如:

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &mut s1;
    let s3 = &mut s1;
    println!("{}", s2); 
}

我们上面对 s2 执行了打印操作,于是 Rust 就会提示我们可变引用只能被借用一次。

但说实话 Rust 编译器做的这个 "忍让" 对于我们而言没有太大意义,因为它要求我们声明多个可变引用之后不能使用其中的任何一个,但问题是声明引用就是为了使用它,不然声明它干嘛。因此我们仍可以认为:对于特定作用域中的特定数据来说,一次只能声明一个可变引用,否则会导致编译错误。

这个规则使得引用的可变性只能以一种受到严格限制的方式来使用,许多刚刚接触 Rust 的开发者会反复地与它进行斗争,因为大部分的语言都允许你随意修改变量。但另一方面,在 Rust 中遵循这条限制性规则可以帮助我们在编译时避免数据竞争。数据竞争(data race)与竞态条件十分类似,它会在指令同时满足以下 3 种情形时发生:

  • 两个或两个以上的指针同时访问同一空间;
  • 其中至少有一个指针会向空间中写入数据;
  • 没有同步数据访问的机制;

数据竞争会导致未定义的行为,由于这些未定义的行为往往难以在运行时进行跟踪,也就使得出现的 bug 更加难以被诊断和修复。Rust 则完美地避免了这种情形的出现,因为存在数据竞争的代码连编译检查都无法通过⚠️。

与大部分语言类似,我们可以通过花括号来创建一个新的作用域范围,这就使我们可以创建多个可变引用,当然,同一时刻只允许有一个可变引用。

fn main() {
    let mut s1 = String::from("hello");
    {
        let s2 = &mut s1;
        s2.push_str(" cruel");
        println!("s2 = {}", s2);
        println!("s1 = {}", s1);
    }
    // 这个 s3 不能声明在上面的大括号之前,也就是不能先声明 s3
    // 因为先声明 s3 的话,那么声明 s2 的时候就会出现两个可变引用
    // 违反了同一时刻只能有一个可变引用的原则
    // 但是将 s3 声明在这里就没有问题,因为声明 s2 的时候 s3 还不存在
    // 声明 s3 的时候 s2 已经失效了
    // 所以此时满足同一时刻只能有一个可变引用的原则,我生君未生、君生我已死
    let s3 = &mut s1;
    s3.push_str(" world");
    println!("s3 = {}", s3);  
    println!("s1 = {}", s1);  
    /*
    s2 = hello cruel
    s1 = hello cruel
    s3 = hello cruel world
    s1 = hello cruel world
     */
}

注意:我们一直说的"一个可变引用"、"多个可变引用",它们针对的都是同一变量;如果是多个彼此无关的变量,那么它们的可变引用之间也没有关系,此时是可以共存的。比如同一时刻有 N 个可变引用,但它们引用的都是不同的变量,所以此时没有问题。

我们一直说的不允许存在多个可变引用,指的是同一变量的多个可变引用,这一点要分清楚。

如果是编程老手的话,那么应该会想到,如果同时存在可变引用和不可变引用会发生什么呢?我们试一下就知道了。

fn main() {
    let mut s1 = String::from("hello");
    let s2 = &s1;
    let s3 = &mut s1;
    println!("{}", s2);
    println!("{}", s3)
}

所以在结合使用可变引用与不可变引用时,还有一条类似的限制规则,我们不能在拥有不可变引用的同时创建可变引用,否则不可变引用就没有意义了。但同时存在多个不可变引用是合理合法的,数据的读操作之间不会彼此影响。

就有点类似于读锁和写锁的关系。

尽管这些编译错误会让人不时地感到沮丧,但是请牢记一点:Rust 编译器可以为我们提早(在编译时而不是运行时)暴露那些潜在的bug,并且明确指出出现问题的地方。你不再需要去追踪调试为何数据会在运行时发生了非预期的变化。

悬空引用

使用拥有指针概念的语言会非常容易错误地创建出悬空指针,这类指针指向曾经存在的某处内存,但现在该内存已经被释放掉、或者被重新分配另作他用了。而在 Rust 语言中,编译器会确保引用永远不会进入这种悬空状态,假如我们当前持有某个数据的引用,那么编译器可以保证这个数据不会在引用被销毁前离开自己的作用域。

让我们试着来创建一个悬空引用,并看一看 Rust 是如何在编译期发现这个错误的:

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

fn main() {
    
}

出现的错误如下所示:

这段错误的提示信息包含了一个我们还没有接触的概念:生命周期,我们会后续详细讨论它。但即使不考虑生命周期,甚至不看错误提示,我们也知道原因。dangle 里面的字符串 s 在函数结束后就会失效,内存会回收,但我们却返回了它的引用。

此处和 C 就出现了不同,C 中的堆内存如果我们不手动释放,那么它是不会自己释放的。而 Rust 中的堆内存会在变量离开作用域的时候自动回收,既然回收了,那么再返回它的引用就不对了,因为指向的内存是无效的。所以我们也能猜到生命周期是做什么的,后续聊。

而这个问题的解决办法也很简单,直接返回 String 就好。

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

这种写法没有任何问题,因为所有权从 dangle 函数中被转移出去了,自然也就不会涉及释放操作了。

小结

让我们简要地概括一下对引用的讨论:

在任何一段给定的时间里,要么只能拥有一个可变引用,要么只能拥有任意数量的不可变引用;

引用总是有效的;

到此这篇关于深入了解Rust中引用与借用的用法的文章就介绍到这了,更多相关Rust引用 借用内容请搜索脚本之家以前的文章或继续浏览下面的相关文章希望大家以后多多支持脚本之家!

相关文章

  • rust实现post小程序(完整代码)

    rust实现post小程序(完整代码)

    这篇文章主要介绍了rust实现一个post小程序,本文通过示例代码给大家介绍的非常详细,感兴趣的朋友跟随小编一起看看吧
    2024-04-04
  • rust 包模块组织结构详解

    rust 包模块组织结构详解

    RUST提供了一系列的功能来帮助我们管理代码,包括决定哪些细节是暴露的、哪些细节是私有的,以及不同的作用域的命名管理,这篇文章主要介绍了rust 包模块组织结构的相关知识,需要的朋友可以参考下
    2023-12-12
  • rust闭包的使用

    rust闭包的使用

    闭包在Rust中是非常强大的功能,允许你编写更灵活和表达性的代码,本文主要介绍了rust闭包的使用,具有一定的参考价值,感兴趣的可以了解一下
    2023-12-12
  • Rust动态数组Vec基本概念及用法

    Rust动态数组Vec基本概念及用法

    Rust中的Vec是一种动态数组,它可以在运行时自动调整大小,本文主要介绍了Rust动态数组Vec基本概念及用法,具有一定的参考价值,感兴趣的可以了解一下
    2023-12-12
  • rust类型转换的实现

    rust类型转换的实现

    Rust是类型安全的语言,因此在Rust中做类型转换不是一件简单的事,本文主要介绍了rust类型转换的实现,具有一定的参考价值,感兴趣的可以了解一下
    2023-12-12
  • Rust 函数详解

    Rust 函数详解

    函数在 Rust 语言中是普遍存在的。Rust 支持多种编程范式,但更偏向于函数式,函数在 Rust 中是“一等公民”,函数可以作为数据在程序中进行传递,对Rust 函数相关知识感兴趣的朋友一起看看吧
    2021-11-11
  • Rust指南枚举类与模式匹配详解

    Rust指南枚举类与模式匹配详解

    这篇文章主要介绍了Rust指南枚举类与模式匹配精讲,枚举允许我们列举所有可能的值来定义一个类型,枚举中的值也叫变体,今天通过一个例子给大家详细讲解,需要的朋友可以参考下
    2022-09-09
  • Rust你不认识的所有权

    Rust你不认识的所有权

    所有权对大多数开发者而言是一个新颖的概念,它是 Rust 语言为高效使用内存而设计的语法机制。所有权概念是为了让 Rust 在编译阶段更有效地分析内存资源的有用性以实现内存管理而诞生的概念
    2023-01-01
  • Rust中的Cargo构建、运行、调试

    Rust中的Cargo构建、运行、调试

    Cargo是rustup安装后自带的,Cargo 是 Rust 的构建系统和包管理器,这篇文章主要介绍了Rust之Cargo构建、运行、调试,需要的朋友可以参考下
    2022-09-09
  • 在Rust中编写自定义Error的详细代码

    在Rust中编写自定义Error的详细代码

    Result<T, E> 类型可以方便地用于错误传导,Result<T, E>是模板类型,实例化后可以是各种类型,但 Rust 要求传导的 Result 中的 E 是相同类型的,所以我们需要编写自己的 Error 类型,本文给大家介绍了在Rust中编写自定义Error的详细代码,需要的朋友可以参考下
    2024-01-01

最新评论