Rust-内部可变性

这篇具有很好参考价值的文章主要介绍了Rust-内部可变性。希望对大家有所帮助。如果存在错误或未考虑完全的地方,请大家不吝赐教,您也可以点击"举报违法"按钮提交疑问。

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中,这种想法是不准确的。下面给出一个示例:

Rust-内部可变性,Rust,rust,开发语言,后端
编译,执行,结果为:
Rust-内部可变性,Rust,rust,开发语言,后端
Rc是Rust里面的引用计数智能指针,多个Rc指针可以同时指向同一个对象,而且有一个共享的引用计数值在记录总共有多少个Rc指针指向这个对象。

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

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

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

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

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

Rust-内部可变性,Rust,rust,开发语言,后端
这次编译通过,执行,结果是符合我们的预期的:
Rust-内部可变性,Rust,rust,开发语言,后端
请注意这个例子最重要的特点。需要注意的是,这里的“可变性”问题跟我们前面见到的情况不一样了。

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

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

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

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

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

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

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

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

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

Rust-内部可变性,Rust,rust,开发语言,后端

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

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

Rust-内部可变性,Rust,rust,开发语言,后端

  • 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:

Rust-内部可变性,Rust,rust,开发语言,后端
get_mut方法与Cell::get_mut一样,可以通过smut self获得&mut T,这个过程是安全的。

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

我们还是用示例来演示一下RefCell怎样使用:
Rust-内部可变性,Rust,rust,开发语言,后端
在函数的签名中,borrow方法和borrow_mut方法返回的并不是&T和&mut T,而是Ref和RefMut。

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

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

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

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

Rust-内部可变性,Rust,rust,开发语言,后端
我们先调用borrow方法,并制造一个指向数组第一个元素的指针,接着再调用borrow_mut方法,修改这个数组。这样,就构造出了同时出现alias和mutation的场景。

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

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

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

Rust-内部可变性,Rust,rust,开发语言,后端
那么 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:

Rust-内部可变性,Rust,rust,开发语言,后端
这个版本是一个new type类型,内部包含了一个T类型的成员。

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

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

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

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

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

以下是修正版:

Rust-内部可变性,Rust,rust,开发语言,后端
在使用unsafe语句块之后,这段代码可以编译通过了。

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

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

Rust-内部可变性,Rust,rust,开发语言,后端从以上代码可以看出,这正是内部可变性类型的特点,即通过共享指针,修改了内部的值。

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

Rust-内部可变性,Rust,rust,开发语言,后端
如果我们用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保存了一个指向局部调用栈上的变量。也就是这里出的问题:

Rust-内部可变性,Rust,rust,开发语言,后端
在’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类型实现的:

Rust-内部可变性,Rust,rust,开发语言,后端
所有具有内部可变性特点的类型都必须基于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://www.toymoban.com/news/detail-804158.html

到了这里,关于Rust-内部可变性的文章就介绍完了。如果您还想了解更多内容,请在右上角搜索TOY模板网以前的文章或继续浏览下面的相关文章,希望大家以后多多支持TOY模板网!

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处: 如若内容造成侵权/违法违规/事实不符,请点击违法举报进行投诉反馈,一经查实,立即删除!

领支付宝红包 赞助服务器费用

