好的,我会帮您整理这段内容,使其更加合理通顺,同时不会遗漏或省略任何内容。以下是整理后的版本:
Rust 的好东西:内部可变性
内部可变性是Rust中一个非常重要的特性,而内部可变这个概念其实并不是Rust独有的,很多其他的编程语言,像C++、Swift其实都有类似的体现。要讲清楚什么是内部可变性,首先我们就要聊聊什么是可变性。
首先我们看第一个函数test1,这个函数中声明了一个名为i的整数类型,中间我省略了一部分操作,在函数的最后,我对这个i做了一个assert。由于我们的i是一个不可变的变量,所以我们不管中间做了什么样的操作,都可以保证这个函数最后i是不会发生变化的,所以这个assert将永远不会失败。
然后我们再看第二个函数,我们将这个变量从一个基本类型换成了一个String。虽然String不是基本类型,但它仍然保持值类型的特性,也就是说,如果这个字符串是不可变的,那么中间不管发生什么样的操作,在函数的最后这个s都将不会发生改变,所以第25行的这个assert也将永远会成功。
我们来到第三个函数,我们声明的这个vector是一个mutable的变量,然后我们取出了其中的一个元素,注意这里是一个不可变的引用。然后中间仍然是做了一些操作,那这个时候我们是否可以断定这个element变量没有发生变化呢?实际上也是可以保证的。虽然我们的vector是可变的,但当我们取出其中的一个不可变的引用之后,这个vector就不能再发生改变了。比如这个地方,我们尝试对vector进行一个clear操作,我们可以发现编译器会报一个错误,说你不能以可变的方式去借用这个v,因为它已经被不可变的借用了。
这个就是Rust的引用规则,其中的一条:可变引用跟不可变引用是不能同时并存的。那么需要理解清楚这中间发生了什么,我们可以把整个代码做一下变换,我们对所有的变量做一下作用域的标注。
我们给每一个变量做了一个生命周期的标注。首先这个最外面的vector,它在这个’a的生命周期下存活,也就是在整个’a的生命周期中,这个vector都是存在的。接下来我们做这个取element的操作,那么我们开辟了一个’b的作用域。然后由于这个操作其实是调用了一个index方法,然后我们把第一个参数,也就是说这个v的引用先声明出来,然后传入这个index,拿到一个element的引用。
然后我们来观察这个index的函数,它的入参和出参分别都有一个引用,但是整个函数其实没有任何显示的生命周期标注,所以这个时候编译器会推导,self的这个引用跟返回值的这个引用是处于相同的生命周期,也就是说这个element跟v都是存活于这个’b生命周期之下。然后我们可以标注这个v,它是’b。
到了中间这个我们要去做clear的操作,我们再开辟一个子的作用域。clear的话它需要一个入参,也就是这个mut的self,我们同样把这个引用给它声明出来,我们这个vm它是存活于’c之下。这个时候我们就会发现’b的生命周期是包含了这个’c的生命周期作用域,也就是说在’c的生命周期之中,’b一定会存活。那这个时候就会出现这个’b,也就是这个vector的不可变引用存活的情况之下,它的可变引用需要被创建。那这个地方就违反了Rust的引用规则,其中的这个可变和不可变引用是不可以互相存活的这样的一个规则。
它背后的整个机制是这个样子。也就是说当我们取出了一个可变变量的其中的一个不可变引用的时候,中间不管发生什么操作,我们都不可以去改变这个不可变的引用。那这就是Rust可变性的一个保证,它可以避免很多我们平时写代码中可能会遇到的问题。
然后我们再来说说什么是内部可变性。这个地方我有一个非常简单的例子,假设我们写了一个Calculator的类型,那它会接收一个function,那这个function定义了一个数字计算的规则。当我们输入一个整数的时候,这个整数会被带入到这个function里面去计算一个结果,然后返回出来。假设我这个地方定义了一个斐波那契的计算函数,然后我们把这个函数传入这个Calculator类型里面,然后进行计算操作。
假设我想对这个Calculator增加一个缓存的能力,也就是说当我们比如说重复执行了这个8,或者是重复执行这个30这个输入的时候,我想把它的结果保存下来。那在后面我需要用到这个值的时候,可以直接返回,避免去调用这个fib函数。那具体的效果就是,当我执行这五个计算操作的时候,它其实只会对不同的这个输入有这个调用,相同的这个输入的时候,它就会复用之前的一个计算结果。
由于我的这个Calculator是一个不可变的类型,那怎么去实现这样的一个效果呢?那么从API的设计上讲,我的计算操作确实是一个不可变的操作,因为它本质上就是一个只读的行为,你输入一个参数,然后我计算一个结果,其实并不需要发生任何的状态的变化。但我又想去做一些内部的缓存策略,这个跟它变量本身的可变性没有关系。那怎么去实现这样的一个效果呢?那这个地方就要用到我们的内部可变性这个概念了。
我们可以看到这个Calculator的结构体,它身上有一个cash的字段,那这个cash我这里用了一个叫Cell的容器去存放一个HashMap。来到eval的这个地方,我们可以发现它在执行这个计算之前,会把这个cash从这个Cell里面取出来,然后去查询这个cash里面有没有之前输入过的这个input。如果有的话,直接就会把它的这个缓存拿出来,赋给这个value这个变量。然后用完这个cash以后,我再把它放回这个Cell里面,通过这个set方法。
那这段逻辑其实也是一个非常标准的函数memorize的一个过程,这个在其他的编程语言里面都是非常容易实现的。那唯独在Rust里面的区别,就是说我怎么在一个不可变的环境下去修改这个cash。那这个地方我用的是Cell,它是用来去实现一些简单的内部可变操作的。你可以把这个Cell理解为它是一个容器,那这个容器虽然它本身是不可变的,但我仍然可以对其中的容器里面的内容去做一些修改。比如我可以把这个值拿出来,然后我也可以赋一个新的值进去。
但是要注意的是,我不能去取这个值的引用,也就是说我在每次使用的时候,需要把这个HashMap从这个Cell里面先take出来。那这个take的操作其实是做了一个交换,就是我拿着一个default的值,去跟Cell里面的值去做交换。因为我不能去说把Cell里面的值拿出来之后就不管了,里面就是留下一个未定义的状态,它一定是要在任何操作之后,这个Cell还是可用的。也就是说这个take操作一定要保证这个做完之后,Cell还是一个合法的状态,不然的话,你可能take之后并没有set一个新的回去,那这个Cell就不能正常工作了。
那它除了这个take之外,还会有一些其他的函数。像这个get,get之所以没有,是因为我们的这个HashMap是不能被copy的。我们可以搜索一下这个get,可以发现如果你的Cell里面存放的这个东西是可以copy的,也就是它可以被trivial copy,按这个内存的内容去做copy,那这个时候它就会有一个get函数。get函数可以把这个Cell里面的内容的副本取出来一份,比如说你这里面放了一个int,你get就可以拿到一个int这个副本,因为它就是做了一个copy操作。然后你再对这个int去做一些计算什么的,然后最后通过set再把它设置回去,这个是可行的。
在这个场景下面,我们可以发现,因为HashMap是不可以被copy的,它只能被move,但是它又可以通过default去构造,所以说这个take就可以使用了。就是它在take的过程中会创建一个default的HashMap,然后跟这个Cell里面的这个值做一个交换。
但实际上我们也会发现,那么我们在做这个eval的时候,每次都会去创建一个空的HashMap。即使这个空HashMap的创建过程可能是没有任何开销的,我们这只是一个假设,但仍然每次都会去做这个default HashMap的构造,包括在set的时候,这个default HashMap也要去做一个drop,整个流程看起来还是有一定的开销在里面。
那这个时候我们就可以使用另外一种版本的Cell,叫做RefCell。RefCell其实是有点像把编译时的一个借用检查搬到了运行时。它这个地方会有一个borrow_mut的函数,它可以取出一个RefCell里面的一个引用,就它的一个封装类型,但是你可以对它直接进行操作,这都是可以的。所以这个地方就可以注释掉了。
那由于这个过程其实还是一个借用的过程,我们对这个cash做了一个借用,那必然就会出现一个情况,就是说我们可能在这个借用存活的情况下,我们又去borrow了一个。即使是做一个immutable的borrow,那也是会有问题的。前面是mutable,现在又去做一个immutable的borrow,那个这个其实在正常的编译时的这个借用检查里面就会出现问题。那在这个场景下,它会有什么样的一个表现呢?其实就是会出现一个运行时的一个panic,告诉我们这个借用已经被可变的借用了这个RefCell。
然后你可能会说,这个borrow跟borrow_mut都不是unsafe方法呀,那它这个运行时还会panic,那是不是不安全呢?其实并不是。那Rust的安全其实并不是保证不会panic,而是保证不会出现undefined behavior。所以其实这个地方,borrow_mut和borrow其实它在注释上都会有写,当我们这个值如果说被可变借用了,那就会出现一个panic。那这个是一个well defined的行为,毕竟你的程序panic,也要比出现一些奇怪的、不可预知的行为要好,所以这个操作其实是一个安全的操作。
那如果说你确实没有办法确定这个地方可不可以成功,你又不想panic,那你可以去使用这个try的一些版本。try_borrow和try_borrow_mut其实都是会给你返回一个Result的,如果说借用成功了就会返回Ok,如果借用失败就会返回一个Error。但是其实我不推荐大家去使用这个try的版本,因为如果说你发现try_borrow失败,那你怎么去处理呢?其实一般来讲出现失败,那就是你的逻辑可能哪个地方有问题了,那需要去看一下是不是可以修改一下你的逻辑,保证不会出现这样的一个运行时的借用冲突。
然后我们去掉这个我们刚才写的这个有问题的代码,我们再运行一下,看一下保存一下,没发现他也是可以正常工作的。那基本上这个RefCell就可以满足我们的这个要求了,它可以一个比较低开销的方式去做一个内部可变这样的一个行为。
但实际上这个RefCell还是有一定的开销的,当我们跟踪进这个borrow_mut的这个函数的时候,我们可以发现,它其实为了去保证运行时的一个借用检查,它需要维护一个借用数量的这么一个状态。在我们去做这个borrow_mut的时候,它其实会对它内部的一个Cell,这是它的一个borrow flag去做一个修改。这个过程其实还是有一定开销的,包括借用销毁的时候,它也要把整个这个借用的这个状态再重置回去,一来一回会对很多这个int去做一个读写操作。
如果说你连这个开销也不想有的话,其实还有一种Cell可以满足你的要求,那就是这个UnsafeCell。其实跟Cell里面的东西的内存
UnsafeCell的内存布局是一样的,除了它在代码优化上可能有一些区别,只不过它提供了一些封装的方法,可以让你去做一个不可变的变量的一个可变修改。那这个地方我们一般用的是这个get方法,get会拿到一个指针,然后我们这个地方需要用unsafe的方法去使用。这个地方都不需要开,然后我们再运行一下,也是没有问题的。
为什么我们这个地方可以使用UnsafeCell呢?是因为这cash的字段其实并不会通过任何公开的方法暴露给外界,然后我们这个地方如果可以保证这个cash的访问是没有任何问题的,那其实你可以使用UnsafeCell去尽可能减少我们运行时的一些开销。那么同时要记得写一下这个safety的注释。那这个就是UnsafeCell的一个使用。
那么我们刚才提到的这三个Cell,其实都是在单线程环境下去使用的一个内部可变性的实现。它们这些类型在定义上,其实都是对这个Sync和Send做了一个否定实现。我们可以随便去看一下,那有这些类型都没有实现这个Sync,那它也它的这个指针也是不会实现Send的。
那么一般来讲,像RefCell和Cell这些类型,可能还会去配合像Rc这样的智能指针去使用,这样的话可以做到共享可变的这样的一个效果。
那么内部可变性,其实建议大家有限的去使用。像这种场景,我们是内部处理一些像cash这样的一些状态,我们可以使用内部可变性。但像正常的,比如说我们想对f这个function去做修改,那这个地方就建议大家直接去使用一个mut的setter去做这样的修改,不要去把这个f也放到一个Cell里面,这样去管理,是没有任何必要的。
我们要搞清楚这个逻辑可变性跟物理可变性的一个区别。虽然说这个cash是物理可变的,但是整个这个Calculator类型,它的逻辑是不可变的。
那今天就是讲了这个单线程下内部可变性的一个实现。那像多线程环境下,我们还有其他的类型可以去使用,那这个视频由于篇幅的问题就不展开了。那么这就是内部可变性的一些简单的介绍。
原文链接: https://dashen.tech/2018/07/07/Rust的内部可变性/
版权声明: 转载请注明出处.