本章覆盖有:
- “租借,borrowing”和“生命周期,lifetime”的概念
- 困扰系统软件的租借典型错误是哪些
- 如何通过租借checker,Rust的严格语法来避免这种典型错误
- 如何通过插入语句块来约束租借的作用域(scope)
- 为什么函数返回的引用需要生命周期指示符(specifiers)
- 如何给函数使用生命周期指示符(lifetime specifiers),它们表示什么
- 租借checker的任务是什么
¶Ownership and Borrowing
上一章介绍到,当将变量a赋值给b时,会有两种情况:如果它们类型是可拷贝的(copyable),它就实现了Copy特质(当然也肯定实现了Clone);如果它们的类型不可拷贝(non-copyable),则没有实现Copy(Clone可能实现,也可能没有)。
第一种情况,用到拷贝语义(copy semantics)。意味着,在赋值过程中,当a保留它对象的所有权(ownership),一个新的对象被创建,初始化值等同于a的值,以及b获得这些新的对象的所有权。当a和b离开它的作用域时,它们拥有的对象被销毁(又叫dropped)。
相反,第二种情况,用到移动语义(move semantics)。意味着,在赋值过程中,a将它的所有权移交给了b,不会有新对象的创建,a不再可访问。当b离开它的作用域时,它拥有的对象被销毁。当a离开它的作用域,不发生任何事情。
所有这些保证了合适的内存管理,只要没有引用被使用到。
但看看这个代码,
1 | let n = 12; |
第一条语句后,变量n拥有一个数。
第二条语句后,变量ref_to_n拥有一个引用,该引用指向同一个由n引用的数。它是一个所有权吗?
它不能作为一个所有权,因为这个数早已经由n所拥有,如果同时被这个引用所“拥有”,它将会被销毁两次。因此,类似这样的引用不能“拥有”对象。
表达式n和*ref_to_n指向同一个对象,但仅n拥有这个对象。变量ref_to_n可以访问这个对象,但不是“拥有”这个对象。这种概念称为租借,borrowing。我们说ref_to_n借了n拥有的数。这种租借,开始于引用指向该对象,结束于该对象的销毁。
关于可变性(mutability),有两种类型的borrowing:
1 | let mut n = 12; |
这段程序中,ref1_to_n将n拥有的值,租借为 mutably 的值,以及ref2_to_n租借为 immutably 的值。第一种是可变租借(mutable borrowing),第二种是不可变租借(immutable borrowing)。可变租借仅能从可变变量中获取。
¶Object Lifetimes
注意到,“作用域,scope”的概念作用于编译期的变量,而不是运行期的对象。对应运行期对象的概念叫“生命周期,lifetime”。在Rust中,一个对象的生命周期,指的是一系列执行指令,从执行指令的创建,到执行指令的销毁。在该时间段,该对象叫做“存活,to live,to be alive”。
当然,作用域和生命周期存在一定关系,但它们不是同一个概念。例如:
1 | let a; |
该程序中,变量a的作用域开始于第一行,而a拥有的对象的生命周期开始于第二行。通常认为,变量a的作用域开始于它的声明,对象的生命周期开始于该对象接收一个值。
即使是变量作用域(scope)的结束,跟对象生命周期(lifetime)的结束也不是同时发生的,
1 | let mut a = "Hello".to_string(); |
结果将输出:“Hello, world!”。
在第一条语句,变量a被声明以及初始化。因此a的作用域开始,接着a拥有的对象被创建,a的生命周期开始。
在第二条语句,变量b被声明,由a移动对象进行初始化。因此,b的作用域开始,a的作用域被悬挂(suspended),因为它被移动了,所以它不可再被访问。b拥有的对象不用创建,因为它就是先前创建的对象。
在第三条语句,b被访问。
在第四条语句,变量a通过new构造器,指派新的值。这里,a恢复(resume)它的作用域(scope),因为它的作用域还没有结束。一个新的对象被创建,该对象的生命周期开始。前面由于变量a被“移动”了,所以它不“拥有”任何对象。所以这里的语句类似于一个初始化。
在第五条语句,a(拥有对象)可被访问了。
在第六条语句,a再次被移动到b,它的作用域再次被悬挂(suspended)。相反,b一直是活动的(active),它拥有的对象由移动的a替换,因此,原先的对象在这里被销毁,以及生命周期结束。如果该对象实现了Drop,在这里,它的drop方法会被调用。
最后,b和a陆续退出它们的作用域。变量b拥有一个对象,该对象被销毁,以及结束它的生命周期。相反,变量a被“移动”了,不再拥有任何对象,也就不会有销毁对象的发生。
¶Errors Regarding Borrowing
C和C++程序编写总是被各种错误困扰,而Rust则通过设计来避免这一类问题。Rust的一种常见错误是“use after move”,前面介绍过。另一种错误如下,
1 | let ref_to_n; |
首先,变量ref_to_n被声明,但没有被初始化。然后,在语句块内,可变变量n被声明并初始化,它分配一个数在栈上,值为12。
然后,原先的变量,用一个指向n的引用进行初始化,它租借(borrow)了这个对象。
接着,变量ref_to_n指向的对象,即值为12的对象,打印输出。
接着,语句块结束,内部变量n结束了它的作用域,它的对象被销毁。
接着,变量ref_to_n指向的对象再次被打印。但该对象原先被n“拥有”,它现在不存在了!
幸运的是,Rust编译器拒绝该代码,产生错误信息“n does not live long enough”。该消息表示,变量n死了(dying),但仍然有指向它“拥有”的对象的引用,它应该活更长一些;至少应该跟租借它对象的租借方一样长。
顺便,C或C++对应的代码如下,
1 |
|
这段程序可被C和C++编译器接受。结果会打印“12”,之后的行为会变得不可预测。
这类程序错误,我们称之为“use after drop”。
有另一种可避免的Rust错误,
1 | let mut v = vec![12]; |
对应的C语言实现是,
1 |
|
以及C++的实现是,
1 |
|
不用多说,最后两个程序会被各自的编译器接受,即使它们的行为是未定义的。相反,Rust编译器会拒绝并抛出错误信息“cannot borrow v as mutable because it is also borrowed as immutable”。让我们看看该程序有什么错误。
首先,可变(mutable)变量v,用一个仅带数12的vector声明和初始化。
然后,变量ref_to_first用原先指向v的引用声明和初始化。引用指向的值包含数12。
然后,另一个数13被添加到vector。但这种插入会导致缓冲区的重新分配。即使在这个例子,ref_to_first仍会继续指向旧的对象,不是有效的内存地址。
最后,这个旧的内存地址,可能是错误的,读取的内存地址的值打印后,是不可预测的结果。
由于从一个vector插入或添加元素时,所有指向vector的引用都“失效”,导致这种错误的出现。通常,这种错误属于广义的错误类别,也就是一个数据结构,通过几种路径、或别名访问,当数据结构被其中一个别名修改时,不能被另一个别名使用。
我们将这类编程错误命名为“use after change by an alias”。
¶How to Prevent “Use After Drop” Errors
Rust防止使用“脱落,dropped”对象的技术是简单的。
把该对象看做是被一个变量指向,遵循栈分配的规则,会按照变量声明的反向顺序被脱落,而不是初始化的反向顺序。
1 | struct X(char); |
该程序会打印“cba”。这三个对象按照顺序“acb”被构造,但三个对象的分配顺序是“abc”,因此回收以及脱落的按相反的顺序。
为了避免使用脱落对象,所有变量,租借其它变量拥有的对象,必须在该变量的 后面 声明。
例如,
1 | let n = 12; |
这段代码会产生错误信息:“m does not live long enough”。这是因为_r同时从m和n进行borrow,虽然不在同一时刻指向两者,但它在m之前声明了。要更正这段代码,改为如下,
1 | let n = 12; |
这段代码是合法的,当m和n拥有的对象被dropped时,不会再有指向它们的引用。
¶How to Prevent “Use After Change by an Alias” Errors
由于其它变量引起对象的改变,导致当前变量使用该对象出现错误。要避免这种错误,使用的规则有几分复杂。
首先,要求考虑任何语句会读这个对象,不会有写操作,就像是该对象的一个 临时不可变租借(temporary immutable borrowing);任何语句变更这个对象,就像是该对象的一个 临时可变租借(temporary mutable borrowing)。这种租借的出现和结束在该语句的内部进行。
然后,租借开始于,获取指向该对象的引用,并分配给一个变量;结束于,该变量的作用域(scope)结束。
下面是一个例子,
1 | let a = 12; |
结果会打印:“12 12 13 14”。
在第3行和最后一行,一个可变租借开始并结束。在第5行,一个不可变租借开始,第6行,一个可变租借开始;它们在语句块末尾结束。在第9行,一个可变租借开始并结束。
这种规则要求,同一时刻,可变租借(即,&mut )的出现不能和其它租借并存。
换句话说,同一时刻:
- 没有租借
- 或,一个单一的可变(mutable)租借
- 或,一个单一的不可变(immutable)租借
- 或,几个不可变(immutable)租借
不能有:
- 几个可变(mutable)租借
- 不能有一个单一的可变(mutable)租借和一个或多个不可变(immutable)租借
¶Listing the Possible Cases of Multiple Borrowings
下面罗列六种允许的情况。
第一种:
1 | let a = 12; |
有两个不可变租借,直到结束为止被持有。
第二种:
1 | let mut a = 12; |
第三种:
1 | let mut a = 12; |
一个不可变租借,紧随后一个临时可变租借。
第四种:
1 | let mut a = 12; |
一个可变租借,紧随后一个临时可变租借。
第五种:
1 | let mut a = 12; |
一个不可变租借,紧随后一个临时不可变租借。
第六种:
1 | let mut a = 12; |
一个可变租借,紧随后一个临时不可租借。
下面是六种不合法情况。
第一种:
1 | let mut a = 12; |
编译出错“cannot borrow a as immutable because it is also borrowed as mutable”。
第二种:
1 | let mut a = 12; |
编译出错“cannot borrow a as mutable because it is also borrowed as immutable”。
第三种:
1 | let mut a = 12; |
编译出错“cannot borrow a as mutable more than once at a time”。
第四种:
1 | let mut a = 12; |
编译出错“cannot assign to a because it is borrowed”。
第五种:
1 | let mut a = 12; |
编译出错“cannot assign to a because it is borrowed”。
第六种:
1 | let mut a = 12; |
编译出错“cannot borrow a as immutable because it is also borrowed as mutable”。
梳理一下。对于当前尚未被borrowed的对象来说,允许的操作是:
- 仅可被不可变租借(immutablely borrowed)数次,然后可以由所有者(owner)和任何租借方(borrower)读取。
- 仅可被可变租借(mutably borrowed)一次,然后有且仅能由这个租借方(borrower)读取和修改。
¶Using a Bock to Restrict Borrowing Scope
当一个对象的租借结束后,对象对其它租借又变得可用了。任何类型的租借都可限制在语句块内。
1 | let mut a = 12; |
这是允许的,因为租借发生在第三行,语句块结束后,租借也结束了,因此第7行的a可以用于其它租借。
类似地,对于函数也一样,在函数结束后,对象再次变得可用。
1 | let mut a = 12; |
这种规则,适用于Rust确保可以自动地决定内存的回收,避免不合法的引用;这种规则允许Rust实现无数据竞争(data-race-free)的并发编程。
¶The Need of Lifetime Specifiers for Returned References
先看这段代码:
1 | let v1 = vec![11u8, 22]; |
结果将会输出:“[11, 22]”。
变量v1和v2各自拥有vector。接着被两个引用borrowed了,分别被_x1和_x2拥有。因此,在第7行后,_x1租借了变量v1拥有的vector,_x2租借了变量v2拥有的vector。这是被允许的,因为_x1在v1之后声明,_x2在v2之后声明,这些引用比它们租借对象存活时间短。
在第八行,有个简单的表达式_x1。因为它是语句块的最后表达式,该表达式的值成为语句的值,因此该值被用来初始化变量result。该值是一个指向v1的引用,result变量租借了vector。这是被允许的,因为result的声明在v1之后,因此可以租借v1拥有的对象。
现在,小小的改动:在第八行替换"1"和"2"。
1 | let v1 = vec![11u8, 22]; |
这会产生编译错误:“v2 does not live long enough”。因为result的值来自于_x2表达式,由于_x2租借了v2的对象,所以result租借了vector。因为result在v2之前声明,因此不能租借它的对象。
Rust编译器致力于这种推理的部分称作“租借检查,borrow checker”。
现在,让我们尝试转换前面两个代码,将内部语句转换为一个函数。第一个变为,
1 | let v1 = vec![118, 22]; |
第二个变为,
1 | let v1 = vec![11u8, 22]; |
两者唯一不同的是func函数体。
根据之前的规则,第一个程序看起来是合法的,第二个不合法。两个func函数本身是合法的。但borrow checker会查找它们不兼容的具体用法。
泛型函数的参数边界使用traits,函数调用是否有效,依据该函数体的内容,这不是好事。主要是因为不能从错误信息理解出错的原因,除非你清楚函数体的内部代码。另一个原因是,函数被调用时,如果该函数体内任意调用了其它函数,确保main函数是合法的,borrow checker需要分析程序的所有函数。这种整个程序的分析带来了过度的复杂。
因此,类似于泛型函数,返回一个引用的函数,必须在函数签名阈将borrow-checking隔离!函数的borrow-check,仅考虑签名,函数体,函数调用的签名,而不需要考虑函数体调用其它函数。
因此,前面的两个程序都会出现编译错误:“missing lifetime specifier”。“声明周期指示器,lifetime specifier”是一个函数签名的一个装饰,它允许borrow checker对函数体和函数调用分离检查。
不管怎样,函数或语句体的租借行为应该是“到此为止”,不应该再将它进一步传递给其它函数或表达式,以避免带来不必要的副作用,未知的错误信息。
¶Usage and Meaning of Lifetime Specifiers
讨论到函数的调用和生命周期,下面是一个简单例子。
1 | fn func(v1: Vec<u32>, v2: &Vec<bool>) { |
在任何Rust函数内,仅可以参考的有:
- 函数参数拥有的对象(
v1拥有的vector); - 本地变量拥有的对象(
s拥有的动态字符串); - 临时对象(动态字符串表达式
"Hello".to_string()); - 静态对象(字符串字面量
"Hello"); - 函数参数租借的对象,由预先存在的某些变量拥有,发生在当前函数调用处(
v2租借过来的对象)。
当一个函数返回一个引用,这个引用的对象不能推断是由函数参数拥有,还是由本地变量拥有,还是由临时对象拥有,因为当函数return时,本地变量,函数参数,临时变量都会被销毁。因此,这个引用将被悬挂。
相反,这个引用却可以推断得知是静态对象,或是函数参数租借的对象。
下面是这两种情况的中第一种情况的示例(尽管代码在Rust不被允许),
1 | fn func() -> &str { |
1 | fn func(v: &Vec<u8>) -> &u8 { |
borrow checker仅对返回值的引用感兴趣,该引用有两种:指向静态对象,或接收的参数的租借对象。borrow checker需要知道哪些引用是指向静态对象,哪些是租借的参数对象;如果有几个参数租借对象,需要知道它们中哪些租借了非静态返回的引用。
不带lifetime specifer的函数签名是不合法的,
1 | trait Tr { |
函数签名参数中有两个引用,返回值类型也包含有两个引用。后两个引用可能指向静态对象,或者参数b的租借对象,或者参数c的第二个字段的租借对象。
下面表达式指定了一种可能的情况,
1 | trait Tr { |
和泛型函数用法一样,在函数名后面,添加一个参数列表。如其说是一个类型参数,实际上它是一个生命周期指示器(lifetime specifier)。
从句<'a>是一个声明。它表示:<<In this function signature, a lifetime specifier is used; its name is "a">>。名字"a"是任意的。它简单表示所有出现的情况,“匹配”这种出现。它类似泛型函数的类型参数,不同的地方在于,生命周期指示器前缀使用单引号,另外,按照约定,泛型参数首字母使用大写,生命周期指示器使用一个小写字母,如a,b,c,…。
然后,函数签名包含其它三个'a生命周期指示器的出现,b参数的类型、c参数的第二字段的类型、返回值的第一个字段的类型。相反,返回值的第三个字段类型用了static生命周期标识。
该a生命周期指示器的用法表示:“返回值的第一个字段租借已存在的b参数和c参数第二个字段的对象,因此它存活比该对象要短”。
相反,static生命周期指示器的用法表示:“返回值的第三个字段指向一个静态对象,因此它可以存活在任何时间,甚至和整个进程一样长”。
当然,这仅是一种可能生命周期注解,下面是另一种,
1 | trait Tr { |
c参数不带注解,引用的对象不租借给返回值的引用,
还有另一种可能生命周期注解,
1 | trait Tr { |
泛型函数有两个生命周期指示器和两个泛型参数。生命周期参数a指定了返回值的第三个字段租借了参数b的对象,以及生命周期参数b指定了返回值的第一个字段租借了参数c的第二个字段的对象。另外,函数有两个类型参数T1和T2,不带trait边界。
¶Checking the Validity of Lifetime Specifiers
编译任何函数时,borrow checker有两个工作:
- 通过函数自身和函数体,检查函数签名是否有效。
- 检查函数体是否合法,统计该函数被调用次数。
本小节,先看第一种情况。
如果函数返回值没有引用类型,borrow checker不做任何处理。
否则,每个返回值引用,必须检查是否有正确的生命周期指示器(lifetime specifier)。
这样一个指示器(specifier)可以是“'static”。这种情况,引用必须指向一个静态对象。
1 | static FOUR: u8 = 4; |
结果将会打印:“true 4 Hello 3.14”。该程序是合法的,因为三个返回值的引用都是静态对象。
相反,下面
1 | fn f(n: &u8) -> &'static u8 { |
将会生成编译错误:“lifetime of reference outlives lifetime of borrowed content…”。这是不合法的,返回值不是一个指向静态对象的引用;它实际上是接收参数的值,该返回值租借了函数参数引用对象的同一个对象。
生命周期指示器可以定义成列表的形式,如下
1 | fn f<'a, 'b>(x: &'a i32, y: &'b i32) -> (&'b i32, bool, &'a i32) { |
结果将打印:“13 true 12”。这是有效的,因为tuple中返回的第一个字段引用的时y表达式的值,以y参数和返回值的第一个字段有同样的生命周期指示器(lifetime specifier);它们的生命周期指示器都是'b。返回值的第三个字段和参数x一样有同一个生命周期指示器'a。
相反,下面
1 | fn f<'a, 'b>(x: &'a i32, y: &'b i32) -> (&'b i32, bool, &'a i32) { |
会产生两个编译错误,两个错误都是:“lifetime mismatch”。实际上,返回值的第一个字段和第三个字段都有生命周期指示器,但不是对应它们函数签名。
注意到,多个返回字段可能仅用了一个生命周期指示器:
1 | fn f<'a>(x: &'a i32, y: &'a i32) -> (&'a i32, bool, &'a i32) { |
这里,'b被替换为'a。但味道完全不一样。
原来的版本是,参数列表指示了会有两个独立的生命周期;这个版本,它们共享生命周期。
这个改动对于租借检查器(borrow checker)来说不简单。让我们考虑一个更复杂的函数体:
1 | fn f<'a>(n: i32, x: &'a Vec<u8>, y: &Vec<u8>) -> &'a u8 { |
该函数是有效的。函数体有三个可能的表达式返回值,所有表达式都租借了参数x的对象。返回值有和参数等同的生命周期,所以满足borrow checker。
相反,下面
1 | fn f<'a>(n: i32, x: &'a Vec<u8>, y: &Vec<u8>) -> &'a u8 { |
一个可能的返回值,会是表达式&y[2],该对象租借自y,这个参数没有生命周期指示器,所以该代码是不合法的。
即使下面代码也是不合法的,
1 | fn f<'a>(x: &'a Vec<u8>, y: &Vec<u8>) -> &'a u8 { |
当处理数据流分析时,编译器会探测到y从不被返回值租借;但borrow checker坚持因为&y[0]是一个可能返回值,所以该段程序被认为是无效的。
¶Using the Lifetime Specifiers of Invoked Functions
上一章节的开始部分说过,borrow checker有两项工作,编译函数时进行检查函数体是否有效,统计函数体内任意被调用函数的签名。
沿用上一节的代码示例。根据租借的规则,“missing lifetime specifier”表示缺少lifetime specifier,我们将原来的加上生命周期指示器,
1 | let v1 = vec![11u8, 22]; |
第二个例子改为,
1 | let v1 = vec![11u8, 22]; |
第一个程序是有效的,结果会打印:“[11, 22]”,第二个会有编译错误:“v2 does not live long enough”。
为什么func这些写法会产生编译错误,前面小节已经解析过了。
让我们看看main函数在第一个程序如何工作的。当func被调用,存活v1、result和v2,按顺序声明,以及v1和v2早已被初始化。func的签名说结果值和第一个参数有相同的lifetime specifier,这意味着result的值不会存活得比v1长。以及,result在v1之后声明,因此它会先于v1销毁。
再看看为什么第二段程序的main函数是不合法的。这里,func的签名说返回值和第二个参数有相同的lifetime specifier,意味着result存活不会比v2长。但result在v2之前声明,而它会在其后被销毁。
这是因为只使用了一个lifetime specifiter,下面改为两个指示器,
1 | fn f<'a, 'b>(x: &'a i32, y: &'b i32) -> (&'b i32, bool, &'a i32) { |
结果将打印“12 true 13”。
相反,如果仅用一个lifetime specifier时,是不合法的,
1 | fn f<'a>(x: &'a i32, y: &'a i32) -> (&'a i32, bool, &'a i32) { |
这会产生编译错误:“j1 does not live long enough”。
在这两个版本中,函数f都会接收i1和j1的引用,首先返回变量r存储的值,然后分别地初始化i2和j2变量。
在第一个版本中,第一个参数和返回值的第一个字段拥有相同的lifetime specifier,这导致了i2必须存活少于i1。类似地,j2必须存活少于j1。实际上,变量的声明顺序需要满足这些要求。
第二个版本,由于只有一个lifetime specifier,所以i2和j2必须存活少于i1和j1。实际上,i2被声明在j1前,不能满足这些要求。