相关文章

  • Rust软件外包开发语言的特点

    Rust 是一种系统级编程语言,强调性能、安全性和并发性的编程语言,适用于广泛的应用领域,特别是那些需要高度可靠性和高性能的场景。下面和大家分享 Rust 语言的一些主要特点以及适用的场合,希望对大家有所帮助。北京木奇移动技术有限公司,专业的软件外包开发公

    2024年02月12日
    浏览(50)
  • 【rust语言】rust多态实现方式

    学习rust当中遇到了这个问题,记录一下,不对地方望指正 多态是面向对象程序设计中的一个重要概念,指同一个行为或操作在不同实例上具有不同的行为或结果。简单来说,多态就是指同一种类型的对象,在不同的上下文中有不同的行为。多态性使得程序可以更加灵活、可

    2024年02月11日
    浏览(46)
  • C语言和Rust语言的互相调用(2)(Rust调用C)

    1.创建项目 rust调用c方式挺多的,这里采用最通俗易懂的方法,用构建脚本进行构建整个项目。 2.编辑build.rs的内容 这里的build.rs:若要创建构建脚本,我们只需在项目的根目录下添加一个 build.rs 文件即可。这样一来, Cargo 就会先编译和执行该构建脚本,然后再去构建整个项

    2024年02月02日
    浏览(50)
  • 【Rust 基础篇】Rust FFI:连接Rust与其他编程语言的桥梁

    Rust是一种以安全性和高效性著称的系统级编程语言,具有出色的性能和内存安全特性。然而,在现实世界中,我们很少有项目是完全用一种编程语言编写的。通常,我们需要在项目中使用多种编程语言,特别是在与现有代码库或底层系统交互时。为了实现跨语言的互操作性,

    2024年02月15日
    浏览(54)
  • Rust 笔记:Rust 语言中的字符串

    Rust 笔记 Rust 语言中的字符串 作者 : 李俊才 (jcLee95):https://blog.csdn.net/qq_28550263?spm=1001.2101.3001.5343 邮箱 : 291148484@163.com 本文地址 :https://blog.csdn.net/qq_28550263/article/details/130876665 【介绍】:本文介绍 Rust 语言中的字符和字符串的用法。 上一节:《 Rust 语言中使用 vector(向

    2024年02月06日
    浏览(52)
  • Rust 笔记:Rust 语言中的常量与变量

    Rust 笔记 Rust 语言中的常量与变量 作者 : 李俊才 (jcLee95):https://blog.csdn.net/qq_28550263?spm=1001.2101.3001.5343 邮箱 : 291148484@163.com 本文地址 :https://blog.csdn.net/qq_28550263/article/details/130875912 【介绍】:本文介绍 Rust 语言中的常量与变量。 上一节:《 上一节标题 》 | 下一节:《

    2024年02月06日
    浏览(59)
  • 【Rust】Rust学习 第十三章Rust 中的函数式语言功能:迭代器与闭包

    Rust 的设计灵感来源于很多现存的语言和技术。其中一个显著的影响就是  函数式编程 ( functional programming )。函数式编程风格通常包含将函数作为参数值或其他函数的返回值、将函数赋值给变量以供之后执行等等。 更具体的,我们将要涉及: 闭包 ( Closures ),一个可以储

    2024年02月12日
    浏览(52)
  • Rust语言从入门到入坑——(5)Rust 所有权

    主要介绍Rust所有权的知识,涉及到变量的作用域,内存释放机制,移动,克隆,引用等知识,很多知识是Rust语言特有机制。 所有权有以下三条规则: - Rust 中的每个值都有一个变量,称为其所有者。 - 一次只能有一个所有者。 - 当所有者不在程序运行范围时,该值将被删除

    2024年02月10日
    浏览(44)
  • Rust编程语言入门之Rust的面向对象编程特性

    Rust 受到多种编程范式的影响,包括面向对象 面向对象通常包含以下特性:命名对象、封装、继承 “设计模式四人帮”在《设计模型》中给面向对象的定义: 面向对象的程序由对象组成 对象包装了数据和操作这些数据的过程,这些过程通常被称作方法或操作 基于此定义:

    2023年04月21日
    浏览(53)
  • rust入门系列之Rust介绍及开发环境搭建

    Rust基本介绍 网站: https://www.rust-lang.org/ rust是什么 开发rust语言的初衷是: 在软件发展速度跟不上硬件发展速度,无法在语言层面充分的利用硬件多核cpu不断提升的性能和 在系统界别软件开发上,C++出生比较早,内存管理容易出现安全问题的背景下。 为了解决开发系统界别软

    2024年02月12日
    浏览(65)

觉得文章有用就打赏一下文章作者

支付宝扫一扫打赏

博客赞助

微信扫一扫打赏

请作者喝杯咖啡吧~博客赞助

支付宝扫一扫领取红包,优惠每天领

二维码1

领取红包

二维码2

领红包