学习C/C++,其实就是在学“内存管理”,即“学会了内存管理,就学会了C/C++的精髓”, 这句同样适用于Rust:
理解了Rust的生命周期,就理解了Rust。
因为Rust的“所有权”机制也是围绕着“生命周期”来设计的。 正是因为Rust的“所有权”机制的出色的设计,使得Rust在“并发”编程领域,能够做到“性能”和“内存安全”二者兼得。但是,这些设计也带来了Rust的学习复杂度,入门门槛相对较高,尤其对于非C/C++的程序员。
关于Rust生命周期和注解语法的细节,可以Rust官方教程(中文版)参考:
这里我们就不讲“是什么?”的细节,而是来思考几个“为什么?”的问题,主要问题如下:
- 问题1:Rust的生命周期与其他编程语言(C/C++、Go、Java)有什么区别?
- 问题2:Rust的这种生命周期的设计有何作用?
- 问题3:为什么需要引入一个“奇葩”的生命周期注解的语法?
问题1:Rust的生命周期与其他编程语言(C/C++、Go、Java)有什么区别?
在C/C++中,栈上变量(对象)的生命周期通过“作用域”来决定,而堆上变量是通过内存动动态分配(malloc、new等)操作来控制。基于此设计,C/C++具有非常大的自由度,有编程人员自行控制并管理内存(生命周期)。
例如,一个简单的例子:
栈上数据的生命周期
#include <iostream>
using namespace std;
int* foo(int a , int b)
{
int sum = a + b;
// 返回一个局部变量的地址,sum的生命周期,仅限于foo函数,foo函数结束后sum就会被释放
return ∑
}
int main()
{
std::cout << foo(1, 2) << std::endl; // 输出结果是不确定的!
return 0;
}
堆上数据的生命周期
#include <iostream>
#include <string.h>
using namespace std;
void foo(char* buf)
{
// .... 做别的事情
// 手抖,把内存释放了
free(buf); // buf 内存生命周期结束了
}
int main()
{
char *p = (char *)malloc(100);
memset(p, 0, 100);
strcpy(p, "hello world");
std::cout << p << endl;
foo(p);
// 不知道foo函数会释放内存,继续使用p
std::cout << p << endl;
return 0;
}
可见,C/C++的对内存操作的非常自由,对程序员的要求较高,必须时刻注意内存安全的问题。虽然,C++新标准和C++生态引入了智能指针,一定程度上可以避免低级错误的发生,但是在面临并发的情况下,如何保证“线程安全”和“内存安全”的前提下,又不牺牲性能,一直是C++生态面临的新课题。
我们再看,Go语言的生命周期:
package main
import "fmt"
func foo(a , b int) *int{
sum := a + b
return &sum
}
func main() {
p := foo(1, 2)
fmt.Printf("%v\n", *p) // 输出结果:3
}
Go语言中通过“逃逸分析”来决定一个变量是分配在栈
上还是堆
上,并结合GC(垃圾回收)内部的“三色标记法”对垃圾变量进行回收。 从语言层面极大的减少了程序员的入门门槛,不需要像C/C++那样手动管理内存、也不会像Java那样完全依赖GC而导致性能问题,算是一个巨大的创新。
任何事物都不是完美的,当然,Go语言也有问题,这里就不展开了。
Java中变量的生命周期
import java.util.ArrayList;
import java.util.List;
class JavaLifetime {
public static List<String> foo() {
// Java程序员不需要不需要考虑栈、堆的概念,需要什么就new
List<String> l =new ArrayList<String>();
l.add("hello");
// 这里的返回类型是引用,而不是把数据拷贝一份, 所以foo函数结束之后,l并不会被释放
// 这里和Go的“逃逸分析”有异曲同工之妙
return l;
}
public static void main(String[] args) {
List<String> l = foo();
System.out.println(l.get(0));
}
}
而C/C++是完全由程序员自己来管理“生命周期”(内存管理),程序员必须对内存管理有非常深入的理解,才能写出运行时安全的程序。
Go和Java走的是另一个极端,在“生命周期”做了很多内存管理的工作,让程序员不必关系数据的生命周期,全部交给GC来管理, 这也牺牲了一部分运行时性能。
最后,我们再看看Rust的生命周期管理的设计:
以官方教程的示例:
fn main() {
let r;
{
let x = 5;
r = &x;
}
println!("r : {}", r);
}
编译是会报错,提示 x
的borrowed value does not live long enough
,即x的生命周期不够长,即x
离开他的作用域之后,就会被立即释放,而引用一个被释放的变量,是内存不安全的行为,所以必须拒绝。
{
let r; // ---------+-- 'a
// |
{
// |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+
这样,这些潜在的“运行时的问题”,在Rust中可以在编译时就发现。
问题2:Rust的这种生命周期的设计有何作用?
基于生命周期和所有权机制,可以解决并发编程中的内存安全的问题。 当然,不是保证绝对不会有并发的内存安全问题。可以减少常见的95%的问题,就已经可以大降低运行时的BUG。
问题3:为什么需要引入一个“奇葩”的生命周期注解的语法?
编译器还很“笨”,而且是个“钢铁直男”。 你必须告诉他在面临“不确定”的情况下,如何抉择。
举个不恰当,但是生动的例子:
小红让小明帮忙买衣服。
小红对小明说:“好看又便宜,有奢侈感又朴实,能突出个性又能不张扬,既休闲又可在商务场景穿……反正就是我想要买的那种衣服啦,知道了吗,你可别买错了哦。”,
小明:“……error”
小红:“为什么?”
小明:“你必须告诉我,具体门店、品牌、型号、颜色、尺寸、价格”
还是引用官方教程中的一个简单例子:
fn longest(x: &str, y: &str) -> &str {
// 编译器不能确定入参和返回值的生命周期
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let x = String::from("xxx");
let y = String::from("xxxx");
let z = longest(&x, &y);
println!("{}", z);
}
以上代码编译会报错。
Rust编译器通过函数签名fn longest(x: &str, y: &str) -> &str
不能确定入参和返回值的生命周期的长短,需要程序员告诉编译器,在面临这种“不确定”的情况下,应该采用什么样的检查策略。
因此,Rust的设计人员想出了一种特殊方式——生命周期注解,这个语法形式有些奇怪的东西。
关于生命周期的的3条规则,这里不赘述,如果对于规则不熟悉的,请参考 生命周期确保引用有效 复习以下3条规则,并且能够用自己的语言“说”出来。
因此,我们用“生命周期注解”告诉编译器“该怎么做”, 修改后的代码如下:
fn longest<'a>(x: &'a str, y: &'a str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let x = String::from("xxx");
let y = String::from("xxxx");
let z = longest(&x, &y);
println!("{}", z);
}
现在,代码编译通过,并且能够输出正确的答案了。
为什么?
我们看看fn longest<'a>(x: &'a str, y: &'a str) -> &str
,这个形式最终的形式,根据生命周期规则的第一条“每个引用参数都自己的生命周期参数”,原始形式是fn longest<'a, 'b>(x: &'a str, y: &'b str) -> &str
,返回值生命周期到底取决于'a
还是 'b
是不能确定的,因此编译会报错。
fn longest<'a>(x: &'a str, y: &'a str) -> &str
完整的写法应该是 fn longest<'a>(x: &'a str, y: &'a str) -> &'a str
, 而'a
则是&x
和 &y
两个引用入参中生命周期最小(最短) 那个,即符合“木桶效应”。
这样,就相当于告诉了编译器:“听好了,编译时,在遇到生命周期不确定的情况,用最短的那个来检查,我说的!编译出了错,我会改代码。”
在Rust程序员和Rust编译器的完美“合作”下,就可以构建出“高性能、内存安全”应用程序。
总结
Rust的设计目的是“既高性能又内存安全”,Rust的生命周期机制就是实现设计目的核心原理,围绕生命周期机制又衍生出了“所有权” 这个语法层面的规则,而这点就是Rust区别其他编程语言的最本质的特点;正是由于这些创新的概念,增加了Rust的入门门槛。
(over)
2022-10-29