用 JS 的视角打开 Rust 编程学习 Rust 稍微有点难。但是 JavaScript/TypeScript 选手来
1 前言
Rust 语言,近年来颇有口碑的语言,其特点是:
- 高性能 - Rust 速度惊人且内存利用率极高。由于没有运行时和垃圾回收,它能够胜任对性能要求特别高的服务,可以在嵌入式设备上运行,还能轻松和其他语言集成。
- 可靠性 - Rust 丰富的类型系统和所有权模型保证了内存安全和线程安全,让您在编译期就能够消除各种各样的错误。
- 生产力 - Rust 拥有出色的文档、友好的编译器和清晰的错误提示信息, 还集成了一流的工具 —— 包管理器和构建工具, 智能地自动补全和类型检验的多编辑器支持, 以及自动格式化代码等等。
当然,让我们今天能谈到他的主要原因还是,由于参与 Rust 的核心开发人员中有着 Js 之父的存在,其语法、风格对我们前端开发人员来说较为友好,且随着前端基建在不断 rust 化以及 rust 在编译成 wasm 后在浏览器端的广泛应用,现下阶段,前端开发人员可以尝试掌握一部分Rust知识。
所以,接下来,我们将基于 Js 知识进行 Rust 对比学习。
2 类型系统
众所周知,JavaScript 是一种弱类型的解释型语言。而 Rust 则不同,Rust 是类型化的编译型语言,相比 JavaScript,它其实更接近于 TypeScript。
基本数据类型上,JavaScript 中会包含 symbol、bigInt、number、string、null、undefined、boolean,而Rust就多了,概括来说,rust内置的基本类型中,我们主要需要关注其中的”number”类型:根据不同的位数、符号位、计算机位数、浮点数,分为 i8、u8、i16、u16、i32、u32、i64、u64、i128、u128、isize、usize、f64、f32 这几种类型,除了这些基本类型之外,rust还有用来代表单一字符的 char 类型、和 boolean 类似的 bool。
当然,除了基本数据类型,Rust 还包含了元组、数组这两种原始复合类型,前者和 TypeScript 中的元组概念类似,后者则和 Js 中的 Array 不同,Rust 中的数组数据是不可变的。
这些基本的知识了解了,我们再来看看结构数据要如何定义,在 TypeScript 中,会通过 type 和 interface 定义对象类(结构类)数据:
type Person {
firstName: string;
lastName: string;
};
const person: Person={
firstName: "firstname",
lastName: "lastname",
}
而对 Rust 来说,则通过类似 C语言中的struct
来定义类型。在初始化变量时,规则也和 Js 略有不同。
struct Person {
firstName: String,
lastName: String,
}
let mut person = Person {
firstName: String::from("John"),
lastName: String::from("Doe"),
};
——好,那现在我们已经基本了解了 Rust 的类型系统,我们来造航母吧看看真正有意思的,首先是泛型系统。
2.1 泛型
先来看一份代码:
pub fn main() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = &number_list[0];
for number in &number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {}", largest);
}
这份代码应该还好理解,我们现在做的是在一个数组中找到最大项。其中有一些和js不太一样的点可以介绍一下(如果前边没有介绍的话):
let mut定义一个可变的变量
&number_list通过引用的方式拿到number_list而不是复制值,实际上如果将 & 修改成非 &也是有效的。不同点在于,非引用的性能开销较大,并且会产生所有权转移。
pub fn noborrowmain() {
let number_list = vec![34, 50, 25, 100, 65];
let mut largest = number_list[0];
for number in number_list {
if number > largest {
largest = number;
}
}
println!("The largest number is {}", largest);
}
这个过程其实很好理解,那么现在我们希望这个方法是通用的,应该怎么做?在Ts中,我们一般会定义一个公共函数,在 Rust 中也是类似的:
💡 左侧为 Rust 实现,右侧为 TypeScript 实现——可以进行拖拽变动大小讲解。
fn largest(list: &[i32]) -> &i32 {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
同理的,Rust 版本也会面临类型问题,我们定义的 largest 函数只适用于 i32 类型——我们前边说过,i32 只是整数类型中微不足道的一个——显然,相比基本类型较少的 Ts,Rust 在这块更需要泛型。
Rust 中也的确存在泛型的概念,我们可以用类似的方式实现。
fn largest_data<T>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
function largest(list: number[]): number
{
let largest = list[0];
for (let item of list) {
if (item > largest) {
largest = item;
}
}
return largest;
}
从 TypeScript 代码中我们不难发现,我们定义的 largest 函数只适用于 number 类型,如果我们尝试进行 string 类型的比较,类型系统就会报错:
而要解决这个问题,在 TypeScript 中我们会用泛型处理。
function largestData<T>(list: T[]): T {
let largest = list[0];
for (let item of list) {
if (item > largest) {
largest = item;
}
}
return largest;
}
console.log(largestData([1, 2, 3, 4, 5]));
console.log(largestData(["a", "b", "c"]));
结构体泛型:上边我们展示的是在函数中使用泛型,和 Ts 非常类似,而在定义结构体时定义泛型同样和 Ts 非常类似,可以简单看看。
- TypeScript
type Point<T> = {
x: T;
y: T;
}
- Rust
struct Point<T> {
x: T,
y: T,
}
在结合Rust的impl特性时,写法是下边这样:
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}
不过,如果就这么尝试编译运行,代码是会报错的:
这是因为,在 Rust 中由于对于大小比较的使用也有严格的类型定义(如二进制类型不支持比较),所以我们这里需要限制 T 为支持大小比较的PartialOrd
类型。
fn largest_data<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest {
largest = item;
}
}
largest
}
当然,在 Rust 中,像PartialOrd
这样的存在,有一个更专业的名称,叫做 traits(特质)。
2.2 traits(特质)
traits,特质,其作用是定义某些类型支持的行为的共同功能。我们可以使用特征以抽象的方式定义共享行为——这个概念其实和其他语言中的 interface 非常类似,虽然略有不同。
而上边我们遇见的PartialOrd
正是用于规范可以进行大小比较的一种特质,只有类型实现了partial_cmp方法,类型之间才可以进行大小比较。
pub trait PartialOrd<Rhs = Self>: PartialEq<Rhs>
where
Rhs: ?Sized,
{
// Required method
fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>;
// Provided methods
fn lt(&self, other: &Rhs) -> bool { ... }
fn le(&self, other: &Rhs) -> bool { ... }
fn gt(&self, other: &Rhs) -> bool { ... }
fn ge(&self, other: &Rhs) -> bool { ... }
}
至于实现方式,我们可以看到下边的需求。
2.2.1 在类型上实现特质
还是上边的largest_data
函数,但是现在我的输入数组希望类型更丰富一些:比如我们写一个File
类型,要求对file.size
做比较。
那么第一步很好办,用 struct 定义 File 结构体。在我们下边的函数中,展示了一个用于比较两个 File 文件的函数。
struct File {
name: String,
buffer: Vec<u8>,
size: u32,
}
pub fn file_size_ord() {
let f1 = File {
name: String::from("f1.txt"),
buffer: vec![114],
size: 10,
};
let f2 = File {
name: String::from("f2.txt"),
buffer: vec![114, 117],
size: 20,
};
let files = vec![f1, f2];
let largest = largest_data(&files);
println!("The largest file is {}", largest.name);
}
不过,当我们尝试运行时会报错,这是因为我们还没有实现特质PartialOrd
。
pub trait PartialOrd<Rhs = Self>: PartialEq<Rhs>
where
Rhs: ?Sized,
{
// Required method
fn partial_cmp(&self, other: &Rhs) -> Option<Ordering>;
// Provided methods
fn lt(&self, other: &Rhs) -> bool { ... }
fn le(&self, other: &Rhs) -> bool { ... }
fn gt(&self, other: &Rhs) -> bool { ... }
fn ge(&self, other: &Rhs) -> bool { ... }
}
在 Rust 中,想要实现一个特质,通过语法impl [trait] for [type]
来完成,所以我们要实现PartialOrd
,要做的就是让 Rust 在进行File
类型的大小比较时,使用结构体中的size
比较——相似的,我们还需要继承PartialEq
实现「等于」的比较。
impl PartialOrd for File {
fn partial_cmp(&self, other: &File) -> Option<std::cmp::Ordering> {
self.size.partial_cmp(&other.size)
}
}
impl PartialEq for File {
fn eq(&self, other: &File) -> bool {
self.size == other.size
}
}
这样之后,我们再次尝试运行代码,就可以正常执行了。
2.2.2 定义特质
正如我们在前边所说,“特质”定义了某些类型支持的行为的共同功能,我们也可以在我们的代码中定义自己的特质。
需要注意的是,“特质”的定义有两个特点:
- 特质中只能包含方法,不能包含其他类型(即我们不能约束结构体)
- 可以直接在特质中实现默认方法
比方说,以 JavaScript 中常用的两个日期初始化库举例:「moment」与「dayjs」,虽然它们都有自己的数据存储结构,但是它们同样都实现了 format 方法,确保使用者在使用它们时可以做初始化操作。
import momentjs from "moment";
import dayjs from "dayjs";
console.log(momentjs().format("YYYY-MM-DD"));
console.log(dayjs().format("YYYY-MM-DD"));
我们可以在Rust中实现自己的FormattableDate
类,要求之后实现的Date类都要实现format方法。
pub trait FormattableDate {
fn format(&self, format_string: &str) -> String;
}
那么如果我们在 Rust 中同时实现 Moment 和 Dayjs,第一步定义的结构体数据虽然可能不同:
pub struct Moment {
year: i32,
month: u32,
day: u32,
hour: u32,
minute: u32,
second: u32,
millisecond: u32,
}
pub struct Dayjs {
y: i32,
m: u32,
d: u32,
h: u32,
min: u32,
s: u32,
ms: u32,
}
但是就 format 而言,两个结构体都可以通过实现自己的format
方法来完成定义:
impl FormattableDate for Moment {
fn format(&self, format_string: &str) -> String {
// format_string: YYYY-MM-DD hh:mm:ss.fff
let re = Regex::new(r"\b(?:YYYY|MM|DD|hh|mm|ss|fff)\b").unwrap();
let result = re.replace_all(format_string, |caps: ®ex::Captures| match &caps[0] {
"YYYY" => self.year.to_string(),
"MM" => format!("{:02}", self.month),
"DD" => format!("{:02}", self.day),
"hh" => format!("{:02}", self.hour),
"mm" => format!("{:02}", self.minute),
"ss" => format!("{:02}", self.second),
"fff" => format!("{:03}", self.millisecond),
_ => caps[0].to_string(),
});
result.into()
}
}
impl FormattableDate for Dayjs {
fn format(&self, format_string: &str) -> String {
// format_string: YYYY-MM-DD hh:mm:ss.fff
let re = Regex::new(r"\b(?:YYYY|MM|DD|hh|mm|ss|fff)\b").unwrap();
let result = re.replace_all(format_string, |caps: ®ex::Captures| match &caps[0] {
"YYYY" => self.y.to_string(),
"MM" => format!("{:02}", self.m),
"DD" => format!("{:02}", self.d),
"hh" => format!("{:02}", self.h),
"mm" => format!("{:02}", self.min),
"ss" => format!("{:02}", self.s),
"fff" => format!("{:03}", self.ms),
_ => caps[0].to_string(),
});
result.into()
}
}
2.3 约束
在 TypeScript 中,我们会用 extends 关键字来约束类型,在 Rust 中同样有效果——实际上我们在2.2.1中经历的必须添加PartialOrd
就是一种约束,这种语法非常简单:
pub fn dosomething<T: FormattableDate>(item: &T) {
...
}
当然,这其实是一种缩写,在非泛型的情况下,我们可以用impl关键字来约束类型:
pub fn dosomething(item: &impl FormattableDate) {
...
}
我们还可以使用多个特质,比如上边的代码我们同时添加FormattableDate
和PartialOrd
约束,通过➕来连接。
pub fn dosomething(item: &(impl FormattableDate + PartialOrd)) {
...
}
当然,我们也会发现,当我们使用了太多的特质约束,会使得函数签名难以阅读,所以 Rust 额外提供了 where 语法用作约束。
- bad
fn some_function<T: Display + Clone, U: Clone + Debug>(t: &T, u: &U) -> i32 {
...
}
- good
fn some_function<T, U>(t: &T, u: &U) -> i32
where
T: Display + Clone,
U: Clone + Debug,
{
...
}
关于约束的部分,不是本章的重点,有机会再讲,大家可以大致了解一下。
3 生命周期
根据 Rust 的官方文档形容:生命周期是我们已经使用过的另一种泛型。生命周期不是确保类型具有我们想要的行为,而是确保引用在我们需要的时间内有效。
实际上,每一个引用都有自己的生命周期,大部分情况下,生命周期是隐式和自动推断的,Rust要求我们使用通用生命周期函数来注释关系。
看到下面这份非常经典的官方示例,这份代码中,我们在函数中间使用了{}
语法定义了一片额外的作用域,当函数运行出该作用域后,作用域内的所有变量声明周期都会结束,变量所占用的内存空间会被释放(这个过程Rust官方称之为悬垂引用)。
pub fn main() {
let r: &i32;
{
let x: i32 = 5;
r = &x;
}
println!("r: {}", r);
}
这就引出Rust生命周期的概念了。
3.1 引用检查器
根据我们在 JavaScript 中掌握的知识,内部作用域可以访问外部作用域的变量,外部作用域无法访问内部作用域的变量,这个规则在 Rust 中是相同的。不同点在于,在 Js 中,由于变量赋值是复制的方式并且 Js 有着自己的垃圾回收机制,在内部作用域的变量被引用时,不会自动清除相应的内存。
function main() {
let r;
{
let x = 5;
r = x;
}
console.log(r);
}
main(); // 5
同样的,在 Rust 版本中如果我们不使用引用的方式来赋值,其同样可以正常运行:
pub fn main() {
let r: i32;
{
let x: i32 = 5;
r = x;
}
println!("r: {}", r);
}
也就是说,我们现在可以得到的第一个结论是:Rust 的生命周期是对一个变量的内存空间而言,而不是对变量本身——听起来拗口的话我们可以理解成,我们学习生命周期是为了确保引用有效。
还是回到生命周期本身,对上边的那串代码来说,其引用范围可以参考下边的注释,那么我们可以将两片作用域分别命名为’a
和‘b
,其中’b
为’a
的内部作用域。
fn main() {
let r; // ---------+-- 'a
// |
{ // |
let x = 5; // -+-- 'b |
r = &x; // | |
} // -+ |
// |
println!("r: {}", r); // |
} // ---------+
通过标记重捕法,我们可以得到引用检查器的运行过程:
- 将 r 的生命周期标记为
’a
,将 x 的生命周期标记为’b
,在这里’a
>‘b
- 编译时,发现 r 引用了比其生命周期更短的
’b
,报错
3.2 函数中的泛型生命周期
现在你已经学了很多Rust的知识了,请你判断一下下边的代码能否运行:
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
——答案是不行,因为引用的概念,使得 Rust 无法判断出参数中的多个引用生命周期是什么关系,所以返回的时候也不知道具体的生命周期,而这是 Rust 不允许的。
这个概念使得 Rust 变得更加生涩,但是其好处也是有的,当显式定义出变量的生命周期后,Rust 就无需额外的垃圾回收程序,而是通过自身的引用转移机制来回收变量。
ok,知道了这个,那么我们该怎么显式的注解生命周期?看到下边的例子:
&i32 // 引用
&'a i32 // 带有显式生命周期的引用
&'a mut i32 // 带有显式生命周期的可变引用
当然,单个注解没有意义,为了在函数签名中使用生命周期注解,需要在函数名和参数列表间的尖括号中声明泛型生命周期(lifetime)参数,就像泛型类型(type)参数一样。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
需要注意的是,生命周期注解本身并不能改变调用者的生命周期,当在函数中使用生命周期注解时,这些注解出现在函数签名中,而不存在于函数体中的任何代码中。生命周期注解成为了函数约定的一部分,这非常像签名中的类型。
我们具体看看,在传入不同域内的变量时,调用longest
函数会有什么不同的表现?
- 同一作用域
pub fn longest_same_main() {
let string1: String = String::from("abcd");
let string2: String = String::from("xyz");
let result: &str = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
在同一作用域下,显然可以正常运行,因为此时两个入参都符合我们的约定,即属于同一生命周期。
result
在内部作用域
pub fn longest_big_main() {
let string1: String = String::from("abcd");
{
let string2: String = String::from("xyz");
let result: &str = longest(string1.as_str(), string2.as_str());
println!("The longest string is {}", result);
}
}
当我们尝试将 result 放到内部作用域,代码仍然可以运行,此时的情况是:第一个入参(string1)属于更长生命周期,第二个入参属于短生命周期,返回结果属于短生命周期。
在这种情况下,Rust 以最短的生命周期为准,只要确认返回值的生命周期为最短的,可以认为是通过引用检查器的。
- result在外部作用域
pub fn longest_small_main() {
let string1: String = String::from("abcd");
let result: &str;
{
let string2: String = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);
}
看到这份代码,先说结论:这份代码跑不通。
虽然最终函数实际返回的是更长生命周期的 string1
,但是 Rust 编译器以最短的生命周期为准,要求我们确保返回值的生命周期为最短的。
总结来说,从这三个例子,我们可以进一步明白的一点是:Rust 定义生命周期的目的是为了向编译器提供足够的信息来确保「内存安全」。
如果你看到这里还是没懂生命周期——这是正常的,这个概念在其他语言中压根不存在,在Rust的社区中,对生命周期的评价往往是:
我们概括一下 3.2,对于函数中的泛型生命周期来说,注意两点就行:
- 和泛型一样,使用生命周期参数,需要先声明
<'a>
- 入参和返回值至少活得和其中最小的生命周期一样久。
回忆下Rust说过的话,再参考上面的内容,可以得出:在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过。
3.3 结构体中的生命周期
不仅仅函数具有生命周期,结构体其实也有这个概念,只不过我们之前对结构体的使用都停留在非引用类型字段上。细心的同学应该能回想起来,之前为什么不在结构体中使用字符串字面量或者字符串切片,而是统一使用 String
类型?原因很简单,String
类型在结构体初始化时,只要转移所有权即可,而引用不能为所欲为。
直接阅读一下下边的代码:我们会知道下边的代码是无法编译的。为什么?因为当结构体i
被内部作用域修改后,实际上其生命周期就被同化在了内部作用域内,这意味着当脱离内部作用域,即便i
实际上是在更长生命周期的外部作用域定义的,但是其生命周期也结束了。
#[derive(Debug)]
struct ImportantExcerpt<'a> {
part: &'a str,
}
fn main() {
let i;
{
let novel = String::from("Call me Ishmael. Some years ago...");
let first_sentence = novel.split('.').next().expect("Could not find a '.'");
i = ImportantExcerpt {
part: first_sentence,
};
}
println!("{:?}",i);
}
3.4 静态生命周期 & 全局变量
在 Rust 中有一个非常特殊的生命周期,那就是 'static
,拥有该生命周期的引用可以和整个程序活得一样久。所有的字符串字面值都拥有 'static
生命周期,我们也可以选择像下面这样标注出来:
let s: &'static str = "I have a static lifetime.";
// 等效于
let s: &str = "I have a static lifetime.";
一般来讲,我们并不会关注到’static
这个生命周期,因为常量、字符串字面值等无需使用static
进行声明。
而会使用到‘static
生命周期的场景,一般是通过static
声明的常量或者变量,不过其中的“静态变量”这种说法本质上又非常奇怪,静态就是静态…怎么还静态变量,这一点就我目前所知,也唯有Rust有这个问题。
看到这个例子:例如我们希望用一个变量来统计程序当前的总请求数
static mut REQUEST_RECV: usize = 0;
fn main() {
unsafe {
REQUEST_RECV += 1;
assert_eq!(REQUEST_RECV, 1);
}
}
只有在unsafe
语句块中,我们才能访问和修改static
变量——Rust
给的理由很充分,这种使用方式往往并不安全,当在多线程中同时去修改时,会不可避免的遇到脏数据。
然而这确实为我们带来了困扰——尤其是我们习惯了使用JavaScript的前端开发。在Js中,我们可以直接在全局定义一个Map
:
export const STATIC_MAP = {
"a":"xxx",
"b":"yyy"
}
而在Rust
中,想实现这一点非常之困难,因为定义静态变量的时候必须赋值为在编译期就可以计算出的值(常量表达式/数学表达式),不能是运行时才能计算出的值(如函数)——而在Rust
中想写Map
,我们必须要运行函数……总之走不通。
那这就很🥚疼了,如果我就是需要有这么个东西,应该怎么办?毕竟在很多项目中,全局缓存是非常常见的需求。
3.4.1 懒初始化的三方宏
lazy_static
是社区提供的非常强大的宏,用于懒初始化静态变量,之前的静态变量都是在编译期初始化的,因此无法使用函数调用进行赋值,而lazy_static
允许我们在运行期初始化静态变量。
use lazy_static::lazy_static;
use std::collections::HashMap;
lazy_static! {
static ref HASHMAP: HashMap<u32, &'static str> = {
let mut m = HashMap::new();
m.insert(0, "foo");
m.insert(1, "bar");
m.insert(2, "baz");
m
};
}
fn main() {
// 首次访问`HASHMAP`的同时对其进行初始化
println!("The entry for `0` is \"{}\".", HASHMAP.get(&0).unwrap());
// 后续的访问仅仅获取值,再不会进行任何初始化操作
println!("The entry for `1` is \"{}\".", HASHMAP.get(&1).unwrap());
}
lazy_static
是通过**宏(什么是宏?后边讲)**的方式,使得在首次访问定义的变量时对其进行初始化,而在后续访问时,只获取值而不在做初始化操作。
在旧版本,其原理是通过并发原语 std::sync::Once
实现,会有一定的性能损失。在每次访问变量时,程序都会执行一次原子指令用于确认静态变量的初始化是否完成。
至于原语是什么,我们下边有机会会说。
除此之外呢,其实目前高版本的Rust也提供了其他的原语,方便我们快速实现一个全局变量。
3.4.2 std::cell::OnceLock
目前Rust底层实现了OnceLock
原语,其本质是“只能写入一次的同步原语”。可以看到下边的函数,通过调用函数的形式可以得到hashmap
,等价于上边三方库实现的HASHMAP。
fn hashmap() -> &'static HashMap<u32, &'static str> {
static HASHMAP: OnceLock<HashMap<u32, &str>> = OnceLock::new();
HASHMAP.get_or_init(|| {
let mut m = HashMap::new();
m.insert(0, "foo");
m.insert(1, "bar");
m.insert(2, "baz");
m
})
}
需要注意的是,在此时就需要指定出我们的’static
静态生命周期,并且使用static
来初始化HASHMAP
,确保其在函数作用域结束后仍然存在。
生命周期的概念…真的太生涩了,上述的内容其实已经够用了——所以我们忽略了一些东西。下边,先讲一些比较简单的东西。
4 包和模块
在我们上边的各种代码中,常常能看到这样的关键字或者语句。这些东西其实就是rust关于模块的能力。
pub fn main(){ ... } // 类似于js中的export
use std::sync::OnceLock; // 类似于js中的import {xxx}
HashMap::new() // 调用模块内的方法
mod HashMap; // 导入整个模块
如果我们用Rust的方式来理解,或许不好讲清楚,但是没关系,这一些能力可以用JavaScript的方式展开。
4.1 关键字
4.1.1 mod & use
我们可以这么直观的理解这两种使用其他模块的方式:
- mod:相当于JavaScript中的默认导入,即
import _ from “lodash”
; - use:相当于JavaScript中的具名导入,即
import {deepClone} from “lodash-es”
;
好了,结束了。
这么理解是可以的,但是并不完全,use关键字同样可以导入整个模块(fe. import * as _ from “lodash-es”
)
use regex;
我们当然也可以使用as
关键字来将具名导入改名,这等价于import { deepClone as dp } from “lodash-es”
)
use std::collections::{HashMap};
use std::sync::OnceLock; // 等于省略了具名
use std::sync::{Once as OOnce}; // as改变
而 mod
关键字则被赋予了更多的能力,我们可以通过 mod 在一个文件内组织多个模块:
mod date;
mod e_life;
mod find;
mod constants;
mod my_module {
pub const MY_CONSTANT: i32 = 42;
pub fn greet() {
println!("Hello from my_module!");
}
}
fn main() {
find::base();
find::noborrowbase();
find::main();
find::file_size_ord();
date::main();
e_life::main();
e_life::longest_same_main();
e_life::longest_big_main();
// e_life::longest_small_main();
constants::main();
constants::lockmain();
constants::varmain();
my_module::greet();
println!("My constant is {}", my_module::MY_CONSTANT);
}
4.1.2 crate & super
我们可以这么直观的理解crate
和super
关键字:
- crate:相当于JavaScript中常见的由打包框架或者
node
提供的alias
根目录能力,即import xxx from “@/xxx”
,在Rust中,crate
关键字默认将main.rs
或者lib.rs
作为根模块。 - super:相当于相对本层的上一层,即
import xxx from “./xxx”
之中的”./”,super可以使用多个。
——这回真没了,这两个没什么东西。
4.1.3 pub
我们可以这么直观的理解pub
关键字:
- pub:相当于
JavaScript
中export
关键字,区别在于pub
就是pub
,没有pub default
。并且实际上,pub关键字在很多其他地方有别的含义,只是在包和模块这里,可以将其认为是export。
4.2 拆分文件
关键字好记,不过想真正在我们的项目中组织起代码,还得学习到更具体的文件级拆分,同时我们也会在这里讲清楚这些关键字和我们认知的Js
模块有什么不同之处。
回顾我们之前的各种语句,总是会出现双冒号的存在,其实我们可以将其理解成在js
导入中的”/”
路径区分符。
use lazy_static::lazy_static;
use std::collections::HashMap;
在Rust
中,文件拆分要遵循两个约定:
- 每个文件夹为一个
crate
的单位,其中的根文件约定为”mod.rs”(根目录默认为main.rs
或者lib.rs
),根文件可以直接访问到同文件夹的其他文件,而其他文件不行。 - 当使用use或者mod进行相对引用时,会自动在同级寻找类似”
date.rs
”或者”date/mod.rs
”这样的文件。
听起来不好理解没关系,来看到代码,在我们这份main.rs文件中,我们尝试了引用date
模块,那么根据我们的约定,我们可以成功定位到date/mod.rs
文件,这个概念非常类似于前端开发中约定import xx from "./date"
能索引到"./date/index.js"
或者”./date.js”
。
而进一步,我们希望可以引用constant
里的内容怎么做?在我们不添加任何其他引用关系语句的情况下,我们使用date
模块并不能找到date/constant
模块:
这是因为Rust
采用了名为module tree
的技术方案,我们以crate
作为根模块概念,从根模块去延伸得到每个子模块的过程中,要求每个模块都被显式声明。
所谓显式声明,源于约定一:
💡 每个文件夹为一个
crate
的单位,其中的根文件约定为”mod.rs”(根目录默认为main.rs
或者lib.rs
),根文件可以直接访问到同文件夹的其他文件,而其他文件不行。
根据约定一,只有根文件可以直接访问到同文件夹的其他文件。这句话用Rust
的解释就是,文件夹下的所有文件,默认私有归属于根文件,不可以被外部访问。
这意味着两件事:
-
文件夹下非
mod.rs
的文件,在mod.rs
没有将其引入module tree
的情况下,无法使用其他模块的能力。 -
如果要公开模块,需要在
mod.rs
根文件上将模块声明为pub
。
通过添加pub mod
,我们的constant
模块会被加入到模块树中,此时树上其他模块自然就可以访问了:
5 宏
再来看到最后一章,“宏”,我们其实已经接触到了宏。比如说,我们在生命周期中提到了lazy-static
这个第三方宏,比如说,我们使用到的每一个println!
同样是宏。
宏的功能,远比我们想象的强大。先说说宏和函数的区别:从根本上来说,宏是一种为写其他代码而写代码的方式,即所谓的 元编程(metaprogramming)。
直观来说,当我们使用vec![xx,xx,xx,xx]
这样的宏时,其实编译器会把它转换成类似下边的形式,然后再进行编译。
{
let mut temp_vec = Vec::new();
temp_vec.push(xx);
temp_vec.push(xx);
temp_vec.push(xx);
temp_vec.push(xx);
temp_vec
}
这个过程中,我们参与编译的代码本质上是上边这份繁琐的代码而不是我们简短的一行代码。
在知道了宏是干什么的之后,我们再来看看宏的使用形式以及定义方式。
5.1 声明宏
声明宏是我们已经接触到的宏,也称为"macro_rules!"
宏,这种宏的定义和使用一般都较为简单。比如我们的vec
宏,其定义如下:
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
解释一下这份代码:
#[macro_export]
代表导出一个宏,只要导入了定义这个宏的 crate,宏就是可用的。macro_rules!
代表声明一个宏vec
代表宏的名字- 后边的部分,代表宏的处理过程,其原理是通过模式匹配,在匹配时生成新的代码并进行替换。
这个模式匹配的过程可以讲讲,以vec
的宏为例子,其结构和match
表达式有些类似,我们这里实际上相当于定义了一个匹配规则,如果有多个可能就是这样:
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
($arg:expr) => {
println!("My macro is called with argument: {}", $arg);
};
}
还是回到vec
的定义,我们可以看到这一块$( $x:expr ),*
,这一部分是什么意思?
- 首先是美元符号💲,美元符号开头的变量为宏变量 。
- 之后是一对括号,其捕获了符合括号内模式的值用以在替代代码中使用。
$()
内则是$x:expr
,其匹配Rust
的任意表达式,并将该表达式命名为$x
。
- 最后一部分,则是
,*
,其中的逗号表示用逗号分隔表达式,分隔的每一部分为$x
,而*代表匹配一个或者多个表达式。
而我们要拿到$x
的话,看到代码,主体中存在$()*
,这一部分即是表示其中的代码块将根据模式中的重复部分进行重复。在这里我们做了多次push
。
#[macro_export]
macro_rules! vec {
( $( $x:expr ),* ) => {
{
let mut temp_vec = Vec::new();
$(
temp_vec.push($x);
)*
temp_vec
}
};
}
那么运行宏,就可以生成我们想要的代码。
5.2 过程宏
第二种形式的宏被称为 过程宏(procedural macros),因为它们更像函数(一种过程类型)。过程宏接收 Rust 代码作为输入,在这些代码上进行操作,然后产生另一些代码作为输出,而非像声明式宏那样匹配对应模式然后以另一部分代码替换当前代码。有三种类型的过程宏(自定义派生(derive),类属性和类函数),不过它们的工作方式都类似。
因为时间关系,我们就不展示过程宏怎么编写,简单看看过程宏的使用。在下边这份代码中,#代表的就是在使用过程宏。
use proc_macro;
#[some_attribute]
pub fn some_name(input: TokenStream) -> TokenStream {
}
这个例子只是告诉了我们过程宏怎么用,并没有真正凸显出过程宏的强大。我们下边将举一些过程宏的使用例子,帮助大家更好理解过程宏:
5.2.1 derive
宏
我们前边讲过trait
,而**derive
** 宏的作用正是用于自动实现某些 trait(比如 Clone
、Debug
、Serialize
等)。通过 #[derive(Trait)]
注解,可以让 Rust 编译器自动生成实现相关 trait 所需的代码。
看到下边的代码,我们前边说过,在特质节我们讲过,只有结构体实现了PartialEq
才可以做比较,而我们这里只是做了宏声明,下边的代码即可用了,并且还多了clone
等方法。
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Person {
name: String,
age: u32,
}
pub fn main() {
// 使用derive生成的Debug输出
let person = Person {
name: String::from("Alice"),
age: 30,
};
println!("{:?}", person);
// 使用derive生成的Clone方法
let cloned_person = person.clone();
// 使用derive生成的PartialEq和Eq进行比较
let another_person = Person {
name: String::from("Bob"),
age: 30,
};
if person == another_person {
println!("person and another_person are equal");
} else {
println!("person and another_person are not equal");
}
if cloned_person == person {
println!("person and cloned_person are equal");
} else {
println!("person and cloned_person are not equal");
}
}
这个其实就是derive
宏带来的便利**。**
5.2.2 cfg
宏
用于根据编译时的条件选择性地包含或排除代码。可以使用 #[cfg]
注解来配置在某些条件下是否编译特定的代码块。
最直观的应用即是根据宿主机不同的架构、系统来选择性编译。我们下边定义了三个函数,由于我们的系统是macos,所以Rust只会编译macos_only_function
。
除了 os 之外,cfg
还支持 target_arch(架构)、version(编译器版本)等字段,也支持在Cargo.toml配置自定义的配置项。
如:
[features]
my_custom_condition = []
5.2.3 强大的#[tauri::command]
相信大家都听过tauri,一种客户端开发技术,其前端部分使用HTML三件套完成,后端部分采用了Rust。
前端和后端其实相当于两个进程,两者通信基本是依靠IPC通信的,如果要手写的话,这个过程是非常繁琐的,但是tauri通过过程宏的方式大幅降低了开发人员的工作量。
tauri提供了#[tauri::command]
宏,允许开发人员直接在前端调用Rust,可以说,在只包含简单类型的情况下(一般来讲不包括二进制数据都可以转换),整个开发过程都被简化到难以想象的程度。
看到下边的代码,这份代码只做了一件事,接受一个字符串,返回一个字符串。
#[tauri::command]
fn helloworld(name: String) -> Result<String, String> {
// back to "Hello World! {name}"
let msg = format!("Hello World! {}", name);
Ok(msg)
}
而在前端的调用方式:
import { invoke } from "@tauri-apps/api/tauri";
invoke("helloworld", { name: "tauri" }).then((res) => {
console.log(res);
});
正如我们所说,宏的目的是增强开发人员的开发体验,在这部分代码中,tauri 无疑极大的增加了我们的开发体验,tauri 以一己之力即承担了复杂而繁琐的数据序列化、反序列化、进程通信等一系列胶水代码。
6 包管理工具
rust 的包管理器叫 cargo,相当于 node 中的 npm——由于我习惯了使用 pnpm,下边用 pnpm 来对比。
6.1 快速启动一个 rust 项目
cargo 提供了多种快速启动的方式,包括cargo new xxx
与 cargo init
,后者和pnpm init
可以对应上。
注意,cargo 默认创建的是二进制项目,如果希望创建一个库的话需要带上参数--lib
。
让我们看看 Cargo 为我们生成了什么:
.
├── Cargo.toml
└── src
└── main.rs
我们首先来看的 toml
文件,这里大家很熟悉了,对应我们的 package.json
:
[package]
name = "dpdm"
version = "0.1.0"
edition = "2021"
[dependencies]
再看到 main.rs 文件:
fn main() {
println!("Hello, world!");
}
此时我们可以运行 cargo build
和 cargo run
两个自带的命令。前者相当于我们在 node 中的 build 步骤,在 nodejs 中会做类似 bundle 这样的操作,而在 rust 这里,实际上做的就是编译的步骤。而后者则是「编译并运行」。
bugyaluwang@yuanxinsuodeMacBook-Pro dpdm % cargo run
Finished `dev` profile [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/dpdm`
Hello, world!
不过 cargo 不支持自定义 scripts,我们可以借助第三方库 cargo-make,从而自定义一些我们自己的命令:
cargo install cargo-make
然后在项目根目录下创建 Makefile.toml
文件,定义自定义脚本任务。例如:
[tasks.build]
script = [
"cargo build",
]
[tasks.test]
script = [
"cargo test"
]
[tasks.custom-script]
script = [
"echo 'Running custom script!'",
"cargo build",
"cargo test"
]
运行定义好的任务,比如:
cargo make build
cargo make test
cargo make custom-script
这样我们就可以像在 package.json
中定义 scripts
一样,通过 cargo-make
来定义和运行自定义任务。
6.2 调试
在 rust 的世界里,入门者只需要掌握两个命令就好了:cargo run
和 cargo build
,其中后者是将 rust 代码打包成二进制文件或者 lib,而前者是在 bin 模式下的 cargo build && ./target/debug/xxx
的合并命令。
这些我们不细说,我们主要看看 rust 支持的一种基于宏的测试模式。在 rust 中,测试就像喝水一样简单——相比 node 以前需要配置 jest 来说。
rust 支持通过宏来在编译时屏蔽部分代码,像这样:
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn appends_extensions() {
assert_eq!(
fixture("extensions/js-file.js"),
resolve_fixture("./extensions/js-file")
);
}
}
通过宏,我们不仅可以将测试代码写在源文件的同一文件下,而且完全不需要额外考虑上下文。而且由于 rust 的优化,我们还可以通过 rust 提供的 IDE 插件进行独立测试。
这对于大型项目非常友好,能节省大量编译的时间。
6.3 打包
相比 node 来讲,rust 的打包是一件很痛苦的事情。尤其是如果我们期望将 rust 打包成 bin 文件执行的话,我们需要考虑到不同操作系统的不同架构。这意味着我们需要在不同的机器上进行编译,所谓交叉编译就是指这个。
不过这在 cross 出来之后,一切都变得美好了。在确保安装了 docker 或者 podman 且正常启动的情况下,cross 可以完成全流程的打包。受益于镜像的稳定性,整个打包过程非常 nice。
首先是安装:
cargo install cross --git https://github.com/cross-rs/cross
然后是打包
cross build --target aarch64-unknown-linux-gnu
是的,没有其他步骤了,剩下的事情 cross
全都会完成。cross 兼容 cargo 的所有命令(—target 同样是 cargo 的选项)。
唯一需要注意的点是,在打包时选择的 target 要注意选择的尾缀,rust 的常见用途之一是作为 cli,线上运行环境大部分在 linux,在使用 target 的时候除了要确定架构和系统之外,还要考虑使用的链接库。
比如:aarch64-unknown-linux-musl
和 aarch64-unknown-linux-gnu
虽然都是 linux 在 aarch64 架构的版本,但是前者使用 musl libc 库,后者使用 glibc 标准库。两者的区别是,musl 生成的可执行文件可以独立运行,不依赖于特定系统上的动态链接库,而后者更倾向于动态链接,依赖系统上安装的 glibc 动态库。
7 总结
近期,Rust 在前端领域似乎在不断迸发生机。从 Tauri 到 Deno,再到 Rspack,Rust 逐渐在前端发挥出极大的作用。
这让我想起一段话:
💡 这是一个关于临界质量的有趣案例。几年前,Rust 对于启动 JavaScript 工具(或者 Python 也是如此)来说是一种相当次优的语言。然而,一些疯狂的人并不在意,只是直接去做了。随着时间的推移,对工具的基础投资水平突然意味着有很多可以利用的东西,以至于它从一种鲜为人知的语言选择变成了一种相当合理的选择。
而本篇,如果能成为你进入 rust 世界的开始,那就再好不过了。下一篇,我们将带来「用五天时间把一个百星项目用 Rust 重构」正式进入实战。
转载自:https://juejin.cn/post/7423915153789992971