揭秘前端眼中的Rust!
关于Rust
ru st是一门强类型的、编译型的、内存安全的编程语言。最早版本的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算法和runtime,本质上还是手动回收,只不过编译器把手动回收的代码插入进去了,程序员不需要自己写而已。
- 只要编译可以通过,那么就一定是内存安全的。
(一)实现原理
rust的内存安全机制可以说是独创的,它有一套非常简单、便于理解的机制,叫做所有权系统,这里面会涉及到两个核心概念,所有权和借用。(二)所有权
任何值,包括指针,都要绑定到一个变量,那么,我们就称这个变量拥有这个值的所有权,比如以下代码,变量str就拥有“hello”的所有权。let str = "hello"
let str = "hello world";let str2 = str;println!("{}", str);println!("{}", str2);
fn main() {let str = String::from("hello world");let str2 = str;println!("{}", str);println!("{}", str2);}
fn main() {let mut str1 = String::from("hello world"); // 这里的mut只是标注这个变量是可变的变量,而非常量。add_str(mut str1, "!!!");println!("{}", str1);}fn add_str(str_1: String, str_2: &str) {str_1.push_str(str_2);}
借用
虽然一个值只能有一个变量拥有其所有权,但是,就像人可以把自己的东西借给其他人用,借给不同的人用一样,变量也可以把自己拥有的值给借出去,上述代码稍作修改:fn main() {let mut str1 = String::from("hello world");add_str(&mut str1, "!!!");println!("{}", str1);}fn add_str(str_1: &mut String, str_2: &str) {str_1.push_str(str_2);}
这两种机制,所形成的本质是:对于一块内存的引用计数,变得异常简单,只要这个内存地址对应的变量在堆里,引用计数就是1,否则就是0,只有这两种情况。绝对不存在,多个变量都指向同一个内存地址的情况,这一下子就把引用计数GC算法的复杂度给大幅度降低了。降低到不需要一个复杂的运行时, 静态检查阶段就可以得到所有需要GC的时机并进行GC了。
Rust的其他特性
rust作为一个非常年轻的编程语言,它拥有许多新语言常见的特性,在特性方面有点类似于Golang、ts和高版本C++的混合。比如说:- 没有继承,只有组合,类似于Golang。继承带来的子类型会带来数学上的不可判定性,即存在一种可能,可以构造出一段包含子类型的代码,无法对它进行类型推倒和类型检查,因为类型不可判定,表现在工程上,那就是编译器在类型推倒时陷入死递归,无法停止。同时,多层的继承也让代码变得难以维护,越来越多的新语言抛弃了继承。
- 有一个好用的包管理器cargo,可以方便的管理各项依赖。依赖存在项目间的隔离,而非统一放在一块,这一点类似于nodejs,golang也在推进依赖的项目间隔离。项目内安装的依赖被写在cargo.toml当中,并且存在cargo.lock,将依赖锁定在特定版本(几乎和npm一致)。
- 大量高级的语言特性:模式匹配、没有null但是有Option(任何可能报错、返回空指针的地方,都可以返回一个Option枚举,基于模式匹配来匹配成功和失败两种情况,null不再对开发者暴露)、原生的异步编程支持等等。
对前端的影响?
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。
(二)WASM
另外,在有了WASM之后,前端也在寻找一个最完美支持WASM的语言,目前来看,也很有可能是Rust。对于WASM来说,带运行时的语言是不可接受的,因为带有运行时的语言,打包成WASM之后,不仅包含了我们自己写的业务代码,同时还有运行时的代码,这里面包含了GC等逻辑,这大大提高了包体积,并不利于用户体验,将带运行时的语言剔除之后,前端能选择的范围便不大了,C++、Rust里面,Rust的优势使得前端界更愿意选择Rust。同时,Rust在这方面,也提供了不错的支持,Rust的官方编译器支持将Rust代码编译成WASM代码,再加上wasm-pack这种开箱即用的工具,使得前端是可以很快的构建wasm模块的。这里做一个简单的演示,下面这一串代码是我从上文提到的swc里面挖出来的:#![deny(warnings)]#![allow(clippy::unused_unit)]// 引用其他的包或者标准库、外部库use std::sync::Arc;use anyhow::{Context, Error};use once_cell::sync::Lazy;use swc::{config::{ErrorFormat, JsMinifyOptions, Options, ParseOptions, SourceMapsConfig},try_with_handler, Compiler,};use swc_common::{comments::Comments, FileName, FilePathMapping, SourceMap};use swc_ecmascript::ast::{EsVersion, Program};// 引入wasm相关的库use wasm_bindgen::prelude::*;// 使用wasm_bindgen宏,这里的意思是,下面这个方法编译成wasm之后,方法名是transformSync,// TS的类型是transformSync#[wasm_bindgen(js_name = "transformSync",typescript_type = "transformSync",skip_typescript)]#[allow(unused_variables)]// 定义一个可以方法,总共方法由于是pub的,因此可以被外部调用。这个方法的目的是:将高版本JS转义成低版本JS// 具体的内部逻辑我们完全不去管。pub fn transform_sync(s: &str,opts: JsValue,experimental_plugin_bytes_resolver: JsValue,) -> Result<JsValue, JsValue> {console_error_panic_hook::set_once();let c = compiler();#[cfg(feature = "plugin")]{if experimental_plugin_bytes_resolver.is_object() {use js_sys::{Array, Object, Uint8Array};use wasm_bindgen::JsCast;// TODO: This is probably very inefficient, including each transform// deserializes plugin bytes.let plugin_bytes_resolver_object: Object = experimental_plugin_bytes_resolver.try_into().expect("Resolver should be a js object");swc_plugin_runner::cache::init_plugin_module_cache_once();let entries = Object::entries(&plugin_bytes_resolver_object);for entry in entries.iter() {let entry: Array = entry.try_into().expect("Resolver object missing either key or value");let name: String = entry.get(0).as_string().expect("Resolver key should be a string");let buffer = entry.get(1);//https://github.com/rustwasm/wasm-bindgen/issues/2017#issue-573013044//We may use https://github.com/cloudflare/serde-wasm-bindgen instead laterlet data = if JsCast::is_instance_of::<Uint8Array>(&buffer) {JsValue::from(Array::from(&buffer))} else {buffer};let bytes: Vec<u8> = data.into_serde().expect("Could not read byte from plugin resolver");// In here we 'inject' externally loaded bytes into the cache, so// remaining plugin_runner execution path works as much as// similar between embedded runtime.swc_plugin_runner::cache::PLUGIN_MODULE_CACHE.store_once(&name, bytes);}}}let opts: Options = opts.into_serde().context("failed to parse options").map_err(|e| convert_err(e, ErrorFormat::Normal))?;let error_format = opts.experimental.error_format.unwrap_or_default();try_with_handler(c.cm.clone(),swc::HandlerOpts {..Default::default()},|handler| {c.run(|| {let fm = c.cm.new_source_file(if opts.filename.is_empty() {FileName::Anon} else {FileName::Real(opts.filename.clone().into())},s.into(),);let out = c.process_js_file(fm, handler, &opts).context("failed to process input file")?;JsValue::from_serde(&out).context("failed to serialize json")})},).map_err(|e| convert_err(e, error_format))}
#[wasm_bindgen(js_name = "transformSync",typescript_type = "transformSync",skip_typescript)]
wasm-pack build --scope swc -t nodejs --features plugin
.├── binding_core_wasm.d.ts├── binding_core_wasm.js├── binding_core_wasm_bg.js├── binding_core_wasm_bg.wasm├── binding_core_wasm_bg.wasm.d.ts└── package.json
// index.jslet settings = {jsc: {target: "es2016",parser: {syntax: "ecmascript",jsx: true,dynamicImport: false,numericSeparator: false,privateMethod: false,functionBind: false,exportDefaultFrom: false,exportNamespaceFrom: false,decorators: false,decoratorsBeforeExport: false,topLevelAwait: false,importMeta: false,},},};let code = `let a = 1;let b = {c: {d: 1}};console.log(b?.c?.d);let MyComponent = () => {return (<div a={10}><p>Hello World!</p></div>);}`;const wasm = require('./pkg/binding_core_wasm');console.log(wasm.transformSync(code, settings))
有没有啥问题?
虽然我上文说了许多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的同学,可能需要做好一些心理准备。 作者简介
—— 活动推荐——
本文由高可用架构转载。技术原创及架构实践文章,欢迎通过公众号菜单「联系我们」进行投稿。