在 Rust 中不能做什么

Photo by Elle Hughes from Pexels

编者注:上周 Armin 在自己的博客上首次发布了这个版本。如果你想再次阅读这篇文章,或者想看看 Armin 还在做什么,一定要去看看。

去年一直很有趣,因为我们用 Rust 建造了很多好东西,并且这是第一次在开发体验上没有更大的障碍。虽然过去的一年我们一直使用 Rust,但是现在感觉不同了,因为生态系统更加稳定,我们遇到的语言或者工具的问题也越来越少。

也就是说,与那些不熟悉 Rust 的人交谈——甚至与同事集体讨论 API——很难摆脱Rust可以成为令人心旷神怡的冒险的感觉。这就是为什么拥有无压力体验的最佳方式是,提前了解你不能(或不应该尝试)做的事情。 知道某些事情无法完成有助于让你的思想回到正确的轨道上。

所以根据我们的经验,这里有一些在 Rust 中不能做的事情,以及应该做什么,我认为应该更好地了解。

Things move

Rust 和 C++(至少对我来说)最大的区别在于 address-of操作符(&)。在 C++(类似于C)中,它只返回应用于它的地址,尽管该语言可能会对你施加一些限制(无论何时这样做是一个好主意),但是通常没有什么可以阻止你获取一个值地址并直接使用它。

但这在 Rust 中通常是没有用的。首先,你在 Rust 取得一个引用时,借用检查器就会检查你的代码,阻止你做任何愚蠢的事情。然而,更重要的是,即使安全取得一个引用,它也不像你想象的那么有用。 原因是Rust中的对象通常会四处移动。

Rust 中通常以这种方式构造对象:

struct Point {
    x: u32,
    y: u32,
}

impl Point {
    fn new(x: u32, y: u32) -> Point {
        Point { x, y }
    }
}

这里的 new 方法(没有获得 self)是一个静态方法实现,它还返回 Pont 的值。这是值通常的构造方式。因此,在函数中获取引用并没有做任何有用的事情,因为该值可能在调用时被移动到新的位置。这与 C++ 的工作原理是非常不同的:

struct Point {
    uint32_t x;
    uint32_t y;
};

Point::Point(uint32_t x, uint32_t y) {
    this->x = x;
    this->y = y;
}

C++ 中的构造函数在已经在分配的内存上运行。 在构造函数运行之前,this 指针就已经提供了内存(通常是堆栈中的某个位置或堆上的new运算符)。 这意味着 C++ 代码通常可以假设实例不会移动。 作为结果,C++ 代码使用 this 指针实际上是非常愚蠢的事情(比如将其存储在另一个对象中)。

这种差别听起来可能很小,但它是一个对编程人员来说会产生巨大的影响的基本因素。特别是,这是你不能使用自引用结构的原因之一。虽然一直有关于不能在Rust 中移动的类型的讨论,但目前还没有合理的解决方案 (未来方向是 the pinning system from RFC 2349)。译者注:Pin 在 1.33 已稳定。

那么我们目前该怎么做的? 这取决于具体情况,但通常答案是用某种形式的句柄替换指针。 不是仅仅在结构中存储绝对指针,而是将偏移存储到某个参考值。 稍后如果需要指针,则按需计算。

例如,我们使用这样的模式来处理内存映射数据:

use std::{marker, mem::{transmute, size_of}, slice, borrow::Cow};

#[repr(C)]
struct Slice<T> {
    offset: u32,
    len: u32,
    phantom: marker::PhantomData<T>,
}

#[repr(C)]
struct Header {
    targets: Slice<u32>,
}

pub struct Data<'a> {
    bytes: Cow<'a, [u8]>,
}

impl<'a> Data<'a> {
    pub fn new<B: Into<Cow<'a, [u8]>>>(bytes: B) -> Data<'a> {
        Data { bytes: bytes.into() }
    }
    pub fn get_target(&self, idx: usize) -> u32 {
        self.load_slice(&self.header().targets)[idx]
    }

