前端视角的 Rust 优秀设计学习

avatar
前端 @北京字节跳动科技有限公司

Rust 简介 & 一些学习建议

Rust 可以算是最近的网红编程语言了,连续几年占据了 stackoverflow 最受欢迎编程语言榜首,从 2020 的报告来看,受欢迎程度远超排名第二的 ts

from:insights.stackoverflow.com/survey/2020

程序员界观众的眼睛都是雪亮的,受欢迎程度绝对是实力的代表,Rust 之所以这么受欢迎,无外乎还是因为其安全、高性能、高并发的特点,而这些特点无外乎来自于其优秀的语言设计,Rust 官网是这么介绍的:「A language empowering everyone to build reliable and efficient software.」,足以体现其设计初衷;

本篇文档大部分介绍和思路出自《Rust 程序语言设计》,关于 Rust 的环境搭建、helloworld、基础语法啥的和更细节的内容就不想系统详细的介绍了,不想只做纯搬运工,大家可以自行阅读原书,或者各种能翻到的资料也会比较多;也无意表达对任何语言的推荐或是倾向性,毕竟所有编程语言只是工具,都有各自的优缺点和适用性,人才是灵活的,再优秀的语言也可以写得不堪入目,再粗糙的语言也能写出华丽的篇章,所以对各位而言,修炼内功才是最重要的 这里只是想简要分享一下 Rust 的设计与其优缺点;

我是刚入坑不久,因为日常工作的应用机会确实也不多,所以很多理解都相对比较理论,希望与感兴趣的同学共同学习实践与进步;不过呢,如果是刚学前端不久的同学,还是不太建议花时间来系统地去学习 Rust 的,毕竟 Rust 的学习曲线特别陡峭,直观感觉要有非常大面积的应用应该还需要长时间的,而且 Rust 与 JS 可以说是两个开发体验的极端,很多概念非常陌生,学多了可能会像武侠小说里两门内功一样相互冲撞;如果感兴趣的话,可以花一些小时间玩玩就好,毕竟有这个学习心态和时间不如好好啃一啃 React、Vue3.0、工程化,甚至深入研究一下 node 可能都要更日常实用一点;

Rust 的设计

Rust 的整体设计其实跟 C++ 较为接近(但也有不少差异),并且因为大家(包括我自己)可能对 JS 会更加熟悉,所以这里想与 JS 、C++ 做类比的方式,让大家可以更切身地感受到 Rust 的设计初衷与原理;至于其他比如字节主流的服务端语言 Golang,因为不是很了解,所以就不过多提及了,感兴趣的同学可以自行研究;

设计原则

Rust 的设计原则是为了追求极致的运行安全和性能,我们知道编程中没有完美之术,就像空间和时间永远只能互换,编程语言永远只能开发体验和运行体验上寻求一个平衡点,所以 Rust 为了追求极致的运行体验,在开发体验上相比于 JS 而言可能要用地狱严格模式来形容;

Rust 的设计总结来看有这么几大支柱:

  • 无垃圾收集的内存管理与安全
  • 无数据竞争风险
  • 零开销的抽象

下文会通过几方面的语言设计来一一介绍;

概念复习

这里要先带大家复(预)习一些枯燥的编程概念,因为确实跟日常工作的相关性不是很大比较容易遗忘,如果不复习一下的话,可能后文很难去理解 Rust 的一些设计初衷;

编译原理

我们知道计算机只能理解最为原始的指令/机器语言,而程序员所面向的高级语言都是需要经过一些 「转换」 为机器语言才能最终在计算机中工作;而这个过程我们一般称之为「编译」,现代的编译过程一般会分为两个过程:

  • 编译前期,包括对源代码的词法分析和语法分析,生成 AST,其中也包括了各个环节的检查和优化工作,这个是各个高级编程语言需要根据自己的语法规则完成的部分;
  • 编译后期,把优化的抽象语法树编译成不同的平台下的可执行机器代码,这里基本上就是跟操作系统打交道的事情了,现代编程语言大多是不会直接关心的,其中最广泛应用的是 LLVM;

编程语言根据其设计特点会做不同的编译器设计和优化,例如 JS 因为其语言强大的灵活性设计,目前主流的编译器主要都是采用解释的过程来运行的,例如 chrome 和 node 上的 V8 引擎,V8 的 Ignition 解释器在完成了编译前期工作后生成了字节码,然后边解析边运行而不是全部编译到机器码才能运行,这个过程中的编译效率是比较高的,但有得必有失,也因为其灵活性而不能做到一些其他语言在编译期的预处理优化,从而损失程序的运行效率;而我们的主角 Rust 则是一种重编译时的语言,并且 Rust 为了最大程度地减少运行时问题,编译的前期过程会设计得比 C++、Go 等语言更加复杂和细致,从而换取更多的编译期优化和更高的运行效率,但相对而言因为编译期的工作较多,编译过程的时间自然也会长一点,下文会详细一一介绍;

更多的细节就不想过多叙述了,不熟悉这个过程的前端同学可以想象一下 babel 的运行原理触类旁通,编译器会根据既定的规则去理解源代码,当然这也是编译器能帮助我们检查出各种源代码问题的原因,也是后文 Rust 能做到各种编译时语法检查的原因;

内存与回收机制

在如今高级语言的封装下,开发者很少需要跟内存打交道,但内存却是理解程序设计、运行机制与性能原理的核心部分

  • 比如我们都知道基本类型、引用变量(指针)是存储在栈内的,栈内存的特点是存取速度快、适合快速写入与释放、容量较少、自动出入栈,存储的是实际的基本值或者引用的内存地址,栈内存是由操作系统直接控制的;
  • 而引用的实际 value 因为需要内存的大小不固定,则是存储在堆里的,堆内存足够大和灵活,可以用来扩展,但反过来申请和释放的开销比较大,且不会被编译器自动管理,需要程序来控制分配和回收;

学习内存的运作你会发现很多很有意思的设计:拿 JS 而言,字符串是一个基础类型,又是一个明显可变大小的变量,按照这个规则字符串又怎么能存储在栈里呢,那又涉及到 V8 会用 StringTable 去存储程序中所有的字符串,其实你看到的两个相同的字符串内存地址是一样的,字符串变量其实也是引用变量,给字符串变量重新赋值、拼接字符串等操作其实跟对象一样都只是改变了引用的内存地址,是不是感觉很神奇;

这里我不想多介绍栈内存和堆内存的更多细节(实际上不复习也忘得差不多了,千万别提什么问题),只是简单提及以便说到内存释放与回收机制的时候更容易理解,与之前的篇章一样,我们还是先简单聊聊垃圾回收(Garbage Collection,GC)是什么,首先计算机的内存是固定有限的,任何程序都不可能无节制地占用内存空间,如同生活中制造的垃圾需要清理一样,已经不再使用过的变量、对象、函数都可以视为程序垃圾,所占用的内存空间都需要回收和再利用,相对应的编程语言都需要设计垃圾回收机制,例如 JS、Golang、Java 可以通过垃圾回收器的机制来自动完成垃圾收集的,也有像 C 这种需要代码来手动完成清理垃圾的,而 Rust 则是跟 C++ 一样采用了 RAII(简单理解即初始化时获取资源,离开作用域时析构函数被调用、内存被释放)的方式来管理内存,下文会详细介绍;

这里也想对垃圾回收的机制做一些简单的介绍,一般的语言都是采用的垃圾收集的机制来识别不再被使用的内存,拿 JS 举例,简单来说垃圾回收器会从根对象开始遍历(例如 web 上是 window,node 上是 global),还在被程序占用的对象则标记为有用,遍历完成没有标记的对象则被认为是需要清理的内存,再加上整理碎片内存就完成了整个过程,可以看出垃圾收集的机制非常消耗资源,垃圾回收本身在程序中也必须得保证独占式以免发生错误的回收,但从开发体验来说确实是帮助开发者屏蔽了内存这个棘手的问题,这样大家是不是更容易理解为什么 C、C++、Rust 这种以高性能高并发为首要目标没有采用自动垃圾收集的机制了;

