在我的交流群里有许多人在讨论 rust。所以陆续有人开始尝试学习 rust,不过大家的一致共识就是:rust 上手很困难。当然,这样的共识在网上也普遍存在。
这篇文章,就是专门为想要学习 rust 的前端开发而写,为大家抛开 rust 的迷雾,让大家感受到,上手 rust,其实没有那么难。从本质上来说,他跟 JavaScript 是非常相似的。大家可以将这篇文章作为 rust 学习的先导片,我将会提前为大家扫清那些阻碍你学习 rust 的障碍,极大的降低 rust 的上手成本。
一、明确区分变量与值
JavaScript 并没有模糊变量与值的概念。然而由于许多人在学习 JavaScript 之初就没有重视变量与值的区别,在表达或者理解时,也经常混用,反正也不会出错,于是久而久之,就形成了刻板印象,变量与值就傻傻分不清了。
一定要记住,变量就是变量,值就是值。
// a 是变量
// 2 是值
// a = 2 是给变量赋值
let a = 2;
在 rust 中,我们就必须要明确变量与值的区别,因为 rust 有一个非常有趣且核心的规定:每一个值,同时只能拥有一个变量。例如,如下代码中,我首先声明了一个变量 a,并且给 a 赋值一个字符串。
然后我声明一个变量 b,并将变量 a 赋值给 b。
let a = "123".to_string();
let b = a;
println!("xxxx, {}", a);
// error: borrow of moved value: `a` value borrowed here after move
再然后,当我想要使用变量 a 时,我们发现报错了。
根据我们刚才的那个规定,b = a 是将其值的所有权,转移给了 b,所以此时变量 a 失去了值。当我们再次想要通过变量 a 访问对应的值时,自然就会出错。
这个规定,在 rust 中,称之为所有权,是 rust 独特的核心设计,也是我们学习 rust 必须掌握的核心知识点之一。明确区分变量与值,能够帮我们快速掌握 rust 的这个核心特性。
二、重视可变与不可变
只有面试过大量的人,你才知道,好多人其实不知道 JavaScript 的基础数据类型是不可变的。对可变与不可变概念的不重视,也是导致前端上手 rust 困难的重要因素之一。
在 JavaScript 中,由于其强大的自动垃圾回收机制,我们在代码上可以随时修改变量的值,因此下面这段代码再正常不过了。
let a = 10;
a = 20;
然而在 rust 中,由于没有垃圾回收机制,编译器必须明确知道变量到底是可变的还是不可变的,因此同样的代码,在 rust 中会直接报错
注意:我们这里说的是变量的可变性和不可变性,而不是值的可变性与不可变性。
let a = 10;
a = 20;
// error: cannot mutate immutable variable `a`
与此同时,如果你要声明一个具有可变性的变量,那么你需要通过语法明确的告诉编译器,这样这段代码就能编译通过。
// 即使这样写,编译器也会告诉你,你声明了一个值,
// 但是这个值还没有被 read 过,就被重写了
let mut a = 10;
a = 20;
复杂的数据类型也保持了一样的规定。不加 mut 的情况下声明的变量,都是不可变的。
// 不加 mut 表示不可变,后续修改就会报错
let mut p = Person {
name: "TOM".to_string(),
age: 32
};
p.name = "TOM2".to_string();
在 rust 的开发中,我们需要明确告诉编译器变量的可变与不可变,习惯了这一点,rust 的学习就进展了一大步。
// 这样表示不可变
let a = 10;
// 添加 mut 表示可变
let mut a = 10;
三、纠正对于基础数据类型的认知
在我们前端开发中,有一个存在非常广泛的共识性知识的错误理解:那就是
基础数据类型存储在栈内存中
我在《JavaScript 核心进阶》中,专门花费了很多篇幅来讲解为什么这是一个错误的理解。不过,很显然,对于前端开发而言,这个知识的理解是否正确,并不重要,因为他不影响我们的代码逻辑和功能实现。因此大家都不够重视。
然而在 rust 中,对于这个知识的理解就显得尤其重要,当你带着这个错误理解来到 rust 的学习,你会感受到非常的不适应。
这里的关键之一,就在于字符串。
在 JavaScript 中,字符串是一个基础数据类型。但往往我们只会在栈内存中存储一些简单的数据,很显然,字符串可以变得复杂和庞大,庞大到整个栈内存可能都放不下。因此,字符串,其实并没有那么简单。
在 rust 中,字符串还原了他的本色,它是一个复杂数据类型,它存在于堆内存中。而与之对应的基本类型,变成了 char,表示单个字符。因此,我们需要非常严肃的对待字符串,把他看成一个复杂类型去学习。
// 声明一个字符串
let hello: String = String::from("hello world!");
// 声明一个字符串片段
let name: &str = "TOM";
// 将字符串片段转成字符串类型
let name1: String = "TOM".to_string();
// 将字符串转成字符串片段
let name2: &str = hello.as_str();
// 一个字符
let a: char = 'h';
四 、精确理解引用类型
纯前端开发者对引用这个概念的理解有点大概差不多就是这样的意思。所以对于按值传递、按引用传递这样的概念理解得不是很透彻。当然,由于 JavaScript 太强大了,精准理解这些概念也没有太大的必要。
但在 rust 中,就必须要求开发者非常明确的搞懂按值访问/传递和按引用访问/传递。
首先,在 JavaScript 中的基本数据类型,总是按值访问/传递。 其原因是因为基本类型在内存中有明确的大小,非常的轻量,因此复制成本非常低,甚至有可能比复制一个引用的成本都还要低。
例如如下代码:
let a = 1;
let b = a;
b++;
console.log(a); // 仍然为1
console.log(b); // 变成了2
这段代码在内存中的表现如下图所示:
在 rust 中,基本类型也有同样的表现。只不过我们要明确告诉编译器,变量 b 是一个可变变量。
let a = 1;
let mut b = a;
b += 1;
println!(" {a:?}"); // 仍然为1
println!(" {b:?}"); // 变成了2
在 rust 中基本类型虽然也可以有引用的写法 let b = &a;,但是为了降低理解成本,我们可以在初学时无视他,因为大多数场景也不会这样使用,就算使用了他的结果也没啥大的区别。
将基本类型传入函数中,也是一样,对于前端开发者来说,他不会发生什么灵异事件让我们理解不了。
// 简写语法:return v + 1
fn addone(v: i32) -> i32 {
v + 1
}
let a = 10;
let b = addone(a);
println!("xxxx, : {}, {}", a, b);
// xxxx, : 10, 11
我们声明了一个不可变变量 a,并将其传入函数 addone 中,此时 a 的值发生一次复制行为,并将复制之后的结果参与到函数的运行中去。因此最终 a 的值不受到函数执行的影响。这里的表现与 JS 一模一样。
其次,在 JavaScript 中的引用数据类型,总是按引用访问/传递。
例如下面这个例子,我声明了两个变量指向同一个值,当我通过任意一个变量引用修改值之后,最终的表现是两个变量都会发生变化。
const book = {
title: 'JavaScript 核心进阶',
author: '这波能反杀',
date: '2020.08.02'
}
const b2 = book;
b2.author = '反杀';
console.log(book); // {title: "JavaScript 核心进阶", author: "反杀", date: "2020.08.02"}
console.log(b2); // {title: "JavaScript 核心进阶", author: "反杀", date: "2020.08.02"}
这段代码在内存中的表现为:
但是,类似的代码,在 rust 中就会出大问题。为什么呢,因为在 rust 中,默认是按照按值访问/传递。查看如下代码。
我需要一个可变的变量 b2,然后通过修改 b2 的值,来观察 book 的变化。
struct Book {
title: String,
author: String,
date: String
}
let book = Book {
title: "rust 核心进阶".to_string(),
author: "这波能反杀".to_string(),
date: "2024.03.12".to_string(),
};
let mut b2 = book;
b2.author = "反杀".to_string();
println!("bookxxxx: {}", book.title);
// error: borrow of moved value: `book` value borrowed here after move
是的,在 rust 中执行这段代码会报错,因为 rust 默认是按值访问,所以当我们在代码中执行 let mut b2 = book; 时,实际上已经将 book 对应的值的所有权,转移给了 b2。
所有权:每个值只能同时拥有一个变量。
此时,当我们再访问 book,编译器就会告诉我们,book 的所有权已经被转移了。
因此,如果我们要模仿出来 JavaScript 那种一样的代码,我们就需要借助引用来完成。
首先我们要约定好,book 的值是可变的。因此要使用 mut 来标识变量。
let mut book = Book {
title: "rust 核心进阶".to_string(),
author: "这波能反杀".to_string(),
date: "2024.03.12".to_string(),
};
其次,对于 b2 来说,所有权不能被 b2 剥夺,因此我们需要使用引用。
// 赋值一份引用,表示借用:而不是所有权转移
let b2 = &book;
但是,b2 也需要被修改,因此 b2 得是一个可变引用。
let b2 = &mut book;
完整代码如下:
struct Book {
title: String,
author: String,
date: String
}
let mut book = Book {
title: "rust 核心进阶".to_string(),
author: "这波能反杀".to_string(),
date: "2024.03.12".to_string(),
};
let b2 = &mut book;
b2.author = "反杀".to_string();
println!("bookxxxx: {}", book.author);
在函数传参时也是这样的逻辑。因为 rust 是默认的按值传递,因此当我们将一个复合类型传入函数时,实际上是把值传进入,这样就会发生所有权的转移。
例如我声明一个简单的函数,然后只是在函数内部访问传入的值。
fn foo(bk: Book) {
println!("bookxxxx: {}", bk.author);
}
然后执行该函数,当我们将 book 传入函数之后,再访问 book,就会发现报错,明确的告诉我们 book 已经失去值的所有权了。
let book = Book {
title: "rust 核心进阶".to_string(),
author: "这波能反杀".to_string(),
date: "2024.03.12".to_string(),
};
foo(book);
// 报错
println!("bookxxxx: {}", book.author);
为了确保 book 不会失去所有权,我们可以改造成按引用传递的方式。类型约束中,加上 &。
fn foo(bk: &Book) {
println!("bookxxxx: {}", bk.author);
}
然后传入引用类型。
foo(&book);
这样,就跟 JavaScript 中的执行表现完全一致了。当然,我们如果要进一步在函数内部修改值,则传入可变引用即可。
fn foo(bk: &mut Book) {
println!("bookxxxx: {}", bk.author);
}
foo(&mut book);
ok,理解了这点小差异,基于 JavaScript 掌握 rust,可以说是信手拈来,毫无压力。
实践中,这种传入可变引用的场景其实是比较少的,按照函数式的指导思想来说的话,我们也应该尽量避免这样使用。
五、诡异的生命周期
按值传递时,内存往往更可控。因此,当我们总是在使用按值传递时,其实不会涉及到太过于复杂的生命周期的概念,编译器就能很轻松识别出来内存应该在什么时候回收。
但是,当我们使用引用时,情况就变得复杂起来。例如我们声明一个结构体。
struct Book2 {
title: &str,
author: &str,
date: &str
}
该结构体三个字段都约定用引用类型来初始化。那么这个时候就有可能会发生一种情况:当我使用引用类型初始化该结构体时,有可能某一个字段的引用所对应的值,被提前销毁掉了,那该结构体该如何自处呢?例如这个例子。
// 声明一个标准字符串类型
let title = String::from("rust 核心进阶");
let book = Book2 {
title: title.as_str(),
...
}
// 按值传递,title 失去值的所有权
read(title);
fn read(book: String) {
println!("xxxxx, {}", book);
}
此时尴尬的事情就发生了,title 的值没了,所以呢,book.title 就访问不到值了。这种情况,被称为悬垂指针。
为了避免这种奇怪的事情发生,因此我们在使用引用时,就必须要明确的告诉编译器,我们到底会不会搞这种骚操作,让悬垂指针的情况出现。
约定的方式很简单,我们可以明确告诉编译器,结构体实例本身,与初始化的几个值,一定会拥有共同的生命周期。不会出现某个值的引用私自额外处理掉的情况。因此,我们会传入一个生命周期泛型,来完成我们这个约定。
struct Book2<'a> {
title: &'a str,
author: &'a str,
date: &'a str
}
如果暂时不懂泛型,可以等懂了泛型再来回顾,这里的 'a 是随便写的一个字母,表达一个与泛型变量类似的概念,也可以是 'b,大家保持一致即可。
这里表达的是,Book2 的实例,与每一个初始化的引用,一定有相同的生命周期,大家会一起共进退。
约定了一致的生命周期之后,如果某个字段引用想要私自转移所有权,对不起,这种情况编译器就不会允许发生。
// 报错:cannot move out of `title` because....
read(title);
在函数中也是一样,当我们要返回引用数据类型时,很多时候就需要标明生命周期,告诉编译器我们的约定。
例如这个案例,函数执行最终会返回入参中的一个,那么入参的生命周期与返回引用的生命周期就应该保持一致。因此我们使用泛型生命周期的语法约定一下即可。
fn longest<'b>(x: &'b str, y: &'b str) -> &'b str {
if x.len() > y.len() {
x
} else {
y
}
}
如果不一致呢?我们就可以约定两个泛型生命周期变量。
fn longest2<'a, 'b>(x: &'a str, y: &'a str) -> &'b str {
let he = "hello";
let wo = "world";
if x.len() > y.len() {
he
} else {
wo
}
}
在一些编译器能够推断出来的场景,就可以不需要约定生命周期。例如:
fn foo(x: &str) -> &str {
x
}
除此之外,当你想要标识一个引用具有全局生命周期时,我们使用 'static。
let s: &'static str = "I have a static lifetime.";
rust 中的生命周期其实就这么简单。我们也有一种方式可以避免使用生命周期:那就是少使用引用。这个就很重要。
当然,有的时候我们还需要结合生命周期与泛型共同使用。看上去代码就很难懂。不过不要慌。把生命周期当成一个泛型变量就好了。
fn longest_with_an_announcement<'a, T>(
x: &'a str,
y: &'a str,
ann: T
) -> &'a str
where
T: std::fmt::Display
{
println!("xxxx T: {}", ann);
if x.len() > y.len() {
x
} else {
y
}
}
where 表示对 T 的类型进行进一步解释说明,明确限定 T 的使用范围。
// 表示将会在 {} 中使用变量
where
T: std::fmt::Display
六、其他
还有一些 rust 的特性我并没有列出来,因为他们中的许多知识理解起来就没有太多的困扰性了,例如 trait、impl、数组、元组、enum、HashMap、mod、其他基础语法等。
当然,要成为 rust 高手,我们必须对栈内存和堆内存有非常准确的掌握,而不是仅仅只局限于知道一个概念。rust 要求我们对内存与数据类型有更精准的掌握。
除此之外,rust 与 JavaScript 一样,也是一门函数式编程语言。
rust 也用 let 与 const 声明变量与常量。这该死的亲切感。
rust 中也闭包。而且 rust 的闭包是显示出来的,理解起来更容易。当然,由于概念上引入了所有权、可变、不可变,所以导致了许多朋友在学习 rust 闭包时也充满了困惑,但是我们上面已经拿捏了这些概念,他们造成的难度都是纸老虎。
rust 的异步编程,有一个最常用的模式:单线程模型,与我们常说的事件循环体系是一模一样的。遗憾的是,许多前端对事件循环掌握得并不好,依然处于一个大概知道有这么个东西的阶段。
rust 也支持泛型,而泛型是 TS 的核心特性之一。rust 也有完善的类型推导机制,所以学习思路和 TS 都是一样的,关键的问题是,TS 的泛型和类型推导,反而更加灵活与复杂。