3

Rust 中几个智能指针的异同与使用场景

 3 years ago
source link: https://ipotato.me/article/57
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-01-16

想必写过 C 的程序员对指针都会有一种复杂的情感,与内存相处的过程中可以说是成也指针,败也指针。一不小心又越界访问了,一不小心又读到了内存里的脏数据,一不小心多线程读写数据又不一致了……我知道讲到这肯定会有人觉得“出这种问题还不是因为你菜”云云,但是有一句话说得好:“自由的代价就是需要时刻保持警惕”。

Rust 几乎把“内存安全”作为了语言设计哲学之首,从多个层面(编译,运行时检查等)极力避免了许多内存安全问题。所以比起让程序员自己处理指针(在 Rust 中可以称之为 Raw Pointer),Rust 提供了几种关于指针的封装类型,称之为智能指针(Smart Pointer),且对于每种智能指针,Rust 都对其做了很多行为上的限制,以保证内存安全。

  • Box<T>
  • Rc<T> 与 Arc<T>
  • Cell<T>
  • RefCell<T>

我在刚开始学习智能指针这个概念的时候有非常多的困惑,Rust 官方教程本身对此的叙述并不详尽,加之 Rust 在中文互联网上内容匮乏,我花了很久才搞清楚这几个智能指针封装的异同,在这里总结一下,以供参考,如有错误,烦请大家指正。

以下内容假定本文的读者了解 Rust 的基础语法,所有权以及借用的基本概念,这里是相关链接

Box<T>

Box<T> 与大多数情况下我们所熟知的指针概念基本一致,它是一段指向堆中数据的指针。我们可以通过这样的操作访问和修改其指向的数据:

let a = Box::new(1);  // Immutable
println!("{}", a);    // Output: 1

let mut b = Box::new(1);  // Mutable
*b += 1;
println!("{}", b);    // Output: 2

然而 Box<T> 的主要特性是单一所有权,即同时只能有一个人拥有对其指向数据的所有权,并且同时只能存在一个可变引用或多个不可变引用,这一点与 Rust 中其他属于堆上的数据行为一致。

let a = Box::new(1);  // Owned by a
let b = a;  // Now owned by b

println!("{}", a);  // Error: value borrowed here after move

let c = &mut a;
let d = &a;

println!("{}, {}", c, d);  // Error: cannot borrow `a` as immutable because it is also borrowed as mutable

Rc<T> 与 Arc<T>

Rc<T> 主要用于同一堆上所分配的数据区域需要有多个只读访问的情况,比起使用 Box<T> 然后创建多个不可变引用的方法更优雅也更直观一些,以及比起单一所有权,Rc<T> 支持多所有权。

Rc 为 Reference Counter 的缩写,即为引用计数,Rust 的 Runtime 会实时记录一个 Rc<T> 当前被引用的次数,并在引用计数归零时对数据进行释放(类似 Python 的 GC 机制)。因为需要维护一个记录 Rc<T> 类型被引用的次数,所以这个实现需要 Runtime Cost。

use std::rc::Rc;

fn main() {
    let a = Rc::new(1);
    println!("count after creating a = {}", Rc::strong_count(&a));
    let b = Rc::clone(&a);
    println!("count after creating b = {}", Rc::strong_count(&a));
    {
        let c = Rc::clone(&a);
        println!("count after creating c = {}", Rc::strong_count(&a));
    }
    println!("count after c goes out of scope = {}", Rc::strong_count(&a));
}

输出依次会是 1 2 3 2。

需要注意的主要有两点。首先, Rc<T> 是完全不可变的,可以将其理解为对同一内存上的数据同时存在的多个只读指针。其次,Rc<T> 是只适用于单线程内的,尽管从概念上讲不同线程间的只读指针是完全安全的,但由于 Rc<T> 没有实现在多个线程间保证计数一致性,所以如果你尝试在多个线程内使用它,会得到这样的错误:

use std::thread;
use std::rc::Rc;

fn main() {
    let a = Rc::new(1);
    thread::spawn(|| {
        let b = Rc::clone(&a);
        // Error: `std::rc::Rc<i32>` cannot be shared between threads safely
    }).join();
}

如果想在不同线程中使用 Rc<T> 的特性该怎么办呢?答案是 Arc<T>,即 Atomic reference counter。此时引用计数就可以在不同线程中安全的被使用了。

use std::thread;
use std::sync::Arc;

fn main() {
    let a = Arc::new(1);
    thread::spawn(move || {
        let b = Arc::clone(&a);
        println!("{}", b);  // Output: 1
    }).join();
}

Cell<T>