了解了这些,不管是在 JS 中还是 Rust 中,对我们实现内存友好型编程都更有帮助;(关于内存存储的其他知识就不多叙述了,大家感兴趣可以自行了解)

语法设计与所有权系统

进入正题,要聊一门语言,就不得不先从语法的设计开始;

常用的数据类型上,Rust 和其他语言一样,也是区分基本类型和复合类型的,与 JS 的主要差别跟其他语言类似无外乎 number 是区分整型和浮点数、字符和字符串有区分、数组用 vector 定义、对象用 Hashmap 定义,这些大家简单过一下教程就能了解,每种类型都有相对应的存储方式,这里想主要说一下结构体(Struct),例如:

struct A {
    a: u8,
    b: u16,
    c: u32,
}

C++ 的同学很熟悉了,JS 的同学可能有点陌生,跟对象的定义类似,但核心差异在于不能随意更改结构体的字段,因为结构体的存储空间是独立的,且在编译阶段就可以确定下来,所以大小确定的结构体是会直接存储在栈上的,这是与 JS 对象在内存上的核心差异,至于有关结构体字段如何对齐、存储顺序,以及结构体方法的定义及存储就不多介绍了,简单示意一下:

struct A {
    a: u8,
    _pad1: [u8; 3], // 补齐存储空间
    b: u16,
    _pad2: [u8; 2], // 补齐存储空间
    c: u32,
}

再来看看 Rust 如何声明常量与变量:

// 常量
const a: i8 = 1;
// 变量
let b: i8 = 1;
// 自动类型推断
let c = 2;
let d = String::from( hello );

是不是感觉很眼熟!!没错,类型声明跟 TS 非常相似,const 与 let 的关键字也与 ES6 一致(编程语言都是借鉴来借鉴去,想想也好,减少大家的学习成本);

但是我们之前说了 Rust 相比于 JS 是严格模式,就算是变量 Rust 也是默认不允许直接修改的,例如以下代码会直接报错:

let a: i8 = 1;
a = 2;

这是因为 Rust 默认变量应该是不可修改的,如果需要明确地修改变量,可以使用 mut (意即 mutable)关键字,例如:

let mut a: i8 = 1;
a = 2;

也可以通过重新声明来直接遮盖(shadow)变量:

let a: i8 = 1;
let a: i8 = 2;

这里是遮盖而不是覆盖,意即在栈内存中是有两个变量的存在,而不是一般意义上的覆盖;

默认不可变也是 Rust 语法设计的一个基本思想,可变需要手动声明,为什么需要这样的语法设计?个人认为是从程序可读性和安全性角度出发的,比如我们在实际项目中经常会遇到比较大的文件或者函数,像 JS 这种可以随意修改变量值及类型的设计很容易因为一些历史原因或疏忽而导致变量被错误修改,所以这里也推荐大家在写 TS/JS 代码的时候,也遵从这样的设计原则,明显不会修改的变量,都用 const 来声明,已知可变的变量才用 let 来声明,尝试去接受 immutable.js 的设计和思想,虽然说实际生产中一般都会编译到 ES5 而对 JS 的运行效率没什么影响,但至少能降低开发中可能出现的风险;

所有权系统

再来谈谈 ****Rust 独有的所有权(ownership)系统设计,这一设计可以算是其高性能又兼顾内存安全的基础,按照 Rust 官方的说法,这个设计对后面其他语言是影响非常大的;以我近期的使用体验而言,熟悉了 JS 的同学第一次接触所有权系统也会比较难受,但正如 Rust 官方所说,一旦你比较熟悉了这个模式和养成了习惯,就能持之以恒地写出非常高效且安全的代码;

