2024-09-22 00:10:37
本文面向对象:
希望尝试了解Rust相关代码仓库(eg.deno)的Rust零基础同学
希望了解Rust语法特性拓展知识范围的同学
希望对编程语言底层逻辑加深理解的同学
前言我们为什么要学习Rust?上图是知乎上一个关于Rust的问题
不少人抱有疑问:“为什么人们还要卷一个Rust新语言出来呢?”
这个问题回答的思路可以有多条:
Rust如何解决现有编程语言的内存管理问题痛点
Rust如何兼顾工程化和性能
Rust如何从源头上提升代码质量
你可能还会问:“可是我目前没有Rust落地的场景?”
这个问题就更好解决了,只要你对目前业界最特色的垃圾回收机制感兴趣,那继续读下去肯定会有一定的收获。
你可能的最后一个问题:“听说Rust学习曲线出了名的陡峭,是不是很难入门啊?”
确实,他的学习难度业界闻名(大概比Java还难一个C的程度),但是他的代码review难度却是公认比较简单的,你不好奇为什么会有这样的现象吗?
缺点VS优点那些舒服的地方看看这红线,像不像小学班主任批改作业的注释?保姆级别的编译报错提示,手把手教你改bug
如果你的代码可以编译成功,那你不需要再考虑内存相关的逻辑,Rust已经完全处理完成,你只需要聚焦于业务内容
可以比肩C/C++的强大性能,底层(操作系统,区块链,WebAssembly等)开发者的利器
不那么舒服的地方新手写一个小时Rust的报错提示可能就长到翻不完,学习难度客观存在
生态不如其他成熟的编程语言那样完善,但依然是富有活力的发展中
Rust的地狱笑话:为什么不尝试用Rust写个链表呢?(由于语言特性原因,这会非常困难)
一些和现有其他热门语言设计有出入的细节(天国的return/天国的分号/::满天飞/'a满天飞)
与众不同的设计基础概念栈与堆栈(stack)和堆(heap)都是可以在运行期使用的内存空间。栈是结构规整,每块大小相当,先进先出的数据结构,而堆的结构较为松散,需要使用空间时还需要进行合理的分配。
一些大小固定的数据类型(eg.Int/Char),这些数据往往会存储在栈中,栈大小固定,不需要计算需要分配的空间,时间开销也更小。
而一些大小不固定的数据类型(eg.JavaScript:Object/Rust:String),这些数据的实际内容会存储在堆内,在栈中存储数据在堆中的指针等相关信息。
JavaScript中的堆和栈
得益于CPU高速缓存,使得处理器可以减少对内存的访问,高速缓存和内存的访问速度差异在10倍以上,栈数据往往可以直接存储在CPU高速缓存中,而堆数据只能存储在内存中。访问堆上的数据比访问栈上的数据慢,因为必须先访问栈再通过栈上的指针来访问内存。
试想,如果堆中数据存储得不到释放,一直无限增加下去,我们的程序势必会出现各种异常的现象。
GC(垃圾回收)如果你是一个一直使用某种高级编程语言(JavaScript/Java/Python等)的开发者,可能会对垃圾回收机制了解的不是很多。
例如JavaScript,由于JavaScript引擎帮你做了一切,所以开发者不需要在代码层面过多关注于内存开销带来的影响。
GC全称GarbageCollection,什么是“垃圾”?我们在程序开发过程中,会使用到一些系统内存,当某块内存使用完毕后,就需要被回收。如果不被回收,这块内存就会一直被占用下去,无法被重复利用,严重的内存泄露会引起程序卡死。
如下图,js堆的大小呈阶梯状上升:
三种垃圾处理方法全自动回收:JavaScript/Java/Python【彻底解放双手】
手动回收:C/C++【自己动手,丰衣足食】
按特定规则自动回收:Rust【剑走偏锋】
全自动的垃圾回收能力在大部分业务场景看起来都非常美好,它大大减小了开发者对于内存控制的心智负担,我们可以把目光集中在其他需要持续关注的角度上。
而在偏向系统层级的底层技术领域(eg.音视频领域/游戏客户端),我们对内存占用的开销有更高的要求,这时候手动进行内存管理使我们有多大的优化空间来施展拳脚。
C/C++带来“自由”的内存管理是把无情的双刃剑,“自由”也伴随着无尽的bug和更高昂的代码理解成本。这时候,为什么不试试Rust?Rust也不需要开发者自己进行空间申请/释放等操作,为了兼顾更好的性能,它引入了特别的【所有权】和【生命周期】概念,编译器在编译时会根据一系列规则进行检查,在执行时就能保证安全且不带来性能开销。
所有权所有权是一组控制Rust程序如何管理内存的规则。
基础规则Rust中的每个值都有一个名为所有者的变量。
一次只能有一个所有者。
当所有者超出范围时,该值将被删除。
简单解释这个规则:我们的每一个量有且只有一个所有者,当所有者失效后,这个值也就不存在了。
让我们来看看Rust的String类型,它比起int/char等类型更为特殊
fn?main()?{????let?s1?=?String::from("hello");????let?s2?=?s1;????println!("{},?world!",?s1);}String::from:从字符串字面值创建String
上面的逻辑如果出现在js/java中,只是非常简单的赋值过程,顺利执行。而Rust要求每一个量只有一个所有者,在执行到第三行lets2=s1;时,"hello"的所有者变成了s2,这个时候再次打印s1,我们只会收获一个error。
用图形描述为下图:栈中的s1转移对"hello"的所有权到s2身上,且s1失效。
假设,如果s1不失效,在编译阶段回收数据时,Rust会发现有两个指针指向同一片内存,"index"这片内存会被错误的释放两次!两次释放相同的内存会导致内存污染,它可能会导致潜在的安全漏洞。
如果我们想要让s1也正常打印,就必须强拷贝一份"hello",如下图:
Rust永远也不会自动创建复杂数据类型的“深拷贝”,因为这对内存开销实在太大,我们在需要时要手动调用。
如果我们想使用一个Int类型的数据:
fn?main()?{????let?x?=?5;????let?y?=?x;????println!("x?=?{},?y?=?{}",?x,?y);}以上代码是可以正常执行的,因为x是简单类型(Int),是固定大小可以存储在栈中的数据类型。这时Rust会帮助我们拷贝一份放在栈中,因为在栈中拷贝是非常快速的。
如果我们的所有权只能简单的唯一归属,那势必会加大我们的编码难度,下面我们来看看引用。
引用引用又被分为可变引用和不可变引用:
let?x?=?10;let?r?=?&x;r就是x的一个不可变引用(引用是对内存中的另一个值的非拥有(nonowning)指针类型)
当我们想希望用指针指向一个变量且希望改变这个变量时,我们也会用到可变引用(&mut):
fn?main()?{????let?mut?s?=?String::from("hello");????change(&mut?s);}fn?change(some_string:?&mut?String)?{????some_string.push_str(",?world");}同一时刻,你只能拥有要么一个可变引用,要么任意多个不可变引用(保证绝对安全)
引用必须总是有效的(无效就是空指针了)
由于Rust新老编译器的区别,编译是否成功也不同:
fn?main()?{???let?mut?s?=?String::from("hello");????let?r1?=?&s;?????let?r2?=?&s;?????println!("{}?and?{}",?r1,?r2);????//?新编译器中,r1,r2作用域在这里结束????let?r3?=?&mut?s;?????println!("{}",?r3);}?//?老编译器中,r1、r2、r3作用域在这里结束??//?新编译器中,r3作用域在这里结束letmuts定义变量s
letr1=&s;r1为s的不可变引用,即指向对应内存的指针
letr3=&muts;r3为s的可变引用,这时s可以更改
因为篇幅原因,这里只简单介绍一下引用的相关知识,希望有更多了解的同学可以阅读后面的深入学习资料。
生命周期基础规则生命周期,简而言之就是有效作用域。部分情况时,我们无需手动的声明生命周期,因为编译器可以自动进行推导。
fn?main()?{{????let?r;????????????????//?---------+--?'a????{?????????????????????//??????????|????????let?x?=?5;????????//?-+--?'b??|????????r?=?&x;???????????//??|???????/????}?????????????????????//?-+???????|????println!("r:?{}",?r);?//??????????|}?????????????????????????//?---------+}以上代码会在编译时报错:因为'x'不能存活那么长。
在Rust中,从数据定义到一对大括号的结束,就是一个生命周期范围(见上图的'a和'b),'a是r的生命周期,'b是x的生命周期,而x的生命周期小于r的生命周期,所以在执行r=&x;后,x的生命周期就结束了,这时r指向了一个被回收的数据的地址,变成了一个悬垂指针,所以就出错了。
当的函数入参出现引用类型时,稍有不慎就可能出现悬垂指针,所以Rust编译器比我们更加紧张:
fn?main()?{????let?string1?=?String::from("long?string?is?long");????let?string2?=?String::from("xyz");????let?result?=?longest(string1.as_str(),?string2.as_str());????println!?("The?longest?string?is?{}",?result);}????fn?longest(x:?&str,?y:?&str)?->?&str?{????????if?x.len()?>?y.len()?{????????????x????????}?else?{????????????y????????}????}上面是一个判断字符串长度的函数,看起来完全ok,但其实也会报错:
这个报错的原因是,Rust无法推断x和y的生命周期谁更长!因为编译器无法分析出要returnx还是y,所以我们要显式声明入参的生命周期。
&i32????????//?一个引用&'a?i32?????//?具有显式生命周期的引用&'a?mut?i32?//?具有显式生命周期的可变引用生命周期的格式如上面所示,我们来修改一下刚才错误的的代码:
fn?main()?{????let?string1?=?String::from("abcd");????let?string2?=?"xyz";????let?result?=?longest(string1.as_str(),?string2);????println!?("The?longest?string?is?{}",?result);}????fn?longest<'a>?(x:?&'a?str?,?y:?&'a?str)?->?&'a?str{????????if?x.len()?>?y.len()?{????????????x????????}?else?{????????????y????????}????}现在就可以正常运行了!
首先请牢记:生命周期标注并不会改变任何引用的实际作用域
生命周期标注简单来说就是你在教编译器做事,且它只是起一个指导作用!
因为我们的编译器有时候还是很智慧的,比如它在一些简单的场景面前可以自己推导出生命周期(当只有一个入参是引用类型的时候,如果能正常编译,返回值的生命周期只可能与这一个入参相关),但是复杂场景它往往无法推测出来!
回到之前的例子,我们的标注可以说明:
和泛型一样,使用生命周期参数,需要先声明<'a>
x、y和返回值至少活得和'a一样久(因为返回值要么是x,要么是y,x、y的生命周期相同)
如果我们不添加标注,对Rust编译器来说,其实相当于:
????fn?longest<'a,'b>?(x:?&'a?str?,?y:?&'b?str)?->?&str{//Rust无法自动推断x返回值的生命周期是'a还是'b,所以我们需要手动“告知”Rust。
欺骗编译器可行吗?我们简单修改一下刚才例子中的代码,让string1和string2拥有不同的生命周期,但是我们在longest函数中还是把两个入参标注为同样的生命周期:
fn?main()?{????let?x?=?5;????let?y?=?x;????println!("x?=?{},?y?=?{}",?x,?y);}0string1的生命周期明显大于string2,结果:
哈哈,不出意料的报错了,再次验证开头说的“生命周期标注并不会改变任何引用的实际作用域”
你永远无法欺骗Rust编译器~
如果你想深入学习文档资料本文只是抛砖引玉!Rust还有很多很多内容值得研究!
以下两篇基本是必读了,本文的部分内容也参考了下面的教程:
Rust程序设计语言-Rust程序设计语言简体中文版
进入Rust编程世界-Rust语言圣经(Rust教程RustCourse)
视频资料在b站发现的宝藏视频,讲解的上面的第一篇文档。
老师讲的的时候会带代码演示,比纯啃文章好理解多了(老师的东北口音也很带劲,越听越精神)
https://www.bilibili.com/video/BV1hp4y1k7SV
参考https://juejin.cn/post/6981588276356317214
栈、堆、队列深入理解,面试无忧-掘金
https://juejin.cn/post/6844904106310516744
原文:https://juejin.cn/post/7099362775621140510