之前被同事安利了很多次Rust,周末没事去Rust官方文档学习了下,记录一些对Rust语言粗浅理解。
一. 所有权系统
要说Rust语言的核心优势,应该就是运行效率+内存安全了,这两者都与其独树一帜的所有权系统有关。要谈所有权系统,GC是个不错的切入点,众所周知,编程语言GC主要包含两种: 手动GC和自动GC,它们各有利弊,总的来说是运行效率和内存安全之间的权衡取舍。而Rust则尝试两者兼顾,Rust的GC,我将其理解为半自动GC或编译期GC,即开发者配合编译器通过所有权约束来明确变量的生命周期,这样Rust在编译期就已经知道内存应该何时释放,不需要运行时通过复杂的GC算法去解析变量的引用关系,也无需像C/C++让开发者对各种内存泄露、越界访问等问题如履薄冰。这也是Rust敢号称可靠的系统级编程语言,运行时效率叫板C/C++的底气来源。
Rust GC的核心就是所有权系统,它基于以下事实:
- 编译器能够解析局部变量的生命周期,正确管理栈内存的收缩扩张
- 堆内存最终都是通过栈变量来读取和修改
那么,我们能否让堆内存管理和栈内存管理一样轻松,成为编译期就生成好的指令呢?Rust就是沿着这个思路走的,它将堆内存的生命周期和栈变量绑定在一起,当函数栈被回收,局部变量失效时,其对应的堆内存也会被回收。
1 | { |
如代码所示,局部变量s和对应的字符串堆内存绑定在了一起,称s对这块堆内存具备所有权,当s无效时,对应String堆内存也会回收。编译器知道s的作用域,也就自然知道何时执行对String执行回收。
Rust所有权系统的核心规则如下:
- Rust 中的每一个值都有一个被称为其 所有者(owner)的变量。
- 值有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃。
规则需要简单,要达成这套规则的完备性,还需要其它系统方方面面的协助和完善。下面展开聊聊。
1. 控制权转移
当发生局部变量赋值,如执行 let s = String::from("big str"); let s1 = s;
时,Rust要么执行深拷贝,代价是运行时开销,要么浅拷贝,代价是s和s1只能有其中一个对String有所有权(否则会导致对堆内存的二次回收)。Rust选择了第二种方案,即s1拥有String的所有权,s在赋值给s1后不再有效,这之后对s的访问将会导致编译错误。在Rust中,这叫做控制权转移,此时也称let s1 = s;
是转移语义,在Rust中,变量与值的交互方式分为以下几种:
- 移动(Move)语义: 浅拷贝,且会发生控制权转移,这是Rust的默认行为
- 克隆(Clone)语义: 深拷贝,通过显式调用clone()来避免控制权转移,如
let s1 = s.clone();
,如此s1和s均可继续使用 - 复制(Copy)语义: 浅拷贝,主要针对值语义这类浅拷贝安全的场景,Rust默认为整型、布尔、字符、浮点、以及元组(当且仅当其包含的类型也都实现Copy的时候)实现了复制语义,因此对于
let a = 5; let b = a;
,不需要显式Clone,也不会发生控制转移,a和b可继续使用 - 引用(Borrowing)语义: 也叫借用语义,Rust引用类似其它语言的指针,Rust创建引用的过程也称为借用,它允许你使用值但不获取其所有权
Clone是比Copy更基础的概念,对支持Copy语义的对象,它必然也是支持Clone的(值语义的浅拷贝就是它的深拷贝)。实现上来说,Clone,Copy均是Rust提供的trait(类似OOP接口,但可包含默认实现,后面Rust OOP编程中再详说),其中Clone trait依赖Copy trait,简单来说: 所有想要实现Copy trait的类,都需要同时实现Clone trait。这样从实现层保证了所有可Copy的对象,必然是可Clone的。
小结下Copy和Clone的区别和联系:
- Clone是显式的,Rust不会在任何地方自动调用clone()执行深拷贝。Copy是隐式的,编译期识别到Copy语义对象的复制时,会自动执行简单浅拷贝,并且不会发生控制转移
- Clone是可重写的,各个类型可以自定义自己的clone()方法。Copy是不可重写的,因为编译器直接执行栈内存拷贝就行了,如果某个类型需要重写Copy,那么它就不应该是Copy语义的
- 支持Copy语义的类型必然支持Clone语义
下面这个例子进一步说明几种赋值语义,引用语义的细节将单独在下一节展开讨论。
1 | // === Case1: 移动 === |
Rust编译器会识别和检查变量类型是否实现或调用了指定trait,从而决定变量赋值是什么语义,以确定控制权归属。
2. 使用引用来避免控制权转移
按照局部变量赋值的控制权转移规则,函数返回值和函数参数的隐式赋值也会导致控制权转移:
1 | fn main() { |
可以看到,在控制权转移规则下,这种控制权转来转去的方式非常麻烦。这种情况下,更合适的做法是使用引用,在不转移控制权的前提下传递参数,但这里我们以另一个函数first_word
为例,该函数求字符串内空格分隔的第一个单词:
1 | // first_word 通过引用借用了 s1,不发生控制权转移,函数返回后也不会回收形参s指向的值 |
选用fist_word
是因为它展示了Rust引用的另一个有意思的特性。由于first_word
返回的引用结果是基于引用参数的局部引用,因此当main调用s.clear()
时,事实上也导致word引用失效了,导致得到非预期的结果。这在其它语言是指针/引用带来的难点之一,即要依靠开发者去解析内存引用关系,确保对内存的修改不会有非预期的副作用。而在Rust中,上面的代码不会通过编译!
和变量一样,Rust中的引用分为可变引用和不可变引用,可变引用需要在可变变量的基础上再显式声明: 如let r = &mut s;
Rust编译器会想尽办法保证引用的两大原则:
- 在任意给定时间,要么只能有一个可变引用,要么只能有多个不可变引用
- 引用必须总是有效的 (例如函数返回一个局部变量的引用将会得到编译错误)
结合上面的规则,s.clear
需要清空string,因此它会尝试获取s的一个可变引用(函数原型为:clear(&mut self)
),而由于s已经有一个不可变引用word,这破坏了规则1,因此编译器会报错。
对于规则2,编译器的借用检查器会比较引用和被引用数据的生命周期,确保不会出现悬挂引用,如以下代码不会编译通过:
1 | { |
以上对引用的限制,有个非常显著的好处就是避免并发数据竞争问题:
- 两个或更多指针同时访问同一数据
- 至少有一个指针被用来写入数据
- 没有同步数据访问的机制
Rust可以在编译期就避免大部分的数据竞争!
3. 生命周期注解
有Rust编译器的殚精竭虑,开发者就能安全使用这套所有权系统而高枕无忧了么,当然不是,编译器所知也仅限于编译期就能获得的信息,比如以下代码:
1 | fn longest(x: &str, y: &str) -> &str { |
上面的代码无法通过编译,因为longest函数的参数和返回值都是引用,编译器无法获悉函数返回的引用是来自于x还是来自于y(这是运行时的东西),那么前面说的借用检查器也就无法通过分析作用域保证引用的有效性了。
这个时候就需要建立一套额外的规则来辅助借用检查器,将本来应该在运行时决议的事情放到编译器来完成,Rust把这套规则叫做生命周期注解,生命周期注解本身不影响引用的生命周期,它用来指定函数的引用参数和引用返回值之间的生命周期对应关系,这样编译器就可以按照这种关系进行引用生命周期推敲,生命周期注释的语法和泛型类似(这也是比较有意思的一点,将引用生命周期像类型一样来抽象):
1 | // 'a 和泛型中的T一样,这里的注解表示: 'a 的具体生命周期等同于 x 和 y 的生命周期中较小的那一个 |
生命周期注解也可用于结构体中,用于声明结构体与其字段的生命周期关系:
1 | // 这个标注意味着 ImportantExcerpt 的实例不能比其 part 字段中的引用存在的更久 |
4. 智能指针
前面讨论的控制权转移(确保一个值只有一个所有者,它负责这个值的回收),引用(也就是指针,用于避免不必要的控制权转移),生命周期注解(用于协助编译期保证引用的有效性),主要都是围绕栈内存来的,只有String是个特例,它的实际内存会分配在堆上,以满足可变动态长度字符串的需求。在Rust中,栈内存和堆内存是被明确指定和分配的,Rust开发者通常会在出现以下情况时考虑用堆:
- 当有一个在编译时未知大小的类型,而又想要在需要确切大小的上下文中使用这个类型值的时候: 比如在String,链表
- 当有大量数据并希望在确保数据不被拷贝的情况下转移所有权的时候
- 当希望拥有一个值并只关心它的类型是否实现了特定 trait 而不是其具体类型的时候
在Rust中,有如下几种指针:
Box<T>
: 运行将数据分配在堆上,留在栈上的是指向堆数据的指针。Box<T>
会在智能指针作用域结束时回收对应堆内存。Box<T>
本身的是移动语义的,类似C++auto_ptr
。Box<T>
与Rust引用的区别在于,前者指向的是堆内存,因此总能保证是有效的,而后者通常指向的是栈内存,因此需要借用检查器,生命周期注解等机制来确保引用是有效的。Rc<T>
:Rc<T>
类似C++shared_ptr
,基于引用计数而非控制权+作用域来回收堆内存,但Rc<T>
对共享数据是只能读的(仍然受限于借用器检查,用于避免数据竞争)。Rc<T>
默认也是移动语义的,可以调用Rc::Clone(rc)
方法(比rc.clone()
方法更轻量)以获得独立的Rc<T>
并增加引用计数。RefCell<T>
: 能够基于不可变值修改其内部值,对RefCell<T>
的借用检查将发生运行时而非编译期。如以下代码会导致运行Panic:
1 | // RefCell<T>例子,以下代码会编译成功,但是运行Panic |
RefCell<T>
在天生保守的Rust编译规则下,为开发者提供了更高的灵活性,但也需要承担更大的运行时风险。一个RefCell<T>
的应用场景是,通过Mock将原本的外部IO行为(&self
参数的trait),替换为内部数据变更(&self
参数不变,但MockStruct通过持有RefCell<T>
实现内部数据可变性)。
另外,由于Rc<T>
支持对相同数据同时存在多个所有者,但是只能读数据,而RefCell<T>
允许在不可变语义下实现内部可变性,那么Rc<RefCell<T>>
就可以实现基于引用计数,可存在多个具有读写数据权限的智能指针(完整版C++ shared_ptr
):
1 | fn main() { |
5. 小结
先总结下前面提到的Rust的各种机制是如何配合所有权系统来实现通过栈内存来管理堆内存,做到运行时零GC负担的:
- 浅拷贝对象: 如i32,float,plain struct,默认直接执行栈拷贝,不涉及控制权转移,和常规语言无二
- 深拷贝对象: 比如String,通过控制权转移来保证单所有者,在所有者退出作用域时,通过Drop trait确保数据被正确回收
- 引用: 本质只是指针地址,借助编译器的借用检查器来避免数据竞态并保证引用的有效性,有时还需要开发者通过生命周期注解进行协助
- 智能指针: 和String类似,也是深拷贝对象,但提供了更灵活的内存控制,包括避免深拷贝和控制权转移,动态大小,引用计数共享数据,内部可变性等
总之,Rust编译器是天生保守的,它会尽全力拒绝那些可能不正确的程序,Rust确实能在编译期检查到很多大部分语言只能在运行期暴露的错误,这是Rust最迷人的地方之一。但是,与此同时,Rust编译器也可能会拒绝一些正确的程序,此时就需要如生命周期注解,Rc<T>
等工具来辅助编译器,甚至通过RefCell<T>
,unsafe等方案来绕过编译器检查。把编译器做厚,把运行时做薄,是Rust安全且高效,能够立足于系统级编程语言的根本。
二. 函数式特性
我在理解函数式编程中提到,现在的语言不再受限于各种编程范式的约束,而是更偏实用主义,Rust也是这样的语言,它受函数式语言的影响颇深。
1. 函数是第一类对象
函数可作为参数,返回值,动态创建,并且动态创建的函数具备捕获当前作用域上下文的能力,也就是闭包,提供标准库容器迭代器模式并支持开发者扩展等,这些都是如今大部分语言的标配,无需过多解释。
有一点需要提一下,Rust的闭包如果要捕获上下文的话,也要考虑到所有权转移的问题(转移,引用,可变引用),并且Rust编译器会尝试自动推测你的闭包希望以那种方式来捕获环境。
2. 变量可变性
Rust中的变量默认是不可变的,但也支持通过let mut x = 5;
声明可变变量。合理使用不可变变量能够利用编译器检查使代码易于推导,可重入,无副作用。
3. 模式匹配
模式匹配我最早在Erlang中接触,这个起初不是很适应的功能在用习惯之后,会发现它可以为程序提供更多对程序控制流的支配权,写出强大而简洁的代码。Rust也支持模式匹配:
1 | struct Point { |
三. 面向对象特性
1. Object
Rust提供基本的结构体字段封装和字段访问控制(可见性),并且允许在此之上扩展结构体方法及方法的可见性:
1 | pub struct MyStruct { |
2. 继承
传统OOP的继承(subclass)主要有两个作用,代码复用 和 子类化(subtype) ,如C++的继承就同时实现了这两点,继承是一把双刃剑,因为传统继承不只是有代码复用和子类化的功能,它还做到了字段复用,即对象父子内存模型的一致性,当引入对象内存模型之后,各种多重继承,菱形继承所带来的问题不堪其扰。虚基类,显式指定父类作用域或者干脆不允许多重继承等方案也是头痛医头,脚痛医脚。
近年兴起的新语言,如Golang就没有继承,它通过内嵌匿名结构体来实现代码复用,但丢失了dynamic dispatch,通过interface{}(声明式接口,隐式implement)来实现子类化,但也带来了运行时开销。
关于subclass, subtype, dynamic dispatch等概念,可以参考我之前的编程范式游记)。
在Rust中,是通过trait来实现这两者的,trait本质上是实现式接口,用于对不同类型的相同方法进行抽象。Rust的trait有如下特性:
- trait是需要显式指明实现的
- trait可以提供默认实现,但不能包含字段(部分subclass)
- trait的默认实现可以调用trait中的其它方法,哪怕这些方法没有提供默认实现(dynamic dispatch)
- trait可以用做参数或返回值用于表达满足该接口的类型实例抽象(subtype)
- trait本身也可以定义依赖(supertrait),如Copy trait依赖Clone trait
- 作为泛型约束时trait可通过+号实现拼接,表示同时满足多个接口
以下代码简单展示了Rust trait的基本特性:
1 | pub trait Summary { |
总的来说,Rust对OOP的支持是比较完善的,舍弃了继承和字段复用,通过trait来完成代码复用和子类化,避免了OOP继承的各种坑。
四. 泛型和元编程
Rust的泛型和元编程赋予语言更强大的灵活性。这里只列举个人目前学习到的一些要点。
Rust泛型的一些特性:
- 模板泛型: 在编译期填充具体类型,实现单态化
- 支持枚举泛型: 如:
enum Option<T> { Some(T), None, }
- Trait Bound:
fn notify(item: impl Summary) {...
等价于fn notify<T: Summary>(item: T) {...
等价于fn notify<T>(item: T) where T: Summary {...
- blanket implementations: 对实现了特定 trait 的类型有条件地实现方法,如标准库为任何实现了
Display
trait的类型实现了ToString
trait:impl<T: Display> ToString for T {
。这意味着你实现了A trait,标准库/第三方库就可以为你实现B trait。这是trait和泛型的一种特殊结合,也是Rust trait和传统OOP不同的地方之一
元编程能够生成代码的代码,如C++的模板由于其在预编译期处理,并且图灵完备,完全可以作为另一种语言来看待,它的执行结果就是另一种语言的代码。Rust的元编程通过宏来实现,宏的语法类似于这样:
1 |
|
这段代码能将let v: Vec<u32> = vec![1, 2, 3];
转换成:
1 | let mut temp_vec = Vec::new(); |
由于宏编程日常开发中使用较少,这里不再展开讨论。
五. 并发编程
基于Rust本身系统级编程语言的定位,Rust标准库本身只提供对OS Thread的基础抽象,即运行时本身不实现轻量级线程及其调度器,以保持其运行时的精简高效。
Rust的所有权系统设计之初是为了简化运行时的内存管理,解决内存安全问题,而Rust作为系统级编程语言,并发自然也是绕不过去的传统难题,起初Rust觉得这是两个独立的问题,然而随着所有权系统的完善,Rust发现所有权系统也能解决一系列的并发安全问题。相较于并发领域佼佼者Erlang前辈的口号”任其崩溃(let it crash)”,Rust的并发口号也是不输分毫: “无畏并发(fearless concurrency)”。下面我们来看看Rust为何如此自信,Rust支持消息交互和共享内存两种并发编程范式。
1. 消息交互
Rust消息交互CSP模型,但也与Go这类CSP语言有一些区别。
1 | fn main() { |
这个小例子有如下需要关注的细节:
- Rust 在创建 channel 时无需指定其大小,因为Rust Channel的大小是没有限制的,并且明确区分发送端和接收端,对channel的写入是永远不会阻塞的
- Rust 在创建 channel 时也无需指定其类型,这是因为 tx 和 rx 是泛型对象,编译器会根据其实际发送的数据类型来实例化泛型(如这里的
std::sync::mpsc::Sender<std::string::String>
),如果尝试对同一个 channel 发送不同类型,或者代码中没有调用tx.send
函数都将会导致编译错误 - Rust编译器本身会尝试推测闭包以何种方式捕获外部变量,但通常是保守的借用。这里 move 关键字强制闭包获取其使用的环境值的所有权,因此main函数在创建线程后对val和tx的任何访问都会导致编译错误
tx.send
也会导致val变量发生控制权转移,因此在新创建线程在tx.send(val)
之后对val的任何访问也会导致编译错误- Rust通过Send trait标记类型所有权是否能在线程间传递(只是标记,无需实现),几乎所有Rust类型的所有权都是Send的(除了像
Rc<T>
这种为了性能刻意不支持的并发的,跨线程传递会导致编译错误。应该使用线程安全的Arc<T>
)
上面的3,4其实就是我们在并发编程常犯的错误:对相同变量的非并发安全访问,由于闭包的存在,使得这类”犯罪”的成本异常低廉。而Rust的所有权系统则巧妙地在编译器就发现了这类错误,因为变量所有权只会同时在一个线程中,也就避免了数据竞争。
2. 共享内存
受Rust所有权系统的影响,Rust中的内存共享初看起来有点繁杂:
1 | use std::sync::{Mutex, Arc}; |
同样,这里面也有一些细节:
- 和channel一样,
Mutex<T>
也是泛型的,并且只能通过lock
才能得到其中的T
值,确保不会忘记加锁 - Mutex会在脱离作用域时,会自动释放锁,确保不会忘记释放锁
- 这里有多个线程需要共享Mutex的所有权,因此需要用到并发安全的引用计数智能指针
Arc<T>
(RC<T>
不是线程安全的)
六 体会
本文主要从所有权系统和编程范式的角度理解Rust,总的来说,这门语言给我的印象是很不错的。
从系统级编程语言的角度来说,它确实兼顾了安全和高效,这中间是Rust编译器在”负重前行”,其它语言的编译器更多关注语法正确性,而Rust编译器还会想尽办法分析和保证代码安全性,这也是所有权系统及其相关机制的本意,这些规则前期可能要多适应下,但遵循这些约束能够换来巨大的健壮性和运行效率收益。
从高级编程语言的角度来说,Rust从多种编程范式(过程式、函数式、面向对象、泛型、元编程等)中取其精华去其糟粕,属于编程范式融合得比较好的,比如要面向对象不要继承,要函数式编程也要控制流,可变性和性能,这让Rust具有强大的灵活性和抽象能力,在图形、音视频、Web/应用前后端等各个应用领域全面发力,包括对游戏服务器这类看重性能的应用场景而言,Rust也很有潜力。
同时,由于Rust本身的多编程范式融合,以及独有的所有权系统,初学者上手Rust学习成本还是比较高的。由于对Rust缺乏实践,本文更多还是提炼汇总,如果有合适的应用场景,倒是很愿意用Rust实践下,增强理解。