所有权系统的基础规则是这样的:

  1. Each value in Rust has a variable that’s called its owner.

Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。

  1. There can only be one owner at a time.

值在任一时刻有且只有一个所有者。

  1. When the owner goes out of scope, the value will be dropped.

当所有者(变量)离开作用域,这个值将被丢弃。

规则 1、2 通俗解释来看就是任何值都只能有一个所有者 owner(可以类比为 JS 中的对象引用),每个值只能有一个 owner,这个是比较关键的设计因素,当 owner 发生了转移,那么之前的 owner 就已经失效不能再被访问了,例如以下的代码在 Rust 里就会直接编译报错:

fn main() {
    let a = String::from( hello );
    let b = a;
    println!( {} , a);
}

Rust 将这个我们看似平常的赋值语句命名为了 「move」,这里字符串的所有者(owner)从 a 转移(move)到了 b,根据规则 2,a 的所有权将会失效,所以如果代码有对 a 的访问将会直接在编译时被检查出来报错:

对于函数而言,如果发生了直接的值传递也会与变量的赋值类似,例如:

fn main() {
    let s = String::from( hello );  // s 进入作用域
    takes_ownership(s);             // s 的值移动到函数里 ...
    s;                              // ... 所以到这里不再有效
}

fn takes_ownership(some_string: String) { // some_string 进入作用域
    println!( {} , some_string);
} // 这里,some_string 移出作用域并调用 `drop` 方法。占用的内存被释放

介绍到这里很多同学就会非常不适应了(包括我自己),感觉相当难受,直观感受等于变量被函数使用过一次就不能再被使用了,当然 Rust 肯定不会设计得这么蠢,如果你想再次使用,可以返回这个值再次重新使用,例如:

fn main() {
    let s1 = String::from( hello );
    let s2 = takes_and_gives_back(s1);
    takes_and_gives_back(s2);
}

// takes_and_gives_back 将传入字符串并返回该值
fn takes_and_gives_back(a_string: String) -> String { // a_string 进入作用域
    a_string  // 返回 a_string 并移出给调用的函数
}

