5

Rust 的包装类型

 3 years ago
source link: https://blog.lxdlam.com/post/b63a9600/
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.
neoserver,ios ssh client

Rust 的包装类型

 2020-11-24  约 3673 字   预计阅读 8 分钟 

对于一门对内存控制足够精细的语言来说,值类型与引用类型的区别是十分重要的:值类型通常意味着较低的拷贝成本,通常来说,这样的类型被分配在栈上(当然,对于 C/C++ 来说,我们可以在堆上直接分配一个值类型对象,如 int),而引用类型则通常分配在堆上,我们需要用一个包装过的对象去维护。

在 Rust 中,值类型和引用类型的界限在语言上提供了很明确的区分,而为了避免 C/C++ 中用户可以不受限制使用裸指针的情况,Rust 将很多裸指针操作都包在了 unsafe 块内,用户使用时必须对这种行为有足够的认知。当然,当用户需要使用指针,或者说引用类型的时候,Rust 也提供了 7 种包装类型来帮助用户更好的管理堆上的内存。

然而,Rust 官方教程和文档对这 7 种包装类型的介绍有很多容易混淆之处,同时网上的很多文章也已经完全脱离了最新版 Rust 的功能描述(如很多文章仍然描述 Cell 只接受实现了 Copy 的类型),导致很多初学者学习时容易产生迷惑和误用。这篇文章是我在复习 Rust 时重新学习包装类型相关时做出的笔记,希望能更好的帮助大家理解 Rust 的包装类型。

本文写作时的 Rust 版本为 Stable Channel 1.48.0。

三个重要的 Trait

Send

Send 是一个 Marker,用于标记一个类型可以在线程间安全的移动。对于绝大部分类型,编译器会自动实现 Send Trait,用户也可以手动实现。如果需要标记一个类型是不可以移动的,需要实现 !Send

下面的代码块应该能较好的解释 Send 的作用:

use std::vec::Vec;
use std::{thread, time};

struct Job {
    number: i32,
}

// 取消这行注释将告诉编译器这个类型跨线程移动是不安全的
// 不应该自动实现 `Send`,导致编译失败
// impl !Send for Job {}

impl Job {
    fn new(number: i32) -> Job {
        Job { number }
    }
}

fn main() {
    let mut v = Vec::with_capacity(5);
    for i in 0..5 {
        let job = Job::new(i);

        v.push(thread::spawn(move || {
            println!("Running job: {}", job.number);
            thread::sleep(time::Duration::from_secs(5 - job.number as u64));
            println!("Finish job: {}", job.number);
        }));
    }

    for handle in v {
        handle.join().unwrap();
    }
}

更多关于 Send 的解释,可以参考 The Rustonomicon.

CopyClone

对于 Rust 来说,复制一个对象是一个显式行为(因为默认语义是移动语义),只有一个例外:Copy

Clone trait 的作用很简单,定义了一个对象的拷贝行为。我们可以用 #[derive(Clone)] 来自动实现 Clone,实际上就是对每一个对象调用了 .clone() 拷贝到新对象中,即执行了一次深拷贝。

Copy 则意味着,当我们使用 let x = y; 时,y 会被自动复制一份到 x,而不是移动到 x。默认的 Copy 实现是按位拷贝内存(即 memcpy),而如果我们想要自己实现,由于 Copy 是一个 Marker trait,意味着它本身没有任何可以实现的方法,所以如果你不使用 #[derive(Copy)] 来实现的话,你只能通过实现 Clone trait 来实现 Copy

你可以认为对于 Rust 来说,Copy 语义所描述的值都可以是值类型(Rust 可以做隐式拷贝),而 Clone 对两种类型都有用。关于 CopyClone 的更多相关内容,可以参考标准库文档

Box 开始

Box<T> 是在学习 Rust 中第一个接触到的包装类型,它的职责很简单:在堆上分配一个 T 类型的内存空间,并将其指针包装后返回给用户。它是一个非常简单的指针封装。

对于用户来说,Box<T> 并没有什么特殊的地方:它自身是一个值,有移动语义,当我们需要它维护的内部对象时,需要使用 .as_ref() 或者 .as_mut() 这样的 API 拿出来其中的值进行操作。当 Box<T> 走到作用域末尾时,里面的内部对象会被自动 drop 掉。

