前言
在前一篇文章:浅谈 Rust 类型设计:对比 TS 中,我主要围绕 Rust 的基础数据类型对比 TS 进行了介绍,从文章中,可以知道,为了保证内存安全,Rust 有更加强的类型要求,不能有任何的隐式类型转换;而且为了更加合理地使用内存,Rust 的数字类型也会划分出多种类型。
本篇文章,我主要围绕 Rust 复合数据类型进行介绍,同时还是会结合 TS 的一些类型进行比较。
复合类型(Compound Type)
字符串
在上一篇文章,我已经介绍过在 Rust 中,字符串是复合类型,而在 TS 中,字符串是基本类型。
Rust 中的字符是 Unicode 类型,每个字符占据 4 个字节内存空间 ;但是在字符串中不一样,字符串是 UTF-8 编码,也就是说字符串中的字符所占的字节数是变化的(1 - 4 字节) 。这样能尽量降低字符串的内存使用空间。
跟我们在 TS 中定义一个字符串不同,在 Rust 中最常用的创建字符串方式如下:
rust
let str: String = String::from("string1");
那么,如果我们照 TS 中的方式,定义一个变量:
rust
let str = "string1";
那么它是什么?答案是,我们创建了一个字符串切片 ,类型是:&str
。
在 Rust 中,我们最常使用的方式,例如传递字符串变量其实更多是通过借用的方式,获取到字符串的引用:
rust
fn say_hello(s: &str) {
println!("{}",s);
}
let str = String::from("hello");
say_hello(&str);
在 Rust 中,字符串类型是可变的,而字符串切片是不可变的 。使用字符字面量创建的字符串切片,在编译时就知道了其内容,因此可以直接硬编码到可执行文件中,使得字符字面量运行快速高效,这得益字符串切片的不可变。
对于 String
类型,因为长度不可预测,所以需要在堆上分配一块可变的内存,并且这是在运行时完成的。
数组
数组在各种编程语言中都是最常见且常用的数据结构之一,在 TS 中定义一个数组:
typescript
const arr: string[] = ['item1', 'item2'];
const arr1: (string | number) = ["item1", 1];
const arr3: Array<number> = [0, 1];
注意, 在 TS 中,arr1
这种定义方式,就是使用联合类型,这个数组可以存储不同类型的值,这在其他语言中并不多见。
在 Rust 中,定义一个数组:
rust
let arr = [1, 2, 3, 4, 5];
// 声明类型
let arr1: [i32; 5] = [1, 2, 3, 4, 5];
注意一个细节,在 Rust 中定义一个数组,是需要声明长度的。
wtf?读到这里,写 TS 的读者有点疑惑,因为在 TS 中所有数组长度是可变的。在 Rust 中,由于数组的元素类型大小固定,且长度也是固定,因此数组是存储在栈上,性能也会非常好。
对于可变的数组,Rust 使用一种叫动态数组的数据类型声明:
rust
let mut mutable_arr: Vec<i32> = Vec::new();
mutable_arr.push(0);
动态数组 Vector
是存储在堆上,因此长度可以动态改变,所以在 Rust 中,我们需要根据实际使用场景选择数组还是动态数组。
合理,一切为了内存。
在本小节开始的时候,我提到 TS 中可以使用联合类型 声明数组类型,正常情况下,在 Rust 中是不允许的。但是,如果要做,也是可以的。其中一种方式就是使用智能指针 Box:
rust
trait Draw {
fn draw(&self);
}
struct Button {
id: u32,
}
impl Draw for Button {
fn draw(&self) {
println!("这是一个按钮 {}", self.id);
}
}
struct Select {
id: u32,
}
impl Draw for Select {
fn draw(&self) {
println!("这是一个 select {}", self.id);
}
}
let elems: Vec<Box<dyn Draw>> = vec![Box::new(Button { id: 1 }), Box::new(Selec
t { id: 2 })];
for e in elems {
e.draw()
}
这里能使用 Box 同一化类型的原因在于:Vec 泛型要求是 Sized
的类型,而 Box 能通过创建指针的方式在堆上给数据指定一块内存,从而使得数据大小确定。
在这个例子我们还引入了 trait(特征),下面讲到结构体的时候会再对其进行介绍。
元组
在 TS 中,声明一个元组:
typescript
const tuple: [string, number] = ["hello", 1];
TS 中的元组非常像上一小节介绍的 Rust 中的数组,看起来就是固定长度的。编译成 JS 后,就是一个简单的数组字面量:
javascript
const tuple = ['hello', 1];
在 Rust 中,元组就可以由多种数据类型组合在一起,长度固定:
rust
let tup: (i32, f64, u8) = (500, 6.4, 1);
并且元素顺序固定,这样就可以如上面代码示例的方式解构出每个值,在 Rust 中,其实这就是一种模式匹配。
也可以使用索引的方式访问元组中的元素:
rust
let tup: (i32, f64, u8) = (500, 6.4, 1);
assert_eq!(tup.0, 500);
枚举
在 TS 中,枚举定义相对来说简单,枚举值相对来说也比较单一:
typescript
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
enum Direction {
Up = 'UP',
Down = 'DOWN',
Left = 'LEFT',
Right = 'RIGHT',
}
枚举值主要有数字和字符串类型,默认是数字,并且从 0 开始向上递增。
这里需要额外提一下的是,在 TS 类型设计中,会有值空间和类型空间 的概念。对于类型空间,所有类型注释、大多数定义的 TS 类型在编译成 JS 后就会被擦除,例如上面元组的例子。但是,枚举是可以当做值来使用,例如:
typescript
enum Direction {
Up, // 0
Down, // 1
Left, // 2
Right, // 3
}
const direction = Direction.Up;
所以,对于枚举这种类型,横跨了值空间和类型空间,在编译成 JS 后代码如下:
javascript
var Direction;
(function (Direction) {
Direction[Direction["Up"] = 0] = "Up";
Direction[Direction["Down"] = 1] = "Down";
Direction[Direction["Left"] = 2] = "Left";
Direction[Direction["Right"] = 3] = "Right";
})(Direction || (Direction = {}));
const direction = Direction.Up;
如果我们要把枚举当成值使用,就不能在 import 时带上 type
这样的标识符:
typescript
import type { Direction } from './constants';
// Error: Direction cannot be used as a value because it was imported using 'import type'
const direction = Direction.Up;
回到 Rust 中的枚举,Rust 中的枚举类型非常灵活,允许你定义各种类型的成员变量:
rust
enum Direction {
Up,
Down,
Left,
Right,
}
enum DirectionU8 {
Up(u8),
Down(u8),
Left(String),
Right(String),
}
let direction = Direction::Up;
let direction1 = DirectionU8::Up(0);
let direction2 = DirectionU8::Left(String::from("left"));
上面的例子可以看出来,在 Rust 中,枚举成员可以灵活设置五花八门的类型,包括结构体。
回到上面的动态数组中不能存放联合类型的数据例子,通过枚举,我们也可以实现同一化:
typescript
trait Draw {
fn draw(&self);
}
struct Button {
id: u32,
}
impl Draw for Button {
fn draw(&self) {
println!("这是一个按钮 {}", self.id);
}
}
struct Select {
id: u32,
}
impl Draw for Select {
fn draw(&self) {
println!("这是一个 select {}", self.id);
}
}
enum Elements {
Button(Button),
Select(Select)
}
let elems: Vec<Elements> = vec![Elements::Button(Button { id: 1 }), Elements::Select(Select { id: 2 })];
for e in elems {
match e {
Elements::Button(button) => button.draw(),
Elements::Select(select) => select.draw(),
}
}
在取枚举值的时候,我们需要使用 match
做一个模式匹配,取出具体的元素实例。
最后值得提一下的就是 Rust 标准库里的枚举类型 Option
,定义如下:
rust
enum Option<T> {
Some(T),
None,
}
标准库大量的场景都会使用,在上一篇文章中,介绍 Rust 基础类型也提到过,它就是用来替代 TS 中的 undefined
和 null
,也就是空值的情况。
结构体
在大多数编程语言中,我们都需要一种更加高级的数据结构来抽象业务模型。在 TS 中,这种数据结构还不止一种,常用的有 interface
、Record
等,但是一般用 interface
组织代码比较多:。
typescript
interface User {
id: number;
name: string;
}
type User = Record<string, User>;
const user: User = {
id: 1,
name: 'atticus'
}
在 Rust 中,一般使用 struct
结构体去抽象高级的数据结构:
rust
struct User {
id: u64,
name: String,
}
let user = User { id: 1, name: String::from("atticus") };
assert_eq!(user.id, 1);
结构体还有一些变体,例如元组结构体和单元结构体:
rust
// 元组结构体
struct Color(i32, i32, i32);
// 单元结构体
struct AlwaysEqual;
let black = Color(0, 0, 0);
如果想给数据模型添加行为了?
在 TS 中,可以直接在 interface
中跟随其它字段一起定义:
typescript
interface User {
id: number;
name: string;
log(): void;
}
const user: User = {
id: 1,
name: 'atticus',
log() {
console.log(this.name);
}
}
在 Rust 中,结构体只是用来定义字段的,如果要添加行为,则需要结合特征(trait) 来实现:
rust
trait LogAble {
fn log(&self);
}
struct User {
id: u64,
name: String,
}
impl LogAble for User {
fn log(&self) {
println!("{}", &self.name);
}
}
let user = User { id: 1, name: String::from("atticus") };
user.log();
所以,在 Rust 的语言设计中,属性和行为的定义是分离的, 我个人也更加喜欢这种编程范式。
在 Rust 标准库中,有非常多的内置的特征实现,例如基础数据类型一般都实现了 **Copy 特征,**能使用 println 打印的数据类型一般实现了 Display 和 Debug 特征。
在类型系统中,有两种常见的类型,一种是 structural
,另一种是 nominal
。在 TS 类型系统中,它背后的设计是 structural:如果两个数据类型有一样的字段和行为即可以互相赋值给对方:
typescript
class Foo {
method(input: string): number { return 0; }
}
class Bar {
method(input: string): number { return 1; }
}
// TS 编译通过
let foo: Foo = new Bar();
interface User {
name: string,
id: number
}
interface User1 {
name: string,
id: number
}
let user: User = {
id: 1,
name: 'atticus1'
}
const user1: User1 = {
id: 2,
name: 'atticus2'
}
// TS 编译通过
user = user1;
而 Rust 中的类型设计是 nominal ,看一个例子:
rust
struct User {
name: String,
id: u32
}
struct User1 {
name: String,
id: u32
}
// ops,编译报错:mismatched types expected `User`, found `User1`
let user: User = User1 { name: String::from("atticus"), id: 1 };
TS 编译后的产物为 JS,为了兼容 JS 中既可以使用 OOP,也可以通过一定的 utils 实现 FP 的方式编程,所以其类型系统的设计需要更加灵活。而 Rust 作为一门新语言,没有任何历史包袱,为了尽可能保证类型安全,内存安全,在类型设计上需要更加严格。
小结
对于 Rust 中的复合类型,比如字符串和数组,为了尽可能提升性能,降低内存的使用,会有字符串切片和长度不可变数组的设计。
Rust 中的枚举非常强大,枚举成员可以存储复杂的数据结构,在例如想要同一化类型的时候,可以借助强大的枚举实现。只是在取值的时候,我们需要借助 match 即模式匹配来实现。作为 Rust 中最受欢迎的特性,这种范式就是基本操作。
作为 Rust 中最灵活、组织代码最重要的结构体,Rust 类型系统的属性为 nominal
,对类型要求非常严格。但是要为抽象数据结构实现行为,还需要借助特征(trait) ,这种属性和行为定义分离的设计,是我个人比较喜欢的编程范式。
最后
行百里路半九十,要熟练掌握 Rust 这门语言,学习其数据类型只是一小步。Rust 中还有很多有意思的特性值得研究,例如借用检查、生命周期、智能指针、宏等。在学习 Rust 的过程中,对比自己掌握的编程语言是一件非常有乐趣的事情。
社区很多人会觉得 Rust 学习曲线比较陡峭,我个人只赞同一半,我个人觉得学习 Rust 并不是难在对于 Rust 的一些语言机制、新的语言概念的理解。它难在学习一门系统级语言,对开发者的计算机功底的全面考验,例如内存和数据存储、并发、数据结构等知识,这部分也是我个人在学习 Rust 过程中不断补强的能力。
最后推荐 Sunface 大佬的中文教程,在帮助我入门 Rust 时提供了很大的作用:Rust语言圣经(Rust Course)。