专栏名称: 全栈前端精选
内容为王,精选为则。从前端到全栈,定期分享前端、客户端、Node、面试、职场感悟等相关高质量文章。小白的大神养成记,你我共勉!
目录
相关文章推荐
51好读  ›  专栏  ›  全栈前端精选

前端视角解读 Why Rust

全栈前端精选  · 公众号  ·  · 2024-07-10 19:20

正文

为什么要学 Rust

因为我们需要使用合适的工具解决合适的问题

目前 Rust 对 WebAssembly 的支持是最好的,对于前端开发来说,可以将 CPU 密集型的 JavaScript 逻辑用 Rust 重写,然后再用 WebAssembly 来运行,JavaScript 和 Rust 的结合将会让你获得驾驭一切的力量。

但是 Rust 被公认是很难学的语言,学习曲线很陡峭。(学不动了

对于前端而言,所需要经历的思维转变会比其他语言更多。从 命令式(imperative)编程语言 转换到 函数式(functional)编程语言 、从变量的 可变性(mutable) 迁移到 不可变性(immutable) 、从 弱类型语言 迁移到 强类型语言 ,以及从 手工或者自动 内存管理到通过 生命周期 来管理内存,难度逐级递增。

而当我们迈过了这些思维转变后,会发现 Rust 的确有过人之处:

  • 从内核来看,它重塑了我们对一些基本概念的理解。比如 Rust 清晰地定义了变量在一个作用域下的生命周期,让开发者在摒弃垃圾回收(GC)前提下,还能够无需关心手动内存管理,让 内存安全和高性能二者兼得
  • 从外观来看,它使用起来感觉很像 Python/TypeScript 这样的高级语言,表达能力一流,但性能丝毫不输于 C/C++,从而让 表达力和高性能二者兼得

  • 拥有 友好 的编译器和清晰明确的错误提示与完整的文档,基本可以做到只要 编译通过,即可上线

大概了解这些后,那我们开始从几个简单的 Rust demo 开始吧~

Ps:这篇文章并不能带你直接掌握或者入门 Rust,并不会涉及到过多 api 讲解,如有需求可直接跳转文末参考资料。

Rust 初体验

可使用 Rust Playground [1] 快速体验

Hello World

// main()函数在独立可执行文件中是不可或缺的,是程序的入口
fn main() {
    // 创建String类型的字符串字面量,使用 let 创建的默认是不可变的
    let target = String::from("rust");
    // println!()是一个宏,用于将参数输出到 STDOUT
    println!("Hello World: {}", target);
}

函数抽象示例

fn apply(value: i32, f: fn(i32) -> i32) -> i32 {
    f(value)
}

// 入参和返回类型为i32(有符号,大小在[-2^31, 2^31 - 1]范围内的数字类型)
fn square(value: i32) -> i32 {
    // 没有写;代表直接返回,相当于 return value * value;
    value * value
}

fn cube(value: i32) -> i32 {
    value * value * value
}

fn main() {
    // js中相当于console.log(`apply square: ${apply(2, square)}`)
    println!("apply square: {}", apply(2, square));
    println!("apply cube: {}", apply(2, cube));
}

控制流与枚举

// 4种硬币的值都属于 Coin 类型
enum Coin {
    Penny,
    Nickel,
    Dime,
    Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
    // 使用 match 进行类型匹配
    match coin {
        Coin::Penny => 1,
        Coin::Nickel => 5,
        Coin::Dime => 10,
        Coin::Quarter => 25,
    }
}

先聊聊堆和栈

我们在写 js 的时候,似乎不需要特别关注堆和栈以及内存的分配,js 会帮忙我们“自动”搞定一切。但这个“自动”正是一切混乱的根源,让我们错误的感觉我们可以不关心内存管理。

我们重新回过来看一看这些基础知识,以及 Rust 是怎么处理内存管理的。

栈空间

栈的特点是 “LIFO,即后进先出” 。数据存储时只能从顶部逐个存入,取出时也需从顶部逐个取出。比如一个乒乓球的盒子,先放进去(入栈)的乒乓球就只能后出来(出栈)。

在每次调用函数,都会在栈的顶端创建一个 栈帧 ,用来保存该函数的上下文数据。比如该函数内部声明的局部变量通常会保存在 栈帧 中。当该函数返回时,函数返回值也保留在该栈帧中。当函数调用者从栈帧中取得该函数返回值后,该栈帧被释放。

堆空间

不同于栈空间由操作系统跟踪管理,堆的特点是 无序 key-value 键值对 存储方式。

堆是在程序运行时,而不是在程序编译时,申请某个大小的内存空间。即动态分配内存,对其访问和对一般内存的访问没有区别。对于堆,我们可以随心所欲的进行增加变量和删除变量,不用遵循次序。

可以这么总结:

  1. 栈适合存放存活时间短的数据。
  2. 数据要存放于栈中,要求数据所属数据类型的大小是已知的。
  3. 使用栈的效率要高于使用堆。

对于存入栈上的值,它的大小在编译期就需要确定 。栈上存储的变量生命周期在当前调用栈的作用域内,无法跨调用栈引用。

堆可以存入大小未知或者动态伸缩的数据类型 。堆上存储的变量,其生命周期从分配后开始,一直到释放时才结束,因此堆上的变量允许在多个调用栈之间引用。

可以将栈理解为将物品放进大小合适的纸箱并将纸箱按规律放进储物间,堆理解为在储物间随便找一个空位置来放置物品。显然,以纸箱为单位来存取物品的效率要高的多,而直接将物品放进凌乱的储物间的效率要低的多,而且储物间随意堆放的东西越多,空闲位置就越零碎,存取物品的效率就越低,且空间利用率就越低。

Rust 如何使用堆和栈

问题来了,我们先看看 JavaScript 是如何使用堆和栈的

JavaScript 中的内存也分为栈内存和堆内存。一般来说:

  • 栈内存中存放的是存储对象的地址;
  • 堆内存中存放的是存储对象的具体内容

  • 对于原始类型的值而言,其地址和具体内容都存在于栈内存中

  • 基于引用类型的值,其地址存在栈内存,其具体内容存在堆内存中

Rust 中各种类型的值默认都存储在栈中,除非显式地使用 Box::new() 将它们存放在堆上。对于动态大小的类型 (如 Vec、String),则数据部分分布在堆中,并在栈中留下 胖指针 指向实际的数据,栈中的那个胖指针结构是静态大小的。

在堆与栈的使用中,各个语言看起来是差不多的,主要区别在于 GC 上。

在 JavaScript 的 GC 中,因为没有一些高级语言所拥有的垃圾回收器,js 自动寻找是否一些内存“不再需要”是很难判定的。因此,js 的垃圾回收实现只能有限制的解决一般问题。

比如现在对于引用的垃圾回收,使用的 标记-清除算法 [2] ,仍然会存在 那些无法从根对象查询到的对象都将被清除 的限制(尽管这是一个限制,但实践中我们很少会碰到类似的情况,所以开发者不太会去关心垃圾回收机制)。

而 Rust 不同于其他的高级语言,它没有提供 GC,也无需手动申请和手动释放堆内存,但 Rust 可以保证我们当前的内存是安全的,即不会出现悬空指针等问题。其中一个原因是因为 Rust 使用了自己的一套内存管理机制: Rust 中所有的大括号都是一个独立的作用域,作用域内的变量在离开作用域时会失效,而变量绑定的数据(无论是堆内还是栈中数据)则自动被释放

fn main() {
    // 每个大括号都是独立的作用域
    {
        let n = 33;
        println!("{}", n);
    }
    // 变量n在这个时候失效
    // println!("{}", n);  // 编译错误
}

那如果碰到这种情况呢:

fn main() {
    let v = vec![123];
    println!("{}", v[0]);
}

v 变量本身分配在栈中,用一个胖指针指向了堆中 v 里的三个元素。当函数退出后,v 的作用域结束了,它所引用的堆中的元素也会被自动回收,听起来不错。

但问题来了,如果想要将 v 的值绑定在另一个变量 v2 上,会出现什么情况呢?

对于有 GC 的系统来说,这不是问题, v v2 都引用同一个堆中的引用,最终由 GC 来回收就是了。

对于没有 GC 的 Rust 而言,自然有它的办法,那就是所有权特性中的 move 语义,这个我们在后面会讲到。

Rust 语言特性

所有权和生命周期 的存在使 Rust 成为内存安全、没有 GC 的高效语言。

所有权:掌控值的生死大权

计算机的内存资源非常宝贵,所有的程序运行的时候都需要某种方式来合理地利用计算机的内存资源,我们再看一下常见的几种语言是如何利用内存的:

语言

内存使用方案

Java、Go

垃圾回收机制,不停地查看一些内存是否没有在使用了,如果不再需要就将其释放,占用更多的内存和CPU资源

C、C++

程序员自己手动申请和释放内存,容易出错且难以排查

JavaScript

在创建变量(对象,字符串等)时自动进行了分配内存,并且在不使用它们时“自动”释放。这个“自动”是混乱的根源,并让 JavaScript(和其他高级语言)开发者错误的感觉他们可以不关心内存管理。

Rust

所有权机制,内存由所有权系统根据一系列的规则来管理,这些规则只会在程序编译期间检查

Rust 的流行和受欢迎是因为它可以在不使用垃圾收集的同时保证内存安全。而其它诸如 JavaScript、Go 等语言则是使用垃圾收集来做内存管理,垃圾收集器以资源和性能为代价为开发人员提供了方便,但是一旦碰到问题,就会很难排查。在 rust 世界里,当你严格遵循规则的时候,就可以抛开垃圾收集实现内存安全。

我们先从一个变量使用堆栈的行为开始,探究 Rust 设计所有权和生命周期的用意。

变量在函数调用时发生了什么

我们先来看一段代码:

fn main() {
    // 定义一个动态数组
    let data = vec![104298];
    let v = 42;
    // 使用 if let 进行模式匹配。
    // 它和直接 if 判断的区别是 if 匹配的是布尔值而 if let 匹配的是模式
    if let Some(pos) = find_pos(data, v) {
        println!("Found {} at {}", v, pos);
    }
}

// 在 data 中查找 v 是否存在,存在则返回 v 在 data 中的下标,不存在返回 None
fn find_pos(data: Vec<u32>, v: u32) -> Option<usize> {
    for (pos, item) in data.iter().enumerate() {
        // 解除 item 的引用,可以访问到 item 的具体值
        if *item == v {
            return Some(pos);
        }
    }

    None
}

Option 是 Rust 的系统类型,它是一个枚举,包含了 Some None ,用来表示值不存在的可能,这在编程中是一个好的实践,它强制 Rust 检测和处理值不存在的情况。

这段代码不难理解,要再强调一下的是, 动态数组因为大小在编译期无法确定,所以放在堆上,并且在栈上有一个包含了长度和容量的胖指针指向堆上的内存。

在调用 find_pos() 时,main() 函数中的局部变量 data 和 v 作为参数传递给了 find_pos(),所以它们会被放在 find_pos() 的参数区。

按照大多数编程语言的做法,现在堆上的内存就有了两个引用。不光如此,我们每把 data 作为参数传递一次,堆上的内存就会多一次引用。

但是,这些引用究竟会做什么操作,我们不得而知,也无从限制;而且堆上的内存究竟什么时候能释放,尤其在多个调用栈引用时,很难厘清,取决于最后一个引用什么时候结束。所以,这样一个看似简单的函数调用,给内存管理带来了极大麻烦。

所有权和 Move 语义

在 Rust 的所有权规则下,上述的问题将不再是问题,所有权规则可以总结:

  • 一个值只能被一个变量所拥有,这个变量被称为所有者。
  • 一个值同一时刻只能有一个所有者。

  • 当所有者离开作用域,其拥有的值被丢弃,内存得到释放。

在所有权的规则下,我们看一下上述的引用问题是如何解决的:

原先 main() 函数中的 data,被移动到 find_pos() 后,就失效了,编译器会保证 main() 函数随后的代码无法访问这个变量,这样,就确保了堆上的内存依旧只有唯一的引用。

但为什么 v 没有被移动反而依旧被复制了呢?一会你就明白了。

所以在所有权规则下,解决了谁真正拥有值的生死大权问题,让堆上数据的多重引用不复存在,这是它最大的优势。

但是很明显它也产生了一些问题,最大的一个就是会让代码变得很复杂,尤其是一些只存储在栈上的简单数据,如果要避免所有权转移之后不能访问的情况,我们就需要调用 clone() 来进行复制,这样效率也不会很高。

Rust 考虑到了这一点,所以在 Move 语义之外,Rust 还提供了 Copy 语义 。如果一个数据结构实现了 Copy trait [3] ,那么它就会使用 Copy 语义。这样,在你赋值或者传参时,值会自动按位拷贝(浅拷贝)。

#[derive(Debug)]
struct Foo;

let x = Foo;
let y = x; // unused variable: `y`

// `x` has moved into `y`, and so cannot be used
println!("{:?}", x); // error: use of moved value
#[derive(Debug, Copy, Clone)]
struct Foo;

let x = Foo;
// 变量命名前加_代表这个变量处于 todo 状态,编译器会忽视 unused 检查
let _y = x;

// `y` is a copy of `x`
println!("{:?}", x); // A-OK!

struct :可以视为 es6 中的 class,但建议还是将 struct 视作是纯粹的数据。

trait :类似于接口, 特性与接口相同的地方在于它们都是一种行为规范,可以用于标识哪些类有哪些方法。

derive :派生,编译器可以通过 derive trait 加上一些基本实现,如

  • Copy [4] :使类型具有 “复制语义”而非 “移动语义”。
  • Clone [5] :可以明确地创建一个值的深拷贝,在使用 Copy 的派生时一般需要把 Clone 加上,因为 Clone 是 Copy 的超集。

  • Debug [6] :使用 {:?} 可以完整地打印当前值。

回到 v 参数的那个问题,因为 v u32 类型实现了 Copy trait ,且分配在栈上,调用 find_pos 时便会自动 Copy 了一份 v'

但如果我们不想使用 copy 语义,避免内存过多的被复制,我们可以使用 “借用”数据

值的借用

我们来看新的一个例子:

fn main() {
    let data = vec![1234];
    let data1 = data;
    println!("sum of data1: {}", sum(data1));
    println!("data1: {:?}", data1); // error1
    println!("sum of data: {}", sum(data)); // error2
}

fn sum(data: Vec<u32>) -> u32 {
    // 创建一个迭代器,fold 方法用法类似 reduce
    data.iter().fold(0, |acc, x| acc + x)
}

很明显上述代码无法通过编译,data 和 data1 在执行赋值语句和执行 sum 方法的时候,所有权均被 move 过去了,在后面再调用他们自然会报错。

编译器也非常智能地提示了我们错误所在:

error[E0382]: borrow of moved value: `data1`
--> src/main.rs:5:29
|
3 | let data1 = data;
| ----- move occurs because `data1` has type `Vec`, which does not implement the `Copy` trait
4 | println!("sum of data1: {}", sum(data1));
| ----- value moved here
5 | println!("data1: {:?}", data1); // error1
| ^^^^^ value borrowed here after move

error[E0382]: use of moved value: `data`
--> src/main.rs:6:37
|
2 | let data = vec![1, 2, 3, 4];
| ---- move occurs because `data` has type `Vec`, which does not implement the `Copy` trait
3 | let data1 = data;
| ---- value moved here
...
6 | println!("sum of data: {}", sum(data)); // error2
| ^^^^ value used here after move

For more information about this error, try `rustc --explain E0382`.
error: could not compile `playground` due to 2 previous errors

但我们只需要这样改一下,可以不使用 copy 的情况下通过编译:

fn main() {
    let data = vec![1234];
    let data1 = &data;
    println!("sum of data1: {}", sum(&data1));
    println!("data1: {:?}", data1);
    println!("sum of data: {}", sum(&data));
}

fn sum(data: &Vec<u32>) -> u32 {
    data.iter().fold(0, |acc, x| acc + x)
}

使用 & 可以来实现 Borrow 语义。顾名思义,Borrow 语义允许一个值的所有权,在不发生转移的情况下,被其它上下文使用。

在 Rust 中,“借用”和“引用”是一个概念, 同时在 Rust 下,所有的引用都只是借用了“临时使用权”,它并不破坏值的单一所有权约束。

所以,默认情况下, Rust 的“借用”都是只读的

当然,我们对值的借用也得有一个限制: 借用不能超过值的生命周期

生命周期我们熟悉,写 React 或者 Vue 的时候,每个组件都有从创建到销毁的生命周期,那在 Rust 里,值的生命周期是怎么样的呢,值的借用限制什么和生命周期有关呢,我们接着往下看。

生命周期:我们创建的值可以活多久

在任何语言里,栈上的值都有自己的生命周期,它和帧的生命周期一致。

在 Rust 中,堆上的内存也引入生命周期的概念:除非显式地做 Box::leak() 等动作,一般来说,堆内存的生命周期,会默认和其栈内存的生命周期绑定在一起。

Box :使用 Box 允许你将一个值放在堆上而不是栈上,留在栈上的则是指向堆数据的指针。除了数据被储存在堆上而不是栈上之外,box 没有性能损失,它们多用于如下场景:

  • 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候(比如 递归类型
  • 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候

我们先来看一个例子:

fn






请到「今天看啥」查看全文