Dive into Rust: Ownership, Borrowing, Lifetime
最近在Rust中文论坛看到一位Java程序员的提问,主要是有关Rust所有权规则的理解问题,想了想,对于常用Java、Python这类有垃圾回收机制语言的程序员,Rust独特的内存安全机制确实较难理解。这篇博客准备总结一下我对Rust内存安全问题的理解,提供一些从Python分析Rust的视角,抛砖引玉。
常见内存安全问题
先列举一下一些常见的内存安全问题:
- 缓冲区溢出
- 解引用空指针
- 访问未初始化内存
- 错误释放
- 释放后使用
- 重复释放
- ……
对于这些内存安全问题,一般常见的编程语言有以下解决方案:
- 程序员自己负责:C/C++系,通常是系统级编程语言的做法,程序员自己注意这些问题,优点是没有运行时负担,这也是为什么一般都是需要高性能的系统级语言采用这种做法,缺点很明显,高度依赖程序员的细心程度,一不小心就出问题,并且很有可能项目上线很久才会发现
- 垃圾回收:典型代表Java,由语言提供运行时管理内存,例如在一个值不会再被访问时释放该空间,优点是不需要程序员手动管理,心智负担小,缺点是牺牲了性能(其实还是会有内存泄漏等问题
- Rust:Rust表示鱼和熊掌要兼得,提倡零成本抽象,提供一套特定的规则,只有遵守规则的程序才能通过编译,在编译期解决内存安全问题,性能没有牺牲,程序员脑子也没有牺牲(大概
下面就聊一聊Rust用来保障内存安全的机制:所有权(Ownership)、借用(Borrowing)、生存期(Lifetime)
所有权
首先看一段Python代码:
输出结果是2和3,Python的垃圾回收机制主要是基于引用计数,简单理解就是把变量看成对实际内存中值的一个引用,那么当这个引用数量为0时,这个值也就不需要了,可以从内存中清理掉。上面我们创建了Foo(3)
这个值,赋值给a变量,这里结果是2,官方文档解释是因为在调用sys.getrefcount
时参数作为临时变量引用,使引用计数被加了一次,后面又赋值a给了b,引用计数又加了1。
上面是Python中的规则,当然解释器实际的垃圾回收是很复杂的,还有一些其它辅助手段,但总之这个过程是没有我们人为干预的。
下面再看一段Rust代码:
我特意写了个错误明显的程序,可以分别剖析,首先直接运行这段程序,看下编译器报错:
Rust的编译器错误提示非常清晰,这里告诉我们,ming
拥有类型Student
的一个实例,在调用print_student(ming);
时发生了“move”,并且还给了个提示说是因为我没有为其实现Copy trait
,接着又告诉我们,在第13行尝试在发生“move”后借用这个值。对于不熟悉Rust的同学会觉得不知所有,这里先提一下Rust的所有权规则(来自《Rust程序设计语言》):
- Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
根据规则一,我们知道在定义变量ming的时候,ming就是实际的Student值的所有者,享有所有权,根据规则二,我们知道,在任何时候,Student实际值,都只能有一个主人,再回顾一下编译器提示:move occurs because ming
has type Student
, which does not implement the Copy
trait。
对于Python程序:
Python中可以认为任何变量实质上都类似于C中的指针,只是指向实际值的一个标记,所以这里打印a和b指向的内存中的位置是相同的,a就是b。
而在Rust中:
let ming2 = ming;
,根据规则二,一个值同一时间只能有一个所有者,所以这里ming2成了所有者,所有权“move”了,ming不再存在,编译器提到了Copy,只有实现了Copy的数据类型,才会在这种重新赋值的场景下,不“move”所有权,而是复制一份新的值。
上面这段代码就不报错,因为Rust中一些基础类型是实现了Copy的,所以let b = a;
的时候,b拥有所有权的是一个被复制出来的1,不违反规则二。
调用print_student
函数,就是把ming的所有权“move”给了这个函数的参数这个变量,再看看规则三,首先什么是作用域呢?作用域就是一个值在程序中有效的范围:
可以简单理解成Rust中任何值遇到右花括号}
就被销毁死掉,Rust强制执行这一规则,那么在print_student
函数结束的时候,实际的student值在内存中不复存在了,可是我们却要在这之后再次使用ming,于是编译器报错,但是注意,编译器给出的实际错误并不直接说student已经不复存在,而是说我borrowed after move,什么是borrow下一步说。
要解决这个问题,可以在print_student
结束的时候用返回值,将所有权再还回来:
这样就不会报错了,但是难道Rust中每个函数都必须将参数当做返回值返回吗?
还有一个办法:
可以为Student实现Clone这个trait(关于Copy,有一套特殊规则,详见文档),在传入参数时显式调用clone方法,把复制体传进去。
但这样还要在每次调用函数时复制一份原有的值,还有其它方法吗?
借用
一般大学计算机相关专业会以C语言作为学生的入门语言,我们在学习C语言的时候,一般都会被告知使用malloc
分配的内存,一定要记得free
掉,程序运行的时候,不同的数据会使用不同的内存,如何在数据不再需要时,释放内存一直是一个麻烦的问题,忘了释放会浪费内存;释放过一次但程序员忘记了,再次释放,这是个未定义行为;数据后面还需要用却提前释放内存了,那更是可怕。前面我们提到Rust的所有权规则第三条,离开作用域就是释放内存,这听上去很简单,但从之前的代码中,这似乎会在一些场景下为我们带来麻烦。
前面我们已经提到了Rust中多个变量与数据之间的两种关系,分别是移动(move)与克隆(clone)。
我们已经发现在使用函数时,所有权规则给我们带来了一些麻烦,于是Rust给我们提供了另一个机制。
还记得这一行代码以及那个错误提示“value borrowed here after move”吗?&变量
表示对变量的引用(reference),&ming
创建了一个指向ming的引用,将它赋值给hong,hong并不会获得实际值的所有权,因此不违反所有权规则二,引用有些像C中的指针,但并不相同。
在讲引用前,要讲一下Rust变量的不可变性,在Python中,实际上是有不可变对象与可变对象之分的,但是没有不可以修改的变量这个概念(要想实现一个运行时不能被修改的类变量需要一些骚操作):
因为变量只是一个“指针”,即使它指向的是不可变对象,也可以随意改变它所指向的数据。
默认情况下,Rust定义一个变量之后就不允许对其进行修改,不仅是类型不可变,值也不能改。
必须使用mut(mutable)关键字,显式标识这个值可以被修改。那么这和引用有什么关系呢?
以后端常说的增删改查来说,如果一个数据,需要被读取10次,这没太大问题,因为读这个操作没有副作用,不会改用数据本身,我们常说HTTP的GET方法具有幂等性也是这个原因,可以如果是修改呢?删除呢?
对于不可变借用,可以有很多个,但同一时间可变变量的可变引用只能有一个,不可变数据没这个限制,因为它就不能有可变引用。
这一条规则避免了数据竞争:
- 两个或更多指针同时访问同一数据。
- 至少有一个指针被用来写入数据。
- 没有同步数据访问的机制。
Rust直接避免了以上情况,有以上行为的程序无法编译通过。
Rust引用的另一条规则是,引用必须总是有效的,这个怎么理解呢?再看我们之前出错的程序,let hong = &ming;
,hong是一个引用,在这之前ming已经在print_student
函数中被销毁了,这在内存安全问题中一般被称为悬垂指针或悬垂引用,C语言中,一个指针指向的内存可能被你手动释放或者分配给其它数据了,但是这个指针没有被更改,程序可以编译通过,这会造成难以排查的bug。Rust在编译前避免这一点。
现在我们可以理解为什么编译器报错“borrowed after move”了。
那什么是借用呢?就是在函数使用引用做参数的时候称之为借用,我一般统统叫借用,有借有还,a借给b,所有权最终还在a那里,之前的函数可以这么改:
参数使用&Student
而不直接使用Student。
生存期
很多地方都喜欢把Rust中的Lifetime翻译成生命周期,但是我觉得这个Lifetime和life cycle并不是一个意思,而life cycle一般才被翻译成生命周期,lifetime还是叫生存期比较好。
什么是生存期呢?简单讲生存期基本上就是作用域,不过生存期一般是用来描述一个引用的作用范围,说白了就是一个引用可以合法的活多久。
那么结合上文引用的第二条规则,就能用生存期来描述了:引用的生存期,绝对不能大于其引用的值的作用域,直白说就是&a不能比a活得长。
以上程序来自《Rust程序设计语言》,根据所有权规则第三条,r从声明那一刻可以一直“活”到最后一个右花括号,而它引用的变量x,只能活到倒数第二个右花括号,这样的程序自然是无法通过编译的。
生存期注解
考虑这样一个函数:
编译器会告诉我们:
编译器直接告诉我们要怎样修改程序:
&'a i32
这样的形式被称为生存期注解,这种情况下,我们告诉编译器,函数返回值至少会和两个参数里寿命最短的那个活一样长。这样编译器就能发现以下这种错误:
编译器会报错:
编译器告诉我们这个借用活得不够长,如果这段程序被允许编译通过,那么就会造成悬垂引用问题了,如果a确实大于b那就还好,反过来,则会出现b被销毁,而b的借用还活着的情况。
在未来的Rust程序中,手动标记的生存期将会越来越少,编译器会自动帮我们推断出大部分情况下借用的生存期。
总结
下面是文章开头提到的那位Java程序员提问的代码:
编译器提示需要指定生存期,接着他改成了这样:
想必看到这里,读者已经能看出程序的错误之处以及如何修复了,当然具体的修改方式还得视需求而定。
Rust的内存安全机制还是比较独特的,尤其是熟悉Python这类语言的程序员初次接触会感到比较陌生。
在此总结一下:
- 数据同一时间有且只能有一个所有者,Python程序员尤其注意
- RAII,数据离开作用域立即被释放内存,Python程序员可以理解成语言层面的一个上下文管理器,离开with语句就调用
__exit__
方法 - 数据默认不可变,同一时间只能有一个可变引用,可以认为没有实际上的可变数据,只有共享数据与独占数据之分,一个数据能被多个人访问的时候不能变,只有有唯一可变引用或者没有引用的时候,这时候是**“独占”**的,所以可以安全地修改。