为了同 Rc<T> 以及 Arc<T> 区别,我们实际上描述 Box<T> 是一个唯一所有权指针:在任何时刻,这个指针只能被一个对象/函数等持有,而不能同时出现在多个地方。对内部真实值的访问会受到 Borrow Checker 的检查,保证引用是安全的。

要注意的是,Box<T> 并没有实现 Send trait,这意味着我们不能直接跨线程移动 Box<T>

一个简单的 🌰:

fn test(num: Box<i32>) {
    println!("Box is moved: {}", num);
}

fn main() {
    let mut b = Box::new(15);
    println!("{}", b);

    let mr = b.as_mut();
    *mr = 20;
    println!("{}", b);

    test(b);
    // 这会导致一个编译错误
    // let r = b.as_ref();
}

RcArc

Rc<T> 的全名是 Reference Counting,从名字上就能看出来,这是一个带有引用计数的包装类型。换句话说,它所维护的资源具有多所有权:允许多个对象/函数等持有同一个资源的所有权。为了保证这样的持有是安全的(即对每个持有者来说,所持有的资源不会发生预期之外的改变),Rc<T> 维护的对象是不可变的,这意味着我们没有任何办法拿到一个 &mut 来改变内部的值。

一个简单的 🌰:

use std::rc::Rc;

fn test(num: Rc<i32>) {
    println!(
        "We got an rc: {} with count: {}",
        num,
        Rc::strong_count(&num)
    );
}

fn main() {
    let rc = Rc::new(10);
    println!("{}", rc);

    let r = rc.as_ref();
    println!("{}", *r);

    // 复制了指针,引用计数 +1
    test(rc.clone());
    // 此时借用没有问题
    let _r = rc.as_ref();
}

Rc<T> 的引用计数并不是原子的,这导致 Rc<T> 的跨线程访问是不安全的。为了解决这个问题,官方库提供了 Arc<T> 来维护跨线程的资源共享。A 在这里的意思就是 Atomically,原子化,即 Arc<T> 的引用计数增减操作是原子操作,保证了跨线程可见是安全的。而在其他地方,它和 Rc<T> 没什么不同。

另一个简单的 🌰:

use std::rc::Rc;
use std::sync::Arc;
use std::thread;

fn test(num: Arc<i32>) {
    println!(
        "We got an arc: {} with count: {}",
        num.as_ref(),
        Arc::strong_count(&num)
    );
}

fn main() {
    let rc = Rc::new(10);
    let arc = Arc::new(1);
    let clone = arc.clone();
    let handle = thread::spawn(move || {
        test(clone);
        // 如果取消掉下面的注释会显示编译错误
        // rc.as_ref();
    });

    handle.join().unwrap();
}

CellRefCell

在聊这两个包装类型之前,我们先聊一聊什么叫“内部可变性”。

内部可变性,其实有 C++ 经验的同学应该很熟悉:const 指针。内部可变性指的是,我们所持有的代理对象是不可变的,这通常意味着我们不能指向一个新的代理对象,或者不能直接原地将这个代理对象给释放,而这个代理对象维护的真实数据,是可变的。

在很多场景中,我们确实需要使用一个不可变的对象,但是需要修改内部的值,这就是内部可变性的用途。一个典型例子是,我们将一个正则表达式编译成了一个内部的数据结构,这个数据结构通常会维护在字符串中的指针等信息,这些数据要求可变,然而,我们并不想让这种修改暴露到外界。这种情况下,我们使用不可修改的正则表达式暴露给用户,但是内部的数据则可以使用内部可变性来维护。

在 Rust 中,如果我们想修改一个值(比如通过 &mut 拿到可变引用并修改),我们通常也需要将原始值定义为 mut 的。为了提供内部可变性,Rust 提供了两种类型来满足这个需求,Cell<T>RefCell<T>

我们首先聊一下 Cell<T>。在很多古老的文章中,Cell<T> 都被描述为 T 必须实现了 Copy trait,然而这个限制在 Rust 1.17.0 之后便被移除了,T 的类型目前没有任何特殊的要求。

对于设置操作来说,我们可以使用 set 来设置一个值,使用 replace 对值进行原地替换,使用 into_inner 消费 Cell<T> 并获取内部的值。而对于取值操作,实现了 Default 的对象我们可以使用 take 将值移动出 Cell<T>,而实现了 Copy 的对象我们可以使用 get。如果要获取内部的可变引用,我们可以使用 get_mut 方法,此时的引用可以被编译器做静态分析。

