从所有权的视角再看 Rust 智能指针

指针和指针的类型

如果一个变量里面存的是另一个变量在内存里的地址,那么这个变量就被叫做指针,引用(用 & 号表示)就是一种指针。并且引用是必定有效的指针,它一定指向一个目前有效(比如没有被释放掉)的类型实例,而指针不一定是引用。也就是说,在 Rust 中还有一些其它类型的指针存在,我们下面就来学习其中一些。

这里要再次明晰一下引用的类型,引用分为不同的类型,单独的 & 符号本身没有什么意义,但是它和其他类型组合起来就能形成各种各样的引用类型。比如:

  • &str 是字符串切片引用类型
  • &String 是所有权字符串的引用类型
  • &u32 是 u32 的引用类型

注:&str、&String、&u32 都是一个整体。

这三种都是引用类型,作为引用类型,它们之间是不同的。但是同一种引用类型的实例,比如 &10u32 和 &20u32,它们的类型是相同的。指针其实也类似,指向不同类型实例的指针,它的类型也是有区别的,这叫做指针的类型。

智能指针

Rust 中指针的概念非常灵活,比如它可以是一个结构体类型,只要其中的一个字段存储其它类型实例的地址,然后对这个结构体实现一些 Rust 标准库里提供的 trait,就可以把它变成指针类型。这种指针可以在传统指针的基础上添加一些额外信息,比如放在额外的一些字段中,也可以做一些额外操作,比如管理引用计数,资源自动回收等。从而显得更加智能,所以被叫做智能指针。

String 和 Vec 就是一种智能指针,我们来看标准库代码中 String 和 Vec 的定义。

pub struct String {
    vec: Vec<u8>,
}

pub struct Vec<T, #[unstable(feature = "allocator_api", issue = "32838")] A: Allocator = Global> {
    buf: RawVec<T, A>,
    len: usize,
}

通过代码我们可以看到,String 和 Vec 实际都定义为结构体。

智能指针可以让代码的开发相对来说容易一些。经过前面的学习,我们知道 Rust 基于所有权出发,定义了一套完整的所有权和借用规则。很多我们习以为常的代码写法,在 Rust 中变成了"违法",这导致很多人觉得学习 Rust 的门槛很高,而智能指针可以在某些方面降低这种门槛。

前面我们看到过这种代码:

fn foo() -> u32 {
    let i = 100u32;
    i
}
fn main() {
    let _i = foo();
}

我们看到,foo() 函数将 i 返回用的不是 move 行为,而是 copy 行为,将 100u32 这个值复制了一份,返回给外面的 _i。foo() 函数调用结束后,foo() 里的局部变量 i 被销毁。

fn foo() -> String {
    let s = "abc".to_string();
    s
}
fn main() {
    let _s = foo();
}

上述代码可以在函数 foo() 里生成一个字符串实例,这个字符串实例资源在堆内存中分配,s 是 foo 函数里的局部变量,拥有字符串资源的所有权。在代码的最后一行,s 把所有权返回给外部调用者并传递给 _s。foo() 调用完成后,栈上的局部变量 s 被销毁。

这种写法可行是因为返回了资源的所有权,如果我们把代码里的 String 换成 &String,把 s 换成 &s 就不行了。

fn foo() -> &String {
    let s = "abc".to_string();
    &s
}
fn main() {
    let _s = foo();
}

你可能会问,既然 String 资源本身是在堆中,为什么我们不能拿到这个资源的引用而返回呢?很简单,在 foo() 函数里,其实我们返回的并不是那个堆里字符串资源的引用,而是栈上局部变量 s 的引用。堆里的字符串资源由栈上的变量 s 管理,而 s 在 foo() 函数调用完成后就被销毁了,堆里的字符串资源也一并被回收了(堆内存的生命周期和创建它的栈内存是绑定在一起的),所以刚刚那段代码当然行不通了。

同样的,下面这段代码也是不允许的。

fn foo() -> &u32 {
    let i = 100u32;
    &i
}
fn main() {
    let _i = foo();
}

