likes
comments
collection
share

写给想学 Rust 的前端同学

作者站长头像
站长
· 阅读数 4

前言

Rust 可能正在逐渐渗透前端的方方面面,所以作为一个前端究竟有没有必要学习 Rust 呢?我认为,还是看个人的精力吧,有那个精力多学一点没有坏处,没那个精力不学也没有影响。本篇不是讨论该不该学 Rust,而是将 Rust 大概是一个什么样的语言展现给可能在观望的小伙伴,并以一个前端的视角来看看 Rust 究竟和前端有什么不一样。

本篇也是我在认真阅读了Rust 程序设计语言(中文版)几遍以后,才敢下笔做一些总结,因能力有限,错误之处还望大家及时指出。

我会从一个语言层面的几个方面来分析 Rust 究竟和 JavaScript 以及 TypeScript 这样的语言的不同之处,以及相似之处,并且希望能给想要学习 Rust 的同学一些语言的梗概,也给前端学习 JS 的同学一些新的理解。

Rust VS JavaScript

属性RustJavaScript
编译器rustcv8
包管理工具cargonpm、yarn、pnpm、cnpm
第三方依赖注册表crates.ionpmjs.com
垃圾回收

数据类型

Rust 的数据类型同样分为基础类型和复杂类型,主要包括以下几类:

  • 基础类型:包括整型、浮点型、布尔型、字符型
  • 复杂类型:元组、数组以及其他复合类型

这点和传统的强类型语言基本是一致的,但是 Rust 也拥有不同的地方。

Rust 声明变量的方法竟然和 JS 出奇的一致,并且很多方面也是类似 JS 或 TS 的写法:

// 声明不可变变量
let a = 1;

// 声明可变变量
// 如果没有 mut 关键字,修改变量会导致报错
let mut b = 2;
b = 3;

// 重复声明变量,会发生遮蔽,即覆盖原有变量
// 此时之前声明的 a 变量无效了
let a = 2;

// 声明元组,近似理解为 TS 中的元组
let c = ('a', 2);
// 元组可以被解构,也是类似 JS 的解构
// 此时变量a就是'a',变量b则是2
let (a, b) = c;

// 声明数组
let arr = [1,2,3,4];
// 还可以有很多方式
// 表示arr1是一个包含两个元素,每个元素的值都是3
let arr1 = [3;2];

流程控制

流程控制则和大多数语言一样,包括 if-else、while 循环、for 循环,不同的是,还多了一个 loop 循环:

// if 后面没有括号,并且后面的值类型只能是 bool 类型
// 并没有 JS 中类型转换的能力,这点其实和其他语言是类似的
if 1 > 2 {
    
} else {

}

// while 循环也是一样,后面没有括号
while 1 > 2 {}

// for 循环有点类似 JS 中的 for-in 循环
let arr = [1,2,3,4];
for i in arr {
    // 这里遍历的值都是值本身,并没有索引
    // 打印1,2,3,4
}

// loop 循环则是 while 不带条件的循环:
loop {
    // 代码块中的代码会不停的循环
    // 退出循环可以使用 break 或者 continue
}

结构体和枚举

Rust 的结构体类似于 C 语言的结构体,这也是 JS 所没有类型。而枚举类型则在 TS 中是有的,但是 Rust 的枚举功能远远多于 TS 中的枚举。

// 普通结构体
struct Person {
    name: String,
    age: u8,
}

// 元组结构体
struct Color(i32, i32, i32);

// 单元结构体
struct Unit;

// 声明结构体
let p = Person {
    name: "qiugu",
    age: 22
};

// 结构体也可以解构
// name 为 "qiugu",age 为 22
let Person { name, age } = p;

枚举是 Rust 中非常重要的数据类型。Rust 中并没有空指针的概念,于是 Rust 通过枚举类型来模拟空的概念:

// 这是 rust 标准库内置的枚举类型 Option
enum Option<T> {
    Some(T),
    None
}

Rust 中很多方法返回的都是 Option 类型,通过处理 Option 类型来拿到具体的值,如果是 None,则表示空的概念。关于如何匹配枚举类型的值,这点后面会说到。

集合

Rust 中常用的集合类型包括以下几种:

  • string
  • vector
  • hashmap

这些类型在 Rust 中都是复杂类型,其中在 JS 常用的基本类型 string,在这里其实非常复杂,并且其他语言中的 string 类型都比较复杂,只是 JS 做了很多工作,简化了 string 的使用。

// 声明可变 String 类型
let mut s = String::from("i am a coder");
// 修改 String
s.push_str("abc");
// i am a code abc

// 注意:这并不是 String 类型,而是字符串切片类型slice
let s1 = "i am str";
// 这样才是 String 类型
let s2 = String::from(s1);

