于玉龙揭秘前端眼中的Rust


                    # 揭秘前端眼中的Rust

51CTO2022-09-18

来源 | 腾讯云开发者

作者 | 于玉龙

本文主要对rust相关内容进行解读分析,希望本文能对此方面感兴趣的开发者们提供一些经验和帮助。

关于Rust

rust是一门强类型的、编译型的、内存安全的编程语言。最早版本的Rust原本是Mozilla基金会的一名叫Graydon Hoare的员工的私人项目,2009年开始,Mozilla开始赞助者们项目的发展,并于2010年,Rust实现了自举——使用Rust构建了Rust的编译器。

Mozilla将Rust应用到构建新一代浏览器排版引擎Servo当中——Servo的CSS引擎在2017年开始,集成到了FireFox当中去。

Rust原本作为一种内存安全的语言,其初衷是代替C++或者C,来构建大型的底层项目,如操作系统、浏览器等,但是因为Mozilla的这一层关系,前端业界也注意到了这门语言,并将它应用在了其他领域,其生态也慢慢繁荣起来。

内存安全——Rust的一大杀手锏

众所周知,当下主流的编程语言当中一般分为两类,一类是自动GC的,如Golang、Java、JavaScript等,另一类则是C++和C,用户需要手动管理内存。

大部分语言的内存模型都是大同小异的。

当代码被执行时,一个个变量所对应的值,就被依次入栈,当代码执行完某一个作用域时,变量对应的值也就跟着出栈,栈作为一个先进后出的结构非常符合编程语言的作用域——最外层的作用域先声明、后结束。但是栈无法在中间插入值,因此栈当中只能存储一旦声明、占用空间就不会改变的值,比如int、char,或者是固定长度的数组,而其他值,比如可变长度的数组vector,可变长度的字符串String,是无法被塞进栈当中的。

当编程语言需要一个预先不知道多大的空间时,就会向操作系统申请,操作系统开辟一块空间,并将这一块空间的内存地址——指针返回给程序,于是编程语言就成功将这些数据存到了堆中,并将指针存到栈当中去——因为指针的大小是固定的,32位程序的指针一定是32bit,64位程序的指针也肯定是64bit。

栈中的数据是不需要做内存管理的,随着代码执行,一个变量很容易被判断还有没有用——只要这个变量的作用域结束,那么再也无法读取到这个变量的值,那么这个变量肯定没用了。只需要随着作用域的声明与结束,不断的入栈和出栈就足以管理栈的内存了,不需要程序员操心。

但是堆当中的数据就不行了,因为程序拿到的只是一个内存指针,实际的内存块不在栈当中,无法随着栈自动销毁。程序也不能在栈当中的内存指针变量销毁时,就将指针对应的空间自动清理——因为可能有多个变量保存的指针都指向了同一个内存块,此时清理这个内存块,会导致意料之外的情况。

基于此,有的程序自带一套非常复杂的GC算法,比如通过引用计数,统计一个内存区块的指针到底保存在多少个变量当中,当引用计数归0时,就代表所有的指向此处的指针都被销毁了,此处内存块就可以被清理。而有的程序则需要手动管理内存空间,任何堆当中开辟的空间,都必须手动清理。

这两种办法各有优劣,前者导致程序必须带一个runtime,runtime当中存放GC算法,导致程序体积变大,而后者,则变得内存不安全,或者说,由于内存管理的责任到了程序员头上,程序员的水平极大程度上影响了代码安全性,忘记回收会导致程序占用的内存越来越大,回收错误会导致删掉不应该删的数据,除此以外还有通过指针修改数据的时候溢出到其他区块导致修改了不应修改的数据等等。

而Rust则采取了一种全新的内存管理方式。这个方式可以简单概括为:程序员和编译器达成某一种约定,程序员必须按照这个约定来写代码,而当程序员按照这个约定来写代码时,那么一个内存区块是否还在被使用,就变得非常清晰,清晰到不需要程序跑起来,就可以在编译阶段知道,那么编译器就可以将内存回收的代码,插入到代码的特定位置,来实现内存回收。换句话说,Rust本质上是通过限制引用的使用,将那些【不好判断某块地址是否还在使用】的情况给规避了,剩余的情况,都是很好判断的情况,简单到不需要专业的程序员,只需要一个编译器,就能很好的判断了。

