Rust 程序设计语言学习——智能指针

avatar
作者
筋斗云
阅读量:0

智能指针(smart pointers)是一类数据结构,它们的表现类似指针,但是也拥有额外的元数据和功能。智能指针的概念并不为 Rust 所独有;其起源于 C++ 并存在于其他语言中。Rust 标准库中定义了多种不同的智能指针,它们提供了多于引用的额外功能。

一、智能指针 box

最简单直接的智能指针是 box,其类型是 Box<T>。box 允许你将一个值放在堆上而不是栈上。留在栈上的则是指向堆数据的指针。

除了数据被储存在堆上而不是栈上之外,box 没有性能损失。不过也没有很多额外的功能。它们多用于如下场景:

  1. 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候
  2. 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
  3. 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候

因为 Box<T> 实现了 Deref trait,它允许 Box<T> 值被当作引用对待。当 Box<T> 值离开作用域时,由于 Box<T> 类型 Drop trait 的实现,box 所指向的堆数据也会被清除。

1.1 使用 Box 在堆上储存数据

Box<T> 是一种智能指针,它允许你拥有一个在堆上分配的值。Box 是 Rust 标准库中的一部分,并且是所有权系统的一部分,用于在 Rust 中管理内存。下面是一个使用 Box<T> 在堆上存储数据的简单例子:

fn main() {     // 创建一个Box,它包含一个i32类型的值     let mut b = Box::new(42);      // 使用解引用运算符*来访问Box中的值     println!("The value is: {}", *b);      // 将Box中的值更改为一个新的值     *b = 50;     println!("The value has been changed to: {}", *b);      // Box会自动在离开作用域时释放堆内存 } 

在这个例子中,我们创建了一个包含 i32 类型值的 Box。我们使用 Box::new() 函数来在堆上分配内存,并存储一个值。然后,我们通过解引用运算符 * 来访问和修改这个值。当 b 离开作用域时,Rust的所有权系统会自动释放这个 Box 所占用的堆内存。

如果你想要在堆上存储更复杂的数据类型,比如结构体,你可以这样做:

struct Point {     x: i32,     y: i32, }  fn main() {     // 使用Box来存储Point结构体的实例     let mut box_point = Box::new(Point { x: 10, y: 20 });      // 访问Point结构体中的字段     println!("Point coordinates: ({}, {})", box_point.x, box_point.y);      // 修改Point结构体中的字段     box_point.x = 30;     box_point.y = 40;     println!("Point coordinates have been changed to: ({}, {})", box_point.x, box_point.y); } 

在这个例子中,我们定义了一个 Point 结构体,然后使用 Box 来存储这个结构体的实例。我们可以通过解引用 Box 来访问和修改结构体的字段。同样,当 box_point 离开作用域时,堆内存会被自动释放。

1.2 Box 允许创建递归类型

递归类型(recursive type)的值可以拥有另一个同类型的值作为其自身的一部分。但是这会产生一个问题,因为 Rust 需要在编译时知道类型占用多少空间。递归类型的值嵌套理论上可以无限地进行下去,所以 Rust 不知道递归类型需要多少空间。因为 box 有一个已知的大小,所以通过在循环类型定义中插入 box,就可以创建递归类型了。

下面的例子展示了如何使用 Box 来创建一个递归类型。这个例子中,我们将定义一个简单的链表,其中每个元素可以包含数据和指向下一个元素的链接。

enum List<T> {     Cons(T, Box<List<T>>),     Nil, }  impl<T> List<T> {     // 创建一个新的链表元素     fn new_cons(head: T, tail: List<T>) -> Self {         List::Cons(head, Box::new(tail))     }      // 创建一个空的链表     fn new_nil() -> Self {         List::Nil     } }  fn main() {     // 创建一个简单的链表: 1 -> 2 -> 3 -> Nil     let list = List::new_cons(1, List::new_cons(2, List::new_cons(3, List::new_nil())));      // 打印链表的元素     println!("List elements:");     let mut cursor = &list;     while let List::Cons(head, tail) = cursor {         println!("{}", head);         cursor = &(*tail); // 解引用Box     } } 

运行结果

List elements: 1 2 3 

在这个例子中:

  1. 我们定义了一个名为 List<T> 的枚举,它可以是 Cons(T, Box<List<T>>),表示链表的一个元素,其中包含数据 T 和指向下一个元素的 Box<List<T>>;或者是 Nil,表示链表的末尾。
  2. 我们为 List<T> 实现了两个方法:new_cons 用于创建一个新的链表元素,new_nil 用于创建一个空的链表。
  3. main 函数中,我们创建了一个简单的链表,其中包含三个元素:1, 2, 3。
  4. 我们使用一个循环来遍历并打印链表中的元素。在循环中,我们使用 while let 来匹配 List::Cons,并解引用 Box 来获取下一个元素。

二、通过 Deref trait 将智能指针当作常规引用处理

实现 Deref trait 允许我们重载解引用运算符(dereference operator)*(不要与乘法运算符或通配符相混淆)。通过这种方式实现 Deref trait 的智能指针可以被当作常规引用来对待,可以编写操作引用的代码并用于智能指针。

Deref trait,由标准库提供,要求实现名为 deref 的方法,其借用 self 并返回一个内部数据的引用。

下面是一个简单的例子,其中我们定义了一个结构体 MyBox,它将一个 i32 值封装起来,并为它实现了 Deref trait,使其可以像引用一样被解引用:

use std::ops::{Deref, DerefMut};  struct MyBox<T> {     value: T, }  impl<T> MyBox<T> {     fn new(value: T) -> MyBox<T> {         MyBox { value }     } }  // 实现Deref trait,允许MyBox表现得像对内部值的引用 impl<T> Deref for MyBox<T> {     type Target = T;      fn deref(&self) -> &Self::Target {         &self.value     } }  // 可选:实现DerefMut trait,允许可变性地解引用MyBox impl<T> DerefMut for MyBox<T> {     fn deref_mut(&mut self) -> &mut Self::Target {         &mut self.value     } }  fn main() {     let mut x = MyBox::new(5);     println!("The value is: {}", *x); // 使用Deref trait解引用x      *x = 10; // 使用DerefMut trait修改值     println!("The value has been changed to: {}", *x); } 

运行结果

The value is: 5 The value has been changed to: 10 

在这个例子中:

  1. 我们定义了一个名为 MyBox 的结构体,它包含一个泛型类型 T 的字段 value
  2. 我们为 MyBox 实现了 Deref trait,通过实现 deref 方法,它返回对 MyBox 内部 value 字段的不可变引用。
  3. 我们还可选地为 MyBox 实现了 DerefMut trait,这允许我们可变地解引用 MyBox。这是通过实现 deref_mut 方法来完成的,它返回对 value 字段的可变引用。
  4. main 函数中,我们创建了一个 MyBox 实例,并使用 * 运算符来解引用它,就像它是一个普通引用一样。我们还展示了如何通过解引用来修改 MyBox 内部的值。

Deref 强制转换如何与可变性交互

类似于如何使用 Deref trait 重载不可变引用的 * 运算符,Rust 提供了 DerefMut trait 用于重载可变引用的 * 运算符。

Rust 在发现类型和 trait 实现满足三种情况时会进行 Deref 强制转换:

  • T: Deref<Target=U> 时从 &T&U
  • T: DerefMut<Target=U> 时从 &mut T&mut U
  • T: Deref<Target=U> 时从 &mut T&U

前两个情况除了第二种实现了可变性之外是相同的:第一种情况表明如果有一个 &T,而 T 实现了返回 U 类型的 Deref,则可以直接得到 &U。第二种情况表明对于可变引用也有着相同的行为。

第三个情况有些微妙:Rust 也会将可变引用强转为不可变引用。但是反之是不可能的:不可变引用永远也不能强转为可变引用。因为根据借用规则,如果有一个可变引用,其必须是这些数据的唯一引用(否则程序将无法编译)。将一个可变引用转换为不可变引用永远也不会打破借用规则。将不可变引用转换为可变引用则需要初始的不可变引用是数据唯一的不可变引用,而借用规则无法保证这一点。因此,Rust 无法假设将不可变引用转换为可变引用是可能的。

三、使用 Drop Trait 运行清理代码

对于智能指针模式来说第二个重要的 trait 是 Drop,其允许我们在值要离开作用域时执行一些代码。可以为任何类型提供 Drop trait 的实现,同时所指定的代码被用于释放类似于文件或网络连接的资源。

在 Rust 中,可以指定每当值离开作用域时被执行的代码,编译器会自动插入这些代码。于是我们就不需要在程序中到处编写在实例结束时清理这些变量的代码 —— 而且还不会泄漏资源。

指定在值离开作用域时应该执行的代码的方式是实现 Drop trait。Drop trait 要求实现一个叫做 drop 的方法,它获取一个 self 的可变引用。

在Rust中,Drop trait用于定义当对象的所有权结束时应该执行的清理逻辑。这通常用于资源的清理,比如文件句柄、网络连接或者自定义的资源。

下面是一个实现了 Drop trait 的例子,这个例子中我们定义了一个简单的结构体 ResourceManager,它在被丢弃时打印一条消息:

struct ResourceManager {     name: String, }  impl ResourceManager {     // 创建一个新的ResourceManager实例     fn new(name: &str) -> ResourceManager {         ResourceManager {             name: name.to_string(),         }     }      // 使用方法来执行一些操作     fn use_resource(&self) {         println!("Using resource: {}", self.name);     } }  impl Drop for ResourceManager {     // Drop trait的实现     fn drop(&mut self) {         println!("Resource {} is being cleaned up.", self.name);     } }  fn main() {     let resource = ResourceManager::new("Resource1");     resource.use_resource();     // 当resource离开作用域时,Drop trait的drop方法会被自动调用 } 

运行结果

Using resource: Resource1 Resource Resource1 is being cleaned up. 

在这个例子中:

  1. 我们定义了一个结构体 ResourceManager,它有一个字段 name,用于存储资源的名称。
  2. 我们为 ResourceManager 实现了一个构造函数 new,它创建并返回一个新的 ResourceManager 实例。
  3. 我们定义了一个方法 use_resource,它模拟了使用资源的行为。
  4. 我们为 ResourceManager 实现了 Drop trait 的 drop 方法,它在 ResourceManager 实例被丢弃时执行,打印一条清理资源的消息。
  5. main 函数中,我们创建了一个 ResourceManager 实例,并使用它。当 resource 变量离开作用域时,Rust 的所有权系统会自动调用 drop 方法来执行清理。

四、智能指针 Rc

为了启用多所有权需要显式地使用 Rust 类型 Rc<T>,其为引用计数(reference counting)的缩写。引用计数意味着记录一个值的引用数量来知晓这个值是否仍在被使用。如果某个值为零个引用,就代表没有任何有效引用并可以被清理。

Rc<T> 用于当我们希望在堆上分配一些内存供程序的多个部分读取,而且无法在编译时确定程序的哪一部分会最后结束使用它的时候。如果确实知道哪部分是最后一个结束使用的话,就可以令其成为数据的所有者,正常的所有权规则就可以在编译时生效。

Rc<T> 通过维护一个引用计数来跟踪有多少个部分正在使用数据。当最后一个引用被销毁时,数据也会被自动释放。

下面是一个使用 Rc<T> 来共享数据的例子:

use std::rc::Rc;  fn main() {     // 创建一个Rc,包含一个字符串     let text = Rc::new("Hello, Rust!".to_string());      // 创建两个对text的引用     let text_ref_a = Rc::clone(&text);     let text_ref_b = Rc::clone(&text);      // 打印引用计数,此时应该是3(原始的text和两个克隆的引用)     println!("Reference count after creating references: {}", Rc::strong_count(&text));      // 使用text_ref_a和text_ref_b     println!("Text A: {}", text_ref_a);     println!("Text B: {}", text_ref_b);      // 当text_ref_a和text_ref_b离开作用域时,引用计数会减少 } 

运行结果

Reference count after creating references: 3 Text A: Hello, Rust! Text B: Hello, Rust! 

这个例子中,我们首先使用 Rc::new 创建了一个包含字符串的 Rc。然后,我们使用 Rc::clone 来克隆 Rc 的引用,这会增加引用计数。我们打印出引用计数,以展示有多少个部分正在使用数据。当 text_ref_atext_ref_b 离开作用域时,引用计数会相应减少。最终,当原始的 text 也离开作用域时,引用计数将变为 0,数据将被自动释放。

请注意,Rc<T> 只适用于单线程环境。如果你需要在多线程环境中共享数据,你应该使用 Arc<T>(Atomic Reference Counted 类型),它提供了线程安全的引用计数。此外,Rc<T> 并不保证线程安全,因为引用计数的修改不是原子操作。如果你尝试在多线程环境中使用 Rc<T>,可能会导致数据竞争和其他问题。在这种情况下,使用 Arc<T> 是更安全的选择。

通过不可变引用, Rc<T> 允许在程序的多个部分之间只读地共享数据。如果 Rc<T> 也允许多个可变引用,则会违反第四章讨论的借用规则之一:相同位置的多个可变借用可能造成数据竞争和不一致。不过可以修改数据是非常有用的!

五、智能指针 RefCell

内部可变性(Interior mutability)是 Rust 中的一个设计模式,它允许你即使在有不可变引用时也可以改变数据,这通常是借用规则所不允许的。为了改变数据,该模式在数据结构中使用 unsafe 代码来模糊 Rust 通常的可变性和借用规则。不安全代码表明我们在手动检查这些规则而不是让编译器替我们检查。

当可以确保代码在运行时会遵守借用规则,即使编译器不能保证的情况,可以选择使用那些运用内部可变性模式的类型。所涉及的 unsafe 代码将被封装进安全的 API 中,而外部类型仍然是不可变的。

5.1 通过 RefCell 在运行时检查借用规则

不同于 Rc<T>RefCell<T> 代表其数据的唯一的所有权。那么是什么让 RefCell<T> 不同于像 Box<T> 这样的类型呢?回忆一下借用规则:

  • 在任意给定时刻,只能拥有一个可变引用或任意数量的不可变引用之一(而不是两者)。
  • 引用必须总是有效的。

对于引用和 Box<T>,借用规则的不可变性作用于编译时。对于 RefCell<T>,这些不可变性作用于 运行时。对于引用,如果违反这些规则,会得到一个编译错误。而对于 RefCell<T>,如果违反这些规则程序会 panic 并退出。

在编译时检查借用规则的优势是这些错误将在开发过程的早期被捕获,同时对运行时没有性能影响,因为所有的分析都提前完成了。为此,在编译时检查借用规则是大部分情况的最佳选择,这也正是其为何是 Rust 的默认行为。相反在运行时检查借用规则的好处则是允许出现特定内存安全的场景,而它们在编译时检查中是不允许的。静态分析,正如 Rust 编译器,是天生保守的。

因为一些分析是不可能的,如果 Rust 编译器不能通过所有权规则编译,它可能会拒绝一个正确的程序;从这种角度考虑它是保守的。如果 Rust 接受不正确的程序,那么用户也就不会相信 Rust 所做的保证了。然而,如果 Rust 拒绝正确的程序,虽然会给程序员带来不便,但不会带来灾难。RefCell<T> 正是用于当你确信代码遵守借用规则,而编译器不能理解和确定的时候。

类似于 Rc<T>RefCell<T> 只能用于单线程场景。如果尝试在多线程上下文中使用 RefCell<T>,会得到一个编译错误。

如下为选择 Box<T>Rc<T>RefCell<T> 的理由:

  1. Rc<T> 允许相同数据有多个所有者;Box<T>RefCell<T> 有单一所有者。
  2. Box<T> 允许在编译时执行不可变或可变借用检查;Rc<T> 仅允许在编译时执行不可变借用检查;RefCell<T> 允许在运行时执行不可变或可变借用检查。
  3. 因为 RefCell<T> 允许在运行时执行可变借用检查,所以我们可以在即便 RefCell<T> 自身是不可变的情况下修改其内部的值。

在不可变值内部改变值就是内部可变性模式。

5.2 RefCell 使用

RefCell<T> 是一种类型,提供了运行时借用检查的封装。它允许在有多个所有者的情况下运行时借用数据,即使借用规则在编译时可能无法满足。RefCell<T> 通常与 Rc<T> 结合使用,以实现在多个部分之间共享可变数据。

以下是一个使用 RefCell<T>Rc<T> 来共享可变数据的例子:

use std::cell::RefCell; use std::rc::Rc;  fn main() {     // 创建一个包含i32的Rc<RefCell<T>>     let value = Rc::new(RefCell::new(1));      // 创建两个对value的引用     let value_ref_a = Rc::clone(&value);     let value_ref_b = Rc::clone(&value);      // 修改value_ref_a中的值     *value_ref_a.borrow_mut() += 1;      // 修改value_ref_b中的值     *value_ref_b.borrow_mut() += 1;      // 打印value的当前值     println!("Value after modifications: {}", value.borrow()); } 

运行结果

Value after modifications: 3 

这个例子中,我们首先使用 Rc::newRefCell::new 创建了一个包含 i32Rc<RefCell<i32>>。然后,我们使用 Rc::clone 来克隆 Rc 的引用,这会增加引用计数。我们使用 borrow_mut 方法来获取对内部数据的可变借用,并修改它。当我们调用 borrow_mut 时,RefCell 会在运行时检查借用规则,确保没有其他的可变借用或不可变借用正在发生。最终,我们使用 borrow 方法来获取对内部数据的不可变借用,并打印其值。

请注意,RefCell<T> 只适用于单线程环境。如果你需要在多线程环境中共享数据,你应该使用 Mutex<T>RwLock<T> 等同步原语。

六、引用循环与内存泄漏

Rust 的内存安全性保证使其难以意外地制造永远也不会被清理的内存(被称为内存泄漏(memory leak)),但并不是不可能。Rust 并不保证完全防止内存泄漏,这意味着内存泄漏在 Rust 中被认为是内存安全的。这一点可以通过 Rc<T>RefCell<T> 看出:创建引用循环的可能性是存在的。这会造成内存泄漏,因为每一项的引用计数永远也到不了 0,持有的数据也就永远不会被释放。

在 Rust 中,使用 Rc<T>RefCell<T> 可以创建循环引用,这可能导致内存泄漏,因为引用计数无法达到零,因此无法自动释放内存。以下是一个创建循环引用的例子:

use std::rc::Rc; use std::cell::RefCell;  struct Node {     value: i32,     next: RefCell<Option<Rc<Node>>>, }  fn main() {     let a = Rc::new(Node {         value: 1,         next: RefCell::new(None),     });      let b = Rc::new(Node {         value: 2,         next: RefCell::new(None),     });      // 制造循环引用     *a.next.borrow_mut() = Some(Rc::clone(&b));     *b.next.borrow_mut() = Some(Rc::clone(&a));      println!("Value a: {}, b: {}", a.value, b.value);     println!("Reference count a: {}, b: {}", Rc::strong_count(&a), Rc::strong_count(&b));     // 此时a和b相互引用,无法自动释放 } 

运行结果

Value a: 1, b: 2 Reference count a: 2, b: 2 

在这个例子中,我们创建了两个 Node 结构体实例 ab,它们通过 next 字段相互引用,形成了一个循环引用。由于 Rc<T> 的引用计数机制,只要存在至少一个引用,内存就不会被释放,这就导致了内存泄漏。

为了解决这个问题,我们可以使用 Weak<T>Weak<T> 是一种不拥有数据的智能指针,它不会增加引用计数。通常与 Rc<T> 一起使用,以打破循环引用。下面是修正后的代码:

use std::rc::{Rc, Weak}; use std::cell::RefCell;  struct Node {     value: i32,     next: RefCell<Option<Weak<Node>>>, }  fn main() {     let a = Rc::new(Node {         value: 1,         next: RefCell::new(None),     });           let b = Rc::new(Node {         value: 2,         next: RefCell::new(None),     });      println!("Reference count a: {}, b: {}", Rc::strong_count(&a), Rc::strong_count(&b));     // 使用Weak来避免循环引用     *a.next.borrow_mut() = Some(Rc::downgrade(&b));     *b.next.borrow_mut() = Some(Rc::downgrade(&a));      println!("Value a: {}, b: {}", a.value, b.value);     // Weak指针不会增加Rc的引用计数     println!("Reference count a: {}, b: {}", Rc::strong_count(&a), Rc::strong_count(&b)); } // a和b离开作用域,由于没有其他强引用,引用计数减到0,内存被释放 // 由于使用了Weak指针,即使存在循环引用,也不会导致内存泄漏 

运行结果

Reference count a: 1, b: 1 Value a: 1, b: 2 Reference count a: 1, b: 1 

在这个修正后的版本中,我们使用 Rc::downgrade 来将 Rc<Node> 转换为 Weak<Node>。这样,abnext 字段持有的是 Weak<Node> 类型的指针,而不是 Rc<Node>。当 ab 离开作用域时,它对应的 Rc<Node> 引用计数减少,由于没有其他强引用,ab 的内存会被释放。这样,循环引用的问题就被解决了。

参考链接

  1. Rust 官方网站:https://www.rust-lang.org/zh-CN
  2. Rust 官方文档:https://doc.rust-lang.org/
  3. Rust Play:https://play.rust-lang.org/
  4. 《Rust 程序设计语言》

广告一刻

为您即时展示最新活动产品广告消息,让您随时掌握产品活动新动态!