Cell<T> 其实和 Box<T> 很像,但后者同时不允许存在多个对其的可变引用,如果我们真的很想做这样的操作,在需要的时候随时改变其内部的数据,而不去考虑 Rust 中的不可变引用约束,就可以使用 Cell<T>Cell<T> 允许多个共享引用对其内部值进行更改,实现了「内部可变性」。

fn main() {
    let x = Cell::new(1);
    let y = &x;
    let z = &x;
    x.set(2);
    y.set(3);
    z.set(4);
    println!("{}", x.get());  // Output: 4
}

这段看起来非常不 Rust 的 Rust 代码其实是可以通过编译并运行成功的,Cell<T> 的存在看起来似乎打破了 Rust 的设计哲学,但由于仅仅对实现了 CopyTCell<T> 才能进行 .get().set() 操作。而实现了 Copy 的类型在 Rust 中几乎等同于会分配在栈上的数据(可以直接按比特进行连续 n 个长度的复制),所以对其随意进行改写是十分安全的,不会存在堆数据泄露的风险(比如我们不能直接复制一段栈上的指针,因为指针指向的内容可能早已物是人非)。也是得益于 Cell<T> 实现了外部不可变时的内部可变形,可以允许以下行为的发生:

use std::cell::Cell;

fn main() {
    let a = Cell::new(1);

    {
        let b = &a;
        b.set(2);
    }


    println!("{:?}", a);  // Output: 2
}

如果换做 Box<T>,则在中间出现的 Scope 就会使 a 的所有权被移交,且在执行完毕之后被 Drop。最后还有一点,Cell<T> 只能在单线程的情况下使用。

RefCell<T>

因为 Cell<T>T 的限制:只能作用于实现了 Copy 的类型,所以应用场景依旧有限(安全的代价)。但是我如果就是想让任何一个 T 都可以塞进去该咋整呢?RefCell<T> 去掉了对 T 的限制,但是别忘了要牢记初心,不忘继续践行 Rust 的内存安全的使命,既然不能在读写数据时简单的 Copy 出来进去了,该咋保证内存安全呢?相对于标准情况的静态借用,RefCell<T> 实现了运行时借用,这个借用是临时的,而且 Rust 的 Runtime 也会随时紧盯 RefCell<T> 的借用行为:同时只能有一个可变借用存在,否则直接 Painc。也就是说 RefCell<T> 不会像常规时一样在编译阶段检查引用借用的安全性,而是在程序运行时动态的检查,从而提供在不安全的行为下出现一定的安全场景的可行性。

use std::cell::RefCell;
use std::thread;

fn main() {
    thread::spawn(move || {
       let c = RefCell::new(5);
       let m = c.borrow();

       let b = c.borrow_mut();
    }).join();
    // Error: thread '<unnamed>' panicked at 'already borrowed: BorrowMutError'
}

如上程序所示,如同一个读写锁应该存在的情景一样,直接进行读后写是不安全的,所以 borrow 过后 borrow_mut 会导致程序 Panic。同样,ReCell<T> 也只能在单线程中使用。

如果你要实现的代码很难满足 Rust 的编译检查,不妨考虑使用 Cell<T>RefCell<T>,它们在最大程度上以安全的方式给了你些许自由,但别忘了时刻警醒自己自由的代价是什么,也许获得喘息的下一秒,一个可怕的 Panic 就来到了你身边!

如果遇到要实现一个同时存在多个不同所有者,但每个所有者又可以随时修改其内容,且这个内容类型 T 没有实现 Copy 的情况该怎么办?使用 Rc<T> 可以满足第一个要求,但是由于其是不可变的,要修改内容并不可能;使用 Cell<T> 直接死在了 T 没有实现 Copy 上;使用 RefCell<T> 由于无法满足多个不同所有者的存在,也无法实施。可以看到各个智能指针可以解决其中一个问题,既然如此,为何我们不把 Rc<T>RefCell<T> 组合起来使用呢?

use std::rc::Rc;
use std::cell::RefCell;

fn main() {
    let shared_vec: Rc<RefCell<_>> = Rc::new(RefCell::new(Vec::new()));
    // Output: []
    println!("{:?}", shared_vec.borrow());
    {
        let b = Rc::clone(&shared_vec);
        b.borrow_mut().push(1);
        b.borrow_mut().push(2);
    }
    shared_vec.borrow_mut().push(3);
    // Output: [1, 2, 3]
    println!("{:?}", shared_vec.borrow());
}

通过 Rc<T> 保证了多所有权,而通过 RefCell<T> 则保证了内部数据的可变性。


About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK