likes
comments
collection
share

菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)

作者站长头像
站长
· 阅读数 16

写在前面

在rust中所有者是最独一无二的特性,他使得rust能保证内存中的GC是安全的。这里将介绍几个概念:借用(borrowing)切片(slices) 和rust在内存中的数据展开

4.1 什么是所有者

4.1.1 RUST所有者得模式

所有者是Rust核心的特性,让我们来看看在内存GC的管理上,RUST有何不同。

现有的GC模式

  1. 不断查询那些不再被使用的内存
  2. 让coder自己分配和释放内存

RUST的模式: 系统所有者指定规则,编译器再编译时检测规则,如果不指定所有者,运行时速度会变慢。

问题来了,作为一个新手我不知道应该怎么指定我所有者的规则该怎么办?

官方回答:你就得多学多练,越是有经验得RUST使用者,对所有者理解越深,你自然而然代码就会越安全和越有效,嘿嘿。听君一席话,的确听了一席话。

4.1.2 堆栈的介绍

这里因为RUST中在使用所有者的时候,部分可能需要通过堆和栈之间的关系才能做出合适的决定,因此简单介绍下。

其实都是程序在运行过程中在内存中用于存储数据的地方。具体的区别可以看栈和堆的区别

  • 栈的特点

    • 后入先出(例子:等电梯)
    • 长度固定,数据已直
    • 存储是有序的
    • 连续的内存区域
    • push(存放)和access(查询)数据更快
  • 堆的特点

    • 长度可变或长度未知
    • 存储是无序的,自由度比较大
    • 不连续的内存区域
    • push(存放)和access(查询)数据更慢

栈的存放更快是因为他是连续的区域,而堆需要分配器去找哪块空闲的区域可以存放

栈的读取更块是因为,不需要用指针去找到底是哪个碎片化的地址存储了相关的变量

4.1.3 所有者的规定

  1. 在rust中,每一个有值的变量都称为所有者(owner)
  2. 在同一个时间都只能有一个所有者
  3. 当所有者离开作用域,值也就被删除了

4.1.4 变量作用域

根据上面的所有者规定我们知道,一个变量在定义前作用域外,其实是无效的,所以一下代码编译会报错:

fn main() {
    {
        // 这之前也无效
        let s = "123"; 
    }
​
    // 无效的valid
    // 在作用域外了
    println!("s -> {}", s)
}

4.1.5 String type的例子

当数据变量存在栈中的时候,每个作用域当结束的时候,栈会被清空,这样如果我们在多个作用域中想要用到同一个变量,我们可能就需要copy自己的代码多次,如果我们的数据存在堆中,我们使用引用的时候,RUST会自己去判断,何时将不用的堆中的数据删除~

这里通过一个String类型的数据来做分析,扩展一个字符串的长度:

  • 先通过String::from来实现字符串需要内存的请求(堆中)
  • 通过push_str来为增加一个string字面量到String上
fn main() {
    let mut s = String::from("hello");
    // 通过push_str的方式来扩展字符串的长度
    s.push_str(", world");
​
    println!("s -> {}", s);
    
    // 没有增加字符串长度的办法
    let a = "hello";
}

通过string字面量的方式定义通常是放在栈里面,因为是连续的空间一开始的长度是固定的,所以不可变,但是通过String来构造,其实是放在堆里面,扩展性更强,背后对应的是两种内存管理的方式。

4.1.6 内存和分配方式

从上面这个例子我们知道,由于栈分配的内存在编译时候其实是已知长度和内容,相当于是硬编码写死在代码里面的,所以其实效率更高,但是其实我们真正代码里面的场景不会是这个样子的,因此,我们需要String类型来定义可变的可以扩展的文本内容,因此我们需要在堆中对其进行内存的分配:

  • 在运行时,内存需要通过内存分配机制来进行分配
  • 当我们使用String类型的时候内存分配器为我们分配内存(利用from方法来申请内存)
  • RUST的GC策略,当变量不再所属于当前的作用域,该内存会被自动回收

4.1.7 数据变量交互方式—转移(move)

可能看这个标题有点迷糊,我们来看代码把,其实讲的意思就是一个相同的值,在不同的变量中间赋值来赋值去,如果两者都是标量类型的话,其实xy都等于5,因为这个时候他们都是放在栈里面的

fn main() {
    let x = 5;
    let y = x;
​
    // 这里会打印出 x -> 5, y -> 5
    println!("x -> {}, y -> {}", x, y)
}

组合类型

接下来我们看一下组合类型的情况:

组合类型组成由三部分(以String为例):

  • 起始指针(ptr)
  • 长度(len)
  • 容量(capacity)

当我将s的值move到s2的时候,操作为:

  • 复制同样一份的ptr,lencapacity
  • s2的指针指向和s同样的堆指针位置

【注意】

  1. rust在这里这个move的操作不需要将堆中的内容重新复制一份

  2. 为了避免释放两次内存可能会造成的报错,当使用moves赋值给s2的时候,这个时候s就失效了

  3. 因为这种特性在这种场景下s的值赋值给了s2其实值就转移了,因为s失效,行为看似是失效了s浅拷贝的引用

    fn main() {
        let mut s = String::from("hello");
        s.push_str(", world");
    
        let s2 = s;
        // 这里会报错,目前这里的s是已经被借用了
        // borrow of moved value: `s` value borrowed here after move
        println!("s -> {}", s);
        println!("s2 -> {}", s2);
    }
    
    

菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)

6.1.8 变量数据交互的方式—克隆(clone)

刚才说的场景,s2s1做了一次浅拷贝,并将s1的浅拷贝失效,如果这个时候我们想同时保留二者,这个时候需要做深拷贝,我们可以使用clone方法

fn main() {
    let mut s = String::from("hello");
    let s2 = s;
    let s3 = s2.clone();
    // 这个时候不会报错
    print!("s2 -> {}, {}", s2, s3);
}

这种方法其实是对堆的深拷贝,具体的示意图可以参考

菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)

4.1.9 栈数据赋值—拷贝

4.1.7中的第一个例子我们可以看到其实整型x -> y值也赋值了,但是也没有产生move这是为什么呢?

理由主要是:RUST的Copy特性,如果一个类实现了Copy特性,老的变量在被赋值给人的变量时会变得不稳定。具体包括哪些呢?

  • 所有的整型类型
  • 布尔类型
  • 浮点数类型
  • 字符类型
  • 元组

这些类型在被赋值的时候都不会发生类似s赋值给s2,导致s失效这种问题

4.1.10 所有者和函数

在函数传递实参的时候,其实是对参数的赋值,所以也会满足上述说的拷贝时候的问题吗,就是基本上所有基础类型都不会存在move但复杂类型会存在move的问题

fn main() {
    let s = String::from("hello");  
    
    takes_ownership(s);
    // 这里编译会报错
    // 因为这里其实s已经被函数借用了、
    // 这个时候的s已经名存实亡了 哈哈哈哈
    println!("x -> {}", s);
    
    let x = 5;                      
    makes_copy(x);
    // 如果这里打印x不会报错,因为所有整型都不会move
    
}

fn takes_ownership(some_string: String) { // some_string comes into scope
    println!("{}", some_string);
} // Here, some_string goes out of scope and `drop` is called. The backing
  // memory is freed.

fn makes_copy(some_integer: i32) { // some_integer comes into scope
    println!("{}", some_integer);
} // Her

4.1.11 作用域和返回值

如果是组合类型存在move的但是我们,通过参数传入之后,原参数已失效,但是后续还要继续使用怎么办,这个时候可以返回一个元组,然后解构继续用(感觉这个操作好多余,不知道后面有没有其他的好办法)

fn main() {
    let s1 = String::from("hello");
​
    // 利用元组结构来继续使用这个s1
    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() returns the length of a String
​
    // 透传返回s,以及计算的结构length
    (s, length)
}

4.2 引用和借用

4.1.10中我们看到s变量失效的这种场景,叫做借用,即这个变量从s被赋予给了s1

引用类型允许我们在没有所有者的情况下拿到部分值(这句话其实我没从代码里面找到实际的例子,如果有大佬的话,可以给我指点一下)

如果我们想要s1s同时有用呢?

  • 第一种使用之前返回变量 + 元组解构的办法来继续拿到另一个值(借用需要将值通过函数返回值的方式还给变量
  • 使用引用,引用的使用方法是使用关键字&
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()
}

4.2.1 引用的原理

将引用值得指针指向对应的引用变量,然后引用变量其实是指向对应堆中的值的,具体的示意图如下:

菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)

4.2.2 变量的引用

不仅函数可以通过引用的方式,普通变量原来move的操作也可以改成引用,这个时候注意以下几点:

  1. 在move的变量前,增加&符号即可
  2. 这个时候s2其实就是类似s1copy的结果,但是在内部代表的是同一个结果
  3. 变量的借用不需要“还”
fn main() {
    let mut s1 = String::from("hello");
    s1.push_str(", world");
    
    let s2 = &s1;
    println!("the str is {}", s2);
}

4.2.3 可变的引用

目前我们学习了引用,但是如果我们想为引用变量,进行操作,即可变的引用类型我们要怎么做的,来看个错误的例子:

fn main() {
    let mut s1 = String::from("hello");
    add_str(&s1);
    println!("the str -> {}", s1);
}

fn add_str(s: &String) {
    // 这里编译会报错
    // 因为默认这里的s是个不可变的变量,默认都是不可变的
    s.push_str("rust");
}

可变的引用只需要在&后,增加mut关键字即可,请看正确的示范:

  • 在调用函数的地方增加&mut
  • 在函数定义时增加&mut
  • 同时只能被引用一次
    fn main() {
        let mut s1 = String::from("hello");
    
        add_str(&mut s1);
    
        println!("s1 -> {}", s1);
    
    }
    
    fn add_str(s: &mut String) {
        s.push_str(", rust");
    }
    

4.2.4 只能有一次引用

这个地方其实是和变量的引用在文中是一章,但是比较容易出错,所以单独写一下,在使用引用的时候有一个重要的规则对一个数据在同一时间(作用域)只能有一个可变的引用,不然不符合这个规则,那么对不起,编译过不了。(目前我的理解是mut的变量只能引用一次,借出去就没了)

这种策略是为了让可变性非常可控,为了避免一下几个行为:

  • 超过两个指针在同时接入一个相同的数据(处理并发问题)
  • 超过一个指针执行写操作
  • 没有同步接入数据的机制
fn main() {
    let mut s1 = String::from("hello");
    s1.push_str(", world");
    let mut s4 = &mut s1;
    s4.push_str(", rust");

    // 这里的s1会报错,报错的理由就是s1是不可变的变量,mut已经被出借了
    // println!("The length of '{}' is {}", s1, len);
    // 这里能看到hello world rust
    println!("the str -> {}", s4);
}

如何处理这种需要多个mut的场景呢?

根据所有者的规则,只要在不同的几个作用域即可,所以我们可以这样安排代码

fn main() {
    let mut s1 = String::from("hello");
    
    // 如果写在这里,mut s4的地方就会报错
    // 因为&mut s1这里已经被出借了
    // let mut s2 = &mut s1;
    {
        // 写在独立的作用域里面就不会报错
        let s2 = &mut s1;
        println!("s2 -> {}", s2);
    }
    
    let mut s4 = &mut s1;
    // println!("the str is {}, {}", s2, s4);

    println!("the str -> {}", s4);
}

另外,如果是非mut的多个引用,在rust中是可以被接受的,但是要注意是在哪里用的,如果最后一次调用完,对应的引用被回收了,那么再去赋值其实是不会报错的,这个很重要我们结合例子看下: 我理解的规则:

  • 如果mut引用创建时,还有引用没被收回不允许出借
  • 有了mut引用不允许其他出借
  • 使用局部作用域,利用所有者规则可以额外出借
fn main() {
    let mut s1 = String::from("hello");
    
    let s2 = &s1;
    let s3 = &s1;
    // 如果print在这里是不会报错的
    // 因为这里print是s2和s3最后被使用的
    // 所以使用完s2, s3会被回收,后面
    // 再出借mut的s1是可以的
    println!("the str is {}, {}", s2, s3);

    let mut s4 = &mut s1;
    // 如果print在这里是不允许出借的
    // 因为s2和s3还是出借状态,s1被s2和s3指向,这个时候不允许出借
    // println!("the str is {}, {}", s2, s3);
    println!("the str -> {}", s4);
}

4.2.5 悬摆的引用

函数没有必要返回一个无效的引用值,这个地方其实我理解官方的意思就是,因为函数执行完毕,我们的作用域内的变量会销毁,但是这个时候,我们如果使用引用的话,我们必须保证引用是有效的,如果这个原引用销毁了,那就不是有效的,所以编译时候会报错:

fn dangle() -> &String {
    let s = String::from("hello");

    // 这里会报错
   	&s
}

// 正确写法
fn dangle() -> String {
    let s = String::from("hello");

    s
}

官方的引用规则

  • 任何时候只能有一个mut的引用和若干个immutable的引用
  • 引用必须总是有效的

4.3 切片类型

切片类型是另一种不需要所有者的数据类型,其目的是为了让我们能够在连续内存的一段连续的数据中,读取一部分数据所用的。

