2. 我在外包学 rust——引用与借用

337 阅读10分钟

书接上回1. 我在外包学rust——所有权

话说上一章节主要介绍了 rust 的所有权这一概念。

  • 我们知道所有权是 rust 用来进行内存管理的,且主要是针对存储在堆(heap)上的数据进行的。
  • 每个值有且只有一个变量作为它的所有者,当变量超出作用域(scope)时,rust 会调用 drop 函数,回收值所占用的堆内存空间。
  • 将变量赋值给新的变量时,会发生所有权转移(move),所谓所有权转移,其实本质上发生了两件事,一个是浅拷贝,一个是旧的变量失效。
  • 对于标量类型,rust 对其实现了 copy trait,将变量赋值给新的变量时,会对值进行拷贝。

以上差不多就是上一章的内容了。

接下来继续所有权的话题,首先来看看所有权在调用函数时会发生生么。

所有权与函数

函数传参

语义上,将值传递给函数和把值赋给变量是类似的。即将值传递给函数也会发生移动(move)或复制(copy)。

fn main(){
    let s = String::from("hello");
    print_string(s);
    println!("{}",s); // 报错
}
fn print_string(some_string:String){
    println!("{}",some_string);
}

上面的代码中,s 传递到print_string()函数中,发生了所有权转移,print_string()函数执行结束,s 离开函数作用域,被 drop。第 4 行再打印 s 就会报错。

fn main(){
    let n = 11;
    print_string( n);
    println!("{}",n); // 11
}
fn print_string(some_num:i32){
    println!("{}",some_num);
}

而对于i32这种标量类型,由于它是存储在栈上的,实现了 copy trait,因此不会报错。

返回值与作用域

函数在返回值的过程中同样也会发生所有权转移。

fn main(){
    let mut s = String::from("hello");
    s = print_string(s);
    println!("{}",s); // "hello"
}
fn print_string(some_string:String)-> String{
    println!("{}",some_string);
    some_string
}

上面的代码中,print_stringsome_string返回,因此函数结束后,就将some_string的所有权移交出去了。但是每次调用函数所有权转来转去,如果函数外部还希望拥有值的所有权,就不得不将变量返回,就很冗余,不够灵活,因此 rust 提供了引用与借用的方式使代码更加灵活。

一个变量的所有权总是遵循同样的模式:

  1. 把一个值赋给其他变量时就会发生转移;
  2. 当一个包含堆(heap)数据的变量离开作用域时,它的值就会被 drop 函数清理,除非数据的所有权移动到另一个变量上。

引用与借用

引用

引用,允许引用某些值,但不取得其所有权,用&来表示。 解引用,用*来表示。

fn main(){
    let s = String:from("hello");
    let l = get_string_len(&s);
    println("The length of {} is {}",s,l);
}
fn get_string_len(s:&String) -> usize{
    s.len()
}

yinyong.png

如上图所示,引用其实是一个指针,指向值的所有者(即变量),变量的指针指向数据本身。

借用

借用就是把引用作为函数参数的行为,这样函数内不过去该数据的所有权。如上面的代码将&s,即s的引用作为参数传给函数get_string_len的行为。

可变引用

使用mut关键字,将引用标记为可变,则可以更改原数据。

fn main(){
    let mut s = String:from("hello");
    let l = get_string_len(&mut s);
    println("The length of {} is {}",s,l);
}
fn get_string_len(s:&mut String) -> usize{
    s.push_str(",world");
    s.len()
}