// String是复杂类型
let s1 = String::from("i am a coder");
let s2 = s1;
// 打印s1会报错,因为s1的所有权已经被转移
// 这也证明了String是一个复杂类型,因为基础类型会复制一个值,而复杂类型只是复制了引用
println!("{}", s1); // 报错

类型系统

可以发现上面所有的示例代码并没有类型注解,原因是因为 Rust 可以自动推导类型(这是不是和 TS 有点像)。

可以在 VSCode 中安装 Rust 的插件,就可以看到变量对应的类型:

写给想学 Rust 的前端同学

Rust 的类型系统除了可以自动推导变量类型,也存在泛型的类型复用能力,可以近似理解为 TS 中的泛型概念。

我们知道 TS 中存在 interface 类型复用类型,以及定义类型的结构包含哪些属性方法。Rust 中同样也存在类似的概念 trait:

// 定义 trait
trait Greet {
    fn hello(&self) -> String;
}

是不是和 interface 非常相似!

如何实现这个 trait 呢?逻辑也是类似的,Rust 中也需要对象才能实现 trait,Rust 中的对象其实就是结构体类型:

// 定义一个单元结构体(什么属性都不包括的结构体)
struct Person;

// 实现 Greet trait
impl Greet for Person {
    // 先不用看方法如何声明,后面会提到
    fn hello(&self) -> String {
        // 注意:语句后面没有分号,表示它是一个表达式,而不是语句
        String::from("hello, man!")
    }
}

// 使用
fn main() {
    let p = Person;
    // 执行 trait 上的方法
    p.hello(); // hello, main!
}

现在是不是对 Rust 更熟悉一点了!

接下来就是 Rust 独有的生命周期概念,它也是泛型的一部分。生命周期又涉及到了引用的概念。引用在 JS 中同样存在,只是和 Rust 引用并不一样:

let x = 5;
// 可以引用任意类型的变量
let r = &5;

println!("x: {}, r: {}", x, r); // x: 5, r: 5

稍微改写一下上面的代码:

let r;
{
    let x = 5;
    r = &x;
    // rust 作用域也存在块级作用域
    // 并且当变量退出该作用域时,引用该变量的其他值,这里就是r也会失效
    // 这样会导致变量r变成一个空引用
}
println!("r: {}", r);

我们可以在编译时就能发现上面代码的问题:

写给想学 Rust 的前端同学

翻译一下,就是变量x的生命周期不如变量r的生命周期长,因为当x退出块级作用域时,变量r还依然存在,而生命周期就是为了确保引用总是有效。上面的例子可以通过作用域直接看出来变量生命周期的长短,但是以下情况无法直接看出来变量的生命周期:

// 返回x、y中的大值
// 注意x、y都是引用类型,并且返回的也是引用类型
// 编译器无法确定返回类型的引用的生命周期是和x一样长,还是和y一样长,或者和x、y都一样长
// 所以编译无法通过
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

这个时候就需要使用生命周期注解来告诉编译器这些引用之间的生命周期关系是怎样的:

// 生命周期注解就是在引用符合后面加上'a,表示该引用生命周期为'a
// 变量x、y,以及返回类型的生命周期都是一样的,说明它们的引用的生命周期也都是一样长
// 这样编译器就可以确定引用都是有效的,编译可以通过
fn longest(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

函数、方法及闭包

在 JS 中函数和方法可以看作是一个意思,但是 Rust 中的函数和方法却是不一样的。

Rust 中的函数就是我们在上一节看到的 longest 函数,指定了参数、以及参数类型,并且指定了返回值类型,还是以上面的函数举例:

// 函数参数必须指定其类型,这和声明变量时自动推导类型表现不一样
// 原因可能因为对于一个函数来说,需要暴露给调用者使用,因此需要明确参数和输出参数的类型
fn longest(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

可以看到上面的函数并没有指定像 return 这样的关键字来返回值,而是将返回值包裹在大括号中了。这是因为 Rust 中块级作用域的最后一个表达式就作为其返回值。注意表达式是不带分号结尾的,带上了分号就变成了语句,而不是表达式,这点在前面已经提到过了。

// x 的值就是 3
let x = {
    let a = 1 + 2;
    a
};

// 函数的返回值就是最后一个表达式的结果,也就是 a+b 的结果
fn foo(a: i32, b: i32) -> i32 {
    let c = a * b;
    let d = c + 1;
    a + b
}

而方法和函数不一样的地方在于,方法是依附于对象存在的,调用函数时,直接函数名称后面接括号就可以调用了,但是方法则需要使用对象来调用,比如,前面提到的 trait 实现,其就是一个方法:

impl Greet for Person {
    // 注意:方法的第一个参数都是 self,表示对象自身,这里并没有用到 self
    fn hello(&self) -> String {
        String::from("hello, man!") 
    } 
}

// 调用方法
p.hello();

// 调用函数
foo();

最后 Rust 中也存在闭包的概念。闭包也是一种函数,只是闭包写法和普通函数不一样,并且可以捕获上下文中的变量:

let a = 1;
// 使用“||”表示参数列表,同普通函数的小括号
// 如果有参数就写在双竖线中间
// 闭包的参数和返回值类型可以不写,编译器会自动推断,但是一旦确定类型,就不能再传其他类型
let b = ||{
    a + 1
};
b();

闭包一般是作为函数或方法的参数,因为它可以捕获上下文中的变量,这点和 JS 是有异曲同工之妙的。

模块化

作为一门强类型语言,模块化是其与生俱来的功能,这点不像 JS,过了很多年才有模块化。

Rust 的模块化,了解几个关键词就能大概掌握了。

// a.rs
// 使用 pub 导出结构体
pub struct Person;

// 导出函数
pub fn foo() -> String {
    String::from('i am a coder')
}

// 没有使用pub导出的数据不能被外部使用
enum Color(u8, u8, u8);

// main.rs
// 声明a模块
// a就是a.rs的文件名称
mod a;
// 使用use指定使用a模块中的哪些内容
// 注意:只能使用a模块中使用pub关键字导出的
use a::Person;
// 也可以写完整的导入路径
use crate::a::Person;
// 导入多个
use a::{Person, foo}
// 或者*匹配所有导出的成员
use a::*;

let p = Person;

内存模型

Rust 的内存模型外观上和 JS 是相似的,比如 Rust 的基本类型存储在栈上,复杂类型则存储在堆上,但是本质上还是区别比较大的。

// 基本类型
let a = 1;
let b = a;

// 复杂类型
let s = String::from("hello");
let s1 = s;

以上代码我们使用一张图来展示其执行过程:

写给想学 Rust 的前端同学

重点就是堆内存的分配,当 s 复制给 s1 的时候,并不会像 JS 那样存在两个“指针”同时指向存储 hello 字符串的内存,而是 s 的“指针”失效了,也就是同一时刻,只能有一个指向该内存的“指针”,这个“指针”并不是真正意义上的指针,在 Rust 中称它为所有者,所有者的规则则称为所有权,于是有这样关于所有权的结论:

  • Rust 中的每个值都有一个所有者(也就是上面提到的“指针”)。
  • 值在任何时刻有且只有一个所有者(赋值以后,s就失效了,只能有一个)。
  • 当所有者(变量)离开作用域,这个值将被丢弃(和 JS 类型,变量离开作用域则失效,但是有所不同)。

关于第三点,在上面生命周期的例子中解释过 Rust 作用域相关规则,当变量离开作用域时,变量的值将会被销毁,此时如果存在引用该值的变量,则会报错:生命周期长度问题。因为 Rust 不允许引用一个被销毁的值,这点和 JS 是不一样的(JS 中存在变量引用了某个值,会导致该值不会被释放,直到引用该值的变量全部退出作用域才会被销毁)。

上面的引用以及所有权还可以这么解释:

let x = 5;
// 表示 y 借用了 x 的值
let y = &x;
// 注意:被借用的值不能再次被赋值
x += 1; // 这么做会报错

let s = String::from("hello");
// 表示 s 的所有权移动到了 s1 上,s 就失去了所有权
let s1 = s;

借用就是创建一个引用,比如例子的变量 y。移动则表示一个变量的所有权移动到另外一个变量上,那么失去所有权的变量就不能被使用了。按照这么一套规则,就能在不需要垃圾回收器的情况下,安全的使用内存了,这也是 Rust 的特色之一。

引用同样也有一套规则:

  • 任意给定时间,要么只能有一个可变引用(防止多个可变引用,导致同一时间数据被改变,产生了数据竞争),要么只能有多个不可变引用(不能同时存在可变引用和不可变引用,原因也是数据竞争)。
  • 引用必须总是有效的(这就是上面引用的值失效时,会报错的原因)。

所有权规则和借用规则都是可以打破的,这就涉及到更复杂的内容,它们不是我今天所要说的内容,所以就暂时忽略了。

其他

除了这些语言通用的内容,Rust 还包括像并发智能指针等功能,这些对前端来说可能涉及到知识盲区了,所以也就不在这里继续说了。

总结

以上就是 Rust 语言的入门级内容了,相比于 JS 来说,Rust 确实更加复杂和繁琐,当然复杂繁琐的同时也带来了更强大的运行机制,比如所有权规则。除此之外,Rust 的内存模型也给我们展示了一个不同于 JS 的垃圾回收的一种内存管理机制。所以这也是无论什么语言,最终都会殊途同归,变化的是语言的写法规范,不变的是内存永远是有限的。