Rust-内部可变性

发布时间:2024年01月16日

Rust的borrow checker的核心思想是“共享不可变,可变不共享”。

但是只有这个规则是不够的,在某些情况下,我们的确需要在存在共享的情况下可变。

为了让这种情况是可控的、安全的,Rust还设计了一种“内部可变性”(interior mutability)。

“内部可变性”的概念,是与“承袭可变性”(inherited mutability)相对应的。大家应该注意到了,Rust中的mut关键字不能在声明类型的时候使用,只能跟变量一起使用。

类型本身不能规定自己是否是可变的。

一个变量是否是可变的,取决于它的使用环境,而不是它的类型。

可变还是不可变取决于变量的使用方式,这就叫作“承袭可变性”。

如果我们用let var : T;声明,那么var是不可变的,同时,var内部的所有成员也都是不可变的;如果我们用let mut var :T;声明,那么 var是可变的,相应的,它的内部所有成员也都是可变的。

我们不能在类型声明的时候指定可变性,比如在struct中对某部分成员使用mut修饰,这是不合法的。

我们只能在变量声明的时候指定可变性。我们也不能针对变量的某一部分成员指定可变性,其他部分保持不变。

常见的具备内部可变性特点的类型有Cell、RefCell、Mutex、RwLock、Atomic*等。其中Cell和RefCell是只能用在单线程环境下的具备内部可变性的类型。下面就来讲解何为“内部可变性”。

Cell

按照前面的理论,如果我们有共享引用指向一个对象,那么这个对象就不会被更改了。

因为在共享引用存在的期间,不能有可变引用同时指向它,因此它一定是不可变的。其实在Rust中,这种想法是不准确的。下面给出一个示例:

在这里插入图片描述
编译,执行,结果为:
在这里插入图片描述
Rc是Rust里面的引用计数智能指针,多个Rc指针可以同时指向同一个对象,而且有一个共享的引用计数值在记录总共有多少个Rc指针指向这个对象。

注意Rc指针提供的是共享引用,按道理它没有修改共享数据的能力。

但是我们用共享引用调用clone方法,引用计数值发生了变化。

这就是我们要说的“内部可变性”。如果没有内部可变性,标准库中的Rc类型是无法正确实现出来的。

具备内部可变性的类型,最典型的就是Cell。

现在用一个更浅显的例子来演示一下Cell的能力:

在这里插入图片描述
这次编译通过,执行,结果是符合我们的预期的:
在这里插入图片描述
请注意这个例子最重要的特点。需要注意的是,这里的“可变性”问题跟我们前面见到的情况不一样了。

data这个变量绑定没有用mut修饰,p这个指针也没有用&mut修饰,然而不可变引用竟然可以调用set函数,改变了变量的值,而且还没有出现编译错误。

这就是所谓的内部可变性——这种类型可以通过共享指针修改它内部的值。

虽然粗略一看,Cell类型似乎违反了Rust的“唯一修改权”原则。

我们可以存在多个指向Cell类型的不可变引用,同时我们还能利用不可变引用改变Cell内部的值。

但实际上,这个类型是完全符合“内存安全”的。

我们再想想,为什么Rust要尽力避免alias和mutation同时存在?

因为假如我们同时有可变指针和不可变指针指向同一块内存,有可能出现通过一个可变指针修改内存的过程中,数据结构处于被破坏状态的情况下,被其他的指针观测到。

Cell类型是不会出现这样的情况的。因为Cell类型把数据包裹在内部,用户无法获得指向内部状态的指针,这意味着每次方法调用都是执行的一次完整的数据移动操作。

每次方法调用之后,Cell类型的内部都处于一个正确的状态,我们不可能观察到数据被破坏掉的状态。

在这里插入图片描述

多个共享指针指向Cell类型的状态就类似图所示的这样,Cell就是一个“壳”,它把数据严严实实地包裹在里面,所有的指针只能指向Cell,不能直接指向数据。修改数据只能通过Cell来完成,用户无法创造一个直(内部可变性)接指向数据的指针。

我们来仔细观察一下Cell类型提供的公开的API,就能理解Cell类型设计的意义了。下面是Cell类型提供的几个主要的成员方法:

在这里插入图片描述

  • get_mut方法可以从smut Cell类型制造出一个smut T型指针。因为smut型指针具有“独占性”,所以这个函数保证了调用前,有且仅有一个“可写”指针指向Cell,调用后有且仅有一个“可写”指针指向内部数据。它不存在制造多个引用指向内部数据的可能性。
  • set方法可以修改内部数据。它是把内部数据整个替换掉,不存在多个引用指向内部数据的可能性。
  • swap方法也是修改内部数据。跟set方法一样,也是把内部数据整体替换掉。与std::mem::swap函数的区别在于,它仅要求&引用,不要求&mut引用。
  • replace方法也是修改内部数据。跟set方法一样,它也是把内部数据整体替换,唯一的区别是,换出来的数据作为返回值返回了。
  • into_inner方法相当于把这个“壳”剥掉了。它接受的是Self类型,即move语义,原来的Cell类型的变量会被move进入这个方法,会把内部数据整体返回出来。
  • get方法接受的是&self参数,返回的是T类型,它可以在保留之前Cell类型不变的情况下返回一个新的T类型变量,因此它要求T:Copy约束。每次调用它的时候,都相当于把内部数据memcpy了一份返回出去。

正因为上面这些原因,我们可以看到,Cell类型虽然违背了“共享不可变,可变不共享”的规则,但它并不会造成内存安全问题。它把“共享且可变”的行为放在了一种可靠、可控、可信赖的方式下进行。它的API是经过仔细设计过的,绝对不可能让用户有机会通过&Cetl获得&T或者&mut T。它是对alias+mutation原则的有益补充,而非完全颠覆。大家可以尝试一下用更复杂的例子(如Cell<Vec>)试试,看能不能构造出内存不安全的场景。

RefCell

RefCell是另外一个提供了内部可变性的类型。它提供的方式与Cell类型有点不一样。Cell类型没办法制造出直接指向内部数据的指针,而RefCell可以。我们来看一下它的API:

在这里插入图片描述
get_mut方法与Cell::get_mut一样,可以通过smut self获得&mut T,这个过程是安全的。

除此之外,RefCel1最主要的两个方法就是borrow和borrow_mut,另外两个try_borrow和try_borrow_mut只是它们俩的镜像版,区别仅在于错误处理的方式不同。

我们还是用示例来演示一下RefCell怎样使用:
在这里插入图片描述
在函数的签名中,borrow方法和borrow_mut方法返回的并不是&T和&mut T,而是Ref和RefMut。

它们实际上是一种“智能指针”,完全可以当作&T和&mut T的等价物来使用。

标准库之所以返回这样的类型,而不是原生指针类型,是因为它需要这个指针生命周期结束的时候做点事情,需要自定义类型包装一下,加上自定义析构函数。

至于包装起来的类型为什么可以直接当成指针使用。

那么问题来了:如果borrow和borrow_mut这两个方法可以制造出指向内部数据的只读、可读写指针,那么它是怎么保证安全性的呢? 如果同时构造了只读引用和可读写引用指向同一个Vec,那不是很容易就构造出悬空指针么?答案是,RefCell类型放弃了编译阶段的alias+mutation原则,但依然会在执行阶段保证alias+mutation原则。示例如下:

在这里插入图片描述
我们先调用borrow方法,并制造一个指向数组第一个元素的指针,接着再调用borrow_mut方法,修改这个数组。这样,就构造出了同时出现alias和mutation的场景。

编译,通过。执行,问题来了,程序出现了panic。

出现panic的原因是,RefCell探测到同时出现了alias和mutation的情况,它为了防止更槽糕的内存不安全状态,直接使用了panic来拒绝程序继续执行。

如果我们用try_borrow方法的话,就会发现返回值是Result::Err,这是另外一种更友好的错误处理风格。

在这里插入图片描述
那么 RefCell是怎么探测出问题的呢?原因是,RefCell内部有一个“借用计数器”,调用borrow方法的时候,计数器里面的“共享引用计数”值就加1。

当这个borrow结束的时候,会将这个值自动减1。同样,borrow_mut方法被调用的时候,它就记录一下当前存在“可变引用”。如果“共享引用”和“可变引用”同时出现了,就会报错。