    fn bytes(&self, start: usize, len: usize) -> *const u8 {
        self.bytes[start..start + len].as_ptr()
    }
    fn header(&self) -> &Header {
        unsafe { transmute(self.bytes(0, size_of::<Header>())) }
    }
    fn load_slice<T>(&self, s: &Slice<T>) -> &[T] {
        let size = size_of::<T>() * s.len as usize;
        let bytes = self.bytes(s.offset as usize, size);
        unsafe { slice::from_raw_parts(bytes as *const T, s.len as usize) }
    }
}

在这种情况下,Data<'a>仅保存对后备字节存储(拥有的 Vec<u8> 或借用的 &[u8] 切片)的写时复制引用。 字节切片以 Header 中的字节开头,并在调用header() 时按需解析。 同样地,通过调用load_slice() 来解析单个切片,该调用获取存储的切片,然后通过按需补偿来查找它。

所以,总结一下: 与其存储指向对象本身的指针,不如存储一些信息,以便稍后计算指针。这也通常称为使用“句柄”。

Refcounts are not dirty

另一个非常有趣的例子——非常容易遇到——也与借用检查器有关。借用检查器不允许你用自己不拥有的数据做愚蠢的事情,有时你会觉得自己好像撞上了一堵墙,因为你认为自己知道得更多。然而,在许多情况下,答案仅仅是一个 Rc<T>

为了使这一点不那么神秘,让我们来看看下面的 C++ 代码:

thread_local struct {
    bool debug_mode;
} current_config;

int main() {
    current_config.debug_mode = true;
    if (current_config.debug_mode) {
        // do something
    }
}

这看起来很简单,但有一个问题:没有什么能阻止你从 curren_config 中借用一个字段,然后将其传递到其他地方。这就是为什么在 Rust 中,与之直接对应的代码看起来要复杂得多:

#[derive(Default)]
struct Config {
    pub debug_mode: bool,
}

thread_local! {
    static CURRENT_CONFIG: Config = Default::default();
}

fn main() {
    CURRENT_CONFIG.with(|config| {
        // here we can *immutably* work with config
        if config.debug_mode {
            // do something
        }
    });
}

很明显,这个 API 并不有趣。首先,config 是不可变的。其次,我们只能访问传递给with 闭包中的 config 对象。任何试图从这个 config 对象中借用并使其在闭包之后仍然存在的尝试都将失败(可能出现“无法推断适当的生存期”之类的情况)。别无他法!

这个API在客观上是不好的。假设我们想要查找更多的线程局部变量。让我们分别来看这两个问题。如上所述,引用计数通常是一个很好的解决方案,用于处理这里的基本问题: 不清楚所有者是谁。

让我们想象一下,这个 config 对象恰好绑定到当前线程,但实际上并不属于它。如果 config 被传递给另一个线程,但是当前线程关闭了,会发生什么?这是一个典型的例子,config 可以有多个所有者。因为我们可能想要从一个线程传递到另一个线程,所以我们需要一个原子引用计数的包装器:一个Arc。这允许我们增加with 块中的引用计数并返回它。重构后的版本是这样的:

use std::sync::Arc;

#[derive(Default)]
struct Config {
    pub debug_mode: bool,
}

impl Config {
    pub fn current() -> Arc<Config> {
        CURRENT_CONFIG.with(|c| c.clone())
    }
}

thread_local! {
    static CURRENT_CONFIG: Arc<Config> = Arc::new(Default::default());
}

fn main() {
    let config = Config::current();
    // here we can *immutably* work with config
    if config.debug_mode {
        // do something
    }
}

这里的变化是,现在线程本地持有一个引用计数的 config。因此,我们可以引入一个返回 Arc<Config> 的函数。在 TLS 的闭包中,我们使用 Arc<Config> 上的clone() 方法增加引用计数并返回它。现在,任何调用 Config::current 的调用者都可以获得那个引用计数后的 config,并且可以根据需要一直持有它。只要有代码保存 Arc,其中的 config 就会保持活动状态。即使原始线程死亡。

但是我们如何让它像 C++ 版本那样可变呢?我们需要一些能提供内部可变性的东西。对此有两种选择。一种是将 config 封装在 RwLock 之类的东西中。第二种方法是让 config 在内部使用锁定。

例如,可能需要这样做:

use std::sync::{Arc, RwLock};

#[derive(Default)]
struct ConfigInner {
    debug_mode: bool,
}

struct Config {
    inner: RwLock<ConfigInner>,
}

impl Config {
    pub fn new() -> Arc<Config> {
        Arc::new(Config { inner: RwLock::new(Default::default()) })
    }
    pub fn current() -> Arc<Config> {
        CURRENT_CONFIG.with(|c| c.clone())
    }
    pub fn debug_mode(&self) -> bool {
        self.inner.read().unwrap().debug_mode
    }
    pub fn set_debug_mode(&self, value: bool) {
        self.inner.write().unwrap().debug_mode = value;
    }
}

thread_local! {
    static CURRENT_CONFIG: Arc<Config> = Config::new();
}

fn main() {
    let config = Config::current();
    config.set_debug_mode(true);
    if config.debug_mode() {
        // do something
    }
}

如果你不需要用这个类型处理多线程,你可以用 Arc 替换 Rc ,用 RwLock 替换 RefCell

总结一下:当你需要借用的数据超过了需要引用的数据的生命周期时。不要害怕使用 Arc,但要注意这将你的数据锁定到不可变。结合内部可变性(如 RwLock)使对象可变。

Kill all setters

但是上面使用 Arc<RwLock<Config>> 模式可能会有点问题,将其替换为 RwLock<Arc<Config>> 会更好。

Rust 做得好的是一种解放的体验,因为如果你做得好,在事后并行化你的代码是非常容易的。Rust 鼓励使用不可变的数据,这使得一切都变得更加容易。

然而,在前面的例子中,我们引入了内部可变性。假设我们有多个线程在运行,所有线程都引用相同的 config,但其中一个线程抛出一个标志。如果并发运行的代码现在不期望标志随机翻转,会发生什么情况?因此,应该谨慎使用内部可变性。理想情况下,对象一旦创建就不会以这种方式更改其状态。我认为这种类型的 setter通常应该是反模式的。

我们不这样做,而是回到之前的情况,config 不是可变的?如果在创建 config 之后,我们没有修改它,而是添加一个API来将另一个 config 提升到 current,结果会怎样呢?这意味着任何当前持有 config 的人都可以安全地知道这些值不会更改。

use std::sync::{Arc, RwLock};

#[derive(Default)]
struct Config {
    pub debug_mode: bool,
}

impl Config {
    pub fn current() -> Arc<Config> {
        CURRENT_CONFIG.with(|c| c.read().unwrap().clone())
    }
    pub fn make_current(self) {
        CURRENT_CONFIG.with(|c| *c.write().unwrap() = Arc::new(self))
    }
}

thread_local! {
    static CURRENT_CONFIG: RwLock<Arc<Config>> = RwLock::new(Default::default());
}

fn main() {
    Config { debug_mode: true }.make_current();
    if Config::current().debug_mode {
        // do something
    }
}

默认情况下,config 仍然是自动初始化的,但是可以通过构造 config 对象并调用make_current 来设置新的 config。这将把 config 移动到一个 Arc,然后将其绑定到当前线程。调用 current() 的人将会得到那个 Arc,然后可以再做他们想做的任何事情。

同样,如果不需要在多线程中使用 Arc,也可以用 Rc 替换 Arc,用 RefCell替换 RwLock。如果只使用线程局部变量,还可以将 RefCellArc 组合在一起。

总结一下:不要使用改变对象内部状态的内部可变性,而是考虑使用一种模式,在这种模式中,你将新状态提升为当前状态,并且通过将 Arc 放入 RwLock,旧状态的当前使用者将继续持有它。

结论

老实说,我希望我能早一点学会以上三件事。主要是因为即使你知道这些模式,你也不一定知道何时使用它们。所以我想下面的咒语,就是我想打印出来挂在某处的咒语:

  • 句柄,而不是自引用指针
  • 引用计数的方法走出生命周期/借用检查的地狱
  • 考虑提升新的状态而不是内部的可变性

本文翻译自 What Not to Do in Rust

发表评论

电子邮件地址不会被公开。 必填项已用*标注

57 − 47 =