当然以上做法给我的感觉还是有一些蠢,比如要使用多个值等会还要返回一个元祖,这肯定要被吐槽死的;所以这里更合适的做法是 Rust 设计的引用( referencing )和借用( borrow ,这里 Rust 使用了 & 和 * 操作符,熟悉 C++ 的同学应该很亲切,JS 的同学可能一脸懵逼,别着急,尝试着去重新理解,& 符号代表引用它们允许你使用这个值但不会占用和转移其所有权,相对的 * 代表解引用(dereferencing),例如:

fn main() {
    let s1 = String::from( hello );
    let len = calculate_length(&s1);
    println!( The length of '{}' is {}. , s1, len);
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

这里我们需要获取字符串的长度,就可以使用引用,并且字符串的所有权不会转移( move ,而创建这个引用的行为就叫做借用( borrow ,引用和借用是没有所有权的,所以你默认只能使用,不能修改,例如以下代码是会直接报错的:

fn main() {
    let s = String::from( hello );
    change(&s);
}

fn change(some_string: &String) {
    some_string.push_str( , world );
}

如果要修改也需要使用 mut 关键字,但是这个代码可以正常运行:

fn main() {
    let mut s = String::from( hello );
    change(&mut s);
}

fn change(some_string: &mut String) {
    some_string.push_str( , world );
}

不过,可变引用有一个很大的限制:在同一时间只能有一个对某一特定数据的可变引用。例如一下代码会直接报错:

let mut s = String::from( hello );

let r1 = &mut s;
let r2 = &mut s;

println!( {}, {} , r1, r2);

这个限制的好处是 Rust 可以在编译时就避免数据竞争。数据竞争(data race)在 C++ 中比较常见,JS 同学可能又没听过了,简单而言,数据竞争可由这三个行为造成:

  • 两个以上的指针同时访问同一内存数据。
  • 至少有一个指针被用来写入内存数据。
  • 相互之间没有同步数据的机制。

数据竞争会导致一些编程中难以察觉的问题,难以在运行时追踪、诊断和修复,Rust 则避免了这种情况的发生,因为它根本就不能通过编译,再往后就是多线程的问题了,因为 JS 本来是单线程的,前端同学基本不需要关注多线程这个概念,所以这里先不多说了;

看到这里是不是已经非常不适应了,为啥字符串变量已经是个引用了还要再为其创建引用,稍安勿躁,别弃疗;

接着看规则 3 是指,当所有者( owner )离开了其作用域,对应的值就会被销毁,相关的堆内存也会被释放,这也可以认为是 Rust 内存回收的基本原则,实际上 Rust 会自动调用 drop 函数来释放内存(你无须关心),规则 3 也直接响应了规则 2,因为垃圾回收的设计必须保证精确性,少回收多回收都会有问题,如果值同一时间有两个 owner,那么按照规则 3 其对应的内存会被释放两次,这显然是不符合预期的,所以 Rust 在浅拷贝的基础上设计了移动(move),即在浅拷贝之后让前一个引用失效;

这里要说明的是,因为以上设计是出于对堆内存回收机制的考虑,所以像整型、浮点数、布尔值这样可以直接存储在栈里的基本类型,如果发生了转移,Rust 则会直接在栈里复制( copy 一份,例如以下代码是不会报错的:

fn main() {
    let a = 123;
    let b = a;
    println!( {} , a);
}

万一你真的需要两个存储在堆里的值发生无损转移,那么只能用复制(clone)功能,例如:

fn main() {
    let a = String::from( abc );
    let b = a.clone();
    println!( {}, {} , a, b);
}

但是因为复制堆内存对性能影响较大,所以官方给出了这么一段话:

When you see a call to clone, you know that some arbitrary code is being executed and that code may be expensive. It’s a visual indicator that something different is going on.

当出现 clone 调用时,你知道一些特定的代码被执行而且这些代码可能相当消耗资源。你很容易察觉到一些不寻常的事情正在发生;

那么同样对于 JS 而言,大家在平常编程的时候是不是也能对这种堆内存复制的代码产生警觉,例如:

function func(obj) {
    // 为了避免对参数对象的更改,这里深度克隆一下
    const _obj = deepClone(obj);
    // do something with _obj
    _obj.x = _obj.x * _obj.x;
    _obj.y = _obj.y + _obj.x;
    return _obj.y * _obj.y;
}
const obj = { x: 1, y: 2, z: 3 };
common_func(obj);

以上可能为了省懒,直接把对象丢到了函数里,又为了解决无意产生的对象修改问题复制了一个对象,相对的可读性和性能可能都会比较差;但如果明确函数参数的意义,改成以下的代码,是不是感觉质量会更高?

function func(x, y) {
    // do something with x, y
    const _x = x * x;
    const _y = y + _x;
    return _y * _y;
}
const obj = { x: 1, y: 2, z: 3 };
common_func(obj.x, obj.y);

最后值得一提的是,所有权系统会在编译阶段就去检查所有代码的所有权规则,Rust 会在开发阶段就给你充分的提示,而不是等到运行时才报错,这一点可能放在 C++ 就是给你报 Runtime Exception 或者 Segment Fault,相信不少同学有学习 C++ 的经历应该都被 C++ 里的段错误折磨得死去活来过,每次看到控制台报出这简洁的一行英文,是不是都有种盖上电脑打把游戏冷静一下的冲动;想到这里,Rust 是不是让你安心很多!

所有权系统的设计出发点都是为了内存安全和运行性能,相信很多同学看完已经觉得一言难尽(好难用啊),但你细细品味又会觉得这样的设计也有其独到之处,因为它会确保所有变量都各司其职不会被滥用,会使得内存回收非常可控且不会出错,不过确实跟其他语言差别较大,所以为什么开头说学了 Rust 和其他语言相比很容易不习惯和冲突。

(当然这里并不是推荐大家在其他语言都参考这样的所有权写法哈,毕竟是 Rust 量身定制的规则,比如 JS 本来就有自己的垃圾回收,你再参考 Rust 无疑是在画蛇添足)

抽象能力

抽象能力的设计是我们评价任何一门语言相当重要的一个环节,以前写 JS 的时候可能会对语言抽象比较陌生,毕竟 JS 是一个弱类型设计,TS 横空出世之后这些概念可能又重新回到了大家的视野,这里我无意去评价弱类型和强类型的优劣,如之前所说,各有千秋,各有适用场景,但我们之前介绍了内存的有关概念,知道了以性能出发的语言一定会采用强类型设计,强类型就又需要考虑抽象性以提高开发效率;

泛型与 Trait

泛型的概念对大部分同学而言应该并不陌生,C++ 不必多说,习惯了 TS 的同学应该也会经常用到,我们在使用的时候可以先不用关注实际的类型,而是可以先表达泛型的特点,比如他们的行为或关联性,例如以下可以用来对不同类型进行排序的函数:

fn largest<T>(list: &[T]) -> T {
    let mut largest = list[0];
    for &item in list.iter() {
        if item > largest {
            largest = item;
        }
    }
    largest
}

fn main() {
    let number_list = vec![34, 50, 25, 100, 65];
    println!( The largest number is {} , largest(&number_list));
    let char_list = vec!['y', 'm', 'a', 'q'];
    println!( The largest char is {} , largest(&char_list));
}

为了方便理解,以下是 TS 的版本:

function largest<T>(list: T[]): T {
    let largest = list[0];
    for (let item of list) {
        if (item > largest) {
            largest = item;
        }
    }
    return largest;
}
const number_list: number[] = [34, 50, 25, 100, 65];
console.log( The largest number is , largest(number_list));
const char_list: string[] = ['y', 'm', 'a', 'q'];
console.log( The largest char is {} , largest(char_list));

但是 TS 因为最终运行是 JS 是不会对运行时有影响的,但强类型语言中泛型一般都会因为类型推断而影响到运行效率,Rust 自然不会允许这个问题存在,那么 Rust 是怎么解决这个问题的呢,Rust 是通过在编译时进行泛型代码的 单态化monomorphization)来保证效率的,单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。例如以下的泛型枚举值:

// Option 是 Rust 内置定义,其泛型定义如下:
enum Option<T> {
    Some(T),
    None,
}

fn main() {
    let integer = Some(5);
    let float = Some(5.0);
}

那么编译器会怎么处理这个泛型 case 呢,简单拿源代码示意一下就是:

enum Option_i32 {
    Some(i32),
    None,
}

enum Option_f64 {
    Some(f64),
    None,
}

fn main() {
    let integer = Option_i32::Some(5);
    let float = Option_f64::Some(5.0);
}

而 Trait 这个概念,为了便于理解,我们可以先想象成接口(Interface),至于接口有什么功能这里就不想赘述了,主要说一下 trait bound,这个概念一般与泛型一起使用,其实我们刚才的第一段 Rust 泛型的示例代码是会报错的,原因在于:

因为 > 这个运算符在 Rust 中被定义为标准库中 trait std``::cmp::PartialOrd 的一个默认方法。所以需要在 T 的 trait bound 中指定 PartialOrd,这样 largest 函数可以用于任何可以比较大小的类型的 slice,当然代码还是会报错:

又回到了之前说的所有权系统,list item 的所有权不能被转移(move),只有 Copy 行为才可以这样操作,所以必须要加上 T: ``PartialOrd + Copy 这样的 bound 才可以通过编译;

动态大小类型

绝大多数情况下,Rust 编译器认为类型必须具有静态已知的 Size,例如整型、结构体、数组等,以便在内存中申请对应的空间,最大程序地节省内存开销,但实际开发中并不总是这样的,总有一些场景需要用到动态 Size 的类型,Rust 中一共有 Slice 和 Trait Object 两种常见的动态大小类型(dynamically sized type);

切片(Slice)就比较容易理解了,JS 中的字符串和数组都有 slice 方法,与之相对应的 Rust 的 Slice 也是大概这个意思,但是有所不同的是 Rust 的 Slice 等同于创建一个原对象的引用,只能用引用来声明,因此是不占用其所有权的,Slice 相当于一个原始数据类型,是保存在栈里的,这里拿字符串 Slice 举例:

let s = String::from( hello world );

let hello = &s[0..5];
let world = &s[6..11];

简单示意一下内存地址来看:

    Slice         hello             world
            [––––––––––––––]   [–––––––––––––]
            │–––│–––│–––│–––│–––│–––│–––│
    stack   │ • │ 05 │   │ • │ 611│
            │–––│–––│–––│–––│–––│–––│–––│
              │                 │
              │<–––––––––––––––––
              │
              │
              v
            │–––│–––│–––│–––│–––│–––│–––│–––│–––│–––│
    heap    │ h │ e │ l │ l │ o │ w │ o │ r │ l │ d │ 
            │–––│–––│–––│–––│–––│–––│–––│–––│–––│–––│

而如果不采用切片(Slice),编译报错不能在编译时获取其大小,例如:

// 编译错误
let hello = s[0..5];

前面我们也介绍过了 Trait,但是与 TS 中的 interface 不同的是,Trait 并不能直接作为类型的声明,例如以下代码是会报错的:

trait Playable {
    fn play(&self);
    fn pause(&self) {println!( pause );}
    fn get_duration(&self) -> f32;
}

// Audio类型,实现Trait Playable
struct Audio {name: String, duration: f32}
impl Playable for Audio {
  fn play(&self) {println!( listening audio: {} , self.name);}
  fn get_duration(&self) -> f32 {self.duration}
}

fn main() {
    let x: Playable = Audio {
        name:  helloworld.mp3 .to_string(),
        duration: 30,
    };
}

其实也比较容易理解,因为 Rust 是需要明确知道所有类型大小的,trait 这种类似于接口的设计很显然不满足,所以 trait object 就是解决这个问题的,与 Slice 类似,也需要用引用(&dyn)的方式来完成声明,例如:

trait Playable {
  fn play(&self);
  fn pause(&self) {
      println!( pause );
  }
  fn get_duration(&self) -> f32;
}

// Audio类型,实现Trait Playable
struct Audio {name: String, duration: f32}
impl Playable for Audio {
  fn play(&self) {println!( listening audio: {} , self.name);}
  fn get_duration(&self) -> f32 {self.duration}
}

// Video类型,实现Trait Playable
struct Video {name: String, duration: f32}
impl Playable for Video {
  fn play(&self) {println!( watching video: {} , self.name);}
  fn pause(&self) {println!( video paused );}
  fn get_duration(&self) -> f32 {self.duration}
}

fn main() {
  let a: &dyn Playable = &Audio{
    name:  helloworld.mp3 .to_string(),
    duration: 30,
  };
  a.play();
  
  let v: &dyn Playable = &Video{
    name:  helloworld.mp4 .to_string(),
    duration: 60,
  };
  v.play();
}

可以看得出来,Rust 虽然是一个函数式编程,但也可以使用 struct 和 trait object 去模拟面向对象的部分特性,当然这个不是重点啦,关于这方面更多的应用大家可以自行了解;

可以看得出来,这两种类型可能都不能在编译期就能确定其大小(例如 Slice 可以是动态长度的切片),所以指向动态类型的指针采用的是是胖指针,该指针占用的内存大小是常规指针的两倍,包括了常规的常规指针指向的内存地址和用来存储其他信息的内存(比如字符串 slice 会额外存储其长度值,trait object 会指向一个虚拟表);

总体感觉,Rust 的抽象设计对前端 TS 的代码编写有一定的参考价值,能不能将这些实际设计多元化应用,就要看大家各自的理解了~

总结

所以综上来看 Rust 这样的设计也注定了其缺点之一是相对于其他语言的编译速度较慢,其主要原因是编译时做了大量的工作,跑一个 github 上稍微复杂点项目,可以先刷几条抖音了再回来看看了(稍微夸张一点),当然也可以看到 Rust 官方一直在用并发、增量等各种方案优化其编译速度,但基于这个设计而言,始终还是一个明显的缺点;

并且对于开发者而言,编程的限制会比较多且复杂,为什么有人戏称 Rust 是开发和编译的时候让你疯掉,C++ 是运行和调试的时候让你疯掉,JS 呢?好像这方面没啥值得吐槽的?嗯,果然 JS 是世界上对程序员最友好的语言(逃);言归正传,作为程序员而言,质量应该始终是放在第一的位置,开发的时候多掉点头发,总好过处理线上问题时掉的头发吧?

另外,Rust 毕竟是一个新兴的语言,从其开发生态、社区资源等各种方面来看,与已经发展和优化了几十年的 JS、C++ 是无法相比的,但我觉得 Rust 与 Golang 类似,虽然永远没法取代 C++ 在编程界的地位,但能够在部分场景中表现得非常亮眼,期待着有更大范围和规模的应用能将 Rust 推向新的舞台!

Rust 的应用与发展

前端视角的应用

说了这么多,相信大家也不会以为 Rust 这么复杂的设计会用来写监听点击事件给用户一个提示吧,但玩笑归玩笑,web 前端发展至今,应用之广泛与复杂早不是互联网早期所能比拟的,随着 wasm 与 Rust 的相辅相成,相信随着时间的推移,用 Rust 代替 C++ 出现在 wasm 上的应用会越来越多,在 web 音视频处理及设计工具、复杂游戏、在线 IDE 等领域我觉得大家有机会勇敢尝试就好,绝对是目前 web 技术上的首选方案;

Rust 在前端工程化领域的应用和前景,早就有 Lee 大神 leerob.io/blog/rust 为其正名,也涌现了不少例如 swc 这样的现象级项目,另外像 webpack 的大型项目编译速度问题也一直被诟病,虽然 webpack 一直在优化,但这种文本的密集计算处理本就不是 JS 擅长的领域,假以时日用 Rust 这样的高性能语言去产生新的打包和插件生态也未尝不能想象;

用为服务器端的 node ,作者在前两年就用 Rust 重新写了一版 deno,相信不少同学有所耳闻,只不过在业内的应用并不多,当然 node addon 也早早地发布了 Rust 的支持(github.com/napi-rs/nap… addon 最流行通用的编写方式,node-api 采用极为通用的方式来支持开发者编写其他语言编写的插件,通过 napi-rs 我们可以在 node 的主体程序里支持 node 不擅长的密集计算、多线程等各类问题,当然了用 C++ 也能达到相同目的,我们前面也提到了不少 Rust 与 C++ 的对比,各有千秋吧;

最后连技术的翘楚 Google (Golang 的创始公司)都在安卓上开始拥抱 Rust 代替 C++(security.googleblog.com/2021/04/rus…~

在字节的应用

无意中翻到了一篇文档:Who's using rust in bytedance ,这里借用一下,可以看到很多重要的服务是用 Rust 来实现的,另外可以看到我们的 serverless 平台也是全面支持 Rust 的,可以看出 Rust 在字节其实已经占据了一席之地,相信未来会在各个领域涌现更多对 Rust 的拥抱!!