从原理上来说,Rust默认的“借用规则检查器”的逻辑非常像一个在编译阶段执行的“读写锁”(read-write-locker)。如果同时存在多个“读”的锁,是没问题的;如果同时存在“读”和“写”的锁,或者同时存在多个“写”的锁,就会发生错误。

RefCell类型并没有打破这个规则,只不过,它把这个检查逻辑从编译阶段移到了执行阶段。

RefCell让我们可以通过共享引用&修改内部数据,逃过编译器的静态检查。

但是它依然在兢兢业业地尽可能保证“内存安全”。我们需要的借用指针必须通过它提供的APIborrow()borrow_mut()来获得,它实际上是在执行阶段,在内部维护了一套“读写锁”检查机制。

一旦出现了多个“写”或者同时读写,就会在运行阶段报错,用这种办法来保证写数据时候的执行过程中的内部状态不会被观测到,任何时候,开始读或者开始写操作开始的时候,共享的变量都处于一个合法状态。

因此在执行阶段,RefCell是有少量开销的,它需要维护一个借用计数器来保证内存安全。

所以说,我们一定不要过于滥用RefCell这样的类型。如果确有必要使用,请一定规划好动态借用出来的指针存活时间,否则会在执行阶段有问题。

Cell和RefCell用得最多的场景是和多个只读引用相配合。比如,多个&引用或者Rc引用指向同一个变量的时候。我们不能直接通过这些只读引用修改变量,因为既然存在alias,就不能提供mutation。

为了让存在多个alias共享的变量也可以被修改,那我们就需要使用内部可变性。

Rust中提供了只读引用的类型有&、Rc、Arc等指针,它们可以提供alias。

Rust中提供了内部可变性的类型有Cell、RefCell、Mutex、RwLock以及Atomic*系列类型等。

这两类类型经常需要配合使用。

如果你需要把一个类型T封装到内部可变性类型中去,要怎样选择Cell和RefCell呢?原则就是,如果你只需要整体性地存入、取出T,那么就选Cell。

如果你需要有个可读写指针指向这个T修改它,那么就选RefCell。

UnsafeCell

接下来,我们来分析Cell/RefCell的实现原理。
我们先来考虑两个问题,标准库中的Cell类型是怎样实现的?假如让我们自己来实现一遍,是否可行呢?
模仿标准库中的Cell类型的公开方法(只考虑最简单的new、get、set这三个方法),我们先来一个最简单的版本V1:

在这里插入图片描述
这个版本是一个new type类型,内部包含了一个T类型的成员。

成员方法对类型T都有恰当的约束。这些都没错。

只有一个关键问题需要注意:对于set方法,直接这样写是肯定行不通的,因为self是只读引用,我们不可能直接对self.value赋值。

而且,Cell类型最有用的地方就在于,它可以通过不可变引用改变内部的值。

那么这个问题怎么解决呢?可以使用unsafe关键字。

用unsafe包起来的代码块可以突破编译器的一些限制,做一些平常不能做的事情。

以下是修正版:

在这里插入图片描述
在使用unsafe语句块之后,这段代码可以编译通过了。

这里的关键是,在unsafe代码中,我们可以把const T类型强制转换为mut T类型。

这是初学者最直观的解决方案,但这个方案是错误的。通过这种方式,我们获得了写权限。通过下面简单的示例可以看到,这段代码是符合我们的预期的:

在这里插入图片描述从以上代码可以看出,这正是内部可变性类型的特点,即通过共享指针,修改了内部的值。

事情就这么简单么?很可惜,有这种想法的人都过于naive了。下面这个示例会给大家泼一盆冷水:

在这里插入图片描述
如果我们用rustc temp.rs编译debug版本,可以看到执行结果为1。

如果我们用rustc -0 temp.rs编译release版本,可以看到执行结果为140733369053192

这是怎么回事呢?因为这段代码中出现了野指针。

我们来分析一下这段测试代码。
在这段测试代码中,我们在CellV2类型里面保存了一个引用。

main函数调用了innocent函数,继而又调用了evil函数。这里需要特别注意的是:在evil函数中,我们调用了CellV2类型的set方法,改变了它里面存储的指针。