这样的一大好处是:

不需要GC算法和runtime,本质上还是手动回收,只不过编译器把手动回收的代码插入进去了,程序员不需要自己写而已。

只要编译可以通过,那么就一定是内存安全的。

实现原理

rust的内存安全机制可以说是独创的,它有一套非常简单、便于理解的机制,叫做所有权系统,这里面会涉及到两个核心概念,所有权和借用。

所有权

任何值,包括指针,都要绑定到一个变量,那么,我们就称这个变量拥有这个值的所有权,比如以下代码,变量str就拥有“hello”的所有权。

当str所在的作用域结束时,str的值就会被清理,str也不再有效。这个和几乎所有主流语言都是一致的,没有什么问题。也很好理解。

但是注意一下,Rust本身区分了可变长度的字符串和不可变长度的字符串,上文是一个不可变长度的字符串,因为其长度不可变,可以保存在栈当中,于是下面这一段代码可以正确执行,就像其他几乎所有主流语言一样:

但如果我们引入一个保存在堆里、长度可变的字符串,我们再来看看同样的代码:

此时,我们会惊讶地发现,代码报错了。为什么呢?

原因在于,第一段代码当中,str这个变量的值,保存在栈里,str这个变量所拥有的,是hello world这一串字符串本身。所以如果令str2=str,那么相当于又创建了一个str2变量,它也拥有这么一串一模一样的字符串,这里发生的是“内存拷贝”。两个变量各自拥有hello world这一个值的所有权,只不过两者的hello world不是同一个hello world。

而第二段代码当中,我们拿到的str,本质上只是一个指向到某一个内存区块的地址,而这个地址,当我们另str2=str的时候,实际上是将这一个地址的值赋值给str2,如果是在其他语言当中,这么写极大概率是没问题的,但是str和str2会指向同一个内存地址,修改str的时候,str2也变了。但是rust当中,同一个值只能被绑定到一个同一个变量,或者说,某一个变量对这一个值有所有权,就像一个东西同一时间只能属于同一个人一样!当令str2=str的时候str保存的地址值,就不再属于str了,它属于str2,这叫做【所有权转移】。所以str失效了,我们使用一个失效的值,那么自然报错了。

以下这些情况都能导致所有权转移:

上文提到的赋值操作:

let str = String::from("hello world"); let str2=str; //str失去所有权!

将一个值传进另一个作用域,比如函数:

let str=String::from("hello world"); some_func(str); //此时str失效。

这样,我们就可以很简单的发现,对于同一个内存区块地址,它同时只能保存在一个变量里,这个变量如果出了作用域,导致这个变量读取不到了,那么这个内存地址就注定永远无法访问了,那么,这个内存区块,就可以被释放了。这个判断过程非常简单,完全可以放在静态检查阶段让编译器来实现。所以rust可以很简单的实现内存安全。

但,上述的写法是很反人类的,这确实解决了内存安全的问题,但是不好用。比如我需要将str传入一个方法做一些逻辑操作,做完操作之后我还希望我能读取到这个str,比如类似于下面这段代码:

左右滑动查看完整代码

我们希望对str进行操作,后面添加三个感叹号然后打印出来,这段代码肯定是错误的,因为当str传入add_str方法时,就将所有权转移到了add_str方法内的变量str_1上,它不再具备所有权,所以就不能使用了,这种情况其实很常见,单纯的所有权机制让这个问题复杂化了,所以rust还有一个机制来解决下面的问题:【引用和借用】。

借用:

虽然一个值只能有一个变量拥有其所有权,但是,就像人可以把自己的东西借给其他人用,借给不同的人用一样,变量也可以把自己拥有的值给借出去,上述代码稍作修改:

add_str传入的不再是mut str,而是&mut str1,这就相当于从mut str1上借了这份数据来使用,但实际上的所有权仍在str1上,内存区块的回收条件,仍然是【str1所在的作用域执行完毕,str1保存的内存地址北出栈而销毁】。

这两种机制,所形成的本质是:对于一块内存的引用计数,变得异常简单,只要这个内存地址对应的变量在堆里,引用计数就是1,否则就是0,只有这两种情况。绝对不存在,多个变量都指向同一个内存地址的情况,这一下子就把引用计数GC算法的复杂度给大幅度降低了。降低到不需要一个复杂的运行时,静态检查阶段就可以得到所有需要GC的时机并进行GC了。

