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

Rust
320
0
0
2023-06-11
目录
  • 楔子
  • 什么是引用
  • 可变引用
  • 悬空引用
  • 小结

楔子

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

// 该函数计算一个字符串的长度
fn get_length(s: String-> (Stringusize) {
    // 因为这里的 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 = 
    */
}

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

什么是引用

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

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

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

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

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

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

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

这里的 &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 s = String::from("hello");
    change_string(&s);
}

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

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

可变引用

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

尽管这些编译错误会让人不时地感到沮丧,但是请牢记一点: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 函数中被转移出去了,自然也就不会涉及释放操作了。

小结

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

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

引用总是有效的;