那么有什么办法能让这种意图变得可行呢?其实是有的,比如使用 Box 智能指针。

Box<T>

Box<T> 是一个类型整体,作为智能指针 Box<T> 可以把资源强行创建在堆上,并获得资源的所有权,让资源的生命期得以被程序员精确地控制。

此时堆上的资源,默认与整个程序进程的存在时间一样久。

我们来看使用 Box 如何处理前面那个示例。

fn foo() -> Box<u32> {
    let i = 100u32;
    Box::new(i)
}
fn main() {
    let _i = foo();
}

通过 Box,我们把栈上 i 的值,强行 copy 了一份并放在堆上的某个地方,然后 Box 指针指向这个地址。虽然我们叫它指针,但它其实是一个结构体(也叫智能指针),类型为 Box<u32>,并且拥有堆上数据的所有权。而返回 Box<u32> 也仅仅是将所有权转移到了变量 main 函数里的变量 _i 上面,但堆数据还在。

当然返回一个整数 i 的指针确实没多大用,如果我们定义了一个结构体,可以采用类似的办法从函数中返回结构体的 Box 指针。

struct Point {
    x: u32,
    y: u32
}

fn foo() -> Box<Point> {
    // 这个结构体的实例创建在栈上,因为它内部的字段都是可 Copy 的原生类型
    // 但即便在栈上,由于它没有实现 Copy 语义,所以在赋值的时候依旧会转移所有权
    // 因此是否会 Move 不取决于它的值是否在栈上,而是取决于它是否实现了 Copy
    // 当然值全部在栈上是实现 Copy 的前提
    let p = Point {x: 10, y: 20};  
    // 将数据拷贝到堆上,由于 Rust 不会自动深度拷贝,对于可 Copy 的类型来说,深浅拷贝是等价的
    // 但对于没有实现 Copy 语义的类型来说,此时的浅拷贝等价于 Move
    Box::new(p)  // 从此刻开始 p 不再有效
}
fn main() {
    let _p = foo();
}

编译期间已知尺寸的类型实例会默认创建在栈上。Point 有两个字段:x、y,它们的尺寸是固定的,都是 4 个字节,所以 Point 的尺寸就是 8 个字节,它的尺寸也是固定的,所以它的实例会被创建在栈上。注意:Point 并没有默认实现 Copy,虽然它的尺寸是固定的。

所以 Point 的实例 p 会创建在栈上,然后通过 Box::new(p) 把 p 实例强行按位复制了一份,并且放到了堆上。但 Point 没有实现 Copy,在创建 Box<Point> 实例的时候会发生所有权转移:资源从栈上 move 到了堆上,原来栈上的那片资源被置为无效状态。然后 foo() 函数返回,把 Box 指针实例 move 给了 _p。之后,_p 拥有了堆上数据的所有权。

Box<T> 的解引用

前面说了,创建一个 Box 实例把栈上的内容包起来,可以把栈上的值拷贝(移动)到堆上,比如:

let val: u8 = 5;
let boxed: Box<u8> = Box::new(val); // 这里 boxed 里面那个 u8 就是堆上的值 

*还可以在 Box 实例上使用解引用符号 ,把里面的堆上的值再次移动回栈上,比如:

let boxed: Box<u8> = Box::new(5);
let val: u8 = *boxed;    // 这里这个val整数实例就是在栈上的值

解引用是 Box::new() 的逆操作,可以看到整个过程是相反的。

另外我们说过 Rust 默认永远不会深度拷贝,只会浅拷贝,因此:

  • 如果 val 实现了 Copy 语义,Box::new(val) 会将 val 拷贝一份到堆上,并返回持有所有权的智能指针,val 依旧保持有效。
  • 如果 val 没有实现 Copy 语义,Box::new(val) 会将 val 移动到到堆上,并返回持有所有权的智能指针,val 不再有效。

