理解Rust的生命周期就是理解了Rust

学习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 &sum; 
}

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);
}

编译是会报错,提示 xborrowed 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

猜你喜欢

转载自blog.csdn.net/yqq1997/article/details/127584102