我们先在来看一个例子,如果需要获取一句话中第一个单词结尾的位置,我们需要怎么操作呢,很简单,可以通过遍历的方法,我们来看下遍历的代码:

【代码思路梳理】:

  • 遍历所有字符串
  • 字符串为" "的时候输出对应的索引值

【关键代码梳理】:

  • first_word这个函数不需要所有者,所以直接使用引用类型就好

  • as_bytes其用法的意思就是,将一个字符串,变为字符的切片,即这里会循环一个数组

    Returns a byte slice of this String's contents.

  1. 遍历数组的方法,利用迭代器来实现,迭代器的使用方法是.iter().enumerate()即可实现迭代器,一个一个输出对应的值,类似next

    fn main() {
        let mut s = String::from("hello world");
    
        let word = first_word(&s); // word will get the value 5
        println!("word -> {}", word);
    
        // 使用完成之后,将对应的字符串置为空
        s.clear(); 
    }
    
    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()
    }
    

思考:通过上述方法来看,如果我们这个时候需要获取第二第三个单词的索引,那其实这个方法会越来越冗长和复杂,其实想想在JS中如果我们要找到第二第三个单词怎么办,通过" "来对字符串进行切割即可~,所以其实RUST也有这种切片类型的概念

4.3.1 字符串切片

回忆下之前,做猜数字游戏的时候的Range的使用方法[1..3]代表从1~3的区间,其实这就是切片,我们来看看代码,通过..这种方式,来获取区域中的[0, 5)这个区间内的所有字符,具体可以翻译成JS中的a.slice(0, 5)

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

菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)

注意点

  1. 使用切片类型,目前简写从第一个元素开始,和切片到末尾可以通过简写的方法

    fn main() {
        let mut s = String::from("hello world");
    
        let hello = &s[..5];
        let world = &s[6..];
    
        println!("hello -> {}, world -> {}", hello, world);
    }
    
  2. 字符串类型切片之后的类型变为了&str这个地方我们需要注意下,所以我们修改之前的first_word函数如下

    // 这里函数的返回值需要是&str
    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
    }
    
  3. 引用类型在被使用之前其所有者必须存在

    fn main() {
        let mut s = String::from("hello world");
    
        // 这个first_word是2中的方法
        let word = first_word(&s); // word will get the value 5
        // 如果clear在这里,那么因为后面word使用的是s的引用
        // 所以在这里清空后s就没有值了
        // 因此编译阶段就会报错
        // s.clear();
        println!("word -> {}", word);
        s.clear(); 
    }
    

【思考】

注意点3中,我观察到报错是:mut类型不能出借给immutable类型,所以我就在想,难道我改了一波类型,这个地方这种奇怪的问题就能被绕过了吗~所以我进行了类型的魔改

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

    let word = first_word(&mut s); // word will get the value 5
    //
    s.clear(); 
    println!("word -> {}", word);

}

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

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

    s
}

但是其实我们发现这里还是报错的,cannot borrowsas mutable more than once at a time因为其实这个地方看到clear定义的时候,是&self是一个mut类型,所以这个地方我们其实明白了,一个变量最多创造一个mut类型的引用,其实还是被规则拦住了!

菜鸡前端的Rust学习笔记(四)—理解所有者(ownership)

4.3.2 String字面量切片

刚才4.3.1中的切片指的是通过String类来进行构造的,如果我们是通过String字面量来进行构造的呢?会发生什么事呢?我们接下去来看~

字符串字面量,现在其实类型是&str的引用,所以其实,对字面量定义的切片,是对其引用的切片,当然字面量的切片也可以作为函数的参数,可以看get_word这个方法,但是如果作为切片接受参数,参数的类型为&str

fn main() {
    let mut s2 = "Hello World";
    s2 = "Rust Hello World";

    let s3 = &s2[..5];
    let s4 = get_word(&s2[..11]);

    println!("s3 -> {}, s4 -> {}", s3, s4); // s3 -> Rust , s4 -> Rust
}

fn get_word(s: &str) -> &str {
    let bytes = s.as_bytes();

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

4.3.3 其他切片

其他切片例如数组的切片其实也一样,我们只需要对其进行引用切片即可~

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

4.4 总结

本章学习了所有者,借用切片的概念,确保了在rust编译过程中的安全性。同时也梳理了一下rust的内存管理控制和使用,以及与其他语言在内存管理上的区别。所有者的影响rust代码的运行,是贯穿遍及整个rust学习者使用过程中的,所以我们必须好好理解