深入了解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引用 借用内容请搜索我们以前的文章或继续浏览下面的相关文章希望大家以后多多支持我们!

(0)

相关推荐

  • 深入了解Rust中泛型的使用

    目录 楔子 函数中的泛型 结构体中的泛型 枚举中的泛型 方法中的泛型 楔子 所有的编程语言都致力于将重复的任务简单化,并为此提供各种各样的工具.在 Rust 中,泛型(generics)就是这样一种工具,它是具体类型或其它属性的抽象替代.在编写代码时,我们可以直接描述泛型的行为,以及与其它泛型产生的联系,而无须知晓它在编译和运行代码时采用的具体类型. 总结一下泛型就是,提高代码的复用能力,处理重复代码.泛型是具体类型或者其它属性的抽象代替,编写的泛型代码不是最终的代码,而是一些模板,里面有一些占

  • 深入了解Rust中函数与闭包的使用

    目录 闭包 高阶函数 发散函数 闭包 Rust 的闭包由一个匿名函数加上外层的作用域组成,举个例子: fn main() {     let closure = |n: u32| -> u32 {         n * 2     };     println!("n * 2 = {}", closure(12));     // n * 2 = 24 } 闭包可以被保存在一个变量中,然后我们注意一下它的语法,参数定义.返回值定义都和普通函数一样,但闭包使用的是两个竖线.我们对

  • 深入了解Rust的切片使用

    目录 为什么要有切片 字符串切片 其它类型的切片 为什么要有切片 除了引用,Rust 还有另外一种不持有所有权的数据类型:切片(slice),切片允许我们引用集合中某一段连续的元素序列,而不是整个集合. 考虑这样一个小问题:编写一个搜索函数,它接收字符串作为参数,并将字符串中的首个单词作为结果返回.如果字符串中不存在空格,那么就意味着整个字符串是一个单词,直接返回整个字符串作为结果即可. 让我们来看一下这个函数的签名应该如何设计: fn first_word(s: &String) -> ?

  • Rust指南之泛型与特性详解

    目录 前言 1.泛型 1.1.在函数中定义泛型 1.2.结构体中的泛型 1.3.枚举类中的泛型 1.4.方法中的泛型 2.特性 2.1.默认特性 2.2.特性做参数 2.3.特性做返回值 前言 在上篇Rust 文章中涉及到了泛型的知识,那么今天就来详细介绍一下Rust 中的泛型与特性.泛型是一个编程语言不可或缺的机制,例如在C++ 语言中用模板来实现泛型.泛型机制是编程语言用于表达类型抽象的机制,一般用于功能确定.数据类型待定的类,如链表.映射表等. 1.泛型 泛型是具体类型或其他属性的抽象代替

  • 深入了解Rust 结构体的使用

    目录 楔子 定义并实例化结构体 简化版的实例化方式 基于已有结构体实例创建 元组结构体 没有字段的空结构体 结构体数据的所有权 使用结构体的示例程序 楔子 结构体是一种自定义的数据类型,它允许我们将多个不同的类型组合成一个整体.下面我们就来学习如何定义和使用结构体,并对比元组与结构体之间的异同.后续我们还会讨论如何定义方法和关联函数,它们可以指定那些与结构体数据相关的行为. 定义并实例化结构体 结构体与我们之前讨论过的元组有些相似,和元组一样,结构体中的数据可以拥有不同的类型.而和元组不一样的是

  • rust引用和借用的使用小结

    目录 引用和借用 引用的使用 可变引用与不可变引用 NLL 总结 引用和借用 如果每次都发生所有权的转移,程序的编写就会变得异常复杂.因此rust和其它编程语言类似,提供了引用的方式来操作.获取变量的引用,称为借用.类似于你借别人的东西来使用,但是这个东西的所有者不是你.引用不会发生所有权的转移. 引用的使用 在rust中,引用的语法非常简单.通过&来取引用,通过*来解引用.例如: fn main() { let s1: String = "Hello".to_string()

  • 解析rust中的struct

    目录 定义struct 实例化struct 取得struct里面的某个值 struct作为函数的放回值 字段初始化简写 struct更新语法 tuple struct Unit-Like Struct(没有任何字段) struct数据的所有权 什么事struct struct的方法 定义方法 ​​​​​​​​​​​​​​方法调用的运算符 关联函数 多个impl块 定义struct 使用struct关键字,并为整个struct命名 在花括号内,为所有字段(field)定义名称和类型 struct

  • 详解Rust中的方法

    目录 Rust中的方法 方法的简单概念 定义方法 Rust自动引用和解引用 带参数的方法 小结 Rust中的方法 方法其实就是结构体的成员函数,在C语言中的结构体是没有成员函数的,但是Rust毕竟也是一门面向对象的编程语言,所以给结构体加上方法的特性很符合面向对象的特点. 方法的简单概念 方法(method)与函数类似:它们使用 fn 关键字和名称声明,可以拥有参数和返回值,同时包含在某处调用该方法时会执行的代码.不过方法与函数是不同的,因为它们在结构体的上下文中被定义,并且它们第一个参数总是

  • 详解Rust中的workspace

    目录 一.目录结构 二.子crata中的Cargo.toml声明 三.代码引用 java项目中用maven管理代码时,如果遇到大型工程,一般会拆分成不同的模块,比如spring-mvc中,通常会按model, view, controller建3个模块,然后根据一定的依赖关系进行引用.这个概念在Rust中是通用的,只不过maven换成了cargo,而模块变成了crate,看下面的例子. 一.目录结构 .├── Cargo.toml├── controller│   ├── Cargo.toml│

  • Rust中的Struct使用示例详解

    Structs是RUST中比较常见的自定义类型之一,又可以分为StructStruct,TupleStruct,UnitStruct三个类型,结合泛型.Trait限定.属性.可见性可以衍生出很丰富的类型. 结构体 1.定义 pub struct User { user_id : u32, user_name: String, is_vip : bool, } 2.实例化这里初始化必须全部给所有的成员赋值,不像C++,可以单独初始化某个值 let user : User = User { user

  • 深入了解Rust中trait的使用

    目录 楔子 什么是 trait trait 作为参数 trait 作为返回值 实现一个 max 函数 楔子 前面我们提到过 trait,那么 trait 是啥呢?先来看个例子: #[derive(Debug)] struct Point<T> {     x: T, } impl<T> Point<T> {     fn m(&self) {         let var = self.x;     } } fn main() {     let p = Po

  • C++ 中引用与指针的区别实例详解

    C++ 中引用与指针的区别实例详解 引用是从C++才引入的,在C中不存在.为了搞清楚引用的概念,得先搞明白变量的定义及引用与变量的区别,变量的要素一共有两个:名称与空间. 引用不是变量,它仅仅是变量的别名,没有自己独立的空间,它只符合变量的"名称"这个要素,而"空间"这个要素并不满足.换句话说,引用需要与它所引用的变量共享同一个内存空间,对引用所做的改变实际上是对所引用的变量做出修改.并且引用在定义的时候就必须被初始化.     参数传递的类型及相关要点: 1 按值

  • 在Web用户控件中引用样式表中样式的方法

    如何在Web用户控件中引用样式表中的样式 复制代码 代码如下: <%@ Control Language="C#" AutoEventWireup="true" CodeFile="gl1.ascx.cs" Inherits="admin_gl1" EnableTheming="True" %> < link href="../App_Themes/qiantai.css&quo

随机推荐