likes
comments
collection
share

浅谈 Rust 类型设计:对比 TS

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

前言

这几年随着越来越多前端基建项目用 Rust 重写,也预示着 Rust 非常有可能成为前端基建的未来,从社区的趋势看,或者已经是了。参加 2023 年杭州 FEDay, 已知字节除了开源的构建工具 Rspack,连跨端的一些技术栈也开始使用 Rust,当然也这依赖字节内部本身有不错的 Rust 生态。开源社区方面,Rollup 团队也开始着手开始布局 rolldown-rs,即 Rollup 的 Rust 版本。Vercel 团队也是有重量级工具使用 Rust 编写,例如 Turbopack。在结束了框架之争、构建工具之争、JS 语言层面核心特性的稳定化后,Web 前端开发也终于从刀耕火种的时代进入趋于稳定的时代。

随着前端应用日益复杂,开始面临一些新的问题,例如巨石应用下的微前端架构引入的几十上百个子应用的构建问题、代码 Lint 和 Prettier 美化等,导致跑一个完整的 CI 工作流动不动就需要花费十几或者几十分钟。无论使用缓存还是并行去执行一些任务,Webpack 本身和社区也是提供了很多方案,字节 Infra 团队也基于 Webpack 做了各种尝试,详情可以看这篇文章:Bundler 的设计取舍:为什么要开发 Rspack,最终发现都没法很好解决巨石应用的构建性能问题。回归到语言特性,JS 本身设计出来只是一个用于运行在浏览器端的脚本语言,作者可能也没想过有一天,JS 需要背负这么多的使命,从服务端到编译再到跨端,最后我们还是得承认,JS 不是万能的。

于是,社区把目光投向了 Rust ,无论是当初语言设计的定位,还是目前的生态去看,它是一个全新的选择。

语言特性

为了接下来更好地理解 Rust 的类型设计,我们先从语言特性出发。

Rust 有以下关键的语言特性:

  • 内存安全,无 GC 且无需手动管理内存,这就依赖编译时的检查,必须提前规避掉不安全的变量引用问题
  • 高性能,编译器基于 LLVM ,这使得 Rust 代码可以通过高度优化的机器代码来实现优异性能
  • 语言级安全性,强大的 cargo 编译器,在编译时提前避免潜在的 runtime 安全问题
  • 高并发性,Rust 在语言层面上支持并发编程,它的并发模型是基于“Actors”模式的,它允许在不同的线程之间安全地共享数据,而无需使用锁或其他同步机制
  • 社区生态,目前因为 Rust 可以应用在多个场景,例如用于开发前端基建工具、数据库、云原生、系统工具、操作系统、区块链等,使得 Rust 社区非常活跃

我个人已经学习 Rust 一段时间,有如下比较喜欢的一些点:

  • cargo 编译器的强大,无论是语法问题、未使用变量、编写文档、单测等方方面面,cargo 编译一条龙服务全部包揽;
  • 内置 Option 枚举,没有 JS null 或者其他语言中的空指针等问题,避免 10 亿美元故事的困扰
  • 模式匹配,语言内置的策略模式
  • 特征(Trait)和 Struct,没有 Class,不需要理解 OOP 中复杂的各种概念,反而推荐使用更 FP 的方式编程

介绍完语言特性,下面进入本文的正题。

基础类型(Primitive Type)

数字类型

在 TS 中,数字只有一种类型:number

let num1: number = 255;
let num2: number = 3.142592;

简单粗暴!

而 Rust 从内存使用考虑,根据可存储的数字范围将数字类型划分为整型、无符号整型、浮点型

let num: u8 = 255;
let num1: u64 = 1024;

let f1: f64 = 3.141592;

而且根据不同的使用场景,将整型划分为以下几种:

  • 8 位i8, u8
  • 16 位i16, u16
  • 32 位i32, u32
  • 64 位i64, u64
  • 128 位i128, u128
  • 视计算机架构而定的isize, usize,若电脑 CPU 是 32 位的,则这两个类型是 32 位的

而浮点类型,根据精确度的要求分为:f32f64

有多个数字类型的区分,在做一些数字运算的时候相对麻烦,为了方便,有时候不得不依赖 as做类型转换,但是一定要确保类型兼容,转换是符合预期的。例如实现一个将浮点数小数部分也转换为整数的方法,比较简单的做法:

fn f64_to_int (value: f64, digits: u32) -> i64 {
  let base: i32 = 10;
  (value * base.pow(digits) as f64).round() as i64
}

assert_eq!(f64_to_int(12.12, 2), 1212);

// 预期外的转换
(300_i32 as i8) // get 44

布尔类型

布尔类型在任何编程语言应该都不太意外的都一样,只有两个值,在 TS 中:

let isUsed: boolean = true;
let isNotUsed: boolean = false;

Rust 中:

let is_used: bool = true;
let is_not_used: bool = false;

布尔类型大多时候用于 if控制语句,因为语言实现问题,在 TS 中,有比较多的隐式类型转换(准确说是编译后的 JS),因此下面这种使用方式是可以的:

// TS 编译通过
if (2) {
  console.log('hello');
}

在 Rust 中,在编译时就报错了:

if 2 {
  print!("hello");
}

// error[E0308]: mismatched types
// expected `bool`, found integer

一切为了内存安全,不应该有任何隐式转换。

字符类型

请注意这里是字符类型,并不是字符串类型,在 TS 中,只有一个字符串类型,没有字符类型的说法。

let str: string = 'abc';

str = '中';
str = '😻';

在 Rust 中,字符类型字符串类型是两种不同的类型,而且字符串类型是一种复合类型(Compound Type),不属于基础类型。下一章节,我们再详细介绍。

Rust 的字符不仅仅是 ASCII 编码,所有的 Unicode 值都可以作为 Rust 字符:

let mut c = 'a';
c = '国';

c = '😻';

注意一个细节,Rust 中的字符类型是用单引号 ' 包裹;在 TS 中,这是没有任何区别的,从习惯上,我个人使用单引号更多。

单元类型

考虑以下场景,如果我想定义一个函数的类型,但是这个函数不要求返回任何数据,这在实际场景中非常常见。例如打印日志或者事件回调函数,在 TS 中,可以通过以下方式定义函数的类型:

type ClickHandler = (e: MouseEvent<HTMLButtonElement>) => void;

我们称 void为 TS 中的单元类型

在 Rust 中,使用 ()表示单元类型,Rust 中的 main函数就是典型的默认不返回任何数据的函数:

fn main () {
  print!("hello");        
}

但是在 TS 中,虽然类型设计上不要求返回任何类型,就算你返回一个数据也是可以的:

type ClickHandler = (e: MouseEvent<HTMLButtonElement>) => void;

// TS 编译通过
const onClick: ClickHandler = (e) => {
  console.log(e.target);
  return e.target;
}

在 Rust 中,则不能这样做:

struct A {
  value: String,
}

trait PrintSome {
 fn print(value: String) -> ();        
}

impl PrintSome for A {
  fn print(value: String) {
    print!("{:?}", value);
    value
  }    
}

// Error: mismatched types 
// expected `()`, found `String`

合理,一切为了内存安全,你返回数据意味着 Rust 在编译时就要做更多的编译时检查,例如借用检查,生命周期标注,来避免悬垂引用。

总结

在 Rust 中,就只有以上 4 种基础类型,限于篇幅,下一篇文章我会继续聊 Rust 中的复合类型。

在 TS 中,基础类型还包括 undefinednullsymbolbigInt等。

语言设计上,Rust 的语言设计,就规避了 undefinednull的引入,而是通过枚举 Option 解决空值问题。而 symbol是 TS 中因为语言问题,引入的一种特殊类型,而 bigInt在 Rust 中众多的数字类型早已覆盖掉。

内存安全一词在文中反复出现,对于无 GC ,无须手动管理内存的 Rust,编译时就要去保证内存安全,因此这也会体现在语言的类型设计和一些 Rust 语言语法机制上,例如借用检查、生命周期标注等。

Reference