揭祕前端眼中的 Rust!

本文推選自騰訊雲開發者社區 -【技思廣益 · 騰訊技術人原創集】專欄。該專欄是騰訊雲開發者社區爲騰訊技術人與廣泛開發者打造的分享交流窗口。欄目邀約騰訊技術人分享原創的技術積澱,與廣泛開發者互啓迪共成長。本文作者是騰訊前端開發工程師於玉龍。

本文主要對 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 本質上是通過限制引用的使用,將那些【不好判斷某塊地址是否還在使用】的情況給規避了,剩餘的情況,都是很好判斷的情況,簡單到不需要專業的程序員,只需要一個編譯器,就能很好的判斷了。

這樣的一大好處是:

(一)實現原理

rust 的內存安全機制可以說是獨創的,它有一套非常簡單、便於理解的機制,叫做所有權系統,這裏面會涉及到兩個核心概念,所有權和借用。

(二)所有權

任何值,包括指針,都要綁定到一個變量,那麼,我們就稱這個變量擁有這個值的所有權,比如以下代碼,變量 str 就擁有 “hello” 的所有權。

let str = "hello"

當 str 所在的作用域結束時,str 的值就會被清理,str 也不再有效。這個和幾乎所有主流語言都是一致的,沒有什麼問題。也很好理解。

但是注意一下,Rust 本身區分了可變長度的字符串和不可變長度的字符串,上文是一個不可變長度的字符串,因爲其長度不可變,可以保存在棧當中,於是下面這一段代碼可以正確執行,就像其他幾乎所有主流語言一樣:

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

此時,我們會驚訝地發現,代碼報錯了。爲什麼呢?

原因在於,第一段代碼當中,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,比如類似於下面這段代碼:

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

我們希望對 str 進行操作,後面添加三個感嘆號然後打印出來,這段代碼肯定是錯誤的,因爲當 str 傳入 add_str 方法時,就將所有權轉移到了 add_str 方法內的變量 str_1 上,它不再具備所有權,所以就不能使用了,這種情況其實很常見,單純的所有權機制讓這個問題複雜化了,所以 rust 還有一個機制來解決下面的問題:【引用和借用】。

借用

雖然一個值只能有一個變量擁有其所有權,但是,就像人可以把自己的東西借給其他人用,借給不同的人用一樣,變量也可以把自己擁有的值給借出去,上述代碼稍作修改:

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

add_str 傳入的不再是 mut str,而是 & mut str1,這就相當於從 mut str1 上借了這份數據來使用,但實際上的所有權仍在 str1 上,內存區塊的回收條件,仍然是【str1 所在的作用域執行完畢,str1 保存的內存地址北出棧而銷燬】。

這兩種機制,所形成的本質是:對於一塊內存的引用計數,變得異常簡單,只要這個內存地址對應的變量在堆裏,引用計數就是 1,否則就是 0,只有這兩種情況。絕對不存在,多個變量都指向同一個內存地址的情況,這一下子就把引用計數 GC 算法的複雜度給大幅度降低了。降低到不需要一個複雜的運行時,靜態檢查階段就可以得到所有需要 GC 的時機並進行 GC 了。

Rust 的其他特性

rust 作爲一個非常年輕的編程語言,它擁有許多新語言常見的特性,在特性方面有點類似於 Golang、ts 和高版本 C++ 的混合。比如說:

對前端的影響?

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 和 Rome 正式版的項目跑在生產環境當中了。

(二)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 later
                let 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))
}

這一段 Rust 代碼的特殊之處在於一些方法上加了這樣的派生,所謂的派生,指的是我們只要加上這一段代碼,編譯器就會幫我們實現約定好的邏輯:

#[wasm_bindgen(
    js_name = "transformSync",
    typescript_type = "transformSync",
    skip_typescript
)]

當加上這一段派生之後,編譯器就會將下面的函數編譯爲二進制的 WASM 函數供 JS 調用。

我們使用 wasm-pack 對代碼進行編譯打包:

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

然後就可以在 JS 當中調用了:

// index.js
let 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 庫,那麼將其轉變爲 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 產品的前端開發工作,負責組內部分前端工具鏈開發和維護的工作。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/HKkt9cNZsUFdCR4nAlHeBA