Rust的其他特性

Rust加上上述的一些特性,使得它成为了一个C++的完美替代。目前,前端领域使用Rust有以下两个方向,一个,是使用Rust来打造更高性能的前端工具,另一个是作为WASM的编程语言,编译成可以在浏览器当中跑的WASM模块。

高性能工具

在之前,前端领域如果希望做一个高性能的工具,那么唯一选择就是gyp,使用C++编写代码,通过gyp编译成nodejs可以调用的API,saas-loader等大家耳熟能详的库都是这样实现的。但更多的情况下,前端的大部分工具都是完全不在乎性能,直接用js写的,比如Babel、ESLint、webpack等等,有很大一部分原因在于C++实在不太好入门,光是几十个版本的C++特性,就足够让人花掉大量的时间来学习,学习完之后还要大量的开发经验才可以学会如何更好的做内存管理、避免内存泄露等问题。而Rust不一样,它足够年轻,没有几十个版本的标准、有和npm一样现代的包管理器,还有更关键的,不会内存泄露,这使得即便rust的历史不长,即便C++也能写Nodejs扩展,但前端领域仍然出现了大量的Rust写的高性能工具。比如:

swc一个Rust写的,封装出Nodejs API的,功能类似Babel的JS polyfill库,但在Rust加持之下,它的性能可以达到Babel的40倍。

Rome也是基于Rust实现,其作者也是是Babel的作者Sebastian。Rome 涵盖了编译、代码检测、格式化、打包、测试框架等工具。它旨在成为处理JavaScript源代码的综合性工具。

RSLint,一个Rust写的JS代码lint工具,旨在替代ESLint。

随着前端愈发复杂,我们必定会逐渐追求性能更好的工具链,也许过几年我们就会看到使用swc和Rome正式版的项目跑在生产环境当中了。

WASM

另外,在有了WASM之后,前端也在寻找一个最完美支持WASM的语言,目前来看,也很有可能是Rust。对于WASM来说,带运行时的语言是不可接受的,因为带有运行时的语言,打包成WASM之后,不仅包含了我们自己写的业务代码,同时还有运行时的代码,这里面包含了GC等逻辑,这大大提高了包体积,并不利于用户体验,将带运行时的语言剔除之后,前端能选择的范围便不大了,C++、Rust里面,Rust的优势使得前端界更愿意选择Rust。同时,Rust在这方面,也提供了不错的支持,Rust的官方编译器支持将Rust代码编译成WASM代码,再加上wasm-pack这种开箱即用的工具,使得前端是可以很快的构建wasm模块的。这里做一个简单的演示,下面这一串代码是我从上文提到的swc里面挖出来的:

左右滑动查看完整代码

可以看出,只要当下已存在一个Rust库,那么将其转变为WASM是非常简单的,读者也可以去折腾一下Golong、C++的WASM,会发现Rust的整个折腾过程比Golang、C++要简单不少。

有没有什么问题?

虽然我上文说了许多Rust的好,但我在学习Rust的时候却有些备受打击,很大的一个原因在于,Rust过于特立独行了。

举一个很简单的例子,在一般的编程语言当中,声明变量和常量,要么有不同的声明方式,如javascript区分let 和const,go区分const和var,要么就是声明出来默认是变量,常量需要额外声明,比如Java声明的变量前面加final就会是常量,而Rust就很特殊,声明出来的默认是常量,变量反而需要额外声明,let a=1得到的是常量,let mut a=1才是变量。

上述提到的,Rust比较特别的点非常多,虽然大部分都只是设计理念不同,没有高下优劣之分,但如此设计确实会给其他语言的开发者带来一部分心智负担。

从我的学习经验来看,Rust本身的学习难度并不低,学习起来实际上未必就比C++简单,社区内也有想学好Rust得先学习C++,不然完全领会不到Rust优雅的说法。想学习Rust的同学,可能需要做好一些心理准备。

于玉龙

腾讯云开发者社区【技思广益·腾讯技术人原创集】作者。腾讯前端开发工程师,毕业于湘潭大学,目前负责腾讯医疗健康工作室下医疗SaaS产品的前端开发工作,负责组内部分前端工具链开发和维护的工作。