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


相关推荐
沐知全栈开发2 小时前
XSLT `<value-of>` 元素详解
开发语言
东哥很忙XH2 小时前
python使用PyQt5开发桌面端串口通信
开发语言·驱动开发·python·qt
手揽回忆怎么睡2 小时前
Java集成whisper.cpp
java·开发语言·whisper
wjs20242 小时前
R 基础语法
开发语言
无限大62 小时前
为什么玩游戏需要独立显卡?——GPU与CPU的分工协作
后端·程序员
JS_GGbond2 小时前
用美食来理解JavaScript面向对象编程
开发语言·javascript·美食
Moment2 小时前
小米不仅造车,还造模型?309B参数全开源,深度思考完胜DeepSeek 🐒🐒🐒
前端·人工智能·后端
艾上编程2 小时前
第三章——爬虫工具场景之Python爬虫实战:行业资讯爬取与存储,抢占信息先机
开发语言·爬虫·python
β添砖java2 小时前
python第一阶段第10章
开发语言·python