Rust编程语言入门之智能指针

Posted 小乔的博客

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Rust编程语言入门之智能指针相关的知识,希望对你有一定的参考价值。

智能指针

智能指针(序)

相关的概念

  • 指针:一个变量在内存中包含的是一个地址(指向其它数据)
  • Rust 中最常见的指针就是”引用“
  • 引用:
    • 使用 &
    • 借用它指向的值
    • 没有其余开销
    • 最常见的指针类型

智能指针

  • 智能指针是这样一些数据结构:
    • 行为和指针相似
    • 有额外的元数据和功能

引用计数(Reference counting)智能指针类型

  • 通过记录所有者的数量,使一份数据被多个所有者同时持有
  • 并在没有任何所有者时自动清理数据

引用和智能指针的其它不同

  • 引用:只借用数据
  • 智能指针:很多时候都拥有它所指向的数据

智能指针的例子

  • String 和 Vec<T>

  • 都拥有一片内存区域,且允许用户对其操作

  • 还拥有元数据(例如容量等)

  • 提供额外的功能或保障(String 保障其数据是合法的 UTF-8 编码)

智能指针的实现

  • 智能指针通常使用 Struct 实现,并且实现了:
    • Deref 和 Drop 这两个 trait
  • Deref trait:允许智能指针 struct 的实例像引用一样使用
  • Drop trait:允许你自定义当智能指针实例走出作用域时的代码

本章内容

  • 介绍标准库中常见的智能指针
    • Box<T>:在 heap 内存上分配值
    • Rc<T>:启用多重所有权的引用计数类型
    • Ref<T>RefMut<T>,通过 RefCell<T>访问:在运行时而不是编译时强制借用规则的类型
  • 此外:
    • 内部可变模型(interior mutability pattern):不可变类型暴露出可修改其内部值的 API
    • 引用循环(reference cycles):它们如何泄露内存,以及如何防止其发生。

一、使用Box<T> 来指向 Heap 上的数据

Box<T>

  • Box<T> 是最简单的智能指针:
    • 允许你在 heap 上存储数据(而不是 stack)
    • stack 上是指向 heap 数据的指针
    • 没有性能开销
    • 没有其它额外功能
    • 实现了 Deref trait 和 Drop trait

Box<T> 的常用场景

  • 在编译时,某类型的大小无法确定。但使用该类型时,上下文却需要知道它的确切大小。
  • 当你有大量数据,想移交所有权,但需要确保在操作时数据不会被复制。
  • 使用某个值时,你只关心它是否实现了特定的 trait,而不关心它的具体类型。

使用Box<T>在heap上存储数据

fn main() 
  let b = Box::new(5);
  println!("b = ", b);
 // b 释放存在 stack 上的指针 heap上的数据

使用 Box 赋能递归类型

  • 在编译时,Rust需要知道一个类型所占的空间大小
  • 而递归类型的大小无法再编译时确定
  • 但 Box 类型的大小确定
  • 在递归类型中使用 Box 就可解决上述问题
  • 函数式语言中的 Cons List

关于 Cons List

  • Cons List 是来自 Lisp 语言的一种数据结构
  • Cons List 里每个成员由两个元素组成
    • 当前项的值
    • 下一个元素
  • Cons List 里最后一个成员只包含一个 Nil 值,没有下一个元素 (Nil 终止标记)

Cons List 并不是 Rust 的常用集合

  • 通常情况下,Vec 是更好的选择
  • (例子)创建一个 Cons List
use crate::List::Cons, Nil;

fn main() 
  let list = Cons(1, Cons(2, Cons(3, Nil)));


enum List   // 报错
  Cons(i32, List),
  Nil,

  • (例)Rust 如何确定为枚举分配的空间大小
enum Message 
  Quit,
  Move x: i32, y: i32,
  Write(String),
  ChangeColor(i32, i32, i32),

使用 Box 来获得确定大小的递归类型

  • Box 是一个指针,Rust知道它需要多少空间,因为:
    • 指针的大小不会基于它指向的数据的大小变化而变化
use crate::List::Cons, Nil;

