• 理解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 
    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;
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    堆上数据的生命周期

    #include 
    #include 
    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;
    }
    
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    可见,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
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    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));
        }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20

    而C/C++是完全由程序员自己来管理“生命周期”(内存管理),程序员必须对内存管理有非常深入的理解,才能写出运行时安全的程序。

    Go和Java走的是另一个极端,在“生命周期”做了很多内存管理的工作,让程序员不必关系数据的生命周期,全部交给GC来管理, 这也牺牲了一部分运行时性能。


    最后,我们再看看Rust的生命周期管理的设计:

    以官方教程的示例:

    fn main() {
        let r;
        {
            let x = 5;
            r = &x;
        }
        println!("r : {}", r);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8

    编译是会报错,提示 xborrowed value does not live long enough,即x的生命周期不够长,即x离开他的作用域之后,就会被立即释放,而引用一个被释放的变量,是内存不安全的行为,所以必须拒绝。

     {
            let r;                // ---------+-- 'a
                                  //          |
            {                     //          |
                let x = 5;        // -+-- 'b  |
                r = &x;           //  |       |
            }                     // -+       |
                                  //          |
            println!("r: {}", r); //          |
        }                         // ---------+
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10

    这样,这些潜在的“运行时的问题”,在Rust中可以在编译时就发现。


    问题2:Rust的这种生命周期的设计有何作用?

    基于生命周期和所有权机制,可以解决并发编程中的内存安全的问题。 当然,不是保证绝对不会有并发的内存安全问题。可以减少常见的95%的问题,就已经可以大降低运行时的BUG。


    问题3:为什么需要引入一个“奇葩”的生命周期注解的语法?

    编译器还很“笨”,而且是个“钢铁直男”。 你必须告诉他在面临“不确定”的情况下,如何抉择。

    举个不恰当,但是生动的例子:

    小红让小明帮忙买衣服。
    小红对小明说:“好看又便宜,有奢侈感又朴实,能突出个性又能不张扬,既休闲又可在商务场景穿……反正就是我想要买的那种衣服啦,知道了吗,你可别买错了哦。”,
    小明:“……error”
    小红:“为什么?”
    小明:“你必须告诉我,具体门店、品牌、型号、颜色、尺寸、价格”
    
    • 1
    • 2
    • 3
    • 4
    • 5

    还是引用官方教程中的一个简单例子:

    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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    以上代码编译会报错。

    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);
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    现在,代码编译通过,并且能够输出正确的答案了。

    为什么?

    我们看看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

  • 相关阅读:
    处理器ChannelHandler的线程安全问题
    2023年【河北省安全员B证】新版试题及河北省安全员B证试题及解析
    数组相关 内容 解析
    【云原生 | Kubernetes 系列】--Gitops持续交付 Argo Rollouts Analysis
    开源治理:安全的关键
    CMake 基础学习
    Datalogic,50年的成功
    Python实现办公自动化读书笔记——自动化处理Word文档
    JVM垃圾回收相关概念
    [附源码]java毕业设计东软电子出版社管理系统
  • 原文地址:https://blog.csdn.net/yqq1997/article/details/127584102