本文作者系360奇舞团前端开发工程师
文章标题:Beyond Compare! Rust Vs Js
笔者最近计划开发一个SSR渲染的Blog。筹备之初,一度犹豫不决:是继续沿用较为熟悉的JavaScript技术栈,还是尝试当下非常热门的Rust相关技术栈,经过考虑,决定要做一些有挑战性的尝试。 一般而言,WEB开发,动手之前,我们都会在脑海里会有一个大概的雏形,也就是说无论采用哪种语言、哪种方案作为主要的技术栈,项目结构通常都是将前后端分离开来,类似于以下这种结构:
Rust的学习曲线通常被认为是比较陡峭的,尤其是对于前端开发者来说,Rust的很多概念与前端使用的语言存在很大的差异,可能会使得很多对此感兴趣的rustaceans[1]多次经历“从入门到放弃”的尴尬阶段。本文试着从前端的视角来找出Rust与js/ts的语法相似处,进而“触类旁通”,加深对于Rust这门语言的了解。
Rust的某些重要的新特性、新的改动总是充满争议,譬如Async。下面是截取自Hacker News中关于Rust Async这一语言特性的两种截然相反的却又极具代表性的两类评价:
"我觉得这样一来,大家都会跃跃欲试去尝试Rust[2]。我本人就非常期待这一刻的到来。这一个新的大门已经开启,等着我们去探索。而且,这个更新具有我们想要的一切特性:开源、工程质量高、设计过程公开,非常厉害!"
反对者认为:
"我真的不能理解,为什么有人会看Rust一团乱麻的异步编程部分然后觉得它是个好设计[3]。我多次试图去理解它,但最后我发现它实在是太混乱了!我真的很喜欢Rust,现在我的大部分工作都是用它做的,但是每当我遇见Rust的异步代码时,我就感到非常的懊恼"
是的,你没看错,Rust中也有Async语法,在这一系列博文中[4],作者作为在2017年到2019年间,推动Async/Await语法的设计的合伙人,可谓是“深层次、多方面全角度”的阐明了Rust的为何采用这样的设计。
对于Rust的异步编程的决策背景是Rust决定用"无栈协程[5]”对用户空间并发进行实施。
理论上,语言都有将工作划分为任务并将这些任务安排到线程上的方式。
Rust的这个系统就是async/await。
并且作者文章中提到了为何Rust会决定——移除"绿色线程"(green threads)。
作者解释了绿色线程如何影响内存管理、性能,以及它们与其他语言和平台的交互问题。
并最终阐述了为什么Rust最终选择基于一个可优化的状态机[6]实现Future,以此构建其异步生态。
注释这一部分往往被大部分开发者忽略...,清晰的项目目录结构和必要的代码注释(现实中往往注释和文档欠缺?...)总是项目中必不可少的组成部分。
这里将几个不同点简单罗列:
Rust 使用 /// 对单行文档注释进行标记,使用 //! 给包、模块或函数添加注释。Rust 还支持 /** ... */ 形式的文档注释。
这些注释可以被解析生成文档:
/// 这是一个文档注释,描述了下面的函数
fn some_function() {
// ...
}
//! 这是一个模块级别的文档注释
/* 这是外层注释开始
// 这里有一行被注释的代码
/* 这是内层嵌套注释开始
更多的代码
*/ 内层嵌套注释结束
还有更多的代码
*/ 外层注释结束
无论是前/后端,异步编程都是一个重要的话题。近年来,许多编程语言中都引入了async/await用于优雅地处理异步操作:
Python 的 asyncio 也有类似的模型。不过,它的 Future 类型是基于回调的,而不是轮询的。Python 异步程序需要一个 "循环",类似于 Rust 中的运行时。
JavaScript 的 Promise 与之类似,但同样基于回调。语言运行时实现了事件循环[7],因此 Promise 解析的许多细节都被隐藏起来。
Rust 和 JavaScript 都支持异步编程,并且都采用了 async/await 语法来简化异步操作的处理流程。不过,它们在异步实现和上下文中存在很多差异。以下是对两种语言中 async/await 的简单对比:
JavaScript 的 async/await 是基于 Promise 的语法糖。当你在函数前加上 async 关键字后,这个函数会隐式返回一个 Promise 对象。
JavaScript 运行在单线程中,依赖事件循环来处理异步行为。await[8] 关键字可以暂停当前 async 函数的执行,等待 Promise 解决,然后恢复执行。
回过头来,我们再看下Rust中的 async/await:
Rust 需要一个Executor来轮询 Future 直到其完成。这与 JavaScript 的事件循环不同,Rust 的Executor可以是多线程的,也可以更加灵活地控制。
Rust 中的 Result[9] 和 Option[10] 类型可以与 async/await 结合,来处理可能的错误或者空值。
由此我们可以得知二者的主要差异大致为以下几点:
JavaScript 中的异步操作是内建在语言和执行环境中的,而 Rust 的异步实现则更为底层,需要Executor的支持。
JavaScript 的异步编程依赖单线程的事件循环,而 Rust 可以利用其强大的并发能力[11],通过多线程执行异步任务。
下面我们重点看一下Rust 中的 async/await:
Rust 强类型系统的一部分,async 函数返回的是一个 Future,这是一种在编译时就确定了输出类型的泛型 trait。
这与 JavaScript 的 Promise 相比,为错误检查和优化提供了更强的保障(当然也更复杂...)。
Rust 的内存安全保证也适用于异步编程。使用 async/await 时,总绕不开所有权和生命周期这类编译难题。
Rust 的异步任务需要一个外部的Executor来驱动 Future 对象的完成。
Rust 允许开发者利用其强大的并发能力,例如通过使用多线程,无需担心数据竞争或其他并发问题,这是 JavaScript 单线程模型所无法比拟的。
Rust 中 Future 的执行是基于轮询(poll-based)的。
当调用一个 Future 的 poll 方法时,如果任务未完成,它会返回 Pending,并在准备就绪时通知Executor再次轮询。这种方式与 JavaScript 的事件循环有本质上的不同。
异步函数返回一个实现了 Future trait 的类型。
此处就需要用 .await 显式等待 Future 完成,并且在等待过程中,编译器保证类型和生命周期的安全:
async fn fetch_data() -> Result<(), Box<dyn Error>> {
let response = reqwest::get("some_url").await?;
let data = response.json::<HashMap<String, String>>().await?;
println!("{:?}", data);
Ok(())
}
Rust 使用 Result 枚举来显式处理错误,这种方式要求开发者在代码中显式地处理每一个可能的错误,这样可以减少运行时错误的发生。
Rust 的 async/await 使得开发者能够写出无需回调函数的异步代码,这样可以避免 JavaScript 中常见的回调地狱问题,使代码更加清晰和易于维护。
在 Rust 中,你可能需要为函数参数和返回值添加生命周期标注,以确保在异步操作中引用的数据在使用期间保持有效。这是 JavaScript 中不存在的概念。
总体来说,Rust 的 async/await 在保证性能、安全性以及类型正确性方面提供了更多的控制和保障,但这也意味着它的使用和理解要远比 JavaScript 更复杂。
以下是一个简单的Rust异步编程的Demo:
// 引入标准库中的`Future`和执行异步任务所需的`executor`。
use std::future::Future;
use std::pin::Pin;
use std::task::{Context, Poll};
use std::thread;
use std::time::Duration;
// 等待一秒钟然后返回一个数字
async fn wait_and_return() -> u32 {
// 模拟一个耗时的I/O操作。在现实世界中,这可能是一个HTTP请求。
thread::sleep(Duration::from_secs(1));
42 // 在等待一秒后返回数字
}
// 主函数也是异步的,这样它可以使用`.await`。
#[tokio::main] // 使用tokio运行时运行主函数
async fn main() {
// `wait_and_return()` 是一个异步函数,但它本身不会执行任何操作。
// 需要调用`.await`来等待它完成。
let number = wait_and_return().await;
println!("Got number: {}", number);
}
回过头来我们简单分析下这段代码:
我们定义了一个异步函数wait_and_return,使用async fn声明。这个函数内部执行了一个阻塞操作(thread::sleep),在现实的应用中,这将是一个非阻塞的异步操作。
使用#[tokio::main]属性宏来指定tokio Executor作为异步代码的运行时(runtime)。这是因为异步函数需要一个Executor来驱动他们,而Tokio是Rust生态中最流行的异步运行时之一。
在main函数中,我们调用wait_and_return().await来等待异步函数的结果。.await会暂停当前任务,并将控制权交回Executor,只有当wait_and_return()函数准备好继续执行时才会恢复。
异步函数返回的是一个Future,它是一个表示未来某个时候会产生值的操作。Future本身并不执行计算,除非我们将它提交到Executor上。
请注意,在真实的异步操作中,我们不会使用thread::sleep这样的阻塞操作。我们会使用非阻塞的异步I/O,例如Tokio提供的异步文件和网络操作。上面的Demo仅用于说明async/.await的使用。使用tokio或其他异步库时,会有配套的功能来处理异步的延时和其他I/O操作。
读到这里,我们由此可得出一个简单的结论:JavaScript和Rust的async/await语法针对的都是异步编程:
但是很重要的一点区别就是JS中的异步调用默认是非阻塞的,而Rust中的异步调用需要在异步运行时(例如tokio)中执行才能实现非阻塞。
比如下面的这个"Go to Sleep"的简单Demo:
use tokio::time::{delay_for, Duration};
async fn sleep() {
println!("sleep start");
// Future await
delay_for(Duration::from_secs(2)).await;
println!("sleep end");
}
#[tokio::main] // 使用tokio作为异步运行时
async fn main() {
println!("main start");
sleep().await;
println!("main end");
}
Rust的Borrow Checker是一种编译时检查机制,它确保程序中的引用是有效的并且遵守Rust的所有权、借用和生命周期规则。这个机制有助于避免数据竞态[12]、垂悬指针[13]和其他内存安全问题。
fn main() {
let s1 = String::from("hello");
let s2 = s1; // s1 的所有权被移动(move)到了 s2
println!("{}, world!", s1); // 错误!s1 已经不再有效
}
在这个Demo中,s1的所有权被移动到了s2。当我们尝试使用s1时,编译器会报错,因为s1不再持有那个字符串的所有权。
fn main() {
// 结构体关联函数
let s1 = String::from("hello");
let s2 = &s1; // 借用s1,s2是一个对s1的不可变引用
println!("{} world", s2); // 正确,s2是不可变引用,可以读取s1的数据
println!("{} world", s1); // 仍然正确,s1的所有权没有被移动
}
在这里s2是对s1的不可变借用,所以我们可以安全地读取s1的值。
fn main() {
let mut s = String::from("hello");
{
let s_mut = &mut s; // 可变借用s
s_mut.push_str(", world");
} // s_mut的借用在这里结束,可以再次借用或使用s
println!("{}", s); // 正确,s现在包含"hello, world"
}
这个Demo展示了可变借用和借用作用域。内部块中,我们对s进行了可变借用,并修改了它的内容。借用结束后,我们又可以安全地访问s。
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
fn main() {
let s1 = String::from("Rust");
{
let s2 = "C";
let result = longest(s1.as_str(), s2);
println!("The longest string is {}", result);
} // s2的生命周期结束
}
在这个Demo中,函数longest有一个生命周期参数'a,它指定了输入参数x和y必须至少和返回的引用一样长。s1和s2在main中有不同的作用域,但它们在调用longest时都是有效的。
Borrow Checker在编译时确保这些规则被遵循,从而在运行时防止出现错误。
在Rust和TypeScript(TS)中,enum都是用来定义一组命名的常量,但它们在使用上有一些显著的差异。
Rust中的enum是非常强大的,它不仅可以像传统的枚举那样用来表示一组相关的常量,而且每个枚举成员可以有不同的类型和数量的关联数据。
以下是Rust中枚举的定义和使用的简单示例:
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}
fn main() {
let msg = Message::Write(String::from("hello"));
// ... 使用match来处理msg
}
在上面的Message枚举中,我们定义了四种不同类型的消息,每一种都可以携带不同类型和数量的数据。例如,Write变种携带一个String,而ChangeColor带有三个i32类型的值。
Rust中的枚举非常适合模式匹配,这使得分解枚举值和访问其数据变得非常方便。
TypeScript的枚举对于前端小伙伴而言就很熟悉了,它更接近于传统的枚举,通常用作定义一组命名的数字常量,虽然TypeScript的枚举也可以定义为字符串。
以下是TypeScript中枚举的定义和使用的Demo:
enum Direction {
Up = 1,
Down,
Left,
Right,
}
// 字符串枚举
enum FileAccess {
// constant members
None,
Read = 1 << 1,
Write = 1 << 2,
ReadWrite = Read | Write,
// computed member
G = '123'.length
}
let dir: Direction = Direction.Up;
在上面的Direction和FileAccess枚举中,我们定义了一些命名的常量。这些值默认从0开始自增,但也可以显式地指定值。
通过以上的对比我们可以看出二者存在的几点差异:
由此我们可以得出以下结论:
尽管两者均用enum关键字定义,但Rust和TypeScript中的enum在使用和功能上有着很大差异。Rust的枚举提供了更强的类型检查和模式匹配能力,而TypeScript的枚举更接近传统的枚举[14]用法,侧重于为一组数值命名。
TypeScript 中的接口(Interfaces)和 Rust 中的 trait 在目的相似,只不过是在定义使用方式上存在一系列的区别。
在 TypeScript 中,接口是一个纯粹的类型层面的抽象,都是在编译时进行检查的,并不会编译成任何 JavaScript 代码。接口可以描述对象的一系列特征,也可以用于注解参数和返回类型:
interface Animal {
name: string;
numberOfLegs: number;
makeSound(sound: string): void;
}
function greet(animal: Animal) {
console.log(`Hello, ${animal.name}`);
}
在 TypeScript 中,接口可以被类实现(rust中,impl <特性名 > for <所实现的类型名 >),它们也可以继承其他接口:
interface Mammal extends Animal {
liveBirth(): void;
}
class Dog implements Mammal {
name: string;
numberOfLegs: number;
makeSound(sound: string) {
console.log(sound);
}
liveBirth() {
console.log("Dogs give birth to live young.");
}
}
由于本文主要面向前端开发伙伴,所以这里有关于TS的接口相关不多赘述。
在 Rust 中,trait 不仅可以定义一组要由某些类型实现的方法,还可以包含默认方法实现:
trait Animal {
fn make_sound(&self, sound: &str);
}
struct Dog;
impl Animal for Dog {
fn make_sound(&self, sound: &str) {
println!("{}", sound);
}
}
Rust 的 trait 可以指定关联类型,并且trait本身可以是泛型的:
trait Animal<T> {
fn baby_name() -> String;
}
struct Dog;
impl Animal<Dog> for Dog {
fn baby_name() -> String {
String::from("puppy")
}
}
除此之外,Rust 的 trait 还可以有条件实现,也就是说基于一些条件为类型自动实现 trait。
TypeScript 的接口在编译后的 JavaScript 代码中没有直接体现,而是完全在编译时用于类型检查;Rust 的 trait 可以影响运行时行为,特别是它们可以用于创建动态的 trait 对象。
在 Rust 中,trait 可以用于约束泛型类型,这对于编写类型安全的泛型函数和结构体至关重要。TypeScript 的接口也可以这样用,但是它们的约束在编译成 JavaScript 后不会保留。
...尽管 TypeScript 的接口和 Rust 的 trait 在概念上相似,它们在实现、功能和约束方面具有显著的差异。
TypeScript 的接口更多地用于静态类型检查和定义对象的形状,而 Rust 的 trait 则提供了深层次的类型系统集成、运行时多态性和类型安全的泛型编程。
思考:rust的trait某种程度上"表现"的很像ts中原型链继承
首先我们看下Rust 的 Structs
在 Rust 语言里,struct(结构体)是一种自定义数据类型,它能够用来创建有组织的数据项的集合。
struct Person {
name: String,
age: u32,
}
impl Person {
fn greet(&self) {
println!("Hello, my name is {}.", self.name);
}
}
let person = Person { name: String::from("Alice"), age: 30 };
person.greet();
class Person {
constructor(name, age) {
this.name = name;
this.age = age;
}
greet() {
console.log(`Hello, my name is ${this.name}.`);
}
}
const person = new Person("Alice", 30);
person.greet();
Rust 有着静态的、强类型系统。每种数据类型都在编译时定义清楚。而 JS 是动态的、弱类型系统,对象属性可以灵活变化。
Rust 通过所有权模型管理内存,而 JS 依靠垃圾回收机制。
Rust 中不可变是默认的,而在 JS 中,对象是默认可变的。
Rust 没有基于原型的继承机制,而是使用 Trait。JS 使用基于原型的继承。
Rust 允许更细粒度的控制数据封装,通过 pub 关键字明确字段的公开程度。在 JS 中,所有对象属性默认都是公开的。
Rust 使用 impl 为类型定义方法,JS 对象或类的方法则直接定义在对象或者类的字面量里。
在 Rust 中,Vec是一种动态数组,被广泛用于存储同类型值的集合。Vec 拥有以下特性:
与 Vec 对应的还有固定大小的数组,其大小在编译时必须已知且无法更改:
let fixed_array: [i32; 5] = [1, 2, 3, 4, 5]; // 定义了固定大小的数组
let mut vec: Vec<i32> = Vec::new(); // 可动态变化的 Vec
vec.push(1); // 向 Vec 添加元素
在 TypeScript 中,数组是 JavaScript 的原生对象的一个扩展,可以容易地在 TypeScript 中使用,并且拥有以下特性:
let fixed_array: number[] = [1, 2, 3, 4, 5]; // 使用类型注解声明数组
let generic_array: Array<number> = [1, 2, 3, 4, 5]; // 使用泛型声明数组
TypeScript 作为 JavaScript 的超集,具有动态类型语言的灵活性,并通过类型注解提供静态类型检查。Rust 有着静态、强类型系统,并且更加注重类型的显式性和安全性。
Rust 的 Vec 类型通常表现出更优的性能和内存使用效率,因为它更灵活地控制内存分配,并且避免了垃圾回收的损耗。
JavaScript和Rust都提供了迭代器(Iterator)的概念,允许开发者在容器(例如数组或向量)上进行遍历操作。
在JavaScript和Rust中使用迭代器,主要有以下几个不同之处:
JavaScript:
迭代器协议在JavaScript中是通过实现Symbol.iterator[15]函数来定义的,该函数返回一个带有next()方法的迭代器对象。
next()方法返回一个带有value和done属性的对象。
通常不需要手动创建迭代器,因为许多内置类型(如Array, Map, Set等)已经默认实现了迭代器协议。
可以使用for...of循环来遍历迭代器。
let array = [1, 2, 3];
for (let value of array) {
console.log(value); // 输出:1 2 3
}
Rust:
Rust中迭代器是通过实现Iterator trait(类似于接口)来定义的,该trait要求定义next()方法。
next()方法直接返回Some(value)或None来表明是否还有值。
Rust中也有许多内置集合类型实现了Iterator trait,如Vec, HashMap, HashSet等。
可以使用for循环来遍历迭代器。
let vector = vec![1, 2, 3];
for value in vector.iter() {
println!("{}", value); // 输出:1 2 3
}
尽管两种语言在迭代器的设计和使用上有共通之处,但也存在显著的差异:
对于Rust 迭代器相关的概念感兴趣的读者朋友推荐阅读Rust迭代器:高阶教程[16]。
在学习和编写Rust的道路上必须克服的第一个主要障碍,就是Borrow Checker本身[17]。
对于前端伙伴来说,学习Rust的一个有效策略是多写、多练、多多的compile Error...
"自从用了Rust,我就习惯于看不到我的代码,而是一大串编译错误。"
或者也可以像本文作者的思路一样,试着在Rust中实现已经我们熟悉的JavaScript/TypeScript功能或者概念,这样可以直观地看到两种语言的相似之处和不同之处,进而加深认知。
参考资料[1]rustaceans: https://medium.com/kbtg-life/that-is-why-you-and-i-should-become-rustaceans-31ddd212e52f
[2]Rust async: https://news.ycombinator.com/item?id=21473418
[3]Rust一团乱麻的异步编程: https://news.ycombinator.com/item?id=37436413
[4]Why async rust: https://without.boats/blog/asynchronous-clean-up/
[5]stackless coroutine: https://ruststack.org/stackless-coroutine/
[6]State Machine: https://en.wikipedia.org/wiki/Finite-state_machine
[7]Event Loop: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Event_loop
[8]JS Await: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/await
[9]Result is similar to Option: https://google.github.io/comprehensive-rust/std-types/result.html?highlight=result#result
[10]Option: https://google.github.io/comprehensive-rust/std-types/option.html?highlight=Option#option
[11]Rust too-many-web-servers: https://ibraheem.ca/posts/too-many-web-servers/
[12]Date races in Rust: https://users.rust-lang.org/t/data-races-in-rust/54627
[13]Dangling Reference: https://www.reddit.com/r/rust/comments/tw2mdd/what_are_dangling_references_in_rust/
[14]what are enums and why are they useful: https://stackoverflow.com/questions/4709175/what-are-enums-and-why-are-they-useful
[15]Symbol.iterator: https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/iterator
[16]Rust的迭代器在整个代码库中被广泛使用: https://blog.jetbrains.com/rust/2024/03/12/rust-iterators-beyond-the-basics-part-i-building-blocks/
[17]Fast Development In Rust, Part One: https://blog.sdf.com/p/fast-development-in-rust-part-one