我们知道,如果值实现了 Copy 语义,那么传递之后两个变量的值前后是独立的,互不影响,因此这个过程叫做复制。如果值没有实现 Copy,那么会转移所有权,因此这个过程叫做移动。但 Copy 和 Move 其实是等价的,都是将栈上数据拷贝一份,只不过根据传递后的两个变量是否有效,而将这个过程区分成了复制和移动。

同样的,如果对 Box<T> 解引用,那么会将 T 拷贝或移动到栈上。

fn main() {
    // 5u8 分配在堆上,但它是可 Copy 的
    let boxed = Box::new(5u8);
    // 会将 Box 里面的值浅拷贝一份到栈上,因为 val 是栈上的局部变量
    // 但由于 u8 具有 Copy 语义,浅拷贝之后彼此独立
    // 因此我们说 *boxed 会将堆上的值复制到栈上
    let val = *boxed;
    // val 和 boxed 均有效
    println!("{} {}", val, boxed);  // 5 5

    // 原本 String 的实际文本是存储在堆上,但结构体(元数据)存储在栈上
    // 而现在结构体和实际文本都存储在堆上,不过这里的 boxed 是存储在栈上的
    let boxed = Box::new(String::from("古明地觉"));
    // 注意:元素必须有一个已知的大小,所以 Box 里面存放的依旧是结构体本身,至于堆上的动态文本数据永远是通过栈上的指针与之关联
    // 任何动态可变的数据,它的可变部分都不会放在结构体里面,而是在结构体内部维护一个指针,这个指针指向一片内存区域
    // 该区域负责存放动态数据,如果满了,那么只需要申请一片更大的内存,改变指针的指向即可,结构体本身的地址是不需要改变的

    // 解引用时,同样会将 Box 里面的值浅拷贝一份到栈上,但 String 没有实现 Copy 语义
    // 这里只是将结构体本身拷贝到栈上,结构体内部的指针指向的堆区动态数据则没有拷贝
    // 浅拷贝之后,原来 Box 里面的值就不能用了,因此这里我们可以说 *boxed 会将堆上的值(字符串结构体)移动到栈上
    let val = *boxed;
    println!("{}", val);  // 古明地觉
    // boxed; boxed 此时不再有效
}

如果 Box<T> 的 T 是 Copy 语义的,那么对这个 Box 实例做解引用操作,不影响原有的 Box 实例。但如果是 Move 语义的,那么解引用操作会把这个 Box 实例的所有权释放。

*需要注意:上面的 boxed 之所以可以移动,是因为 boxed 是智能指针。但如果 boxed 只是一个普通引用,那么就不可以这么做了,Rust 会提示我们无法移动。如果是可 Copy 的,那么浅拷贝一份,这个没有问题。但如果不是可 Copy 的,就会报错:告诉我们指定类型没有实现 Copy trait,而不会发生移动。

因为普通引用,如果通过 * 将所有权转移,那么会导致原有变量不可用。而我们之所以要拿它的引用,就是为了让它保持有效,于是就产生了矛盾,因此 Rust 提示不允许移动。

总之,如果要转移所有权,那么需要将变量本身赋值给别的变量,明确表示要转移。而对引用进行解引用这种方式是不允许转移的,它只能拷贝,因此当无法转移时会提示你该类型没有实现 Copy trait。

关于这些细节,我们不用太担心能不能一次性掌握好,因为用错的时候,Rustc 小助手会贴心地准确提示我们,所以不要有心理负担,慢慢熟悉了就好。

Box<T> 实现了 trait

Box<T> 的好处在于它的明确性,它里面的资源一定在堆上,所以我们就不用再去关心资源是在栈上还是堆上这种细节问题了,类型被 Box<> 包起来的过程就叫作该类型的盒化(boxed)。

Rust 在标准库里为 Box<T> 实现了 Deref、Drop、AsRef<T> 等 trait,所以 Box<T> 可以直接调用 T 实例的方法,访问 T 实例的值。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("{:?}", self);
        println!("I'am a method of Point.");
    }
}

