深入理解Rust中的所有权概念

所有权

  • 所有权的认识
  • 移动,克隆,拷贝
  • 所有权与函数
  • 返回值与作用域
  • 引用与借用
  • 可变引用
  • 悬垂引用(Dangling References)
  • Slice类型
  • 所有权的认识

    所有程序都必须管理其运行时使用计算机内存的方式。一些语言中具有垃圾回收机制,在程序运行时有规律地寻找不再使用的内存;在另一些语言中,程序员必须亲自分配和释放内存。

    Rust 则选择了第三种方式:通过所有权系统管理内存,编译器在编译时会根据一系列的规则进行检查。如果违反了任何这些规则,程序都不能编译。在运行时,所有权系统的任何功能都不会减慢程序。

  • 所有权规则
    首先,让我们看一下所有权的规则。当我们通过举例说明时,请谨记这些规则:
  • Rust 中的每一个值都有一个 所有者(owner)。
    值在任一时刻有且只有一个所有者。
    当所有者(变量)离开作用域,这个值将被丢弃。

    移动,克隆,拷贝

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

    这种方式叫浅拷贝(与OC里的概念一致),示意图如下

    两个指针指向同一地址,引发一个问题:

    当 s2 和 s1 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free)的错误,也是之前提到过的内存安全性 bug 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

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

    如果你在其他语言中听说过术语 浅拷贝(shallow copy)和 深拷贝(deep copy),那么拷贝指针、长度和容量而不拷贝数据可能听起来像浅拷贝。不过因为 Rust 同时使第一个变量无效了,这个操作被称为 移动(move),而不是叫做浅拷贝。上面的例子可以解读为 s1 被 移动 到了 s2 中。那么具体发生了什么,如图所示。

    这样就解决了我们的问题!因为只有 s2 是有效的,当其离开作用域,它就释放自己的内存,完毕。

    另外,这里还隐含了一个设计选择:Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何 自动 的复制都可以被认为是对运行时性能影响较小的。

    如果我们 确实 需要深度复制 String 中堆上的数据,而不仅仅是栈上的数据,可以使用一个叫做 clone 的通用函数。第五章会讨论方法语法,不过因为方法在很多语言中是一个常见功能,所以之前你可能已经见过了。

    这是一个实际使用 clone 方法的例子:

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

    当出现 clone 调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生。

    我们再来看这段代码:

    fn main() {
        let x = 5;
        let y = x;
    
        println!("x = {}, y = {}", x, y);
    }
    

    这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过 x 依然有效且没有被移动到 y 中。

    原因是像整型这样的在编译时已知大小的类型被整个存储在栈上,所以拷贝其实际的值是快速的。这意味着没有理由在创建变量 y 后使 x 无效。换句话说,这里没有深浅拷贝的区别,所以这里调用 clone 并不会与通常的浅拷贝有什么不同,我们可以不用管它。

    Rust 有一个叫做 Copy trait 的特殊注解,可以用在类似整型这样的存储在栈上的类型上(第十章将会详细讲解 trait)。如果一个类型实现了 Copy trait,那么一个旧的变量在将其赋值给其他变量后仍然可用。

    Rust 不允许自身或其任何部分实现了 Drop trait 的类型使用 Copy trait。如果我们对其值离开作用域时需要特殊处理的类型使用 Copy 注解,将会出现一个编译时错误。要学习如何为你的类型添加 Copy 注解以实现该 trait,请阅读附录 C 中的 “可派生的 trait”。

    那么哪些类型实现了 Copy trait 呢?你可以查看给定类型的文档来确认,不过作为一个通用的规则,任何一组简单标量值的组合都可以实现 Copy,任何不需要分配内存或某种形式资源的类型都可以实现 Copy 。如下是一些 Copy 的类型:

    所有整数类型,比如 u32。
    布尔类型,bool,它的值是 true 和 false。
    所有浮点数类型,比如 f64。
    字符类型,char。
    元组,当且仅当其包含的类型也都实现 Copy 的时候。比如,(i32, i32) 实现了 Copy,但 (i32, String) 就没有。

    所有权与函数

    fn main() {
        let s = String::from("hello");  // s 进入作用域
    
        takes_ownership(s);             // s 的值移动到函数里 ...
                                        // ... 所以到这里不再有效
    
        let x = 5;                      // x 进入作用域
    
        makes_copy(x);                  // x 应该移动函数里,
                                        // 但 i32 是 Copy 的,
                                        // 所以在后面可继续使用 x
    
    } // 这里,x 先移出了作用域,然后是 s。但因为 s 的值已被移走,
      // 没有特殊之处
    
    fn takes_ownership(some_string: String) { // some_string 进入作用域
        println!("{}", some_string);
    } // 这里,some_string 移出作用域并调用 `drop` 方法。
      // 占用的内存被释放
    
    fn makes_copy(some_integer: i32) { // some_integer 进入作用域
        println!("{}", some_integer);
    } // 这里,some_integer 移出作用域。没有特殊之处
    

    当尝试在调用 takes_ownership 后使用 s 时,Rust 会抛出一个编译时错误。这些静态检查使我们免于犯错。试试在 main 函数中添加使用 s 和 x 的代码来看看哪里能使用它们,以及所有权规则会在哪里阻止我们这么做。

    返回值与作用域

    返回值也可以转移所有权。下面展示了一个返回了某些值的示例

    fn main() {
        let s1 = gives_ownership();         // gives_ownership 将返回值
                                            // 转移给 s1
    
        let s2 = String::from("hello");     // s2 进入作用域
    
        let s3 = takes_and_gives_back(s2);  // s2 被移动到
                                            // takes_and_gives_back 中,
                                            // 它也将返回值移给 s3
    } // 这里,s3 移出作用域并被丢弃。s2 也移出作用域,但已被移走,
      // 所以什么也不会发生。s1 离开作用域并被丢弃
    
    fn gives_ownership() -> String {             // gives_ownership 会将
                                                 // 返回值移动给
                                                 // 调用它的函数
    
        let some_string = String::from("yours"); // some_string 进入作用域。
    
        some_string                              // 返回 some_string 
                                                 // 并移出给调用的函数
                                                 // 
    }
    
    // takes_and_gives_back 将传入字符串并返回该值
    fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
                                                          // 
    
        a_string  // 返回 a_string 并移出给调用的函数
    }
    

    变量的所有权总是遵循相同的模式:将值赋给另一个变量时移动它。当持有堆中数据值的变量离开作用域时,其值将通过 drop 被清理掉,除非数据被移动为另一个变量所有。
    虽然这样是可以的,但是在每一个函数中都获取所有权并接着返回所有权有些啰嗦。如果我们想要函数使用一个值但不获取所有权该怎么办呢?如果我们还要接着使用它的话,每次都传进去再返回来就有点烦人了,除此之外,我们也可能想返回函数体中产生的一些数据。

    我们可以使用元组来返回多个值

    fn main() {
        let s1 = String::from("hello");
    
        let (s2, len) = calculate_length(s1);
    
        println!("The length of '{}' is {}.", s2, len);
    }
    
    fn calculate_length(s: String) -> (String, usize) {
        let length = s.len(); // len() 返回字符串的长度
    
        (s, length)
    }
    

    但是这未免有些形式主义,而且这种场景应该很常见。幸运的是,Rust 对此提供了一个不用获取所有权就可以使用值的功能,叫做 引用(references)。

    引用与借用

    示例中的元组代码有这样一个问题:我们必须将 String 返回给调用函数,以便在调用 calculate_length 后仍能使用 String,因为 String 被移动到了 calculate_length 内。相反我们可以提供一个 String 值的引用(reference)。引用(reference)像一个指针,因为它是一个地址,我们可以由此访问储存于该地址的属于其他变量的数据。 与指针不同,引用确保指向某个特定类型的有效值。

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

    首先,注意变量声明和函数返回值中的所有元组代码都消失了。其次,注意我们传递 &s1 给 calculate_length,同时在函数定义中,我们获取 &String 而不是 String。这些 & 符号就是 引用,它们允许你使用值但不获取其所有权。

    注意:与使用 & 引用相反的操作是 解引用(dereferencing),它使用解引用运算符,*。我们将会在第八章遇到一些解引用运算符,并在第十五章详细讨论解引用。

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

    **我们将创建一个引用的行为称为 借用(borrowing)。**正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来。当你使用完毕,必须还回去。我们并不拥有它。

    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() {
        let mut s = String::from("hello");
    
        change(&mut s);
    }
    
    fn change(some_string: &mut String) {
        some_string.push_str(", world");
    }
    

    首先,我们必须将s 改为 mut。然后在调用change函数的地方创建一个可变引用 &mut s,并更新函数签名以接受一个可变引用 some_string: &mut String。这就非常清楚地表明,change 函数将改变它所借用的值。

    可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用。这些尝试创建两个 s 的可变引用的代码会失败:

    fn main() {
        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 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

    一如既往,可以使用大括号来创建一个新的作用域,以允许拥有多个可变引用,只是不能 同时 拥有:

    fn main() {
        let mut s = String::from("hello");
    
        {
            let r1 = &mut s;
        } // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用
    
        let r2 = &mut s;
    }
    

    Rust 在同时使用可变与不可变引用时也采用的类似的规则。这些代码会导致一个错误:

    fn main() {
        let mut s = String::from("hello");
    
        let r1 = &s; // 没问题
        let r2 = &s; // 没问题
        let r3 = &mut s; // 大问题
    
        println!("{}, {}, and {}", r1, r2, r3);
    }
    

    我们 不能在拥有不可变引用的同时拥有可变引用。

    不可变引用的用户可不希望在他们的眼皮底下值就被意外的改变了!然而,多个不可变引用是可以的,因为没有哪个只能读取数据的人有能力影响其他人读取到的数据。

    注意一个引用的作用域从声明的地方开始一直持续到最后一次使用为止。例如,因为最后一次使用不可变引用(println!),发生在声明可变引用之前,所以如下代码是可以编译的:

    fn main() {
        let mut s = String::from("hello");
    
        let r1 = &s; // 没问题
        let r2 = &s; // 没问题
        println!("{} and {}", r1, r2);
        // 此位置之后 r1 和 r2 不再使用
    
        let r3 = &mut s; // 没问题
        println!("{}", r3);
    }
    

    悬垂引用(Dangling References)

    在具有指针的语言中,很容易通过释放内存时保留指向它的指针而错误地生成一个 悬垂指针(dangling pointer),所谓悬垂指针是其指向的内存可能已经被分配给其它持有者。相比之下,在 Rust 中编译器确保引用永远也不会变成悬垂状态:当你拥有一些数据的引用,编译器确保数据不会在其引用之前离开作用域。

    让我们尝试创建一个悬垂引用,Rust 会通过一个编译时错误来避免:

    文件名:src/main.rs

    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
    

    这里的解决方法是直接返回 String:

    fn main() {
        let string = no_dangle();
    }
    
    fn no_dangle() -> String {
        let s = String::from("hello");
    
        s
    }
    

    这样就没有任何错误了。所有权被移动出去,所以没有值被释放。

    小小的总结一下:

  • 在任意给定时间,要么 只能有一个可变引用,要么 只能有多个不可变引用。
  • 引用必须总是有效的。
  • Slice类型

    slice 允许你引用集合中一段连续的元素序列,而不用引用整个集合。slice 是一类引用,所以它没有所有权。

    这里有一个编程小习题:编写一个函数,该函数接收一个用空格分隔单词的字符串,并返回在该字符串中找到的第一个单词。如果函数在该字符串中并未找到空格,则整个字符串就是一个单词,所以应该返回整个字符串。

    让我们推敲下如何不用 slice 编写这个函数的签名,来理解 slice 能解决的问题:

    fn first_word(s: &String) -> ?
    

    first_word 函数有一个参数 &String。因为我们不需要所有权,所以这没有问题。不过应该返回什么呢?我们并没有一个真正获取 部分 字符串的办法。不过,我们可以返回单词结尾的索引,结尾由一个空格表示。试试如示例中的代码。

    文件名:src/main.rs

    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()
    }
    

    ⬆️ first_word 函数返回 String 参数的一个字节索引值

    因为需要逐个元素的检查 String 中的值是否为空格,需要用 as_bytes 方法将 String 转化为字节数组。

        let bytes = s.as_bytes();
    

    接下来,使用 iter 方法在字节数组上创建一个迭代器:

        for (i, &item) in bytes.iter().enumerate() {
    

    我们将在第十三章详细讨论迭代器。现在,只需知道 iter 方法返回集合中的每一个元素,而 enumerate 包装了 iter 的结果,将这些元素作为元组的一部分来返回。enumerate 返回的元组中,第一个元素是索引,第二个元素是集合中元素的引用。这比我们自己计算索引要方便一些。

    因为 enumerate 方法返回一个元组,我们可以使用模式来解构,我们将在第六章中进一步讨论有关模式的问题。所以在 for 循环中,我们指定了一个模式,其中元组中的 i 是索引而元组中的 &item 是单个字节。因为我们从 .iter().enumerate() 中获取了集合元素的引用,所以模式中使用了 &。

    在 for 循环中,我们通过字节的字面值语法来寻找代表空格的字节。如果找到了一个空格,返回它的位置。否则,使用 s.len() 返回字符串的长度:

    if item == b' ' {
                return i;
            }
        }
    
        s.len()
    

    现在有了一个找到字符串中第一个单词结尾索引的方法,不过这有一个问题。我们返回了一个独立的 usize,不过它只在 &String 的上下文中才是一个有意义的数字。换句话说,因为它是一个与 String 相分离的值,无法保证将来它仍然有效。考虑一下如下示例中使用了上一个示例中 first_word 函数的程序。

    fn main() {
        let mut s = String::from("hello world");
    
        let word = first_word(&s); // word 的值为 5
    
        s.clear(); // 这清空了字符串,使其等于 ""
    
        // word 在此处的值仍然是 5,
        // 但是没有更多的字符串让我们可以有效地应用数值 5。word 的值现在完全无效!
    }
    

    这个程序编译时没有任何错误,而且在调用 s.clear() 之后使用 word 也不会出错。因为 word 与 s 状态完全没有联系,所以 word 仍然包含值 5。可以尝试用值 5 来提取变量 s 的第一个单词,不过这是有 bug 的,因为在我们将 5 保存到 word 之后 s 的内容已经改变。

    我们不得不时刻担心 word 的索引与 s 中的数据不再同步,这很啰嗦且易出错!如果编写这么一个 second_word 函数的话,管理索引这件事将更加容易出问题。它的签名看起来像这样:

    fn second_word(s: &String) -> (usize, usize) {
    

    现在我们要跟踪一个开始索引 和 一个结尾索引,同时有了更多从数据的某个特定状态计算而来的值,但都完全没有与这个状态相关联。现在有三个飘忽不定的不相关变量需要保持同步。

    幸运的是,Rust 为这个问题提供了一个解决方法:字符串 slice。

    字符串 slice(string slice)是 String 中一部分值的引用,它看起来像这样:

    fn main() {
        let s = String::from("hello world");
    
        let hello = &s[0..5];
        let world = &s[6..11];
    }
    

    对于 Rust 的 … range 语法,如果想要从索引 0 开始,可以不写两个点号之前的值。换句话说,如下两个语句是相同的:

    #![allow(unused)]
    fn main() {
        let s = String::from("hello");
    
        let slice = &s[0..2];
        let slice = &s[..2];
    }
    

    依此类推,如果 slice 包含 String 的最后一个字节,也可以舍弃尾部的数字。这意味着如下也是相同的:

    fn main() {
        let s = String::from("hello");
    
        let len = s.len();
    
        let slice = &s[3..len];
        let slice = &s[3..];
    }
    

    也可以同时舍弃这两个值来获取整个字符串的 slice。所以如下亦是相同的:

    fn main() {
        let s = String::from("hello");
    
        let len = s.len();
    
        let slice = &s[0..len];
        let slice = &s[..];
    }
    

    当我们有了slice的知识,我们的编程题就可以完美解决了:

    fn first_word(s: &String) -> &str {
        let bytes = s.as_bytes();
    
        for (i, &item) in bytes.iter().enumerate() {
            if item == b' ' {
                return &s[0..i];
            }
        }
    
        &s[..]
    }
    
    物联沃分享整理
    物联沃-IOTWORD物联网 » 深入理解Rust中的所有权概念

    发表评论