fn main() 
  let list = Cons(1, 
    Box::new(Cons(2, 
      Box::new(Cons(3, 
        Box::new(Nil))))));


enum List   
  Cons(i32, Box<List>),
  Nil,

  • Box
    • 只提供了”间接“存储和 heap 内存分配的功能
    • 没有其它额外功能
    • 没有性能开销
    • 适用于需要”间接“存储的场景,例如 Cons List
    • 实现了 Deref trait 和 Drop trait

二、Deref Trait(1)

Deref Trait

  • 实现 Deref Trait 使我们可以自定义解引用运算符 * 的行为。
  • 通过实现 Deref,智能指针可像常规引用一样来处理

解引用运算符

  • 常规引用是一种指针
fn main() 
  let x = 5;
  let y = &x;
  
  assert_eq!(5, x);
  assert_eq!(5, *y);

Box<T> 当作引用

  • Box<T> 可以替代上例中的引用
fn main() 
  let x = 5;
  let y = Box::new(x);
  
  assert_eq!(5, x);
  assert_eq!(5, *y);

定义自己的智能指针

  • Box<T> 被定义成拥有一个元素的 tuple struct
  • (例子)MyBox<T>
struct MyBox<T>(T);

impl<T> MyBox<T> 
  fn new(x: T) -> MyBox<T> 
    MyBox(x)
  


fn main() 
  let x = 5;
  let y = MyBox::new(x);  // 报错
  
  assert_eq!(5, x);
  assert_eq!(5, *y);

实现 Deref Trait

  • 标准库中的 Deref trait 要求我们实现一个 deref 方法:
    • 该方法借用 self
    • 返回一个指向内部数据的引用
  • (例子)
use std::ops::Deref;

struct MyBox<T>(T);

impl<T> MyBox<T> 
  fn new(x: T) -> MyBox<T> 
    MyBox(x)
  


impl<T> Deref for MyBox<T> 
  type Target = T;
  
  fn deref(&self) -> &T 
    &self.0
  


fn main() 
  let x = 5;
  let y = MyBox::new(x);  
  
  assert_eq!(5, x);
  assert_eq!(5, *y);  // *(y.deref())

三、Deref Trait (2)

函数和方法的隐式解引用转化(Deref Coercion)

  • 隐式解引用转化(Deref Coercion)是为函数和方法提供的一种便捷特性
  • 假设 T 实现了 Deref trait:
    • Deref Coercion 可以把 T 的引用转化为 T 经过 Deref 操作后生成的引用
  • 当把某类型的引用传递给函数或方法时,但它的类型与定义的参数类型不匹配:
    • Deref Coercion 就会自动发生
    • 编译器会对 deref 进行一系列调用,来把它转为所需的参数类型
      • 在编译时完成,没有额外性能开销
use std::ops::Deref;

fn hello(name: &str) 
  println!("Hello, ", name);


fn main() 
  let m = MyBox::new(String::from("Rust"));
  
  // &m &MyBox<String> 实现了 deref trait
  // deref &String
  // deref &str
  hello(&m);
  hello(&(*m)[..]);
  
  hello("Rust");


struct MyBox<T>(T);

impl<T> MyBox<T> 
  fn new(x: T) -> MyBox<T> 
    MyBox(x)
  


impl<T> Deref for MyBox<T> 
  type Target = T;
  
  fn deref(&self) -> &T 
    &self.0
  


fn main() 
  let x = 5;
  let y = MyBox::new(x);  
  
  assert_eq!(5, x);
  assert_eq!(5, *y);  // *(y.deref())

解引用与可变性

  • 可使用 DerefMut trait 重载可变引用的 * 运算符
  • 在类型和 trait 在下列三种情况发生时,Rust会执行 deref coercion:
    • 当 T:Deref<Target=U>,允许 &T 转换为 &U
    • 当 T:DerefMut<Target=U>,允许 &mut T 转换为 &mut U
    • 当 T:Deref<Target=U>,允许 &mut T 转换为 &U

四、Drop Trait