然而,我们并不总是想消费 Cell<T> 拿到值,也许我们只需要一个引用;或者来说,我们需要在运行时执行一些借用操作(例如在多线程环境下),而这些操作不能被静态分析。这个时候,Rust 提供了 RefCell<T>RefCell<T> 并没有什么魔法,它只是使用了一组 Wrapper 对象(Ref/RefMut)来包装引用,同时实现了运行时的借用检查——也就是说,对 RefCell<T> 进行了非法的借用时,可能会导致运行时 panic。

一组混合的 🌰:

use std::cell::{Cell, RefCell};
use std::mem::drop;

fn main() {
    let c = Cell::new(10);
    let rc = RefCell::new(15);

    println!("{:?}, {:?}", c, rc);

    // 如果想让下面这一行编译通过,需要对 c 添加 mut
    // let rc = c.get_mut();
    // *rc = 5;
    // 相反,我们可以用 replace
    c.replace(1);
    // 而对于 RefCell 来说,这样的引用是可以的
    let mut rc_mut = rc.borrow_mut();
    *rc_mut = 20;

    // 会显示值被 borrow
    println!("{:?}", rc);
    // 此时直接使用 rc.borrow() 会 panic
    // 更好的做法是使用 rc.try_borrow() 获取一个 Result<>
    // rc.borrow();
    if let Ok(_) = rc.try_borrow() {
        println!("We got an reference!");
    } else {
        println!("Somebody must has got a mutating reference!");
    }

    drop(rc_mut);
    // drop 掉 rc_mut 之后,值就可以被正常显示了
    println!("{:?}", rc);

    // 多个不可变的引用是可以的
    let b1 = rc.borrow();
    let b2 = rc.borrow();
    println!("{:?}, {:?}, {:?}", rc, b1, b2);

    // 它也会 panic,整体借用规则跟 Rust 静态分析的一致 ;)
    // let bm = rc.borrow_mut();

    let _val = c.into_inner();
    // into_inner 之后,c 就被消费了,下面的操作会导致编译错误
    // c.get();
}

Cell<T>RefCell<T> 都没有实现 Send,所以他们也都是不能跨线程访问的。

MutexRwLock

Mutex<T>RwLock<T> 不是锁吗?为什么会出现在包装类型的文章里?

对于 Rust 来说,Mutex<T>RwLock<T> 都与一个内部可变的资源强绑定,并且提供了运行时的锁竞争检查机制,实现了 Send。除此之外,这两类型的语义和约束与 Cell<T> 以及 RefCell<T> 差不了太多,就不再展开说了。

这次的 🌰 可以直接看 Mutex<T>官网样例RwLock<T>官网样例

区别及用途

我们首先可以看到下面这个表格:

类型所有权修改语义引用检查SendBox<T>唯一可变引用修改编译期未实现Rc<T>多重可变引用修改编译期未实现Arc<T>多重可变引用修改编译期实现Cell<T>唯一内部可变性编译期未实现RefCell<T>唯一内部可变性运行时(引用计数)未实现Mutex<T>唯一内部可变性运行时(锁竞争检查)实现RwLock<T>唯一内部可变性运行时(锁竞争检查)实现

简单来说,你需要考虑下面的问题:

  • 考虑资源的所有权,只允许单一所有权还是多重所有权?
  • 对于可以被修改的资源,提供可变引用还是内部可变性?
  • 引用检查是静态的还是运行时的?
  • 是否需要跨线程?是否提供锁的机制?

搞明白了这些问题,该如何选择就一目了然了。同时,在标准库中,这些包装类型均推荐组合使用来实现更复杂的予以包装,例如多线程中传递 Mutex,我们应该使用 Arc<Mutex<T>>

Rust 的包装类型之旅到这里就结束了。可以看到,Rust 为了解决多种语义的覆盖问题,巧妙的设计了这几种模型并实现成为了标准库,给了用户充分的灵活性的同时避免了来自于 C/C++ 中的语义不清晰甚至是歧义的问题,在默认移动语义的基础上实现了精细化的堆内存管理控制。不过,也有人批评这种标准实现太过繁琐,我们也许只需要一些约定然后提供简单的封装,复杂的需求交给各自或者第三方实现也许会更好。孰是孰非,还是交给语言的使用者决断吧。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK