Rust号称是一个内存安全的不需要GC的语言。这其实有点违反常识,如果没有GC,仅仅通过静态分析怎么保证内存安全?难道通过超级高的语言学习门槛使得只有能写出内存安全的代码的资深程序员才会去写Rust,从而保证内存安全吗?…恩,很有可能。
不过说实在的,Rust其实是靠一套Ownership和Lifetime机制去尽力实现上述的目标。这里说尽力,也真的只能是尽力,因为实在是有些需求需要在运行时搞事情,这时候静态分析就没办法了,代码一旦跑起来就没有编译器什么事情了,也就只能自求多福。但是实际上,我们很少会遇到非要在运行时搞事情的需求,因此绝大多数时候Rust的这套机制都能保证你代码在内存方面的健壮性。
文本我们来聊聊Rust引入Lifetime的必要性和Lifetime到底是干嘛的。
很多人一开始接触Rust都有个疑问,这个Lifetime标志看着好蛋疼,好反人类。我从未见过如此xxx的编程语言!我写了那么多年C,C++没有Lifetime一样跑得好好的,你Rust搞个这玩意儿除了让我无比崩溃以外到底是图啥。
当然是图内存安全啦!不过在我们继续回答这个问题之前,我们先看一看所谓的内存不安全
到底是指啥。一般来说,内存不安全是指:
- use after free
- dangling pointer
对应的中文解释就是:
- 访问已经被free的内存(导致segment fault)
- 指针指向的内存被“污染”,所谓的野指针
第一个很好理解,那啥叫指针指向的内存被污染呢?比如说以下C代码:
int* foo(int t) {
int a = t;
return &a;
}
void manipulate(int delta, int * value) {
int change = delta * delta;
*value += change;
}
int main() {
int * value = foo(10);
manipulate(2, value);
printf("%d\n", *value); // expect *value equals 14 ???
}
foo
返回了一个指针,指向的是栈上的一个地址。但是你要知道,一旦foo函数执行完,栈就“销毁”了。这里的销毁不是free,而是说这个地址之后会另做它用,你存在里面的内容可能随后就会被修改。换句话说,此时value指针指向的地址不再保证其中存的值的10了。既然无法保证里面的值是我们预设的,那么后面的操作结果显然也不会如预期。
这里你会不自觉地意识到,在foo函数中,变量a的有效期只是在函数内,到函数外就无效了。变量a的有效期,变量a的有效期,或者说变量a的生命周期……是不是就Rust的Lifetime。
对了!其实你可以看到Lifetime是一个遍布于所有编程语言的概念,并不是Rust独创的。大多数GC语言runtime会去跟踪对象的lifetime以便在适当的时机释放它,但是Rust并没有runtime给你做gc,但lifetime又是管理内存不得不提到的概念,因此Rust把它明确化了。
啊?这是不是意味着我必须对GC的那一套技术了熟于心?听说GC语言是需要在编译期插入代码才能实现GC的,那是不是意味着我在Rust中搞的这些Lifetime其实就相当于在干GC语言编译器干的事情?感觉好可怕……
当然不是啦!如果真的编译器能够知道在哪儿插代码来给你释放内存,那就不可能存在什么Full GC的STW。接下来我们来聊聊Rust中Lifetime到底是为了保证内存安全的哪方面从而让你不得不在代码中标明的。
简单说,Lifetime标记在Rust中主要是为了避免use after free这个问题。也就是说指针指向的内存不知道在哪儿已经free了,然后当你不明所以地访问了该指针,然后系统就segment fault了。看下面的例子:
int * foo(int t) {
int *p = (int*)malloc(sizeof(int));
*p = t;
return p;
}
void doStupidThing(int * p) {
printf("I'am so evil that I want to free your pointer");
free(p);
}
int main() {
int* p = foo(10);
free(p);
//doStupidThing(p);
*p ++; // segment fault
}
以上这段代码肯定是要报错的,你一眼就看出来了。但是如果去掉main中的free转而调用doStupidThing这个函数,你还能看出来吗?如果doStupidThing函数中又函数套函数地层层调用,你还能看出来吗——这其实就是我们实际项目中的情况。除非你特别小心,否则你很难掌控一个指针是否已经被free了。
以上例子仅仅是最简单的场景,实际上单个指针的这种use after free的case,配合OwnershipRust是能够静态分析出来的,也就是说这种case在Rust中你是不必要声明Lifetime的。
必须声明Lifetime的是如下Case,为了证明Lifetime不是Rust中特有的概念,我们还是拿C举例:
struct person {
const char* name,
int age
}
struct person* new_person(const char* name, int age) {
struct person* a = (struct person*)malloc(sizeof(person));
a->name = name;
a->age = age;
return a;
}
struct person* who_is_older(struct person* a, struct person* b) {
if(a->age > b->age) {
return a;
}
return b;
}
void work() {
struct person foo = {"foo", userInput()};
/*
| do a lot of works -- part1
|
*/
struct person* bar = new_person("bar", 24);
/*
| do a lot of works -- part2
|
*/
struct person* the_oldder = who_is_older(&foo, bar);
/*
| do a lot of works -- part3
|
*/
printf("the older man is %s\n", the_older->name);
}
以上代码中,who_is_older
函数的入参是两个指针,返回一个指针。具体返回哪个指针是根据age字段来决定的,因此在编译期间是无法确定的。
在以上代码中我注释掉了3段复杂的代码,分别用part1~3来表示。你不用管这段代码做了什么,正如你在写代码的时候很可能也不知道你调用的函数或者引用的库到底做了什么。
首先来看,当我们注释掉三段代码时,这段代码是可以正常运行的。foo的生命周期是从work函数第一行到work函数最后一行。bar的生命周期是从new_person函数调用开始,由于是通过malloc在堆上分配的内存,因此其生命周期要到对其调用free函数才截止。但是以上代码没有free它,因此可以认为bar的生命周期是从new_person调用到进程结束(在进程内部可以认为是永远)。
在我们调用who_is_older时,foo和bar都是在它们的生命周期内。最后当我们打印the_older->name时,由于foo和bar都有效,因此不会出现什么问题。
换句话说,如果我们这段代码要保证绝对安全,有一个必要前提是:当我在使用the_older时,foo和bar既没有被篡改也没有被free!。
那我们开始搞点事情。
假设我们的part3是free(bar)
,结果会如何呢?也就是说代码变成了:
void work() {
struct person foo = {"foo", userInput()};
/*
| do a lot of works -- part1
|
*/
struct person* bar = new_person("bar", 24);
/*
| do a lot of works -- part2
|
*/
struct person* the_oldder = who_is_older(&foo, bar);
// part3
free(bar);
printf("the older man is %s\n", the_older->name);
}
很显然,如果the_older == bar 也就是当foo的年龄小于24,打印the_older时程序会崩溃。但是如果the_older == foo,则没有问题。这段代码会不会崩溃完全依赖于userInput
,也就是说这段代码是不安全的。上面这段代码的生命周期如下所示:
----- foo start: struct person foo = {"foo", userInput()};
|
|
|
|
|---- bar start: struct person* bar=new_person("bar", 24);
|
|
|
|
|----------------- the_older start: struct person* the_oldder = who_is_older(&foo, bar);
|
|
|---- bar end: free(bar);
|
|
|----------------- use the_older !!! error: printf("the older man is %s\n", the_older->name);
|
|
|
-------------------- foo end , the_older end: }
从上图的生命周期标注可以看出,the_older的生命周期和bar的生命周期有交集,但the_order的生命周期并不是bar的一个子集,换句话说the_older的生命周期并不是小于等于bar的。而the_older又有可能指向bar,因此在使用the_older时就可能发生segment fault。
那么Rust是怎么通过Lifetime来解决这个问题的呢?我们来看看who_is_older在rust中怎么写:
fn who_is_older<'a>(a: &'a person, b: &'a person) -> &'a person {
if a.age > b.age {
a
} else {
b
}
}
其实就是多了一个'a
。'a
这么牛逼么?写了就内存安全了?其实不然,’a只是一个标记,是给编译器看的。这个函数签名我们可以这样解读:
假如返回值的生命周期是a,那么两个入参的生命周期也
至少
是a。
这样,我们使用函数返回值时就可以放心使用而不用担心segment fault了。a具体是什么我不管,编译器你自己去看两个入参的生命周期和返回值的生命周期能不能符合以上规则,不符合就compile error。
回到我们的C代码,你会发现假如the_older的声明周期是a,而foo的生命周期是整个work函数,a是它的一个子集,也就是说foo的生命周期至少
是a这个结论是成立的。但是,由于the_older的生命周期并不是bar的生命周期的一个子集,因此bar的生命周期至少
为a这个结论不成立。因此,编译器会报错,告诉你编译不通过。我们把上面C代码改写成Rust代码如下:
fn work(foo_age: i32) {
let foo = person{name: "foo", foo_age,};
let the_older: &person;
{
let bar = person{name:"bar", 24,};
the_older = who_is_older(&foo, &bar);
} // rust 中作用域结束会自动Drop掉作用域内的对象,这里就相当于C中调用free(bar)
println!("the older man is {}", the_older.name);
}
fn who_is_older<'a>(a: &'a person, b: &'a person) -> &'a person {
if a.age > b.age {
a
}
b
}
这段代码无法编译通过就是因为编译器发现work函数中foo bar the_older无法满足函数who_is_older
中的lifetime限制。当我们做出调整,以下代码才可以编译通过:
#[derive(Debug)]
struct person {
name: String,
age: i32,
}
fn main() {
let foo = person{name: String::from("foo"), age: 24,};
{
let bar = person{name: String::from("bar"), age: 25,};
let the_older: &person;
the_older = who_is_older(&foo, &bar);
println!("the older man is {:?}", the_older);
}
}
fn who_is_older<'a>(x: &'a person, y: &'a person) -> &'a person {
if x.age > y.age {
return x
}
y
}
这样,Rust通过人肉限制Lifetime就可以避免C中一些use after free的Bug,把这些问题扼杀在编译期。
以上是Lifetime在函数声明中的必要性以及它的价值,其实在struct中也有Lifetime,有时候你会很烦它,但是它同样也解决use after free的问题。
比如在C中:
struct person {
int* salary
}
void do_stuff() {
int* money = get_it_from_another_library();
struct person Alice = {money};
// with a lot of works
//here you can't promise that the money pointer is still valid
printf("%d", Alice->salary);
}
这个问题也是普遍存在的问题,在Rust中当结构体中存储了引用类型,在使用该结构体实例时也需要靠lifetime来保证引用内存的安全性。比如下面的例子:
struct Foo<'a> {
x: &'a i32,
}
fn main() {
let f : Foo;
{
let n = 5; // variable that is invalid outside this block
let y = &n;
f = Foo { x: y };
}; // n dropped here
println!("{}", f.x);
}
编译器会阻止这段代码,因为最后一行打印f.x时,由于x是n的引用,而n只在block生效,在block结尾被drop调了,这时f.x其实是个dangling pointer。为什么编译器能识别出来呢?——因为lifetime标记啊!
在struct中的lifetime要理解成:
假设结构体实例的生命周期是a,那么结构体中x的引用的生命周期至少是a。
然后上述代码你会发现在实例化Foo时,y的生命周期小于f的生命周期,于是编译器就报错了!这也是把错误扼杀在摇篮中!
所以,初学者不要害怕Rust的Lifetime和Ownership,它们都是帮你写出Robust代码的好帮手!理解它们的Why是很重要的,这样你才能利用好它们,不至于看到报错之后一脸懵逼。