Drop Trait

  • 实现 Drop Trait,可以让我们自定义当值将要离开作用域时发生的动作。
    • 例如:文件、网络资源释放等
    • 任何类型都可以实现 Drop trait
  • Drop trait 只要求你实现 drop 方法
    • 参数:对self 的可变引用
  • Drop trait 在预导入模块里(prelude)
/*
 * @Author: QiaoPengjun5162 qiaopengjun0@gmail.com
 * @Date: 2023-04-13 21:39:51
 * @LastEditors: QiaoPengjun5162 qiaopengjun0@gmail.com
 * @LastEditTime: 2023-04-13 21:46:50
 * @FilePath: /smart/src/main.rs
 * @Description: 这是默认设置,请设置`customMade`, 打开koroFileHeader查看配置 进行设置: https://github.com/OBKoro1/koro1FileHeader/wiki/%E9%85%8D%E7%BD%AE 
 */
struct CustomSmartPointer 
    data: String,


impl Drop for CustomSmartPointer 
    fn drop(&mut self) 
        println!("Dropping CustomSmartPointer with data: ``!", self.data);
    


fn main() 
    let c = CustomSmartPointer data: String::from("my stuff");
    let d = CustomSmartPointer data: String::from("other stuff");
    println!("CustomSmartPointers created.")


运行

smart on  master [?] is 

RUST 语言特性之所有权

新年开工,开启 work & study 模式,接着来学习 RUST 语言。

作为一名 C/C++ 程序员,C/C++ 语言中的指针是使用得最爽的,几乎无所不能,各种奇技淫巧也层出不穷。但 C/C++ 语言中最折磨人的也是指针,伴随着开发过程的就是和指针导致的内存问题做斗争。

也许是意识到指针的巨大杀伤力,现代语言中都不再提供指针特性,改为引用和托管指针。其实在 Java 语言中,new 一个对象后得到的是一个指向对象的东西,本质上也是一个“指针”,但这个“指针”不可以随意修改,访问受到严格控制,和 C/C++ 语言中的指针有着本质的区别。

与指针息息相关的是内存管理,在 C/C++ 中都提供了申请内存和释放内存的函数或操作符,其使用原则也相当简单,使用时申请,不使用后释放。真所谓大道至简,但并没有什么用。不知多少程序员栽在忘记释放、多次释放、释放后仍然去访问等坑中。现代程序设计语言考虑到由程序员来管理内存不靠谱,基本上都提供了 GC(Garbage Collection, 垃圾回收) 机制,但 GC 机制无论如何设计,总会带来一定的开销,会拖慢速度,增加内存占用。

RUST 语言没有提供 GC 机制,但也不用程序员来管理内存,它是如何做到的?其秘诀就是所有权

RUST 使用包含特定规则的所有权系统来管理内存,这套规则允许编译器在编译过程中执行检查工作,而不会产生任何的运行时开销。这实际上是综合了上面两种内存管理的优势,看起来似乎没有短板。那是不是以后的程序设计语言都会采用这种设计?

也不一定,一切需要交给时间,只有经过时间的检验,才能确定这是一种好的设计,毕竟 RUST 语言还是太年轻。

阻碍这一设计的广泛使用的原因之一是所有权是一个非常新鲜的事务,理解起来也非常困难。原因之二是为了让所有权能够正常运转,制定了很多规则,只有遵循这些规则,才能写出无误的代码。虽然这些规则由编译器检查,但是程序员面对编译器哗啦啦的错误,也会一脸懵圈吧。

虽然 RUST 语言中的所有权规则比较复杂,但是有编译器把关,监督程序员不犯一些低级错误,比起运行后调试是抓狂,还是要好得多。

这篇文章先聊聊所有权的一些基本规则,更多的则需要在实际场景中再展开说明。

不过在了解所有权概念之前,先了解一下栈内存和堆内存。

栈内存和堆内存

首先,栈内存和堆内存只是一种软件上的概念,主要是从软件设计的角度进行划分。其实,从硬件层面上看,内存只是一长串字节。 而在虚拟内存层面上,它被分成三个主要部分:

  • 栈区,所有局部变量都存放在哪里。

  • 全局数据,其中包含静态变量,常量和类型元数据。

  • 堆区,所有动态分配的对象都在其中。 基本上,所有具有生命周期的东西都存储在这里。

全局数据区一般存放在一个固定的区域,不存在分配和释放的问题,不在讨论之列。

栈会以我们放入值时的顺序来存储它们,并以相反的顺序将值取出,即“后进先出”策略。栈空间有一个限制,就是所有存储在栈中的数据都必须拥有一个已知且固定的大小。对于那些在编译期无法确定大小的数据(动态分配,比如根据用户的输入值决定分配多少个数组),只能将它们存储在堆中。

堆空间的管理较为松散:将数据放入堆中时,先请求特定大小的空间。操作系统会根据请求在堆中找到一块足够大的可用空间,将它标记为已使用,并把指向这片空间地址的指针返回给程序。当程序不再需要这块内存时,通过某种方式来将这些内存归还给操作系统。根据内存管理的算法,可能还存在相邻空间合并的问题,也就是将相邻区域的较小块内存合并为较大块内存,这取决于内存管理算法。

很明显,向栈上推入数据要比在堆上进行分配更有效率一些,因为:

  1. 操作系统省去了搜索新数据存储位置的工作;这个位置永远处于栈的顶端。

  2. 操作系统在堆上分配空间时还必须首先找到足够放下对应数据的空间,并进行某些记录工作来协调随后进行的其余分配操作。

  3. 堆上分配内存,得到的是指向一块内存的指针。通过指针访问内存,多了跳转的环节,所以访问堆上的数据要慢于访问栈上的数据。

  4. 分配命令本身也可能消耗不少时钟周期。

栈空间只存在进栈和出栈两种操作,不存在内存管理问题,所以,内存管理问题实际上就是堆内存管理的问题。

所有权

所有权并不是 RUST 所独有的概念,Swift 语言中就存在。在我的理解中,所有权就相当于 C++ 中的智能指针,智能指针持有对象,智能指针结束生命周期,释放所持有的对象。

但智能指针存在循环引用的问题,虽然为此又引出了强指针和弱指针来解耦循环引用,但这种解耦依赖于程序员,并没有在语言层面上保证不会循环引用。

RUST 则通过一套所有权规则来保证不会存在 C++ 智能指针那样的问题。

所有权规则其实也不复杂,主要有如下三条:

  • Rust中的每一个值都有一个对应的变量作为它的所有者。

  • 在同一时间内,值有且仅有一个所有者。

  • 当所有者离开自己的作用域时,它持有的值就会被释放掉。

初次接触,可能理解上也有一些困难,下面逐条解释一下。

变量作用域

简单来讲,作用域是一个对象在程序中有效的范围。这个比较容易理解,在 Java 和 C++ 语言中都有作用域的概念,比如在一段程序块(通常使用一对大括号包括起来)中声明的变量,在程序块外面无法使用。

下面使用一段简短的 RUST 代码说明一下:


    ...               // 由于变量 s 还未被声明,所以它在这里是不可用的
    let s = "hello";  // 从这里开始,s 可用
    ...
                     // 作用域到此结束,s 不再可用

但需要注意的是,上面的代码使用了字面量字符串,它的值被硬编码到了当前的程序中,也就是存在全局数据区内,所以并没有涉及到内存的分配与释放。

为了说明 当所有者离开自己的作用域时,它持有的值就会被释放掉 这条规则,将上面的程序稍微修改一下:


    ...
    let s = String::from("hello");
    ...

上面的代码中,s 为 String 类型,在调用 String::from 时,该函数会请求自己需要的内存空间。

研究上面的代码,可以发现一个很适合用来回收内存给操作系统的地方:变量 s 离开作用域的地方。Rust 在变量离开作用域时,会调用一个叫作 drop 的特殊函数。String 类型的作者可以在这个函数中编写释放内存的代码。

注:Rust会在作用域结束的地方(即 处)自动调用 drop 函数

熟悉 C++ 的朋友可能会说,这不就是资源获取即初始化(Resource Acquisition Is Initialization, RAII)吗?对的,许多技术就是这样相通的,假如你在 C++ 中使用过类似的模式,那么理解 当所有者离开自己的作用域时,它持有的值就会被释放掉 这条规则就容易得多。

移动

上面的规则看起来很简单,但是如果多个变量与同一数据进行交互,问题就会复杂起来。比如下面的代码:

let s1 = String::from("hello");
let s2 = s1;

此时的内存布局类似于下图:

看到这张图,熟悉 C++ 的朋友会会心的一笑,这不就是所谓的"浅拷贝"吗? 对,技术就是这样传承的。

根据前面的规则,当一个变量离开当前的作用域时,Rust 会自动调用它的 drop 函数,并将变量使用的堆内存释放回收。很明显,上面的代码存在问题: s1 和 s2 指向了同一个地址,当s2和s1离开自己的作用域时,它们会尝试去重复释放相同的内存。这就是 C++ 中经常碰到的的二次释放问题。

RUST 如何解决这一问题?

为了确保内存安全,同时也避免复制分配的内存,Rust 在这种场景下会简单地将 s1 废弃,不再视其为一个有效的变量。因此,Rust也不需要在 s1 离开作用域后清理任何东西。

也就是说,let s2 = s1; 这句赋值语句,相当于隐式调用了一个类似于 C++ 中的 std::move 函数,转移了所有权。

试图在 s2 创建完毕后使用 s1 会导致编译时错误。

error[E0382]: use of moved value: `s1`
 --> src/main.rs:5:28
  |
3 |     let s2 = s1;
  |         -- value moved here
4 |
5 |     println!(", world!", s1);
  |                            ^^ value used here after move
  |
  = note: move occurs because `s1` has type `std::string::String`, which does
  not implement the `Copy` trait

这就是 在同一时间内,值有且仅有一个所有者 规则,同时还隐含了另外一个设计原则:Rust 永远不会自动地创建数据的深度拷贝。因此在 Rust 中,任何自动的赋值操作都可以被视为高效的。

克隆

当你确实需要去深度拷贝 String 堆上的数据时,可以使用一个名为 clone 的方法。

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = , s2 = ", s1, s2);

经过克隆,此时 s1 和 s2 的内存布局如下图所示:

因为 s1 和 s2 相互独立,所以释放不存在问题。

但需要注意,堆内存复制比较耗资源。所以,当你看到某处调用了 clone 时,你就应该知道某些特定的代码将会被执行,而且这些代码可能会相当消耗资源,这时需要特别小心,要评估一下是否有必要这样做。

其实在 C++ 中,设计对象的深拷贝和浅拷贝同样存在考量。

所有权与函数

在 C++ 中,将指针问题复杂化的一个因素就是各种函数调用与返回,RUST 语言同样如此。

将值传递给函数在语义上类似于对变量进行赋值。将变量传递给函数将会触发移动或复制,就像是赋值语句一样。至于何时移动何时复制,和变量类型有关。下面的代码展示了变量在函数传递过程中作用域的变化。

这些不用特别去记忆,RUST 可以通过静态检查使我们免于犯错。

对于返回值,同样如此。

总结起来,变量所有权的转移总是遵循相同的模式:

  • 将一个值赋值给另一个变量时就会转移所有权。

  • 当一个持有堆数据的变量离开作用域时,它的数据就会被drop清理回收,除非这些数据的所有权移动到了另一个变量上。


如果在所有的函数中都要获取所有权并返回所有权显得有些烦琐,假如你希望在调用函数时保留参数的所有权,这会涉及到 C++ 程序员非常熟悉的特性:引用。

限于篇幅原因,关于 RUST 中的引用,在下一篇文章中详细阐述,敬请关注!

以上是关于Rust编程语言入门之智能指针的主要内容,如果未能解决你的问题,请参考以下文章

Rust语言中级教程之指针

Rust编程语言入门之Rust的面向对象编程特性

Rust编程语言入门之高级特性

Rust编程语言入门之项目实例:- 命令行程序

Rust编程语言入门之无畏并发

Rust编程语言入门之编写自动化测试