前端学Rust - 所有权(重点)

5,955 阅读9分钟

所有权

Rust的核心特性就是所有权 所有程序在运行时都要管理他们使用计算机内存的方式,有些语言有垃圾收集机制,在运行时不断寻找不再使用的内存(例如js),还有一些语言,程序员需要显示的分配和释放内存 (例如 c、c++)。 Rust则是采用了第三种方式:

  • 内存通过一个所有权系统来管理,其中包含一组编译器在编译时检查的规则
  • 程序运行时,所有权特性不会减慢程序的运行速度,因为Rust将他们都提前到了编译时

所有权解决的问题

前提:Stack 和 Heap

在Rust这种系统级编程语言里,值是在stack上还是在heap上对语言的行为和你要做的决定有重大的影响。

Stack:栈存储,所有存储在其中的数据必须拥有已知的固定大小。

Heap:堆内存,所有大小未知的数据或者运行时大小可能发生改变的数据必须存放在heap上,当你把数据放入heap时,操作系统在heap里找到一块足够大的空间,把他标记位在用,并返回一个指针,这个过程叫做“分配”。

解决的问题

  • 跟踪代码的哪些部分正在使用heap的哪些数据
  • 最小化heap上的重复数据量
  • 清理heap上未使用的数据

所有权的规则

  • 每个值都有一个变量,这个变量是该值的所有者
  • 每个值同一时间只能有一个所有者
  • 当所有者超出作用域(scope)时,该值将被删除 (作用域概念基本和js里区别不大)

变量是值的所有者

尝试使用一个非标量类型-String类型 (这里的String类型和字符串不是一个概念,类似于js中的基本包装类型) 来解释: 首先使用String::from函数从字符串字面值创建出String类型,示例:

fn main() {
    let mut s = String::from("Hello");
    
    s.push_str(", World");

    println!("{} !",s);
}

为什么String类型的值可以修改,字符串字面值却不能修改?

因为 字符串字面值,在编译时就已经知道它的内容了,其文本内容直接被硬编码到最终的可执行文件里。所以它速度快、高效,但是不可变。回收也是简单的出栈。

String类型,为了支持它的可变性,需要在heap上分配内存来保存编译时未知大小文本内容,所以他会在运行时通过调用String::from来请求内存,当用完String后需要使用某种房似锦将内存返回给操作系统(js的CG、c++的手动)。那在Rust中是如何做的呢: 当拥有这个值的变量走出作用域范围时,内存会立即自动交还给操作系统,这个过程是当前变量自动调用了drop函数。 再说明白一点就是,Rust里值有一个所有者,就是对应的变量,它会在超出作用域是自动触发drop。

每个值同一时间只能有一个所有者

那么问题来了,我们改造上面的代码,如下:

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

    println!("{},{}", s1, s2);
}

这样,rust就会编译报错: error[E0382]: borrow of moved value: s1 ,这就是所有权的第二个规则:每个值同一时间只能有一个所有者。 先做一个假设,假设同时拥有两个所有者,则 s2 和 s1 的指针都指向同一块内存(js当前就是这样)。

image.png

那么当两个变量都离开作用域时,它们都会尝试释放同一块内存,是典型的重复释放(double free)问题,可能会导致某些正在使用的数据发生损坏。 为了解决这个问题Rust采用了非常直接的方式,即直接使得s1变为未初始化的状态,而让s2绑定堆内存的数据,也就是将值的所有权从s1移交给s2

image.png

这个过程在Rust里面被称为Move,其实从图中我们不难看出本质就是浅拷贝(和js一样)+ 原变量无效化。

这就是所有权的规则及为什么要这样设定,核心就是规范操作,提高安全性。

引申 - 变量和数据的交互方式

  • clone 既然我们知道了存在head中的数据赋值是move过程,也就是一个浅拷贝,在我们需要使用深拷贝时,Rust提供了一个函数:clone
fn main() {
    let s1 = String::from("Hello");
    let s2 = s1.clone();

    println!("{} {}",s1,s2);
}
  • copy trait 整数这种完全存放在strack上的类型(类比基本数据类型)本身存在一个copy trait的实现。 如果一个类型或者该类型的一部分实现了drop trait ,则Rust不允许它再去实现一个copy trait。 copy trait 作用就是将栈上的数据拷贝。这句话的意思也就是能存放在栈上的数据直接赋值给另一个,示例: let s1 = 1; let s2 = s1.clone(); 结果就是会将s1的值copy给s2,但是如果这是个存在heap上的值,它实现了drop trait ,就不允许实现一个copy trait,只能move。

所有权与函数

  • 将值传递给函数参数和把值赋给变量是类似的,也就是要么是移动,要么是复制 。示例:
fn main(){
    let s = String::from("Hello World");

    take_ownership(s); // 这里s的值会移动到take_ownership的函数里,此行之后 s 失效

    let x = 5;

    makes_copy(x); // 这里5会copy到makes_copy函数里,和复制给另一个变量一样

    println!("{}",x);
}

