一名 Java 开发的 Rust 学习笔记

本文首发于 2024-03-22
笔者的主力语言是 Java,近三年 Kotlin、Groovy、Go、TypeScript 写得比较多。早年间还写过一些 Python 和 JavaScript。总得来说落地在生产中的语言都是应用级语言,对于系统编程级语言接触不多。但这不妨碍我写下这么一篇笔记,说不定也有一些常年在应用层的同学想领略一下 Rust 的风采呢。
1.核心解决的问题
Rust 和 C、C++一个级别。更多是在解决 C 语言自由带来的问题:
多线程并发问题。
基本类型大小晦涩的问题:C 语言标准中对许多类型的大小并没有做强制规定,比如 int、long、double 等类型,在不同平台上都可能是不同的大小,这给许多程序员带来了不必要的麻烦。相反,Rust 在语言标准中规定好各个类型的大小,让编译器针对不同平台做适配,生成不同的代码,是更合理的选择。
尽量避免内存安全问题。
空指针:C++在访问内存地址时不会先做校验,如果尝试访问一个内存地址,但是这块内存已经被释放了。就会出错。
野指针:指针指向的位置是不可知的(随机的、不正确的、没有明确限制的)指针变量在定义时如果未初始化,其值是随机的,访问就会出错。
悬空指针:内存空间在被释放了之后,继续使用。它跟野指针类似,同样会读写已经不属于这个指针的内容。
使用未初始化内存:不只是指针类型,任何一种类型不初始化就直接使用都是危险的,造成的后果我们完全无法预测。
非法释放:内存分配和释放要配对。如果对同一个指针释放两次,会制造出内存错误。如果指针并不是内存分配器返回的值,对其执行释放操作,也是危险的。
缓冲区溢出:指针访问越界了,结果也是类似于野指针,会读取或者修改临近内存空间的值,造成危险。
执行非法函数指针:如果一个函数指针不是准确地指向一个函数地址,那么调用这个函数指针会导致一段随机数据被当成指令来执行,是非常危险的。
对象读写问题。
C++里面允许全局变量,指针爱咋传咋传,如果对全局变量不小心修改了,整个代码逻辑都会出问题。
对同一个对象的不安全读写操作:比如边遍历一个vector边对这个 vector 做着一些插入删除操作。
C 语言的思想是:尽量不对程序员做限制,尽量接近机器底层,类型安全、可变性、共享性都由程序员自由掌控,语言本身不提供太多的限制和规定。安全与否,也完全取决于程序员。所以要写好 C 代码肯定不会比写好 Java 简单的。
2.那么代价是什么?
默认的所有权机制。使很多语言过来的程序员无法适应。
基于所有权而引入的一系列机制:
借用
Copy
内部可变性
生命周期标记
特殊的错误处理机制
2.1 每个值同时只有一个 Owner(所有权机制)
每个值在 Rust 中都有一个变量来管理它,这个变量就是这个值、这块内存的所有者;
每个值在一个时间点上只有一个管理者;
当变量所在的作用域结束的时候,变量以及它代表的值将会被销毁。
这种直接赋值的方式在大多数语言中非常常见,但是在 Rust 中不行。因为它需要保证全程只有一个变量引用这块内存。
所有权还有一个 Move 的操作:一个变量可以把它拥有的值转移给另外一个变量,称为“所有权转移”。赋值语句、函数调用、函数返回等,都有可能导致所有权转移。
2.2 借用
从前面的例子看起来,Rust 中一个变量永远只能有唯一一个入口可以访问,那可就太难使用了。因此,所有权还可以借用。
借用指针的语法有两种:
&:只读借用
&mut:可读写借用
借用指针(borrow pointer)也可以称作“引用”(reference)。借用指针与普通指针的内部数据是一模一样的,唯一的区别是语义层面上的。它的作用是告诉编译器,它对指向的这块内存区域没有所有权。
对于 &mut 型指针,可能大家会混淆它与变量绑定之间的语法。如果 mut 修饰的是变量名,那么它代表这个变量可以被重新绑定;如果 mut 修饰的是“借用指针 &”,那么它代表的是被指向的对象可以被修改。
关于借用指针,有以下几个规则:
借用指针不能比它指向的变量存在的时间更长。
&mut 型借用只能指向本身具有 mut 修饰的变量,对于只读变量,不可以有 &mut 型借用。
&mut 型借用指针存在的时候,被借用的变量本身会处于“冻结”状态。
如果只有 &型借用指针,那么能同时存在多个;如果存在 &mut 型借用指针,那么只能存在一个;如果同时有其他的 &或者 &mut 型借用指针存在,那么会出现编译错误。
借用指针只能临时地拥有对这个变量读或写的权限,没有义务管理这个变量的生命周期。因此,借用指针的生命周期绝对不能大于它所引用的原来变量的生命周期,否则就是悬空指针,会导致内存不安全。
2.3 生命周期标记
前面我们提到了生命周期的概念,现在让我们展开来讲讲。
Rust 的每个引用都有自己的生命周期(lifetime),它对应着引用保持有效性的作用域。在大多数时候,生命周期都是隐式且可以被推导出来的,就如同大部分时候类型也是可以被推导的一样。当出现了多个可能的类型时,我们就必须手动声明类型。类似地,当引用的生命周期可能以不同的方式相互关联时,我们就必须手动标注生命周期。Rust 需要我们注明泛型生命周期参数之间的关系,来确保运行时实际使用的引用一定是有效的。
所以,生命周期最主要的目标在于避免悬垂引用,进而避免程序引用到非预期的数据。
而具体实现主要是在 Rust 的编译器中,名为借用检查器(borrow checker),它被用于比较不同的作用域并确定所有借用的合法性。
我们用两段简单的代码来解释这个机制。
在这里,我们将 r 的生命周期标注为了'a,并将 x 的生命周期标注为了'b。如你所见,内部的'b 代码块要小于外部的'a 生命周期代码块。在编译过程中,Rust 会比较两段生命周期的大小,并发现 r 拥有生命周期'a,但却指向了拥有生命周期'b 的内存。这段程序会由于'b 比'a 短而被拒绝通过编译:被引用对象的存在范围短于引用者。
下面这段代码修复了这个问题。
这里的 x 拥有长于'a 的生命周期'b。这也意味着 r 可以引用 x 了,因为 Rust 知道 r 中的引用在 x 有效时会始终有效。
接下来我们看一段需要手动标记生命周期的场景。
我们需要给返回类型标注一个泛型生命周期参数,因为 Rust 并不能确定返回的引用会指向 x 还是指向 y。实际上,即便是编写代码的我们也无法做出这个判断。因为函数体中的 if 代码块返回了 x 的引用,而 else 代码块则返回了 y 的引用。
在这种情况下,我们需要显示的标记生命周期标记。
这段代码的函数签名向 Rust 表明,函数所获取的两个字符串切片参数的存活时间,必须不短于给定的生命周期'a。这个函数签名同时也意味着,从这个函数返回的字符串切片也可以获得不短于'a 的生命周期。而这些正是我们需要 Rust 所保障的约束。记住,当我们在函数签名中指定生命周期参数时,我们并没有改变任何传入值或返回值的生命周期。我们只是向借用检查器指出了一些可以用于检查非法调用的约束。注意,longest 函数本身并不需要知道 x 与 y 的具体存活时长,只要某些作用域可以被用来替换'a 并满足约束就可以了。
当我们在函数中标注生命周期时,这些标注会出现在函数签名而不是函数体中。Rust 可以独立地完成对函数内代码的分析。但是,当函数开始引用或被函数外部的代码所引用时,想要单靠 Rust 自身来确定参数或返回值的生命周期,就几乎是不可能的了。函数所使用的生命周期可能在每次调用中都会发生变化。这也正是我们需要手动对生命周期进行标注的原因。
当我们将具体的引用传入 longest 时,被用于替代'a 的具体生命周期就是作用域 x 与作用域 y 重叠的那一部分。换句话说,泛型生命周期'a 会被具体化为 x 与 y 两者中生命周期较短的那一个。因为我们将返回的引用也标记为了生命周期参数'a,所以返回的引用在具化后的生命周期范围内都是有效的。
生命周期的标注并不会改变任何引用的生命周期长度。如同使用了泛型参数的函数可以接收任何类型一样,使用了泛型生命周期的函数也可以接收带有任何生命周期的引用。在不影响生命周期的前提下,标注本身会被用于描述多个引用生命周期之间的关系。
生命周期的标注使用了一种明显不同的语法:它们的参数名称必须以撇号(')开头,且通常使用全小写字符。与泛型一样,它们的名称通常也会非常简短。'a 被大部分开发者选择作为默认使用的名称。我们会将生命周期参数的标注填写在 &引用运算符之后,并通过一个空格符来将标注与引用类型区分开来。
拥有显示生命周期的引用例子:&'a i32
拥有显示生命周期的可变引用:&'a mut i32
单个生命周期的标注本身并没有太多意义,标注之所以存在是为了向 Rust 描述多个泛型生命周期参数之间的关系。例如,假设我们编写了一个函数,这个函数的参数 first 是一个指向 i32 的引用,并且拥有生命周期'a。它的另一个参数 second 同样也是指向 i32 且拥有生命周期'a 的引用。这样的标注就意味着:first 和 second 的引用必须与这里的泛型生命周期存活一样长的时间。
2.4 特殊的错误处理机制
Rust 的错误处理机制和 Go 特别像。可恢复的错误应该尽量使用 Result 类型。
对于 Result 而言,它拥有 Ok 和 Err 两个变体。其中的 Ok 变体表明当前的操作执行成功,并附带代码产生的结果值。相应地,Err 变体则表明当前的操作执行失败,并附带引发失败的具体原因。
这可能会让人联想到 go 里的if err != nil之类的啰嗦代码。好消息是 Rust 有很多语法糖,可以让代码写得很优雅。
不可恢复的错误则是使用 Panic。其作为一种“fail fast”机制,处理那种万不得已的情况(比如 FFI 场景下和 C 交互,避免 underfined behavior;线程池里一个线程 panic,及时关闭,不要把整个线程拉下水)。实现机制有两种方式:unwind 和 abort。
unwind 方式在发生 panic 的时候,会一层一层地退出函数调用栈,在此过程中,当前栈内的局部变量还可以正常析构。
abort 方式在发生 panic 的时候,会直接退出整个程序。
在常见的操作系统上,默认情况下,编译器使用的是 unwind 方式。所以在发生 panic 的时候,我们可以通过一层层地调用栈找到发生 panic 的第一现场,就像前面例子展示的那样。
但是,unwind 并不是在所有平台上都能获得良好支持的。在某些嵌入式系统上,unwind 根本无法实现,或者占用的资源太多。在这种时候,我们可以选择使用 abort 方式实现 panic。
3.Compile 与 Runtime
Rust 支持静态、动态链接。
Runtime 时程序结构封闭。但由于标准库的元编程功能强大,即便是对比 Java 这种 Runtime 灵活的语言也不会落多少下风。
4.命名规范
C 语言风格,类似 Go,越简单越好。我认为语言上偏简单的设计,则对工程师的能力要求更强。
5.标准库
Rust 官方模块管理、工具链(Cargo)的能力都是不错的。新语言没有包袱,很舒服。类似 Go。
6.Composite litera(复合字面值)
类似 Go 中的使用 field:value 的复合字面值形式对 struct 类型变量进行值构造:
这种值构造方式可以降低结构体类型使用者与结构体类型设计者之间的耦合。这个是真的很香,Groovy 和 Kotlin 也有类似的支持,很方便使用。
7.对于编程范式的支持
Rust 中还是以面向对象为主,以 Trait(有点像 Java 的抽象类,可以包含函数、常量、类型)做组合。
7.1 面向对象编程
结构体的声明以及如何 new 一个对象已经在第 6 节演示过了。演示下一个结构体如何实现 trait。
一个结构体可以实现多个 trait 的方法,trait 也可以有自己的默认方法。
7.2 函数式编程
7.2.1 Itertor(迭代器)
这块倒没有什么神秘的地方,只要实现了 Iterator 这个 trait 就可以获取迭代器。
不仅如此,我们也可以写出纯函数式风格:
如果在 Java8 里,这里会很老实一个个去计算。但在 Rust 里,编译器会做 unrolls——因为它知道每次都有 12 值,可以成批的去计算。所以用迭代器相关的接口也不用担心性能的问题,这就是 Rust 的好处——零代价抽象。
这里其实我们把 Functor(函子)的 demo 也写出来了。
7.2.2 Closure(闭包)
以一个函数为例,转换为等价逻辑的闭包:
看起来就是个语法糖,但其实没有这么简单。我们随便来抛出几个问题——当编译器把闭包语法糖转换为普通的类型和函数调用的时候:
结构体内部的成员应该用什么类型,如何初始化?应该用 u32 或是 &u32 还是 &mut u32?
函数调用的时候 self 应该用什么类型?应该写 self 或是 &self 还是 &mut self?
7.2.3 Currying(柯里化)
在计算机科学中,柯里化是把接受多个参数的函数变换成接受一个单一参数(原函数的第一个参数)的函数,并返回接受余下的参数和返回结果的新函数的技术。这个技术以逻辑学家 Haskell Curry 命名。
在 Rust 中实现 Currying 需要了解其内部的一些实现机制(见https://stackoverflow.com/questions/64005006/how-to-implement-a-multi-level-currying-function-in-rust):
第一个问题是,Fn 是什么?看起来是个关键字。是的,它其实是一个 Trait,用于实现编译后的匿名函数。诸如此类的还有 FnOnce、FnMut。
这几个 trait 的主要区别在于,被调用的时候 self 参数的类型。FnOnce 被调用的时候,self 是通过 move 的方式传递的,因此它被调用之后,这个闭包的生命周期就已经结束了,它只能被调用一次;FnMut 被调用的时候,self 是 &mut Self 类型,有能力修改当前闭包本身的成员,甚至可能通过成员中的引用,修改外部的环境变量;Fn 被调用的时候,self 是 &Self 类型,只有读取环境变量的能力。
第二个问题是,Box 是什么?dyn 又是什么?在 Rust 的编译器规则中,它需要知道每个函数返回类型需要多少空间,这就意味着类型需要被确定。那么该如何解决呢?就需用到这两个关键字了:
8.异常流:Result 与 Panic
可恢复错误一般使用 Rusult 类型,不可恢复错误一般使用 Panic,具体在 2.4 部分提到过。不再赘述。
9.并发
Rust 通过一系列基础设施和编译器检测保证了线程安全。核心是两个特殊的 trait。
std::marker::Sync:如果类型 T 实现了 Sync 类型,那说明在不同的线程中使用 &T 访问同一个变量是安全的。
std::marker::Send:如果类型 T 实现了 Send 类型,那说明这个类型的变量在不同的线程中传递所有权是安全的。
这个抽象是比较有意思的。Rust 语言本身并不知晓“线程”“并发”具体是什么,而是抽象出了一些更高级的概念 Send/Sync,用来描述类型在并发环境下的特性。
比如我们最常见的创建线程的函数 spawn,它的完整函数签名是这样的:
我们需要注意的是,参数类型 F 有重要的约束条件 F: Send + 'static, T: Send+'static。但凡在线程之间传递所有权会发生安全问题的类型,都无法在这个参数中出现,否则就是编译错误。
我们可以看到,上述函数就是一个普通函数,编译器没有对它做任何特殊处理。它能保证线程安全的关键是,它对参数有合理的约束条件。
其他基础设施:
Arc:Arc 是 Rc 的线程安全版本。它的全称是“Atomic reference counter”。注意第一个单词代表的是 atomic 而不是 automatic。它强调的是“原子性”。它跟 Rc 最大的区别在于,引用计数用的是原子整数类型。
Mutex:系统编程经典工具,锁。
RwLock:RwLock 就是“读写锁”。它跟 Mutex 很像,主要区别是对外暴露的 API 不一样。对 Mutex 内部的数据读写,RwLock 都是调用同样的 lock 方法;而对 RwLock 内部的数据读写,它分别提供了一个成员方法 read / write 来做这个事情。其他方面基本和 Mutex 一致。
Atomic:原子类型数据。
Barrier:使用一个整数做初始化,可以使得多个线程在某个点上一起等待,然后再继续执行。
Condvar:ondvar 的一个常见使用模式是和一个 Mutex<bool>类型结合使用。我们可以用 Mutex 中的 bool 变量存储一个旧的状态,在条件发生改变的时候修改它的状态。通过这个状态值,我们可以决定是否需要执行等待事件的操作。
ThreadLocal:线程局部变量。
注意:死锁问题无法通过编译检测发现。
实际上我们可以看到,Rust 保证内存安全的思路和线程安全的思路是一致的。在多线程中,我们要保证没有数据竞争,一般是通过下面的方式:
多个线程可以同时读共享变量;
只要存在一个线程在写共享变量,则不允许其他线程读/写共享变量。
Rust 如果没有“默认内存安全”打下的良好基础,其实就没办法做到“线程安全”;正因为在“内存安全”问题上的一系列基础性设计,才导致了“线程安全”基本就是水到渠成的结果。我们甚至可以观察到一些“线程安全类型”和“非线程安全类型”之间有趣的对应关系,比如:
Rc 是非线程安全的,Arc 则是与它对应的线程安全版本。当然还有弱指针 Weak 也是一一对应的。Rc 无须考虑多线程场景下的问题,因此它内部只需普通整数做引用计数即可。Arc 要用在多线程场景,因此它内部必须使用“原子整数”来做引用计数。
RefCell 是非线程安全的,它不能在跨线程场景使用。Mutex/RwLock 则是与它相对应的线程安全版本。它们都提供了“内部可变性”, RefCell 无须考虑多线程问题,所以它内部只需一个普通整数做借用计数即可。Mutex/RwLock 可以用在多线程环境,所以它们内部需要使用操作系统提供的原语来完成“锁”功能。它们有相似之处,也有不同之处。Mutex/RwLock 在加锁的时候返回的是 Result 类型,是因为它们需要考虑“异常安全”问题——在多线程环境下,很可能出现一个线程发生了 panic,导致 Mutex 内部的数据已经被破坏,而在另外一个线程中依然有可能观察到这个被破坏的数据结构。RefCell 则相对简单,只需考虑 AssertUnwindSafe 即可。
Cell 是非线程安全的,Atomic*系列类型则是与它对应的线程安全版本。它们之间的相似之处在于,都提供了“内部可变性”,而且都不提供指向内部数据的方法。它们对内部数据的读写,都是整体读出、整体写入,不可能制造出直接指向内部数据的指针。它们的不同之处在于,Cell 的条件更宽松。而标准库提供的 Atomic*系列类型则受限于 CPU 提供的原子指令,内部存储的数据类型是有限的,无法推广到所有类型。其实我们完全可以仿造 Cell 类型,设计一个可以应用于所有类型的通用型 Atomic<T>类型——内部用 Mutex 实现,提供 get / set 方法作为对外 API。这个尝试已经在第三方开源库中实现,如需了解,上 GitHub 搜索“atomic-rs”即可。
Rust 的这套线程安全设计有以下好处:
免疫一切数据竞争;
无额外性能损耗;
无须与编译器紧耦合。
我们可以观察到一个有趣的现象:Rust 语言实际上并不知晓“线程”这个概念,相关类型都是写在标准库中的,与其他类型并无二致。Rust 语言提供的仅仅只是 Sync、Send 这样的一般性概念,以及生命周期分析、“borrow check”分析这样的机制。Rust 编译器本身并未与“线程安全”“数据竞争”等概念深度绑定,也不需要一个 runtime 来辅助完成功能。然而,通过这些基本概念和机制,它却实现了完全通过编译阶段静态检查实现“免除数据竞争”这样的目标。这样的设计正是 Rust 的魅力所在。
正因为解耦合如此彻底,Rust 语言才会如此精简,它只提供了非常基本的并行开发相关的基本抽象。而且标准库中实现的这些基本功能,其实都可以完全由第三方来实现。理论上来讲,其他语言中出现了的更高级的并行程序开发的抽象机制,一般都可以通过第三方库的方式来提供,没必要与 Rust 编译器深度绑定。
10.元编程
Rust 的元编程基于宏(macro)实现。实现在编译器端来做扩展。它的调用方式为 some_macro! (...)。宏调用与普通函数调用的区别可以一眼区分开来,凡是宏调用后面都跟着一个感叹号。宏也可以通过 some_macro! [...]和 some_macro! {...}两种语法调用,只要括号能正确匹配即可。
与 C/C++中的宏不一样的是,Rust 中的宏是一种比较安全的“卫生宏”(hygiene)。首先,Rust 的宏在调用的时候跟函数有明显的语法区别;其次,宏的内部实现和外部调用者处于不同名字空间,它的访问范围严格受限,是通过参数传递进去的,我们不能随意在宏内访问和改变外部的代码。C/C++中的宏只在预处理阶段起作用,因此只能实现类似文本替换的功能。而 Rust 中的宏在语法解析之后起作用,因此可以获取更多的上下文信息,而且更加安全。
11.范型
Rust 对应范型编程支持良好,甚至还可以配合 where 关键字做一系列的条件约束。
在这个代码例子中,这个 I 必须是 Iterator 且 Item 类型。
12.方法重载
Rust 并不支持方法重载,也不支持参数默认值。Go 那边也是这么考虑的,些惯 Java 的人表示难受。
13.语法糖
Rust 应该是我见过语法糖最多的语言了。总得来说还是挺有用的,虽然面向系统底层,但是通过一些良好的 API 设计和语法糖,可以在编写系统级程序时没有那种很明显面向底层的感觉。
版权声明: 本文为 InfoQ 作者【泊浮目】的原创文章。
原文链接:【http://xie.infoq.cn/article/19dcfe0113f292194419a8add】。文章转载请联系作者。







评论