修改后的指针指向的谁呢?是innocent函数内部的一个局部变量。最后在main函数中,innocent函数返回后,再把这个CellV2里面的指针拿出来使用,就得到了一个野指针。

我们继续从生命周期的角度深人分析,这个野指针的成因。在main函数的开始,table.cell变量保存了一个指向local变量的指针。

这是没问题的,因为local的生命周期比table更长,table.cell指向它肯定不会有问题。有问题的是table.cell在evil函数中被重新赋值。这个赋值导致了table.cell保存了一个指向局部调用栈上的变量。也就是这里出的问题:

在这里插入图片描述
在’long:'short的情况下,&'long类型的指针向&'short类型赋值是没问题的。

但是这里的&Table<'long>类型的变量赋值给&Table<'short>类型的变量合理吗?事实证明,不合理。

证明如下。我们把上例中的CellV2类型改用标准库中的Cell类型试试:
type Cellv2=std::cell::Cell;

其他测试代码不变。编译,提示错误。

使用我们自己写的CellV2版本,这段测试代码可以编译通过,并制造出了内存不安全。使用标准库中的Cell类型,编译器成功发现了这里的生命周期问题,给出了提示。

这说明了CellV2的实现依然是错误的。虽然最基本的测试用例通过了,但是碰到复杂的测试用例,它还是不够“健壮”。

而Rust对于“内存不安全”问题是绝对禁止的。

不像C/C++,在Rust语言中,如果有机会让用户在不用unsafe的情况下制造出内存不安全,这个责任不是由用户来承担,而是应该归因于写编译器或者写库的人。

在Rust中,写库的人不需要去用一堆文档来向用户保证内存安全,而是必须要通过编译错误来保证。

这个示例中的内存安全问题,不能归因于测试代码写得不对,因为在测试代码中没有用到任何unsafe代码,用户是正常使用而已。

这个问题出现的根源还是CellV2的实现有问题,具体来说就是那段unsafe代码有问题。

按照Rust的代码质量标准,CellV2版本是完全无法接受的垃圾代码。

那么,这个bug该如何修正呢?为什么&'long类型的指针可以向&'short类型赋值,而&Cell<'long>类型的变量不能向&Cell<'short>类型的变量赋值?因为对于具有内部可变性特点的Cell类型而言,它里面本来是要保存&'long型指针的,结果我们给了它一个&'short型指针,那么在后面取出指针使用的时候,这个指针所指向的内容已经销毁,就出现了野指针。

这个bug的解决方案是,禁止具有内部可变性的类型,针对生命周期参数具有“协变/逆变”特性。这个功能是通过标准库中的UnsafeCell类型实现的:

在这里插入图片描述
所有具有内部可变性特点的类型都必须基于UnsafeCell来实现,否则必然出现各种问题。这个类型是唯一合法的将&T类型转为&mut T类型的办法。绝对不允许把&T直接转换为&mutT而获得可变性。

这是未定义行为。

大家可以自行读一下Cell和RefCell的源码,可以发现,它们能够正常工作的关键在于它们都是基于UnsafeCell实现的,而UnsafeCell本身是编译器特殊照顾的类型。

所以我们说“内部可变性”这个概念是Rust语言提供的一个核心概念,而不是通过库模拟出来的。

实际上,上面那个CellV2示例也正说明了写unsafe代码的困难之处。

许多时候,我们的确需要使用unsafe代码来完成功能,比如调用C代码写出来的库等。但是却有可能一不小心违反了Rust编译器的规则。比如,你没读过上面这段文档的话,不大可能知道简单地通过裸指针强制类型转换实现&T到&mut T的类型转换是错误的。

这么做会在编译器的生命周期静态检查过程中制造出一个漏洞,而且这个漏洞用简单的测试代码测不出来,只有在某些复杂场景下才会导致内存不安全。

Rust代码中写unsafe代码最困难的地方其实就在这样的细节中,有些人在没有完全理解掌握Rust的safe代码和unsafe代码之间的界限的情况下,乱写unsafe代码,这是不负责任的。

文章来源:https://blog.csdn.net/zhuyufan1986/article/details/135611081
本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。