Rust语言当中的Ownership所有者概念解析

        Ownership这个话题,必须是熟练使用C语言的人,才有意思。

        C语言里面,有个语法,就是取地址。假设有个变量aaa,假设它是int型变量,它头上记着的具体值为3. 那么,通过&aaa这个表达式,可以取到存放变量aaa的对应的内存地址。

        C语言当中写的&aaa, 到了下层虚拟机当中,汇编语言的代码,也就是&符号对应的汇编语言的命令,对应的是LEA. 所以C语言中的&aaa的意思,在下层虚拟机当中,是load effective address of aaa的意思。

        这几句能懂,您再往下看。这几句看不懂,您赶紧回去学C语言。

        问题在于,&aaa这个表达式,传出来的值,也就是内存地址的这个信息,可以被其他的指针或者多重指针利用。也就是说,其他的指针或者多重指针,可以通过一系列的操作,也找到&aaa对应的这个地方。假设一个不知道什么地方写的代码,错误操作,找到&aaa所传达的内存地址,找到地址之后,不分青红皂白,就把实际值给改了。这时候,你再printf(“%d”, aaa); 的时候,就会发现,aaa的值不是3了。谁改的,不知道。谁在什么情况下改的,不知道。除了aaa这个变量指向了该内存地址,还有什么其他变量或指针,会指向这里,也很难搞清楚。

        程序代码量比较大的时候,假设出现了这种情况,上帝来了都得哭啊。

        在内存使用这种根本性的问题上,性命攸关,一定要解决C家族语言这种野指针到处乱指的问题。最后Rust语言引入了ownership的概念,提出了比较成功的解决方案。

        请读者把刚才int aaa = 3; 然后通过&aaa取到内存地址,然后通过内存地址找到3,然后不经任何检查就随意把3改成其他值,这个情况详细反思一下。

        这个情况,最根源的问题,不是int aaa = 3, 也不是&aaa取到的这个指针,把这个指针告诉什么什么人。这都不是问题的关键。

  最根源的问题是3这个值被改了。也就是3这个值,所处的这片内存,处于失控状态。

        怎么控制呢?Rust语言给出的答案,是把3这个值,关进一个小房间,然后小房间要关门落锁。

  而且这个门有讲究。这个门是玻璃门。你要想看看房间内存放的实际值是多少,允许你看。谁想看都行。多少人同时看都行。

  但是你如果想进去,把3这个值改了,那么你必须把玻璃门打开。而这时候你会发现,玻璃门上面的锁,只有一把钥匙。

       在关门落锁的状态下,要么你直接拥有这个玻璃门上的锁对应的那个唯一的钥匙,要么你去找到原来的owner, 把钥匙借过来。如果原来的owner不愿意借给你,你可以把他杀掉,你取而代之,变成该钥匙的owner。

       总之,争夺的就是玻璃门的这把唯一的钥匙。谁有这把钥匙,谁就能把玻璃门打开,然后进去修改里面的值。

       这把钥匙的所有权与使用权也是分离的。想使用这把钥匙,你要么把原来的owner杀掉,取而代之,成为这把钥匙的主人。然后再操作玻璃门,打开门之后改变里面存储的实际值。要么你就找到钥匙的主人,向人家借用一下钥匙。人家如果同意把钥匙借给你,你就获得了这把唯一的钥匙。但人家也有可能不借给你。

  主人不愿出借钥匙的时候,你就没有打开玻璃门的可能了,所以你就更没有机会进屋去改实际值了。

       实际上,上述思路,在Rust语言初创阶段,其他语言也在考虑相同的问题。C语言在2011版的规范当中,引入了ACID关键字。本文读者当中,凡是事先就知道ACID关键字到底是什么中心思想的,ownership的概念看一眼就理解了。从哲学上来说,创立ownership概念的那个历史阶段,相关的哲学思潮是比较活跃的。Rust在众多解决方案当中,引入了ownership的概念,将其做成了工程中比较可行的解决方案,并获得了业界认可。

       有经验的编程人员,对于上文当中关于ownership的那些说法,应该不陌生。在Java和C#当中的代理,也就是delegate,背后考虑的也是相同的问题。由于Rust语言没有JVM这样的运行时,所以代理的实现,也只能通过指针的方式去实现了。所以Rust使用的是多重指针的技术,在多重指针的某一层上面,加一个ownership的判断,只有符合规范的取地址操作,才能真正取得有意义的内存地址。

        为了读者思路清晰,这里再重申一下,ownership到底是神马意思。

  设置玻璃门,然后给玻璃门加锁,这个本质上就是设定读写权限。

  玻璃门的意思是,读取内存的内容,不做任何限制。谁想读谁读。而写入到内存,这个要严加管理。

  怎么严加管理呢?就是玻璃门的锁,只有一把钥匙。该钥匙的所有权与使用权分离,允许把钥匙借出去。但是不论借给谁,钥匙也仍然是只有一把。

  只有实际拿着钥匙的那个人,能进去写内存。在程序运行的某个特定时间点上,到底谁能进去写内存,是确定的一个唯一的人。

       取得钥匙的时候,要么你把原来的主人杀掉,你取而代之,变成钥匙的owner。要么你就去借钥匙。

       你把原主人杀掉之后,不论谁再去找原主人,都没用,那人已经不是valid的状态了,找他不会有任何结果。

  Rust官方教材当中的说法是,原主人已经不再是valid的状态了。要想拿到钥匙,只能来找你。

  如果是借钥匙,也就是你想borrow人家的钥匙,原主人同不同意,那就不一定了。你必须事先告诉人家,借到钥匙之后,进屋要干啥,干完之后啥时候归还钥匙,这些都要事先取得同意,主人才有可能把钥匙借给你。

       由于ownership这个事情,思想方法上比较啰嗦,所以,Rust的源码,看起来比C/C++的源码要啰嗦。但啰嗦的好处是,将来编译完成之后,获得的可执行程序,其可靠性是相当之高。

       基本概念搞清楚之后,我们通过代码来看看技术细节。

       假设有个变量声明的语句, let aaa : i32 = 3; 那么这句话的意思是,有个变量,变量名为aaa, 数据类型为int型的数据类型,占用内存的宽度是32个比特位。然后通过赋值语句,将3这个值赋给了aaa这个变量。

  现在3这个值被关进小房间了。由于没有加mut关键字,所以aaa 尽管拥有ownership,也无法进屋去更改3这个值。mut这个关键字,在Rust语言当中,是“允许变化”的意思。

       上面这句let aaa = 3;之后,假设再写一句aaa = 4; 编译器就要报错。程序无法编译。因为尽管aaa拿着钥匙,但因为let语句当中没有加mut,所以不允许把3这个值给改了。任何人想改都不行。

       现在再来一个新的语句,假设写一句 let mut bbb: f64 = 5.0;那么这句话的意思是,有个变量,变量名为bbb, 数据类型为浮点型的,占用64比特宽度的内存。现在把5.0这个值关进了小房间,玻璃门的钥匙是bbb拿着呢。由于bbb有钥匙,也就是声明的时候是mut bbb,所以可以再写一句bbb = 6.0。这时候,bbb有关的这两句不会报错,可以正常编译。

   上面说的这些,没啥疑问的话,再往下看。

  下面假设,声明了一个字符串型变量,假设有这么一句, let ccc = “Hello world”;那么这句话的意思是,把Hello world这个字符串关进了小房间,玻璃门的钥匙是ccc拿着呢。

        假设下面又出现一行代码, let ddd = ccc; 这时候,这就ownership转移了,也就是说,ddd把ccc杀掉了,现在小房间,玻璃门,和房间内的Hello world,都没有变,变化的是ccc已经死了,ddd变成了钥匙的主人。

        这时候,假设写一句,println!(“ccc的内容是{}”, ccc);编译器会报错,提示的错误信息是,ccc已经不是一个valid的变量了。也就是说,ccc已经死了。这时候改写为println!(“ddd的内容是{}”, ddd); 就可以正常编译。这也就是说,ccc死了之后,钥匙的所有权已经转移到ddd的头上了。

  英文教材当中的说法是,Hello world这个内存内容,包括小房间,玻璃门,都没有变,变化的是,玻璃门对应的钥匙的ownership已经从ccc头上move 到了ddd头上了。

        实际上面let ccc = “Hello world”;这个写法是错误的,Rust语言当中,要声明字符串型的变量,正确写法是let ccc = String :: from(“Hello world”); 。上面就是为了写作方便,那么写了一下。如果现在再写一句let ddd = ccc; 那么就是ddd把ccc杀掉,从而取得了ownership。

        如果我们的业务逻辑,不是杀掉ccc,而是保留ccc的情况下,把内容复制一下,复制到ddd头上,那么应该使用的正确的语句是 let  ddd = ccc.clone(); 也就是字符串克隆一下。

        有了这些基础知识之后,我们要建立起一个基本的概念,也就是说,在Rust语言中,使用let eee = fff;这样的赋值语句,真实的情况是,eee杀掉了fff而取得了所有权。这句执行完之后,fff已经无法再使用了。如果源码中要用,编译器就会报错。

        但上述说法简单推而广之也不行。有个典型的情况提醒读者一下,在操作系统当中,一些简单的数字,如0,1,2,3这样的简单的int型数值,好多其他程序,甚至操作系统本身就在不断地使用,所以这类操作系统默认提供的数值,不会被关进玻璃门里面。所以Rust语言当中出现了let xxx = 5; 然后let yyy = xxx;的时候,xxx并未被杀掉,这时候写一句println!(“xxx = {}, yyy = {}”, xxx, yyy); 编译器不会报错。也就是操作系统那边默认保存的一些数值,也就是os 自己的stack里面保存的一些正整数0123之类的数值,不会关进小黑屋。

        重申一遍,如果是字符串类型的变量,或者是数值计算过程当中,出现的复杂的小数,那么,本文当中,ownership这些语法规则都是成立的。如果是简单的int型,0,1,2,3一直到255,这种数值,或者是ascii码表当中的一些简单字符,这个操作系统一开机就会有个事先存放的地方,你关不关进玻璃屋都没啥意思。你用Rust语言生成的新的可执行程序,你还没开始执行呢,操作系统在其他场合也要用这些数值,人家是有个固定的栈内存(stack),去管这些确定的值。所以,Rust的编译器不会管这种情况下的ownership。

        凡是复杂的字符串,或者计算过程当中出现的一些复杂数值,都被操作系统放在堆内存(heap),然后ownership的这套机制就正常执行了。

        重申一遍,ownership的概念,纯粹就是为了控制内存的读写权限,而设定的一套概念。

        杀掉之前的主人,获取钥匙的ownership,英文被称为 “move ownership from 原主人 to 新主人”。所以,任何时候,主人只能有一个。

        钥匙的主人才有写内存的权限。读内存的权限很宽松,谁想来看都行。假设一个新情况。假设我们写了一句,let ggg = String :: from(“I love you!”); 那么,这就是把I love you!这个字符串,新创建的这个字符串,关进小房间,玻璃门锁好,主人是ggg。

        假设我们有另外好几个变量,都想挤到玻璃门前面,去读取一下内容,那么没问题,代码写成如下这样就行。

  Let hhh = ⋙  然后let jjj = ⋙ 然后let kkk = ⋙ 。这些代码摆着,都能够正常编译。

       &ggg就是取地址,和C语言当中的取地址的概念完全一致。取了ggg这个变量的地址之后,就是来到玻璃门前,读取了内存的内容。这些都是OK的。

       麻烦的是,要从ggg头上把钥匙借来,打开玻璃门,进去修改内容。

       重申一遍,我们现在的目标是,不要杀掉ggg,而是借来钥匙,然后打开玻璃门,修改完内容之后,把钥匙还回去。英文的术语是borrow mutable license。也就是说,把写内存的权限借来,用一下,用完再还回去。那么,Rust语法当中也是允许这种情况的。代码应该这么写,首先是ggg变量声明那句,必须改写一下,改为let mut ggg = String :: from (“I love you!”);只有这样的声明语句,将来才能允许修改I love you!这个实际值。

  然后 let mmm = & mut ggg;这句话比较关键。我们详细解释一下。

        原来出现的是let kkk = ⋙ 这句话就是C语言当中的取地址。Rust语言当中就是取地址之后,读取内存的内容。仅涉及到读取。允许多个对象同时读取。

        而let mmm = & mut ggg;这句话的意思是,取到ggg的内存地址,而且有写内存的权限。这句话在Rust的语法当中,是正确的。这句话就是mmm来借钥匙,ggg会失去钥匙的ownership,而mmm会获得钥匙的ownership。

        在let mmm = & mut ggg;这句话执行完之后,现在ggg还活着,ggg已经没有钥匙了,而mmm拿着钥匙,现在是这么个状态。

        但是借钥匙可能借不成。因为,Rust语法规定,只要玻璃门外面,还有其他变量,在那读取内存,借钥匙的事儿就不行。必须玻璃门外面,一个人都没有的情况下,才允许借钥匙。

        我们刚才写了好几个取地址的语句,现在玻璃门外面站着好几个人,比如hhh, jjj, kkk, 这几个变量都还活着,而且都通过&ggg的方式,来到了玻璃门前。假设现在突然把里面的内容改了,玻璃门里面原来是I love you!, 现在突然被改成了I hate you!, 那玻璃门外面这几个hhh, jjj, kkk,上一个时刻读取内存,读到的内容,和下一个时刻,读到的内容,就不一致了。这种纯粹读取内存,然后读到的东西毫无控制地就跳掉了,这种情况在Rust语言中是不允许的。

        解决的办法是,先把玻璃门外的所有只读操作,全部kill掉。清理干净之后,Rust语言才允许玻璃门钥匙从一个人头上借到另一个人头上。

        也就是说,之前的只读操作,全部杀光,然后才允许执行let mmm = & mut ggg;这句话。那么,改代码的时候可以这么改,如下图所示:

  上面左图,无法正常编译。上面右图可以。右图就是加了一对大括号。

  上图意思是,左边的代码,玻璃门外面站着好几个只读权限的人。他们在那里站着,想要把钥匙借给另外的人,编译器会报错。解决办法是,通过右图所示,加个大括号。大括号结束的时候,hhh, jjj, 和kkk这三个变量,都会由于括号结束,而死掉。英文的意思是they went out of scope.

  也就是上面右图的hhh,jjj, 和kkk,生死都在一对大括号里面。当程序执行完第八行的大括号之后,从下一句开始, hhh, jjj, kkk 都已经死掉了。

  这时候存放着I love you字符串的那个玻璃门外面就没人了。这时候,通过let mut mmm = & mut ggg;的语句,再去借用玻璃门的钥匙,这个时候就能正常编译通过了。

        Rust语言的这种borrow ownership的语法规则,读者如果仔细体会一下的话,你会知道这么做的好处的。当程序代码量很大的时候,出现在玻璃门外面的具有只读权限的人,到底是什么来路,发挥什么作用,有的时候真的很难搞清楚。把所有只读权限的人都清理干净之后,再打开玻璃门,进去修改实际值,甚至是使用类似C语言当中的free,把房间连同里面的内容全部摧毁了,这种操作也是逻辑绝对清楚了,后果绝对可控了。

        在本小节的最后,扯一句其他的。Ownership这套概念,发挥威力最大的场合,是多线程编程的场合。任何一个经验不足的程序员,只要你去看C/C++, java, c# , python的多线程编程的部分,你都无法理解那些东西的中心思想是什么。当然,本质上来说,多线程的话题,是属于操作系统的话题,与编程语言关系不大。

  总的来看,多线程编程的中心思想就是,有个房间,但没有安装玻璃门,门外站着好几个人,分别要进到房间里面去,有的是读取内容,有的是要往里写新内容。在某个特定的时间点上,到底是谁在读,谁在写,读到的东西是谁写的,往里写的内容到底是哪来的,往里写的内容一旦出错,将会有什么后果,都是乱的一塌糊涂。通过Rust的这套Ownership的技术,将所有出岔子的可能性都给约束死了,cargo check的时候绝对给你检查的明明白白,确保软件的代码质量。所以,Rust的技术,确实是十分推荐的。

猜你喜欢

转载自www.cnblogs.com/mooyee/p/11577891.html
今日推荐