fn main() {
    let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    boxed.play();  // 点操作符触发deref
    /*
    I'am a method of Point.
    Point { x: 10, y: 20 }
    */    
    // 等价于如下,先通过解引用拿到 Point 实例,再将它的引用传给 self 参数
    (*boxed).play();
    /*
    I'am a method of Point.
    Point { x: 10, y: 20 }
    */
    // 注意:这里虽然使用了 *boxed,但我们并没有将它赋值给别的变量,所以 boxed 依然有效
}

另外,既然 Box<T> 拥有对 T 实例的所有权,那么也可以对 T 实例进行写操作。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    *boxed = Point {    // 这一行,使用解引用操作更新值 
        x: 100,
        y: 200
    };
    println!("{:?}", boxed);  // Point { x: 100, y: 200 }
}

*使用 boxed 更新的时候,只需要更新 T 即可,这里的 boxed 仍是 Box<T> 类型。

Box<T> 的 Clone

Box<T> 能否 Clone,取决于 T 是否实现了 Clone,因为我们也需要把 T 的资源克隆一份。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

fn main() {
    // 虽然 i32 是可 Copy 的,但我们将它的值拷贝到了堆上
    let n1 = Box::new(1);
    // 所以 Box<T> 不是可 Copy 的,我们必须 clone 一份,否则 n1 不再保持有效
    let mut n2 = n1.clone();
    println!("{} {}", n1, n2);  // 1 1
    *n2 = 666;
    println!("{} {}", n1, n2);  // 1 666

    // Box<T> 在 clone 的时候会创建一个新的 Box,并将 T 深度拷贝一份
    // 但不是所有的 Box<T> 都可以 clone,它取决于 T 是否可以 clone
    let p = Box::new(Point { x: 1, y: 2 });
    // p.clone(); 不合法,因此 T 不可以 clone,编译器建议我们给 Point 加上 #[derive(Clone)]
}

比较简单。

&Box<T>

Box<T> 本身作为一种类型,对它做引用操作当然是可以的。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    boxed.play();        // 调用类型方法
    let y = &boxed;      // 取 boxed 实例的引用
    y.play();            // 调用类型方法
    println!("{:?}", y);
    /*
    I'am a method of Point.
    I'am a method of Point.
    Point { x: 10, y: 20 }
    */
}

在示例中,boxed 是一个所有权型变量,y 是一个引用型变量。它们都能调用到 Point 类型上的方法。当然,对 Box 实例做可变引用(&mut)也是可以的。

#[derive(Debug)]
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let mut boxed: Box<Point> = Box::new(Point{x: 10, y: 20});
    let y = &mut boxed;     // 这里 &mut Box<Point>
    y.play();               // 调用类型方法
    println!("{:?}", y);    // 修改前的值
    **y = Point {x: 100, y: 200};  // 注意这里用了二级解引用
    println!("{:?}", y);    // 修改后的值
    /*
    I'am a method of Point.
    Point { x: 10, y: 20 }
    Point { x: 100, y: 200 }
    */
}

注意里面做了两次解引用,第一次是对 &mut 做的,第二次是对 Box 做的。

Box<dyn trait>

回忆一下之前说的 trait object,它代表一种类型,这种类型可以代理一批其他的类型。但是 dyn trait 本身的尺寸在编译期是未知的,所以 dyn trait 的出现总是要借助于引用或智能指针。而 Box<dyn trait> 是最常见的,甚至比 &dyn trait 更常见。原因就是 Box<dyn Trait> 拥有所有权,这就是 Box<T> 方便的地方,而 &dyn Trait 不拥有所有权,有的时候就没那么方便。

我们来看使用 Box<dyn trait> 做函数参数的一个示例。

struct Atype;
struct Btype;
struct Ctype;

trait TraitA {}

impl TraitA for Atype {}
impl TraitA for Btype {}
impl TraitA for Ctype {}

fn doit(x: Box<dyn TraitA>) {}

fn main() {
    let a = Atype;
    doit(Box::new(a));
    let b = Btype;
    doit(Box::new(b));
    let c = Ctype;
    doit(Box::new(c));
}