fn take_ownership(some_string: String){
    println!("{}",some_string);
}

fn makes_copy(some_number: i32){
    println!("{}",some_number);
}
  • 函数在返回值的过程中同样会发生所有权的转移。示例:
fn main(){
    let s = gives_ownership(); 
    let s1 = String::from("hello1"); 
    let s2 = take_and_give_back(s1);
    println!("{},{}",s,s2)
}

fn gives_ownership()->String{
    String::from("hello")
}

fn take_and_give_back(a_string: String)->String{
    a_string
}

引用

这里有个问题,我在main函数里的值,所有权移动到另一个函数,但是我当前main后面还想使用这个值,该怎么办? 让函数使用某个值,但是不获得它的所有权-引用 先看示例:

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

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

其中,参数的类型为&String,&符号就标识允许你引用某些值而不取得它的所有权。  如图,s就是s1的引用。

image.png

ps: 个人理解,引用存在的意义和前面的move一样,只不过这是通过从语法上区分的方式来解决的,编译看到这个语法就知道这个东西不用去drop释放。

可变引用和不可变引用

不可变引用

借用了对应所有权的读能力,不允许修改对应的值,如上述示例,则不可修改s的值。

可变引用

借用了对应所有权的读、写能力,允许修改对应的值,修改上述示例:

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

fn get_length(s:&mut String)->usize{
    s.push_str(", world!");
    s.len()
}
  • 在特定作用域内,对某一块数据,只能有一个可变的引用。示例:
fn main(){
    let mut s = String::from("hello"); 
    let s1 = &mut s;
    let s2 = &mut s; // 会编译报错
    println!("{},{}",s1,s2)
}
  • 不可以同时拥有一个可变引用和一个不可变引用
  • 可以拥有多个不可变引用

悬空引用问题

悬空指针就是过早地释放了指针指向的内存,也就是说,堆内存已经释放了,而指针还指向这块堆内存。在 rust 中,编译器可以保证不会产生悬空引用:如果有引用指向数据,编译器会确保引用指向的数据不会超出作用域,示例:

fn main() {
    let reference_to_nothing = dangle();
}

fn dangle() -> &String { // Ruts 在这一行就会报错:error[E0106]: missing lifetime specifier (生命周期问题)
    let s = String::from("hello"); 
    &s
} // s 超出作用域,字符串的内存释放

Slice(切片)类型

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

练习题:输入一个字符串,返回该字符串中找到的第一个单词。

fn main() {
    let mut s = String::from("hello world");
    let index = first_word(&s);

    s.clear(); // 修改原有s
    println!("{}",index)
}

fn first_word(s: &String)->usize {
    let bytes = s.as_bytes();

    for (i,&elem) in bytes.iter().enumerate() {
        if elem == b' '{
            return i;
        }
    }
    s.len()
}

我们通过first_word函数去获取第一个单词的结束索引,但是我们在获取到改索引后对原有变量s做了修改,此时我们已经将5这个结果保存到 index 变量了,但是s变化时index没有同步。 这里问题就产生了,当我们的index需要同步s的变化时,靠人为不断触发肯定不符合Rust的”安全性“。所以Rust为这类问题提供了解决方法:slice类型。

字符串slice

字符串silce是对String中一部分值的引用,示例:

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

此时,和上面我们了解的引用概念不同,这里的hello变量是一个指向s索引0的指针,长度为5的slice。world则是一个包含指向s索引6的指针和长度值为5的slice。 如下图所示: image.png

知道了slice类型之后,我们重写first_word来返回一个slice。

fn main() {
    let mut s = String::from("hello world");
    let word = first_word(&s);

    s.clear();   // 编译器报错
    println!("{}",word)
}
 
fn first_word(s: &String)->&str {   // 返回类型 ”字符串slice“ ,写为 &str
    let bytes = s.as_bytes();

    for (i,&elem) in bytes.iter().enumerate() {
        if elem == b' '{
            return &s[..i];
        }
    }
    &s[..]
}

此时,仍然修改s的值就会出现编译时报错,报错为:error[E0502]: cannot borrow s as mutable because it is also borrowed as immutable

上文中可变引用的描述中有:不可以同时拥有一个可变引用和一个不可变引用, 在这里就是 word属于不可变引用,而clear 属于可变引用,此时就会导致编译失败。

其他类型slice

数组也可以使用slice类型,示例:

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

    println!("{:?}",slice)
}

总结

所有权、借用和 slice 这些概念让 Rust 程序在编译时确保内存安全。Rust 语言提供了跟其他系统编程语言相同的方式来控制内存,但数据的所有者在离开作用域后自动清除其数据的功能则让我们无须额外编写和调试相关的控制代码。 所有权系统影响了很多Rust的其他部分的工作方式。