限制

  1. 在特定作用域内,对某一块数据,只能有一个可变引用。这样做的好处:在编译时防止数据竞争。

    数据竞争,满足以下三个条件时会发生数据竞争:

    1. 两个以上指针访问同一个数据;
    2. 至少有一个指针用于写入数据;
    3. 没有使用任何机制来同步对数据的访问。
  2. 不可同时拥有一个可变引用和一个不可变引用,多个不可变引用是允许的。

  3. 但是可以通过创建新的作用域来允许非同时的创建多个可变引用。

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

    上面的代码是没问题的。但是,下面的代码就会有问题,仅仅是将 a 的调用放到了 b 的作用域之后:

    fn main() {
        let mut s = String::from("hello world");
        let a = &mut s;
        {
            let b = &mut s;
            println!("{}",b);
        }
        println!("{}",a);
    }
    
    error[E0499]: cannot borrow `s` as mutable more than once at a time
     --> src/main.rs:6:17
      |
    4 |     let a = &mut s;
      |             ------ first mutable borrow occurs here
    5 |     {
    6 |         let b = &mut s;
      |                 ^^^^^^ second mutable borrow occurs here
    ...
    9 |     println!("{}",a);
      |                   - first borrow later used here
    

    rust 的编译器真的严格啊,在 4-7 行的作用域之后再调用 a,编译器就会教你做人了,因为 b 和 a 都是 s 的可变引用,那么 4-7 行里很有可能对 s 指向的数据做出了修改,那么之后获取到的 a 很可能就是错的,会导致程序错误的。所以 rust 就干脆不允许你这么干了。

悬空引用(Dangling References)

悬空指针:一个指针引用了内存的某个地址,而这块内存可能已经释放并分配给其他人使用了。 在 Rust 中,编译器可以保证引用永远都不是悬空引用。 如果你引用了某些数据,编译器将保证在引用离开作用域之前数据不会离开作用域。

fn main(){
   let r = dangle();
}
fn dangle()→6 String{
   let s = String::from("hello");
   &s
}

上面这种会造成悬空指针的代码,rust 就直接报错了。

引用的规则

  1. 在任何情况下,只能满足下列条件之一:
    1. 一个可变的引用;
    2. 任意数量的不可变引用。
  2. 引用必须一直有效。

引用规则大汇总

最近又发现了新的学习资料Rust 语言从入门到实战;亲测不错,讲的深入浅出,九浅一深的。。。 下面就是这门课程里对引用借用规则的总结:

  • 引用和借用是一体两面,你把东西借给别人用,也就是别人持有了对你这个东西的引用。
  • 所有权型变量的作用域是从它定义时开始到所属那层花括号结束。
  • 引用型变量的作用域是从它定义起到它最后一次使用时结束。
  • 引用(不可变引用和可变引用)型变量的作用域不会长于所有权变量的作用域。这是肯定的,不然就会出现悬垂引用,这是典型的内存安全问题。
  • 一个所有权型变量的不可变引用可以同时存在多个,可以复制多份。
  • 一个所有权型变量的可变引用与不可变引用的作用域不能交叠,也可以说不能同时存在。
  • 某个时刻对某个所有权型变量只能存在一个可变引用,不能有超过一个可变借用同时存在,也可以说,对同一个所有权型变量的可变借用之间的作用域不能交叠。
  • 在有借用存在的情况下,不能通过原所有权型变量对值进行更新。当借用完成后(借用的作用域结束后),物归原主,又可以使用所有权型变量对值做更新操作了。
  • 可变引用的再赋值,会执行移动操作。赋值后,原来的那个可变引用变量就不能用了。这有点类似于所有权的转移.因此一个所有权型变量的可变引用也具有所有权特征,它可以被理解为那个所有权变量的独家代理,具有排它性。

多级引用规则

  • 对于多级可变引用,要利用可变引用去修改目标资源的值的时候,需要做正确的多级解引用操作,比如例子中的 **c,做了两级解引用。
  • 只有全是多级可变引用的情况下,才能修改到目标资源的值。
  • 对于多级引用(包含可变和不可变),打印语句中,可以自动为我们解引用正确的层数,直到访问到目标资源的值,这很符合人的直觉和业务的需求。

切片 slice

切片是 rust 中另一种不持有所有权的数据类型。

引子——一个例子

以一道题作为引子:

  1. 它接收字符串作为参数
  2. 返回它在这个字符串里找到的第一个单词
  3. 如果函数没找到任何空格,那么整个字符串就被返回
fn main(){
    let mut s = String::from("Hello world");
    let word_index = first_word(&s);
    s.clear();
    println!("{}",word_index);
}
fn first_word(s:&String) -> usize {
    let bytes = s.as_bytes();
    for (i,&item) in bytes.iter().enumerate(){
        if item == b' ' {
            return i;
        }
    }
    s.len()
}

上面的代码是实现了功能的。不过,其实word_index的值是和 s 强关联的。如果在第 4 行调用s.clear()word_index的值仍然是 5。也就是说,first_word函数的有效性脱离了字符串s的上下文,要保证sword_index的同步性,就需要很繁琐的工作了。

而字符串切片就是为了解决这一类问题而设计的。

字符串切片就是指向字符串中一部分内容的引用。&字符串变量[开始索引..结束索引]左闭右开区间。

fn main(){
    let s = String::from("Hello world");
    let hello = &s[0..5];
    let world = &s[6::11];
    // 其他写法
    // let hello = &[..5];
    // let world = &[6..];
    // let world = &s[6..s.len()]
    // let hello_world = [0..s.len()];
    // let hello_world = [..];
}

变量world包含了一个指向s索引 6 处的指针和一个值为 5 的 len

image.png

注意:字符串切片的范围索引必须发生在有效的UTF-8字符边界内。 如果尝试从一个多字节的字符中创建字符串切片,程序会报错并退出。

用字符串切片重写例子

fn main(){
    let mut s = String::from("Hello world");
    let word_index = first_word(&s);
    s.clear(); // 报错
    println!("{}",word_index);
}
fn first_word(s:&String) -> &str {
    let bytes = s.as_bytes();
    for (i,&item) in bytes.iter().enumerate(){
        if item == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

这样第四行调用s.clear()就会报错:

error[E0502]: cannot borrow `s` as mutable because it is also borrowed as immutable
 --> src/main.rs:4:5
  |
3 |     let word_index = first_word(&s);
  |                                 -- immutable borrow occurs here
4 |     s.clear(); // 报错
  |     ^^^^^^^^^ mutable borrow occurs here
5 |     println!("{}",word_index);
  |                   ---------- immutable borrow later used here

For more information about this error, try `rustc --explain E0502`.

字符串字面值是切片

字符串字面值直接被存储在二进制程序中。变量s的类型是&sr,它是一个指向二进制程序特定位置的切片。&s是不可变引用,所以字符串字面值也是不可变的

将字符串切片作为参数传递

之前的函数签名:fn first word(s:&String) -> &str是将字符串的引用作为参数传递的。 有经验的Rust开发者会采用&str作为参数类型,因为这样就可以同时接收String&str类型的参数了,即fn first word(s:&str) -> &str

  • 使用字符串切片,直接调用该函数
  • 使用String,可以创建一个完整的String切片来调用该函数

定义函数时使用字符串切片来代替字符串引用会使我们的API更加通用,且不会损失任何功能。

fn main(){
    let my_string = String:from("Hello world"); // String 类型
    let word_index = first_world(&my_string[..]);
    let my_string_literal =  "hello world"; // &str 类型
    let word_index = first_world(my_string_literal);
}
fn first_word(s:&str) -> &str {
    let bytes = s.as_bytes();
    for (i,&item) in bytes.iter().enumerate(){
        if item == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

其他类型的切片

如数组:

fn main(){
    let a = [1,2,3,4,5];
    let s = &a[1..3];
}

与字符串切片的含义一样,s包含一个指向a索引为 1 处的指针和一个值为 2 的len

下图是根据“圣经”字符串与切片章节整理的图谱。字符串与切片

image.png

结尾

今天的摸鱼就到这吧。各位大帅,我继续去看视频,再探再报!