Rust:内部可变性 & 型变

Rust:内部可变性 & 型变


内部可变性

在前一节博客的末尾,我们讨论了内部可变性与协变共同导致的垂悬借用,但我并没有给出一个实际的解决方案。本节博客就深入了解内部可变性,并了解Rust是如何安全地提供内部可变性的。

当持有一个复合类型的不可变借用时,意味着我们不能修改这个类型。但是例如结构体,如果持有结构体不可变借用,意味着所有字段都是不可修改的,这种不可变的粒度太大了。而内部可变性就是指,当持有不可变借用时,依然可以修改复合类型内部的部分字段。

可以类比一些面向对象语言(尤其是Java),对于一个类内部的属性,不允许你直接进行修改和读取,必须通过指定的接口get/set进行读写,因为这些方法已经经过了安全的封装。

实现内部可变性,在Rust 1.83之前可以用原生指针实现,比如上节博客的:

rust 复制代码
struct MyCell<'a> {
    value: &'a i32,
}

impl<'a> MyCell<'a> {
    fn set(&self, other: &'a i32) {
        unsafe {
            std::ptr::write(&self.value as *const &i32 as *mut &i32, other);
        }
    }
}

这里强行把一个&self.value的不可变借用,转化为了可变原生指针。但是在新版Rust已经禁止了这个能力。现在Rust只提供一种方案实现内部可变性,并基于这个方案实现了两个常用工具CellRefCell


UnsafeCell

CellRefCell都是基于UnsafeCell实现的,它是Rust官方提供的内部可变性实现方案,允许你使用不可变借用来修改数据。

定义如下:

rust 复制代码
#[lang = "unsafe_cell"]
#[stable(feature = "rust1", since = "1.0.0")]
#[repr(transparent)]
#[rustc_pub_transparent]
pub struct UnsafeCell<T: ?Sized> {
    value: T,
}

它的内容很简单,就是一个value: T存储可变的数据。


get

get是整个UnsafeCell最核心的方法,也体现了它最根本的逻辑,代码如下:

rust 复制代码
#[inline(always)]
#[stable(feature = "rust1", since = "1.0.0")]
#[rustc_const_stable(feature = "const_unsafecell_get", since = "1.32.0")]
#[rustc_as_ptr]
#[rustc_never_returns_null_ptr]
pub const fn get(&self) -> *mut T {
    // We can just cast the pointer from `UnsafeCell<T>` to `T` because of
    // #[repr(transparent)]. This exploits std's special status, there is
    // no guarantee for user code that this will work in future versions of the compiler!
    self as *const UnsafeCell<T> as *const T as *mut T
}

这个代码其实很简单,从签名可以看出来就是get方法接受一个不可变借用&self,但是返回一个可变的原生指针*mut T

函数体内部只有一行代码:

rust 复制代码
self as *const UnsafeCell<T> as *const T as *mut T

它先获得self的原生指针*const UnsafeCell<T>,这一步是很合理的。随后把*const UnsafeCell<T>转化为了 *const T,再转化为*mut T

这里有一个让人匪夷所思的地方,为什么*const UnsafeCell<T>可以直接变成*const T?它们根本就不是一个类型!

你可以回看UnsafeCell<T>,它有一个属性#[repr(transparent)],这个属性的含义是:结构体的内存布局,要和它内部唯一的字段一模一样。因此从内存的角度UnsafeCell<T>T是一模一样的,所以允许这种转化。

get函数体内部官方给的注释也指出了这一点,但是用户在代码中不能这么做,它是Rust库函数的特权语法。

这个get方法是一个safe的方法,因为它只创建了原生指针,并没有使用原生指针。


其它方法
  • into_inner

into_inner用于拿到内部的值,并且是以值的形式:

rust 复制代码
#[inline(always)]
#[stable(feature = "rust1", since = "1.0.0")]
#[rustc_const_stable(feature = "const_cell_into_inner", since = "1.83.0")]
#[rustc_allow_const_fn_unstable(const_precise_live_drops)]
pub const fn into_inner(self) -> T {
    self.value
}

它直接以值的形式返回了self.value,这意味着如果它没有实现Copy,这会导致所有权的转移。

  • replace

replace用于替换内部的数据T,并返回原本的数据。

rust 复制代码
#[inline]
#[unstable(feature = "unsafe_cell_access", issue = "136327")]
pub const unsafe fn replace(&self, value: T) -> T {
    unsafe { ptr::replace(self.get(), value) }
}

方法接受一个不可变借用,随后通过self.get()拿到原生指针,传入ptr::replace中。此时通过原生指针将值设置到T中,并且ptr::replace会返回原本的值,它被作为最后一个表达式从UnsafeCell::replace中返回出去。这是一个unsafe方法。

  • get_mut

get_mut用于拿到内部数据的可变借用:

rust 复制代码
#[inline(always)]
#[stable(feature = "unsafe_cell_get_mut", since = "1.50.0")]
#[rustc_const_stable(feature = "const_unsafecell_get_mut", since = "1.83.0")]
pub const fn get_mut(&mut self) -> &mut T {
    &mut self.value
}

它要求&mut self,意味着它需要满足借用检查。在RefCell中,实现内部可变性是通过*mut T的原生指针实现的,但是如果是&mut T,它无法逃过借用检查,不允许你通过一个不可变借用&self拿到一个可变借用&mut T

比如:

rust 复制代码
let mut num = UnsafeCell::new(5);
let num_b1 = num.get_mut();
let num_b2 = num.get_mut();

*num_b1 += 1;
*num_b2 += 1;

这一段代码无法编译通过,因为在同时存在多个T的可变借用,这违背了借用规则。

而且UnsafeCell不提供方法得到内部数据的不可变借用,设想一个场景:

当用户拿到UnsafeCell内部数据的不可变借用,由于不可变借用可以同时存在,因此可以调用get方法再拿到可变原生指针。此时就破坏了借用规则,也就是共享不可变(多个不可变借用期间,不能修改数据)。

而假设用户拿到的是一个可变借用,那么用户就无法调用get方法了,因为get要求&self的借用。而已经存在一个可变借用情况下,是无法进行不可变借用的,进而保证了无法调用到get方法。

由此可见,UnsafeCell没有破坏借用规则,保证了"共享不可变,可变不共享",这是通过以下设计共同达成的:

  1. 提供get接口,接受不可变借用&self,返回可变指针*mut T,达到内部可变性
  2. 提供get_mut接口,接受可变借用&mut self,返回可变借用&mut T
    • 阻止存在可变借用期间,调用get方法,导致多个途径可修改同一数据,破坏"可变不共享"
  3. 不提供不可变借用接口
    • 阻止存在不可变借用时,调用get方法,破坏"共享不可变"

不变性

如果只是遵循借用规则,那还远不能说UnsafeCell是一种安全的内部可变性手段。内部可变性与协变还会共同导致垂悬借用。

回看之前博客的例子:

rust 复制代码
struct MyCell<'a> {
    value: &'a i32,
}

impl<'a> MyCell<'a> {
    fn set(&self, other: &'a i32) {
        unsafe {
            std::ptr::write(&self.value as *const &i32 as *mut &i32, other);
        }
    }
}

fn func<'b>(c: &MyCell<'b>) {
    let tmp: i32 = 2025;
    c.set(&tmp);
    println!("change value: {}", c.value);
}

static X: i32 = 2005;
fn main() {
    let cell = MyCell { value: &X };
    func(&cell);
    println!(" end value: {}", cell.value);
}

这段代码由于垂悬借用,会导致未定义行为。因为MyCell具有内部可变性,同时还具有协变的性质。导致了MyCell<'static>可以替代MyCell<'a>,编译器发生生命周期的误判。如果你无法理解这个过程,请回看上一篇博客的详细分析。

UnsafeCell不是一个协变类型,而是一个不变类型

UnsafeCell带有一个属性#[lang = "unsafe_cell"],这也是Rust内部的特殊标记,这直接修改了UnsafeCell的型变类型,从一个协变类型变成了不变类型。

示例:

rust 复制代码
fn test_cell<'l: 's, 's>(mut left: UnsafeCell<&'s i32>, right: UnsafeCell<&'l i32>) {
    left = right;
}

函数接受两个生命周期'l's,其中'l: 's意味着'l生命周期更长,型变角度就是'l <: 's

假设UnsafeCell是一个协变类型,就有UnsafeCell<&'l i32> <: UnsafeCell<&'s i32>,那么以上代码是合法的,编译器检查可以通过。

但是以上代码会报错,因为UnsafeCell是不变类型,意味着UnsafeCell<&'l i32>UnsafeCell<&'s i32>没有任何关系。即使确实'l生命周期更长,但是经过UnsafeCell包装后,这两个生命周期没有任何关系!

这才是UnsafeCell最关键的一点,它破坏了协变,从而保证用户在使用内部可变性的同时,一定不会触发协变,进而不会触发垂悬借用。

总的来看,UnsafeCell既提供了内部可变性,又遵循了借用规则,还破坏了协变。从各方面来说,它都称得上一个安全的内部可变性方案。

那为什么还要叫做UnsafeCell?原因在于,使用不可变的借用,得到了一个可变的原生指针,这可能导致一些问题。等到用户使用get返回的指针时,就需要用到unsafe操作了。

实际上在需要内部可变性时,不会直接使用UnsafeCell,而是使用它的基础上封装出的CellRefCell


Cell

Cell实现了最基本的内部可变性,适用于实现了Copy的类型,实现在std::cell中。

它的定义如下:

rust 复制代码
pub struct Cell<T: ?Sized> {
    value: UnsafeCell<T>,
}

这个结构体很简单,只有一个value,它是UnsafeCell<T>

  • new

构造一个Cell非常简单,它实现了new方法:

rust 复制代码
Cell::new(10);
Cell::new("hello");

这样就把一个类型包裹进了Cell中。

  • get

通过get方法,可以拿到Cell内部的值:

rust 复制代码
let num_cell = Cell::new(5);
let num = num_cell.get();
println!("The number is: {}", num);

要注意,这个方法只有实现了CopyT可以使用。它最后其实是调用了self.value.get(),这个方法之前讲解过,返回的是一个值表达式,因此如果是非Copy类型,会导致内部数据的所有权直接被转移走。Cell直接禁止你使用非Copy的类型调用,保证所有权留在Cell内部。

  • replace

通过replace方法,可以替换Cell内部的值,源码如下:

rust 复制代码
#[inline]
#[stable(feature = "move_cell", since = "1.17.0")]
#[rustc_const_stable(feature = "const_cell", since = "1.88.0")]
#[rustc_confusables("swap")]
pub const fn replace(&self, val: T) -> T {
    mem::replace(unsafe { &mut *self.value.get() }, val)
}

它没有去调用UnsafeCell::replace,而是亲自去调用mem::replace。因为UnsafeCell::replace是一个unsafe的方法,而Cell需要封装为一个安全的接口,所以使用了mem::replace进行安全的替换操作。这个接口与ptr::replace不同,它操作的是借用而非原生指针,借用检查就有了用武之地,因此更加安全。

同样的,它最后返回了T,这会导致所有权的转移,或者发生拷贝。

  • set

通过set方法,可以修改Cell内部的值,源码如下:

rust 复制代码
#[inline]
#[stable(feature = "rust1", since = "1.0.0")]
pub fn set(&self, val: T) {
    self.replace(val);
}

这个方法对于任意的T都可以使用,哪怕不是Copy的类型。它调用了自己的self.replace,注意不是self.value.replace。这个方法会返回内部的值表达式,而set中没有用任何变量接收这个返回值,这意味着如果T是一个非Copy类型,会在replace返回的同时将原本的数据进行析构,防止内存泄露。

  • get_mut

通过get_mut方法,可以拿到内部值的可变借用:

rust 复制代码
let mut num_cell = Cell::new(5);
let r = num_cell.get_mut();
*r = 10;
println!("{}", num_cell.get());

这个方法调用的就是UnsafeCell::get_mut,拿到内部值的可变借用,如果T是非Copy类型,那么就需要通过这种方式拿到内部的数据。

以上三个就是Cell最核心的接口,它对UnsafeCell进行了包装,提供了固定的方法让使用内部可变性。

Cell是对UnsafeCell的安全封装,比如get方法直接拿到数据拷贝而非*mut T原生指针,replace方法也替换为了更加安全的版本。在这些基础上,又继承了UnsafeCell的型变机制以及对借用检查的兼容。所以在需要内部可变性时往往不使用UnsafeCell而是Cell


RefCell

Cell中,只有Copy的类型可以使用get,否则只能通过可变借用修改数据。但是如果需要对数据进行多份不可变借用,那Cell用起来确实比较别扭。

但是一旦提供可变借用的手段,那么就可能破坏借用规则。为此Rust又封装了RefCell,它允许用户持有内部数据的不可变借用,这确实破坏了编译期的借用规则,但是它内部自己维护了一套运行时借用检查,保证不会发生内存错误。

定义如下:

rust 复制代码
type BorrowCounter = isize;

#[rustc_diagnostic_item = "RefCell"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct RefCell<T: ?Sized> {
    borrow: Cell<BorrowCounter>,
    value: UnsafeCell<T>,
    
    #[cfg(feature = "debug_refcell")]
    borrowed_at: Cell<Option<&'static crate::panic::Location<'static>>>,
}

它有三个属性:

  1. borrow:这是一个Cell<isize>,用Cell提供内部可变性刚刚好,这是一个计数,用于在运行时统计借用情况
  2. value:这是实际存储的数据
  3. borrowed_at:这个字段用于统计第一次借用位置

这个borrowed_at比较复杂,它也通过Cell保证内部可变性,Option表示为空的情况,最内部是一个crate::panic::Location,这个Location是一种文件元信息,存储了文件名,行号,列号。当运行时发生违背借用规则的行为,panic!就会输出这个Location内部的信息,帮助程序员定位借用错误。

borrow是一个计数器,它也是整个RefCell运行时借用检查的核心。初始borrow = 0表示没有任何借用。

  • 进行可变借用:检查borrow == 0,表示当前没有任何借用,借用完毕后borrow = -1
  • 进行不可变借用:检查borrow >= 0,表示当前不存在可变借用,borrow += 1,当前borrow的值是多少,就说明有多少个不可变借用

光说规则显得很晦涩,其实它的机制很简单,通过接口来深入了解它是如何达到运行时借用检查的。


Ref

当尝试从RefCell中得到一个不可变借用,实际拿到的不是&T,而是Ref<T>,它是经过封装的不可变借用。

rust 复制代码
#[stable(feature = "rust1", since = "1.0.0")]
#[must_not_suspend = "holding a Ref across suspend points can cause BorrowErrors"]
#[rustc_diagnostic_item = "RefCellRef"]
pub struct Ref<'b, T: ?Sized + 'b> {
    value: NonNull<T>,
    borrow: BorrowRef<'b>,
}
  • value指向的具体数据,NonNull防止为空,内部存储的是原生指针*const T
  • borrow是引用计数

BorrowRef的结构如下:

rust 复制代码
struct BorrowRef<'b> {
    borrow: &'b Cell<BorrowCounter>,
}

他内部是一个借用 &Cell<BorrowCounter>,实际上指向了某个RefCell内部的counter

BorrowRef析构时,引用计数会自减:

rust 复制代码
#[rustc_const_unstable(feature = "const_ref_cell", issue = "137844")]
impl const Drop for BorrowRef<'_> {
    #[inline]
    fn drop(&mut self) {
        let borrow = self.borrow.get();
        debug_assert!(is_reading(borrow));
        self.borrow.replace(borrow - 1);
    }
}

drop中,先拿到内部的计数值BorrowCounter,随后使用replace接口将borrow - 1替换进去,实现了析构时自动减少计数。

BorrowRef创建时,引用计数会自增:

rust 复制代码
const UNUSED: BorrowCounter = 0;

#[inline(always)]
const fn is_reading(x: BorrowCounter) -> bool {
    x > UNUSED
}

impl<'b> BorrowRef<'b> {
    #[inline]
    const fn new(borrow: &'b Cell<BorrowCounter>) -> Option<BorrowRef<'b>> {
        let b = borrow.get().wrapping_add(1);
        if !is_reading(b) {
            None
        } else {
            borrow.replace(b);
            Some(BorrowRef { borrow })
        }
    }
}

通过new创建一个BorrowRef,需要传入一个&Cell<BorrowCounter>

首先borrow.get().wrapping_add(1)对这个计数器进行自增处理,然后进入is_reading判断当前是否有可变借用。如果有可变借用,那么返回None表示创建失败。如果没有则把原数据更新为自增后的值,最后返回创建好的BorrowRef

is_reading 判断x > 0。如果已经存在可变借用,那么原本的值就是-1。自增后为0,无法满足条件,说明当前有借用正在读取。反之如果大于0,说明当前没有可变借用,要么这是第一次创建,要么有别的不可变借用。

因此BorrowRefnew的时候增加引用计数,在drop的时候减少引用计数,这是基于RAII机制动态维护借用数量。

了解了Ref的机制后,看看RefCell是如何创建不可变借用的。

  • try_borrow

borrow尝试获得一个不可变借用,返回一个Result,源码如下:

rust 复制代码
#[stable(feature = "try_borrow", since = "1.13.0")]
#[inline]
#[cfg_attr(feature = "debug_refcell", track_caller)]
#[rustc_const_unstable(feature = "const_ref_cell", issue = "137844")]
pub const fn try_borrow(&self) -> Result<Ref<'_, T>, BorrowError> {
    match BorrowRef::new(&self.borrow) {
        Some(b) => {
            #[cfg(feature = "debug_refcell")]
            {
                if b.borrow.get() == 1 {
                    self.borrowed_at.replace(Some(crate::panic::Location::caller()));
                }
            }
            
            let value = unsafe { NonNull::new_unchecked(self.value.get()) };
            Ok(Ref { value, borrow: b })
        }
        None => Err(BorrowError {
            #[cfg(feature = "debug_refcell")]
            location: self.borrowed_at.get().unwrap(),
        }),
    }
}

try_borrow将自己的self.borrow传入BorrowRef::new创建一个借用,刚刚已经介绍过在new内部会自动检查借用,最后对返回值进行模式匹配。

如果是Some,说明创建不可变借用成功。如果b.borrow.get() == 1,说明这是第一次进行借用,将借用位置的数据存储到borrowed_at中,方便后续错误处理。

随后通过self.value.get()拿到裸指针*mut T,并把这个指针包装袋NonNull里面,这表示这个指针一定不能为空,这是一层增强安全性的包装。最后返回Ok(Ref { value, borrow: b }),这是在将指向原数据的非空指针和引用计数包装为Ref返回给用户使用。

但是如果违反了借用检查,也就是最开始的模式匹配为None,此时就返回一个错误,错误中包裹的信息是borrowed_at,也就是第一次发生借用的位置,用户就可以定位错误。

  • borrow

borrow可以直接拿到一个不可变借用,而非得到一个Result。但是如果违背了借用检查,就会panic!导致程序崩溃。

源码:

rust 复制代码
#[stable(feature = "rust1", since = "1.0.0")]
#[inline]
#[track_caller]
#[rustc_const_unstable(feature = "const_ref_cell", issue = "137844")]
pub const fn borrow(&self) -> Ref<'_, T> {
    match self.try_borrow() {
        Ok(b) => b,
        Err(err) => panic_already_mutably_borrowed(err),
    }
}

borrow内部,直接复用了try_borrow的逻辑,最后对返回的Result进行模式匹配。如果是Ok就把内部的Ref直接返回,反之则基于错误信息panic!


RefMut

当对RefCell进行可变借用,拿到的也不是&mut T,而是RefMut<T>

结构如下:

rust 复制代码
#[stable(feature = "rust1", since = "1.0.0")]
#[must_not_suspend = "holding a RefMut across suspend points can cause BorrowErrors"]
#[rustc_diagnostic_item = "RefCellRefMut"]
pub struct RefMut<'b, T: ?Sized + 'b> {
    value: NonNull<T>,
    borrow: BorrowRefMut<'b>,
    marker: PhantomData<&'b mut T>,
}

valueborrow字段与之前的Ref功能是相同的,一个用于存储数据,另一个用于维护借用检查。此外还有一个marker,他是PhantomData类型,这个会在稍后讲解,它可以将RefMut的型变改为不变。

BorrowRef相同,BorrowRefMut基于RAII机制,在newdrop时对借用计数进行修改:

rust 复制代码
struct BorrowRefMut<'b> {
    borrow: &'b Cell<BorrowCounter>,
}

const UNUSED: BorrowCounter = 0;

impl<'b> BorrowRefMut<'b> {
    #[inline]
    const fn new(borrow: &'b Cell<BorrowCounter>) -> Option<BorrowRefMut<'b>> {
        match borrow.get() {
            UNUSED => {
                borrow.replace(UNUSED - 1);
                Some(BorrowRefMut { borrow })
            }
            _ => None,
        }
    }
}

#[rustc_const_unstable(feature = "const_ref_cell", issue = "137844")]
impl const Drop for BorrowRefMut<'_> {
    #[inline]
    fn drop(&mut self) {
        let borrow = self.borrow.get();
        debug_assert!(is_writing(borrow));
        self.borrow.replace(borrow + 1);
    }
}

但是不同的是,因为-1表示不可变借用。在newBorrowRefMut把计数自减,而drop时把计数自增,与BorrowRef刚好相反。

new时判断当前的计数是不是0,如果是,说明当前不存在任何借用,通过borrow.replace(UNUSED - 1)把计数设置为-1

  • try_borrow_mut

try_borrow_mut尝试获得一个可变借用,返回Result,源码如下:

rust 复制代码
#[stable(feature = "try_borrow", since = "1.13.0")]
#[inline]
#[cfg_attr(feature = "debug_refcell", track_caller)]
#[rustc_const_unstable(feature = "const_ref_cell", issue = "137844")]
pub const fn try_borrow_mut(&self) -> Result<RefMut<'_, T>, BorrowMutError> {
    match BorrowRefMut::new(&self.borrow) {
        Some(b) => {
            #[cfg(feature = "debug_refcell")]
            {
                self.borrowed_at.replace(Some(crate::panic::Location::caller()));
            }

            let value = unsafe { NonNull::new_unchecked(self.value.get()) };
            Ok(RefMut { value, borrow: b, marker: PhantomData })
        }
        None => Err(BorrowMutError {
            #[cfg(feature = "debug_refcell")]
            location: self.borrowed_at.get().unwrap(),
        }),
    }
}

这段代码逻辑和当时try_borrow几乎一模一样。通过BorrowRefMut::new(&self.borrow)直接创建一个BorrowRefMut,在new内部进行借用检查,对返回值进行模式匹配。

如果是Some,就记录借用位置borrowed_at,因为可变借用是独占的,所以无需进行if判断,直接记录。最后把原生指针取出来包装到NonNull中,最后构造一个RefMut包装到Ok中返回。

如果是None,说明违反了借用规则,此时将第一次借用的记录borrow_at包装在Err内部返回。

  • borrow_mut

borrow_mut用于获得一个可变借用,如果违反借用规则就panic!,源码如下:

rust 复制代码
#[stable(feature = "rust1", since = "1.0.0")]
#[inline]
#[track_caller]
#[rustc_const_unstable(feature = "const_ref_cell", issue = "137844")]
pub const fn borrow_mut(&self) -> RefMut<'_, T> {
	match self.try_borrow_mut() {
	    Ok(b) => b,
	    Err(err) => panic_already_borrowed(err),
	}
}

这个也和borrow几乎一模一样,就是复用了try_borrow_mut的逻辑。如果是Ok就把内部的RefMut拆出来返回,反之则panic!

最后可以通过以下逻辑图回顾一下:

图中蓝色代表我们直接操作的结构体,黄色是结构体内部的属性,绿色则是相关方法。不得不说,RefCell的设计还是十分精妙的,逻辑简约而严谨。

通过borrowborrow_mut方法分别可以获取不可变借用Ref与可变借用Ref,这两个借用内部维护了指向数据的指针NonNull<*const T>,以及引用计数器BorrowRefBorrowRefMut。引用计数器基于RAII机制,在new时进行借用检查并修改计数,drop时恢复计数。

另外的,借用内部存NonNull<T>,而NonNull内部实际存储了原生指针*const T,这是为了避免编译器的借用检查,将RefCell内部的借用检查完全由运行时负责。

最后,RefRefMut分别实现DerefDetefMut,它们可以直接当做&T&mut T使用。关于这两个Trait会在智能指针章节介绍。

讲完了理论,展示一下用法:

rust 复制代码
fn main() {
    let data = RefCell::new(42);

    // 多个不可变借用(Ref<i32>)
    { 
        let r1 = data.borrow();   // 第一个 Ref
        let r2 = Ref::clone(&r1); // 克隆出第二个 Ref
        println!("r1 = {}, r2 = {}", *r1, *r2); // 当普通 &T 用
    } // r1, r2 在这里 drop,借用计数归零

    // 独占可变借用(RefMut<i32>)
    {
        let mut rm = data.borrow_mut(); // RefMut 独占借用
        *rm += 1; // 当普通 &mut T 用
        println!("rm = {}", *rm);
    } // drop,释放独占借用

    // 再次只读借用,看到修改后的值
    {
        let r = data.borrow();
        println!("after mutation = {}", *r);
    }
}

前面的理论确实枯燥,用起来真的非常简单。Ref<T>直接当做&T,而RefMut<T>直接当做&mut T,外层看起来几乎没有任何区别。


常见型变

之前说过,Rust大部分结构体都是协变,但也有不少其它类型是不变或逆变,接下来梳理一下Rust中常见类型的型变。

重要的型变:

  • &'a T:在 'aT 上是协变,对应的 *const T 也是协变
  • &'a mut T: 在 'a 上是协变,但是在 T 上是不变
  • fn(T) -> U: 在参数 T 上是逆变/不变,在返回值 U 上是协变
  • 内部可变类型:在 T 上都是不变,对应的 *mut T 也是不变,比如UnsafeCell<T>Cell<T>RefCell<T>

上面这一段看起来有点懵,什么叫做在'a是协变,在T是不变,这都是些啥啊?

接下来用案例一个一个分析。

&'a T

&'a T:在 'aT 上是协变,对应的 *const T 也是协变

接下来都使用以下结构体做案例:

rust 复制代码
struct MyStruct<'inner> {
    s: &'inner str,
}

这是一个含有内部借用的结构体。

  • 'a协变:意味着'a可以从一个长生命周期变为短生命周期
rust 复制代码
// &'a T 在 'a 协变
fn variance_ref_lifetime<'long: 'short, 'short>(x: &'long i32)
{
    let _: &'short i32 = x;
}

以上代码编译通过,'long生命周期长于'short,所以存在'long <: 'short关系。

因为&'a T'a协变,所以 &'long i32 <: &'short i32。子类型可以作为父类型使用,以上代码中将长生命周期赋值给短生命周期变量就是成立的,编译通过。

  • T协变:意味着T内部的生命周期可以用长生命周期变为短生命周期
rust 复制代码
// &'a T 在 T 协变
fn variance_ref_T<'long: 'short, 'short>(x: &MyStruct<'long>)
{
    let y: &MyStruct<'short> = x;
}

代码同样可以编译通过。

因为&'a TT协变,所以 &MyStruct<'long> <: &MyStruct<'short>,此处的T就是MyStruct。以上xy的赋值就是合法的。

所以说,'a协变意味着生命周期本身的传递关系,而T协变意味着T内部包含生命周期时的关系传递。


&'a mut T

&'a mut T: 在 'a 上是协变,但是在 T 上是不变

  • 'a协变:意味着'a可以从一个长生命周期变为短生命周期
rust 复制代码
// &'a mut T 在 'a 协变
fn variance_refmut_lifetime<'long: 'short, 'short>(x: &'long mut i32)
{
    let y: &'short mut i32 = x;
}

以上代码编译成功,原因和&'a T一样,'a位置是协变的。

  • T不变:意味着内部含有不同生命周期的T之间不能以任何形式互相传递
rust 复制代码
// &'a mut T 在 T 不变
fn variance_refmut_T<'long: 'short, 'short>(x: &mut MyStruct<'long>)
{
    let y: &mut MyStruct<'short> = x;
}

这段代码会报错,分析一下。

与之前相同的是存在'long <: 'short关系,但由于&mut MyStructMyStruct是不变的,因此&mut MyStruct<'long>&mut MyStruct<'short>之间没有任何关系。你不能把x赋值给y,它们两个类型关系是不变而非协变。

有人就要问了,为什么?明明把长生命周期变为短生命周期,可以保证在y期间整个生命周期都是合法的啊?问题在于这可能导致垂悬借用。

思考一个问题,&mut T是可变借用,意味着可以通过这个借用修改T内部的字段,因此它具有内部可变性。一旦T还具有协变的性质,直接就会导致垂悬借用。

例如:

rust 复制代码
fn dangerous<'long: 'short, 'short>(mut_ref: &'long mut MyStruct<'long>, temp_str: &'short str)
{
    let shorter_mut: &mut MyStruct<'short> = mut_ref;
    shorter_mut.s = temp_str;
} 

fn main() {
    let mut ms = MyStruct { s: "hello" };
    {
        let s = String::from("shorter");
        dangerous(&mut ms, s.as_str());
    }
    println!("s: {:?}", ms.s);
}

这段代码,通过一个dangerous修改 MyStruct 内部的借用s

外部的ms生命周期是'longer,内部temp_str的生命周期是'short。那么x的具体类型就是&mut MyStruct<'longer>,而shorter_mut尝试通将 &mut MyStruct<'longer> 转换为类型 &mut MyStruct<'short>,假设它是协变,那么这行代码可以通过。

随后shorter_mut.s = temp_str,这是把内部的s重新赋值为temp_str。由于两者的生命周期都是'short,所以这行代码没有问题。但是实际上此处发生了长生命周期被短生命周期覆盖,也就是所谓的协变,导致编译器的误判。

最后在main中输出,但是此时内部的字符串已经销毁了,产生未定义行为。

还好以上行为并不会发生,因为在shorter_mut创建的时候,就已经被编译器找到问题了。因为&'a mut T中的T是不变,你根本不能把 &mut MyStruct<'longer> 转换为 &mut MyStruct<'short>

为什么&'a TT是协变,但是&'a mut T中却是不变?就在于mut提供了修改能力,如果允许长生命周期被短生命周期覆盖,修改的时候就可以把短生命周期的借用覆盖原本长生命周期的借用,这就会导致垂悬。但是如果只是读取,那长生命周期变短是没有问题的。

最后提一嘴,原生指针*const T对于T是协变的,*mut T对于T是不变的。


fn(T) -> U

fn(T) -> U: 在参数 T 上是逆变/不变,在返回值 U 上是协变

此处 fn(T) -> U 是一个函数指针,曾经简单讲过,它内部存储的是函数的实际地址,可以直接用它调用函数。

  • 在参数 T 上是逆变/不变:
rust 复制代码
fn print_str(s: &'static str) { 
    println!("{}", s);
}

fn takes_short(f: fn(&str)) {
    let s = String::from("hi");
    f(&s);
}

fn main() {
    takes_short(print_str); // 编译错误
}

这段代码虽然简单,但是需要绕个弯子来分析。

print_str是一个函数,它接受一个&'static str,用于输出字符串。

takes_short则接受一个函数指针,内部创建一个临时字符串,尝试用这个内部字符串调用函数。

最后在main中,把print_str作为函数指针传给takes_short,这段代码会报错。

带上生命周期,print_str的类型就是fn(&'static str),现在尝试把它传给fn(&str),假设&str的生命周期是'a'a一定小于等于'static,因为'static是最长的生命周期。因此存在'static <: 'a的关系。但是由于fn(T)对于T是逆变的,此处传递失败,编译错误,你不能把fn (&'static str)转换为fn (&'a str)

思考一个问题,为什么这里不允许把一个长生命周期转为短生命周期?明明只是一个只读借用。

因为这其实是一个逆向的过程,把print_str这个函数指针传给takes_short,实际上是takes_short反过来调用print_str函数。对于一个需要&'static str的函数,takes_short却尝试传一个'a生命周期的参数进去,生命周期不够长,所以报错。这里要绕个弯子,可以多思考一下。

以上代码编译不通过,只能证明T不是协变,还不能证明它是逆变,因此再看一个例子:

rust 复制代码
fn print_str(s: &str) {
    println!("{}", s);
}

fn takes_static(f: fn(&'static str)) {
    f("Hello");
}

fn main() {
    takes_static(print_str); 
}

这段代码和刚才类似。不过print_str的类型变成了fn(&str),而takes_static需要的函数指针变成了fn(&'static str)

main中调用takes_static。假设print_str参数的生命周期为'a,那么print_str就是fn(&'a str)。现在尝试把fn(&'a str)作为fn(&'static str)类型使用,'a一定小于等于'static,直觉上一个短生命周期怎么可能当做长生命周期用呢?

这里编译通过了,因为fn(T)对于T是逆变。因此存在fn('a) <: fn('static),那么fn('a)就可以当做fn('static)使用。

从逻辑上来理解,其实是takes_static在内部调用了print_str函数。print_str需要一个生命周期'a,而takes_static传了一个'static进去,长生命周期替代短生命周期,这可太合理了。这是和刚才同样的逆向思考过程。

要注意,目前fn(T)T是唯一存在逆变的情况,未来有可能从逆变变为不变。

  • 在返回值 U 上是协变

如果理解了前面的,这个就更好理解了。

rust 复制代码
fn make_long() -> &'static str {
	"Long life"
}

fn takes_short<'short>(f: fn() -> &'short str) {
	let ret = f();
	println!("returned: {}", ret);
}

fn main() {
	takes_short(make_long); 
}

函数make_long返回了一个&'static str,整体的类型是fn () -> &'static str。而takes_short接受一个函数指针fn() -> &'short str

main函数中,尝试把make_long作为参数传给takes_short。也就是把fn () -> &'static str转化为fn() -> &'short str,这是合法的,因为fn() -> UU是协变,存在关系fn() -> &'static str <: fn() -> &'short str,转化合法。

从逻辑上思考,takes_short调用make_long是为了拿到一个'short生命周期的借用,但是make_long返回了一个'static借用,生命周期更长了,这不是更加安全了吗?


内部可变类型

凡是内部可变类型,生命周期都是不变,因为内部的UnsafeCell是不变,这个就不细讲了。


结构体型变规则

我之前说,结构体大部分都是协变,实际上它有独立的规则,这取决于内部的字段。

例如:

rust 复制代码
struct MyStruct<'a, T> {
    r: &'a T,
}

由于&'a T'a是协变,T也是协变,所以MyStruct<'a, T>'a是协变,T也是协变。

当有多个字段作用于同一泛型,此时就要按照以下规则:

  1. 若都为协变,则最终体现为协变
  2. 若都为逆变,则最终体现为逆变
  3. 其余所有情况都是不变

案例一:

rust 复制代码
struct Combo<'a, T> {
    a: &'a T,
    b: &'a mut T,
}

画个表格分析:

'a T
&'a 协变 协变
&'a mut T 协变 不变
Combo<'a, T> 协变 不变
  • 对于'a:所有字段都是协变,因此最终就是协变
  • 对于T:由于不全是协变,最终表现为不变

案例二:

rust 复制代码
struct Combo<'a, T, U, X> {
    arg: &'a T,
    f1: fn(T) -> U,
    f2: fn(X) -> U,
    f3: fn(X) -> U,
}

分析:

'a T U X
&'a T 协变 协变 - -
fn(T) -> U - 逆变 协变 -
fn(X) -> U - - 协变 逆变
fn(X) -> U - - 协变 逆变
Combo<'a, T, U, X> 协变 不变 协变 逆变

这个案例稍微复杂一点了。

  • 对于T:完全由协变组成,最终表现为协变
  • 对于Y:它不是完全由协变组成,或者完全由逆变组成,最终表现为不变
  • 对于U:完全由协变组成,最终表现为协变
  • 对于X:完全由逆变组成,最终表现为逆变

PhantomData

之前说,内部可变性 加上协变会导致垂悬借用。那么我们只要破坏协变就好了,要如何破坏协变?了解了结构体的型变规则之后,你应该就理解了,给某个内部可变的T加上一个不变或者逆变的字段,就可以破坏协变。

但是假如真的把这个字段添加加进去,一方面它占用了内存空间,另一方面每次创建实例都要给这个字段一个具体的值。但是这个字段实际上根本用不上,我们只希望用它来限制某个泛型参数的型变。

为此Rust提供了一个工具PhantomData,它叫做幻影类型,被包含在std::marker中。它的定义如下:

rust 复制代码
#[lang = "phantom_data"]
#[stable(feature = "rust1", since = "1.0.0")]
pub struct PhantomData<T: PointeeSized>;

如你所见,这是一个不包含任何字段的类型!因此他也是一个零大小类型,不占用任何内存空间。

它被Rust内部标记,当向<>泛型参数中传入任意类型,Rust都会视为它持有该类型,从而协助借用检查,影响型变。

假设你有一个Combo结构体如下:

rust 复制代码
struct Combo<'a, T> {
    s: &'a T,
}

现在它对'aT都是协变的,如何让T变成不变?

你可以加一个字段&'a mut T来影响它:

rust 复制代码
struct Combo<'a, T> {
    s: &'a T,
    _marker: &'a mut T,
}

这个时候T就变成了不变。但是这种方案并不优雅,刚刚才讲过它的问题。

问题在于,想要影响ComboT,就必须持有一个&'a mut T。这个时候你就可以使用PhantomData来装作拥有这个类型字段。

rust 复制代码
struct Combo<'a, T> {
    s: &'a T,
    _marker: PhantomData<&'a mut T>,
}

使用PhantomData,编译器会认为结构体内部存在一个&'a mut T字段,从而影响到T的型变。但实际上这根本没有造成任何实际上的内存占用,所以得名"幻影类型"。

例如之前的RefCell就使用了PhantomData

rust 复制代码
#[stable(feature = "rust1", since = "1.0.0")]
#[must_not_suspend = "holding a RefMut across suspend points can cause BorrowErrors"]
#[rustc_diagnostic_item = "RefCellRefMut"]
pub struct RefMut<'b, T: ?Sized + 'b> {
    // NB: we use a pointer instead of `&'b mut T` to avoid `noalias` violations, because a
    // `RefMut` argument doesn't hold exclusivity for its whole scope, only until it drops.
    value: NonNull<T>,
    borrow: BorrowRefMut<'b>,
    // `NonNull` is covariant over `T`, so we need to reintroduce invariance.
    marker: PhantomData<&'b mut T>,
}

因为这个RefMut可以当做一个可变借用,它在运行时维护。思考一下RefMut和一个真正的借用还有哪些区别?

从借用检查上,RefMut有运行时借用检查,T: ?Sized + 'b的约束也保证了T的内部生命周期长于'b

从接口上,RefMut实现了Deref,可以自动解引用,基于RAII自动释放借用。

好像这就是可变借用的全部?

不,&'a mut T还有一点,它对'a是协变,对T是不变,而RefMut也要保证这个特性。

T被保存在NonNull<T>中,内部实际存储的是*const T*const T对于T是实际上是协变!这就违背了可变借用的型变规则。

PhantomData<&'b mut T>告诉编译器,我在逻辑上持有一个可变借用&'b mut T,这样就可以影响到T'b的型变规则,那么T就变成了一个不变类型。

而对于不可变借用,T是协变的,因此如果去看Ref<T>的结构,就不包含这个PhantomData


相关推荐
红尘散仙5 小时前
我把终端小说阅读器接上了 AI Agent:TRNovel 现在能用 skill 生成书源了
人工智能·后端·rust
卷毛的技术笔记6 小时前
告别硬编码!Spring AI Alibaba 实现 AI Agent 智能工具调用(Tool Calling)
java·人工智能·后端·python·spring·ai编程
isyangli_blog6 小时前
OpenDayLight (Carbon 版本) 启动与组件安装
开发语言·php
vb2008116 小时前
FastAPI APIRouter
开发语言·python
Benszen6 小时前
KVM虚拟化解决方案
开发语言·perl
会编程的土豆6 小时前
Go 语言反射(Reflection)详解
开发语言·后端·golang
東雪木6 小时前
多线程与并发编程 专属复习笔记
java·开发语言·笔记·java面试
喵个咪7 小时前
GoWind Toolkit Go后端代码生成 完整全流程实战
后端·go·orm
杨充7 小时前
1.3 浮点型数据设计灵魂
开发语言·python·算法
噜噜噜阿鲁~7 小时前
python学习笔记 | 11.3、面向对象高级编程-多重继承
java·开发语言