这里使用智能指针和普通引用都可以的,但如果 dyn trait 出现在结构体里,那么 Box<dyn trait> 形式就比 &dyn trait 形式要方便得多,因为不需要生命周期。

Arc<T>

Box<T> 是单所有权或独占所有权模型的智能指针,而 Arc<T> 是共享所有权模型的智能指针,也就是多个变量可以同时拥有一个资源的所有权。和 Box<T> 一样,Arc<T> 也会保证被包装的内容被分配在堆上。

Arc 的主要功能是和 clone() 配合使用,在 Arc 实例上进行 clone() 操作时,总是会将资源的引用数 +1,而保持原来那一份资源不动,这个信息记录在 Arc 实例里面。每一个指向同一个资源的 Arc 实例走出作用域,就会给这个引用计数 -1。直到最后一个 Arc 实例消失,目标资源才会被销毁释放。可以看一下示例。

use std::sync::Arc;

#[derive(Debug)]    // 这里不需要目标类型实现 Clone trait
struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn play(&self) {
        println!("I'am a method of Point.");
    }
}

fn main() {
    let arced: Arc<Point> = Arc::new(Point{x: 10, y: 20});
    let another_arced = arced.clone();      // 克隆引用
    println!("{:?}", arced);                // 打印一份值
    println!("{:?}", another_arced);        // 打印同一份值
    arced.play();
    another_arced.play();
    let arc3_ref = &another_arced;
    arc3_ref.play();
    /*
    Point { x: 10, y: 20 }
    Point { x: 10, y: 20 }
    I'am a method of Point.
    I'am a method of Point.
    I'am a method of Point.
     */
}

我们可以看到,相比于 Box<T>,Arc<T> 的 clone 不要求 T 实现了 Clone trait。Arc<T> 的克隆行为只会改变 Arc 的引用计数,而不会克隆里面的内容。由于不需要克隆原始资源,所以性能是很高的。

类似于 Box<T>,Arc<T> 也实现了 Deref、Drop、Clone 等 trait。因此,Arc<T> 也可以符合人类的习惯,访问到里面类型 T 的方法,Arc<T> 的不可变引用 &Arc<> 也可以顺利调用到 T 上的方法。

然后 Arc 也可以作用在 self 参数上面,以及用于包裹 dyn trait。但要注意的是,Arc 里面的值不允许修改,虽然 Arc<T> 是拥有所有权的,但 Arc<T> 不提供修改 T 的能力,这也是 Arc<T> 和 Box<T> 不一样的地方。后面我们在并发编程部分会讲到 Mutex、RwLock 等锁,想要修改 Arc 里面的内容,必须配合这些锁才能完成,比如 Arc<Mutex<T>>。

其实很好理解,共享所有权的场景下,如果任意一方能随意修改被包裹的值,那就会影响其他所有权的持有者,整个就乱套了。所以要修改的话必须引入锁的机制。

Arc<T> 与不可变引用 & 的区别

首先它们都是共享对象的行为,本质上都是指针。但 Arc<T> 是共享了所有权模型,而 & 只是共享借用模型。共享借用模型就得遵循借用检查器的规则——借用的有效性依赖于被借用资源的 scope,对于这个的分析是非常复杂的。而所有权模型是由自己来管理资源的 scope,所以处理起来比较方便。

小结

我们一起学习了最常用的两个智能指针:Box<T> 和 Arc<T>,当然 Rust 里还有很多智能指针,比如 Rc、Cell、RefCell 等等,每一种智能指针类型都有自己的特点。但是不管怎样,学习的方法都是一样的,那就是从所有权的视角去分析研究。

后面我们会看到,在智能指针的加持下,Rust 代码写起来会非常流畅,可以和 Java 不相上下。再结合 Rust 强大的类型系统建模能力,等你写得熟练之后,在中大项目中,使用 Rust 甚至会有超越 Python 的开发效率。

本文来自于:极客时间《Rust 语言从入门到实战》

posted @ 2023-11-15 16:21  古明地盆  阅读(220)  评论(0编辑  收藏  举报