第N次入门Rust - 3.所有权(Ownership)

文章目录

  • 前言
  • 3.1 各语言内存管理方式对比
  • 3.2 栈(stack)和堆(heap)
    • 3.2.1 存储数据
    • 3.2.2 访问数据
    • 3.2.3 函数调用
    • 3.2.4 所有权存在的原因
  • 3.3 所有权规则
    • 所有权规则主要内容
    • 3.3.1 如何理解
      • 值的关系
      • 值的清除
      • 所有权解决的问题
    • 3.3.2 变量作用域(scope)
    • 3.3.3 内存与分配——以String类型解释所有权
      • 前置知识
      • 创建String类型的值
      • 变量与数据交互的方式1--移动(Move)
      • 变量与数据交互的方式2--克隆(Clone)
      • 变量与数据交互的通俗归纳
      • Copy trait 和 Drop trait
    • 3.3.4 所有权与函数
    • 3.3.5 引用和借用
      • 介绍
      • 可变引用和不可变引用
        • 概念和语法
        • 可变引用针对数据竞争的限制
        • 可变引用各种例子
      • 悬空引用(Dangling References)
      • 引用小结
      • 值-引用-可变性之间的转换关系
    • 3.3.6 切片
      • 字符串切片
      • 字符串切片例子——获取字符串中第一个单词
      • 字符串字面值是切片
      • 将字符串切片作为参数传递
      • 从字符串切片引伸其它类型的切片(切片小结)


前言

这一篇介绍Rust的第一个核心:所有权,建议在忘了所有权的基础知识的时候多看本文两遍~

所有权是Rust最独特的特性,它让Rust无需GC就可以保证内存安全。


3.1 各语言内存管理方式对比

  • Rust的核心特性就是所有权。
  • 所有程序在运行时都必须管理它们使用计算机内存的方式:
    • 有些语言有垃圾回收机制,在程序运行时,它们会不断地寻找不再使用的内存。(如java, python);
    • 在其他语言中,开发者必须显式地分配和释放内存。(如C++);
  • Rust采用第三种方式:
    • 内存是通过所有权系统来管理的,其中包含一组编译器在编译时检查的规则;
    • 当程序运行时,所有权特性不会减慢程序的运行速度;

3.2 栈(stack)和堆(heap)

这里讨论的不是数据结构中的栈和堆,而是内存管理中的栈和堆。相信有其它编程语言基础的同学一看到这两个概念,DNA就动了,因为栈和堆在程序运行中一定会涉及到。

3.2.1 存储数据

以表格的形式对比stack和heap数据存储的区别:

对比项 stack heap
数据存放方式

stack按值的接收顺序来存储,按相反的顺序将它们移除(后进先出,LIFO):

1. 添加数据叫压栈;

2. 移除数据叫出栈;

操作系统首先需要找到一个足够大的空间来存放数据,然后要做好记录方便下次分配

操作系统在heap里找到一块足够大的空间,把它标记为在用,并返回一个指针,即这个空间的地址;

数据大小

所有存储在stack上的数据必须拥有已知的固定大小

存储的数据多大就申请多大的空间存放

数据存放空间申请速度对比

快,因为创建stack的时候就申请了一批空间,后续新数据写入时不需要重新申请空间,数据直接放stack的顶端

慢,因为每次写入新数据时都要进行一次申请

空间限制

stack的大小编译时已经决定了,一个stack大小往往比机器内存上限小得多

理论上操作系统分配多少就有多少

内存组织情况

其它:

  1. 把值压入stack的过程不叫分配,叫“压栈”,在heap中获取一块可用内存空间的过程叫作“分配”
  2. 为什么要求所有存储在stack上的数据必须拥有已知的固定大小:因为编译时大小未知的数据或运行时大小可能发生变化的数据必须存放在heap上;
  3. 指针的大小是固定的,因此可以把指针存放在stack中,如果想要访问指针指向的实际数据,必须使用指针来定位。

3.2.2 访问数据

以表格的形式对比stack和heap数据访问的区别:

对比项 stack heap
访问速度

快,因为stack比heap小且每一个数据能直接一步访问到

慢,因为需要先获得指向heap数据的指针(指针有可能在stack上也有可能在heap上),然后再通过指针找到heap的数据

其它:

  • 对于现代的处理器来说,由于缓存的缘故,如果指令在内存中跳转的次数越少,那么速度就越快;
  • 如果数据存放的距离比较近,那么处理器的处理速度就会更快一些,例如在stack上;
  • 如果数据之间的距离比较远,那么处理速度就会慢一些,例如在heap上;

3.2.3 函数调用

当代码调用函数时,stack上的数据值会被传入到函数(其中也会包括指向heap的指针);函数本地的变量被压到stack上。当函数结束后,这些值会从stack上弹出;

3.2.4 所有权存在的原因

传统的编程语言需要使用stack和heap完成不同的工作,stack中保存一些编译时能确定大小的数据,heap中保存一些编译时无法确定大小的数据;但这个过程往往会出现下列问题:

  1. 同一份数据被多个线程进行写操作;
  2. 存放在stack中的数据能随着函数的结束被出栈,但是heap中的数据无法根据这个信息确定是否要释放,通常这衍生出两种流派:开发者全手动或半自动释放(如C/C++),垃圾回收(Java、Python等);

Rust根据过去这些编程语言的内存管理特点总结出了内存使用规则(即所有权),只要按照这个使用规则去管理内存,就能免去手动管理的繁琐和使用垃圾回收的低效(当然,这还是需要代价的,那就是付出脑力去适应);

所有权规则并不是内存管理的最优策略,它实际上是牺牲了一些内存使用灵活性以换来内存使用安全和相对高效的内存使用体验

3.3 所有权规则

所有权规则主要内容

  1. Rust中的每一个值都有一个被称为所有者(owner)的变量
  2. 值在任一时刻有且只有一个所有者
  3. 当所有者(变量)离开作用域,这个值将被删除

3.3.1 如何理解

但从上面三条规则看起来比较抽象,这里给一个比较通俗的理解

值的关系

值与值之间有如下两种关系:完全无关从属关系

  1. 完全无关,比如有两个变量a: i32b: str,就像是一盒巧克力中的两颗巧克力;
  2. 有从属关系,比如有一个变量v: vec,和v[0],一个是动态数组,一个是动态数据中的一个元素,就像v是一盒巧克力,v[0]是这盒巧克力中的其中一颗巧克力,那么当有人问巧克力v[0]此时此刻在哪里的时候,你只能说v[0]v里面,或者把v[0]取出来放到另一盒巧克力中(比如一个结构体s: MyStruct的某个字段或者一个HashMap中),总不能说这颗巧克力同时在两个盒子中吧(抖机灵的或者想讨论哲学的乖乖站好给我叉出去[○・`Д´・ ○],这里只讨论巧克力在宏观世界中的物理位置)。

我猜你会觉得上面这个关系很好理解(毕竟现实生活就是这样吗),可是当你试图把其它编程语言的设计带入到这个“巧克力场景”中,你就会发现你问其它语言一个朴素的问题,其它语言给你讲哲学(比如可以有多个指针指向内存中同一块数据,这不就相当于它回答你“这块巧克力即在我的嘴里,也在你的心里”一样嘛[○・`Д´・ ○])。

“哲学”设计能赋予开发者灵活性,而“朴素”设计会在一定程度上限制了开发者“灵活的思维”,但却保证了数据是可控的(不会存在即吃了又还没有吃的叠加态~)

根据上面的说明可发现值与值之间能构成树,每个值只有一个持有者(默认自己是自己的持有者),如下所示:

|-a:i32							# 从属自己
|-b:str							# 从属自己
|-hash_map: HashMap<str, str>	# 从属自己
	|-entry<str, i32>			# entry从属于hash_map
		|-key:str				# key从属于entry
		|-value:i32				# value从属于entry
|-v:vec<i32>					# 从属自己
	|-1							# 从属于v
	|-100						# 从属于v
	|-200						# 从属于v

当一个值更改持有者时,相当于更改了子树,比如v[0]取出来后设置到hash_map中,不可能v[0]的数据同时属于两棵子树(那就变成图了)。

值的清除

当值从这个“值的森林”中移除时,应该把它和它的子树一并移除,不然如果它移除了,但是它的子树(假如叫sub_tree)没有移除,为了满足上面讲到持有者的条件,就会想到两种可能发成的情况:

  1. sub_tree挂到一个新的持有者下面,但此时程序无法自动知道谁是新的持有者,所以这种可能被排除;
  2. sub_tree自己称为一棵单独的树,这个时候相当于有一个新的树根,但开发者找不到这个树根,也就用不了sub_tree,所以这种可能也被排除了;

因此此时就可以确定这些值都是可以被清除。

所有权解决的问题

  • 跟踪代码的哪些部分正在使用heap的哪些数据;
  • 最小化heap上的重复数据量;
  • 清理heap上未使用的数据以避免空间不足;
  • 一旦懂得所有权的意义,就不需要经常去向stack或heap了,但还需要知道管理heap数据是所有权存在的原因,这有助于解释它为什么会这样工作;

3.3.2 变量作用域(scope)

  • 作用域是一个项(item)在程序中有效地范围;
  • 对于一个变量值来说,它有两个重要的时间点:
    1. 这个变量值进入作用域的时刻
    2. 这个变量值离开作用域的时刻(准确来说是这个变量值在作用域中最后一次使用的时刻)
  • 例子,变量s的作用域:
    fn main() {
        // s 不可用
        let s = "hello";    // s 可用
                            // 可以对 s 进行相关操作
    }   // s 作用域到此结束,s 不再可用
    

3.3.3 内存与分配——以String类型解释所有权

前置知识

  • 为什么使用String类型解释所有权:

    1. String类型比其它基础标量数据类型更复杂;
    2. String的数据存储在heap上面(准确来说是String的胖指针部分存放在stack上面,String的具体数据存储在heap上面),而基础标量类型数据存储在stack上面;
  • 这里只重点将String类型在所有权上的知识点。

  • 字符串字面值:在程序里手写的那些字符串值,它们是不可变的。

    • 字符串字面值不能满足日常开发需求,因为很多字符串数据无法在编写代码前就可以确定。
  • 针对字符串字面值无法满足开发需求的问题,Rust提供第二种字符串类型:String:

    • **String在heap上分配空间,能够存储在编译时未知数量的文本。
  • 指针:

    • 原始指针:这里指的是类似C语言那种最朴素指针;
    • 胖指针:指是一个结构体,里面一定会包含一个原始指针,然后可能还会带上一些其它字段(比如长度等),胖指针是可以保存在stack上面的(或者说胖指针的值可以被复制,但是当胖指针被复制的时候它指向的真实数据会被移动),因为它的类型大小可以固定,而它的真实数据则保存在heap中。

创建String类型的值

  • 可以使用from函数从字符串字面值创建出String类型:
    let s = String::from("hello");
    
  • String类型字符串是可以被修改的:
    fn main() {
        let mut s = String::from("Hello");
        s.push_str(", World");
        println!("{}", s);
    }
    
  • 之所以String类型的值可以修改,而字符串字面值不能修改,是因为两者处理内存的方式不同。

变量与数据交互的方式1–移动(Move)

多个变量与同一个数据使用一种独特的方式来交互——移动。

  • 浅拷贝(shallow copy)、深拷贝(deep copy)和移动(Move)的区别:
    • 浅拷贝:只对值在stack的部分进行复制,如果值在heap上也有数据的话,那么原本和副本都会有指针指向heap上同一份数据。
    • 深拷贝:不仅对值在stack的部分进行复制,同时也会将值在heap上的数据进行复制,并使副本在stack中指向heap数据的指针指向heap中的复制数据。
    • 移动:通常这个值是一个胖指针,只复制这个胖指针的数据部分(包括一个指向真实数据的指针和少量字段),然后原本指向该真实数据的胖指针失效,从而保证指向真实数据的指针只有一个。(我并没有说值是保存在stack上还是heap上,而是根据值是否是一个指针来确定是执行复制还是移动,后面会说明rust会判断类型是否实现了一个名为Copy的trait来判断是复制还是移动)
      • 这里不知道rust的具体实现,反正可以通俗理解成这样:
        var1 绑定了 value_a
        当进行var1的值赋予var2时
        if value_a是一个胖指针 {
            完整复制value_a给var2
            var1失效
        } else {
            完整复制value_a给var2 // var1也有一份一模一样的数据
        }
        

Rust不会自动创建数据的深拷贝,默认的所有操作都是移动。

变量与数据交互的方式2–克隆(Clone)

  • 克隆即深拷贝(deep copy)。
  • 如果真想对heap上面的数据进行深度拷贝,而不仅仅是stack上的数据,可以使用clone()方法。

变量与数据交互的通俗归纳

给定一个值,根据值的构成考虑在进行多变量交互时的行为:

  • 值不是胖指针 -> 直接拷贝;
  • 值是一个胖指针 -> 发生移动,把旧的胖指针数据字段复制给新的胖指针,然后旧的胖指针失效;

Copy trait 和 Drop trait

  • 如果一个类型实现了Copy trait,那么旧的变量跟在赋值后仍然可用。

  • 如果一个类型或者该类型的一部分实现了Drop trait,那么Rust不允许它再实现Copy trait。(即一个类型实现Copy trait,必须要保证它的每一个元素都实现Copy trait)

  • 使用Copy trait的要求:

    • 任何简单标量的组合类型都可以是Copy的。
    • 任何需要分配内存或某种资源都不是Copy的。
  • 一些属于Copy的类型:

    • 整型
    • 布尔类型(bool)
    • 浮点型
    • 字符型(char)
    • 元组,但必须要保证元组中每一个类型都是Copy的

3.3.4 所有权与函数

  • 在语义上,将值传递给函数和把值赋给变量是类似的:
    • 将值传递给函数,将会发生复制或移动;
    • 如果值不是胖指针(或不包含胖指针),则发生复制,如果值是胖指针(或包含胖指针),则发生移动;
  • 函数在返回值的过程中同样会所有权的转移。
  • 一个变量的所有权总是遵循同样的模式:
    • 把一个值赋给其他变量时就会发生移动;
    • 当一个胖指针离开作用域时,它指向的真实数据就会被drop函数清理,除非数据的所有权移动到另一个变量上;
  • 如何让函数使用某个值,但不获得其所有权?
    • 方法一:将值传给函数,用完以后将值返回给调用方;
    • 方法二:使用“引用”;

3.3.5 引用和借用

介绍

  • 引用(reference):获取一个指向某个值的指针,通过这个指针间接使用这个变量的值,从而不需要获得这个值的所有权。这个指针就是引用。
    • 引用也是指针,但它的特点是不会获得它指向的真实数据的所有权。
  • 借用(borrow):把值的引用作为函数参数的行为叫做借用。
    • “引用”是一种数据结构,而“借用”是一种行为。
  • 为什么需要引用:如果没有引用,那么当需要使用某个数据时,就需要先获得这个数据的所有权才能使用,这非常麻烦。引用就是针对这种数据只是稍微用一下就归还给原持有者的情况。

可变引用和不可变引用

概念和语法
  • 和变量一样,引用默认情况下也是不可变的。
  • 可变引用:如果想通过引用修改引用指向的值,则可以使用可变引用。只有可变变量由可变引用。

引用操作符&语法:

说明 真实数据类型 表示该类型的引用 获取某个值的引用
不可变引用 MyType &MyType let my_ref: &MyType = &my_value;
可变引用 MyType &mut MyType let my_ref: &mut MyType = &mut my_value;

引用可变性和变量可变性的关系:

说明 声明 变量的不可变引用 变量的可变引用
不可变变量 let x = ...; &x 没有
可变变量 let mut y = ...; &y &mut y
可变引用针对数据竞争的限制
  • 数据竞争:以下三种行为同时满足的话会发生数据竞争

    1. 两个或多个指针同时访问同一个数据;
    2. 至少有一个指针用于写入数据;
    3. 没有使用任何机制来同步对数据的访问;
  • 为了防止数据竞争,针对上述三个发生数据竞争的条件,Rust对可变引用做出两个限制:

    1. 在特定作用域内,对某一块数据,只能有一个可变的引用;
    2. 不可以同时拥有一个可变引用和一个不可变引用;但可以同时拥有任意数量的不可变引用;
  • 上述两个限制可理解为读锁和写锁。

  • 可以通过创建新的作用域,来允许非同时的创建多个可变引用。

    • 注意:需要保证同一个时刻不能在当前作用域的可访问范围内有多个指向同一个值的可变引用。
    • 错误例子:
          fn main() {
              let mut s1 = String::from("Hello");
              let r1 = &mut s1;
              {
                  // 在这个作用域中,r1和r2同时生效,违反可变引用的限制
                  let r2 = &mut s1;
                  println!("s1 {} ", r2);
              }
              println!("s1 {}", r1);
          }
          ```
      
    • 正确例子:
        fn main() {
            let mut s1 = String::from("Hello");
            {
                // 在这个作用域中,只有r2
                let r2 = &mut s1;
                println!("s1 {} ", r2);
            }
            // 在这个作用域中,只有r1,r2已经被回收了
            let r1 = &mut s1;
            println!("s1 {}", r1);
        }
        ```
    
    
可变引用各种例子
  • 错误例子1:同一个值在同一个作用域内声明了两个指向它的可变引用。
    fn main() {
        let mut s1 = String::from("Hello");
        let r1 = &mut s1;
        let r2 = &mut s1;                       // 报错:超过一个s1的可变引用
    
        println!("s1 {} and s1 {}", r1, r2);
    }
    
  • 错误例子2:同一个值在同一个作用域内既有可变已用也有不可变引用。
    fn main() {
        let mut s1 = String::from("Hello");
        let r1 = &s1;
        let r2 = &s1;
        println!("{} {}", r1, r2);
    
        // 屏蔽下面两行则没有错误
        let r3 = &mut s1;
        println!("{} {} {}", r1, r2, r3);
    }
    

悬空引用(Dangling References)

  • 悬空指针:一个指针引用了内存中的某个地址,而这块内存已经释放并分配给其他人使用了。
  • 在Rust里,编译器可保证永远都不是悬空引用。
    • 如果开发者引用了某些数据,编译器将保证在引用离开作用域之前数据不会离开作用域。(数据的销毁会晚于它的引用)
  • 一个错误例子:
    fn main() {
        let r = dangle();
    }
    
    fn dangle() -> &String {
        let s = String::from("hello");
        &s                              // 因为s的销毁比&s要早,所以编译报错
    }
    

引用小结

  • 一个作用域内在任何给定的时刻对一个值的引用情况,只能满足下列条件之一:
    • 只有一个可变的引用。
    • 有任意数量不可变的引用。
  • 引用必须一致有效,即数据的销毁会晚于它的引用。

引用可变性和变量可变性的关系:

说明 声明 变量的不可变引用 变量的可变引用
不可变变量 let x = ...; &x 没有
可变变量 let mut y = ...; &y &mut y

值-引用-可变性之间的转换关系

第N次入门Rust - 3.所有权(Ownership)_第1张图片

  • 图中标示出所有 值-值、值-引用、引用-引用之间可进行的转换关系,没有标识连线的项之间说明无法之间转换。
  • 不可变变量与可变变量之间可以互相转换。
  • 不可变变量可以获取其不可变引用,但无法获取可变引用。
  • 可变变量可以获取其可变引用和不可变引用。
  • 可变引用可之间转换为不可变引用,但是不可变引用无法直接转换为可变引用。
  • 上述关系不仅适用于变量之间的值和引用的传递,还适用与函数参数与返回值的传递。
  • 上面不同转换时等号左边的类型声明可以省略。

3.3.6 切片

  • 切片(Slice)是Rust的一种不持有所有权的数据类型。通常切片拥有一个指针指向一个容器,同时拥有一个范围用于规定通过切片可访问到的容器中元素的范围
  • 切片是一种胖指针。

字符串切片

  • 字符串切片是指向字符串中一部分内容的引用。

  • 字符串切片形式:&str_name[start_index..end_index];

    • 其中&str_name表示切片是来自str_name的一个部分引用。
    • start_index表示切片的起始索引值。
    • end_index表示切片的终止位置的下一个索引值。
    • 注意切片范围是左闭右开区间:[start_index, end_index)
  • 字符串切片类型:&str

    • &str是一个不可变引用。
  • 语法糖:

    • st到字符串结尾:&str_name[st..]
    • 从字符串开头到ed&str_name[..ed]
    • 从字符串开头到字符串结尾:&str_name[..]
  • 注意:

    • 字符串切片的范围索引必须在有效的UTF-8字符边界内。
    • 如果尝试从一个多字节的字符中创建字符串切片,程序会报错并退出。
  • 给出例子,在下面的代码中,有字符串s以及两个字符串切片helloworld。其中sworld的内存关系如下图所示。可以看出:

    • 字符串s拥有三个元素:指向heap数据的指针、字符串长度、字符串容量。
    • 字符串切片world拥有两个元素:指向heap数据起始位置的指针、切片长度。
fn main() {
    let s = String::from("hello world");
    let hello = &s[0..5];
    let world = &s[6..11];
    
    println!("{}, {}", hello, world);
}

第N次入门Rust - 3.所有权(Ownership)_第2张图片

字符串切片例子——获取字符串中第一个单词

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

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

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[..]
}

字符串字面值是切片

  • 字符串字面值被直接存储在二进制程序中。
  • 通过例子可发现字符串字面值是字符串切片类型:
    let s = "Hello World";  // s 的类型是 &str
    
  • &str是一个不可变引用,所以字符串字面值也是不可变的。

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

  • 建议传给函数的字符串引用参数以切片的形式给出,即&str,这样可以保证函数既可以接收到&String类型的参数,也可以接收&str类型的参数。
  • 注意:&String&str是两个不同的类型:
    • &String是字符串值的引用。
    • &str是字符串值的切片。
  • 对于一个以&str为入参的函数:fn first_word(s: &str) -> &str { /*...*/ }
    • 当入参是一个&String类型时,会创建一个完整的切片来调用该函数,即&str_name[..]
    • 当入参是一个字符串切片(&str)时,会直接调用该函数。
  • 定义函数时使用字符串切片来代替字符串引用,可以是API更加通用,且不会损失任何功能。

从字符串切片引伸其它类型的切片(切片小结)

  1. 切片的语法形式:
类型 声明切片类型 获取值的切片
不可变切片 &[MyCollectionType] let x: &[MyCollectionType] = &my_value[st..ed];
可变切片 &mut [MyCollectionType] let x: &mut [MyCollectionType] = &mut my_value[st..ed]; // 需要保证my_value是可变的
  1. 切片的结构:通常是包含一个指针字段 和 一个标识切片区间长度的字段。

你可能感兴趣的:(第N次入门Rust,rust,开发语言,后端)