- Box
是“指针”,指向一个在堆上分配的对象; - Vec
是“指针”,指向一组同类型的顺序排列的堆上分配的对象,且携带有当前缓存空间总大小和元素个数大小的元数据; - Rc
和Arc 也是某种形式的、携带了额外元数据的“指针”,它们提供的是一种“共享”的所有权,当所有的引用计数指针都销毁之后,它们所指向的内存空间才会被释放。
Trait
静态方法
1 | // 静态方法 |
trait中也可以定义静态函数
1 | pub trait Default{ |
trait包含default函数,无参数的函数,返回的类型是实现该trait的具体类型
Rust中没有构造函数的概念, Default trait实际上可以看作一个针对无参数构造函数的统一抽象
与C+相比, Rust定义静态函数无需使用Static关键字,因为它把self参数显式在参数列表中列出来了
扩展方法
trait给其他类型添加成员方法, 哪怕这个类型不是自己写的
1 | trait Double{ |
完整函数调用语法
1 | struct Chef; |
成员方法和普通函数没本质区别
1 | struct T(usize); |
trait约束和继承
Rust的trait另一个用处是作为泛型约束使用
1 | use std::fmt::Debug; |
my_print函数引入一个泛型T, 所以参数不是具体的类型, 而是一组类型
冒号后面加trait名字,就是泛型参数约束条件, 要求T类型实现Debug这个trait
泛型约束另一种写法是where子句1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16use std::fmt::Debug;
// fn my_print<T:Debug>(x:T){
// println!("The value is {:?}", x);
// }
fn my_print<T>(x:T) where T:Debug{
println!("{:?}", x);
}
fn main(){
my_print("China");
my_print(41_i32);
my_print(true);
my_print(['a','b','c']);
}
trait允许继承,类似这样
1 | trait Base { |
最后impl满足的时候两个都需要
1 | trait Base { |
其实trait Derived:Base{}等同于trait Derived where Slef:Base{}
Derive
Rust里面为类型impl某些trait的时候,逻辑是非常机械化的
许多类型重复而单调地impl某些trait是非常鼓噪地
为此Rust提供了特殊的attribute,自动impl某些trait
1 |
|
它的语法是在你希望impl trait类型前面写#[derive(…)], 括号里面是你希望impl地trait地名字
这样写了之后,编译器就帮你自动加上impl块,类似如下
1 | impl Copy for Foo{} |
这些都是特殊地trait,帮我们自动实现默认的逻辑
trait别名
跟type alias类似,trait也可以起别名 trait alias
1 | pub trait Service { |
总结
- trait本身可以携带泛型参数;
- trait可以用在泛型参数的约束中;
- trait可以为一组类型impl,也可以单独为某一个具体类型impl,而且它们可以同时存在;
- trait可以为某个trait impl,而不是为某个具体类型impl;
- trait可以包含关联类型,而且还可以包含类型构造器,实现高阶类型的某些功能;
- trait可以实现泛型代码的静态分派,也可以通过trait object实现动态分派;
- trait可以不包含任何方法,用于给类型做标签(marker),以此来描述类型的一些重要特性;
- trait可以包含常量。
数组和字符串
数组
数组是一个容器,它在一块连续空间内存中,存储了一系列的同样类型的数据。
数组中元素的占用空间大小必须是编译期确定的。数组本身所容纳的元素个数也必须是编译期确定的,执行阶段不可变。
如果需要使用变长的容器,可以使用标准库中的Vec/LinkedList等
1 | // 定长数组 |
在Rust中,对于两个数组类型,只有元素类型和元素个数都完全相同,这两个数组才是同类型的。
数组与指针之间不能隐式转换。同类型的数组之间可以互相赋值
1 | let mut xs:[i32; 5] = [1,2,3,4,5]; |
把数组xs作为参数传给一个函数,这个数组并不会退化成一个指针。
而是会将这个数组完整复制进这个函数。
函数体内对数组的改动不会影响到外面的数组。
对数组内部元素的访问,可以使用中括号索引的方式
1 | let mut xs:[i32; 5] = [1,2,3,4,5]; |
内置方法
比较
1 | fn main(){ |
也可以对数组执行遍历操作
1 | fn main(){ |
多维数组
既然[T; n]是一个合法的类型,那么它的元素T当然也可以是数组类型,因此[[T; m]; n]类型自然也是合法类型
1 | fn main(){ |
数组切片
对数组取借用borrow操作,可以生成一个“数组切片”(Slice)。
数组切片对数组没有“所有权”,我们可以把数组切片看作专门用于指向数组的指针,是对数组的另外一个“视图”。
比如我们有一个数组[T; n],它的借用指针的类型就是&[T; n]。
它可以通过编译器内部魔法转换为数组切片类型&[T]。
数组切片实质上还是指针,它不过是在类型系统中丢弃了编译阶段定长数组类型的长度信息,而将此长度信息存储为运行期的值
1 | fn main(){ |
变量v是[i32; 3]类型;变量s是&mut [i32; 3]类型,占用的空间大小与指针相同。它可以自动转换为&mut [i32]数组切片类型传入函数mut_array,占用的空间大小等于两个指针的空间大小。
通过这个指针,在函数内部,修改了外部的数组v的值。
DST和胖指针
Slice与普通的指针是不同的,它有一个非常形象的名字:胖指针(fat pointer)。
与这个概念相对应的概念是“动态大小类型”(Dynamic Sized Type, DST)。
所谓的DST指的是编译阶段无法确定占用空间大小的类型。为了安全性,指向DST的指针一般是胖指针。
1 | 对于不定长数组类型[T],有对应的胖指针&[T]类型; |
由于不定长数组类型[T]在编译阶段是无法判断该类型占用空间的大小的,目前我们不能在栈上声明一个不定长大小数组的变量实例,也不能用它作为函数的参数、返回值。
但是指向不定长数组的胖指针的大小是确定的,&[T]类型可以用做变量实例、函数参数、返回值。
1 | fn raw_slice(arr:&[i32]){ |
我们arr是长度为5的i32类型的数组。address是一个普通的指向arr的借用指针。
我们可以用as关键字把address转换为一个胖指针&[i32],并传递给raw_slice函数。
在raw_slice函数内部,我们利用了unsafe的transmute函数。
我们可以把它看作一个强制类型转换,类似reinterpret_cast,通过这个函数,我们把胖指针的内部数据转换成了两个usize大小的整数来看待
对于DST类型,Rust有如下限制:
- 只能通过指针来间接创建和操作DST类型,&[T] Box<[T]>可以,[T]不可以;
- 局部变量和函数参数的类型不能是DST类型,因为局部变量和函数参数必须在编译阶段知道它的大小因为目前unsized rvalue功能还没有实现;
- enum中不能包含DST类型,struct中只有最后一个元素可以是DST,其他地方不行,如果包含有DST类型,那么这个结构体也就成了DST类型。
Rust设计出DST类型,使得类型暂时系统更完善,也有助于消除一些C/C++中容易出现的bug。这一设计的好处有:
- 首先,DST类型虽然有一些限制条件,但我们依然可以把它当成合法的类型看待,比如,可以为这样的类型实现trait、添加方法、用在泛型参数中等;
- 胖指针的设计,避免了数组类型作为参数传递时自动退化为裸指针类型,丢失了长度信息的问题,保证了类型安全;
- 这一设计依然保持了与“所有权”“生命周期”等概念相容的特点。
数组切片不只是提供了“数组到指针”的安全转换,配合上Range功能,它还能提供数组的局部切片功能。
Range
Rust中的Range代表一个“区间”,一个“范围”,它有内置的语法支持,就是两个小数点..
1 | fn main(){ |
需要注意的是在begin..end这个语法中,前面是闭区间,后面是开区间。
这个语法实际上生成的是一个std::ops::Range<_>
类型的变量
1 | use std::ops::Range; |
这个类型本身实现了Iterator trait,因此它可以直接应用到循环语句中。
Range具有迭代器的全部功能,因此它能调用迭代器的成员方法。
比如我们要实现从100递减到10,中间间隔为10的序列,可以这么做
1 | fn main(){ |
在Rust中,还有其他的几种Range,包括
- std::ops::RangeFrom代表只有起始没有结束的范围,语法为start..,含义是[start, +∞);
- std::ops::RangeTo代表没有起始只有结束的范围,语法为..end,对有符号数的含义是(-∞,end),对无符号数的含义是[0, end);
- std::ops::RangeFull代表没有上下限制的范围,语法为..,对有符号数的含义是(-∞, +∞),对无符号数的含义是[0, +∞)。
数组和Range之间最常用的配合就是使用Range进行索引操作
1 | fn print_slice(arr:&[i32]){ |
边界检查
我们的“索引”都是一个合法的值,没有超过数组的长度。
如果我们给“索引”一个非法的值会怎样呢?
1 | fn main(){ |
执行thread ‘main’ panicked at ‘index out of bounds : the len is 5 but the index is 10’。
可以看出如果用/test 10,则会出现数组越界,Rust目前还无法任意索引执行编译阶段边界检查,但是在运行阶段执行了边界检查。
在Rust中,“索引”操作也是一个通用的运算符,是可以自行扩展的。
如果希望某个类型可以执行“索引”读操作,就需要该类型实现std::ops::Index trait
如果希望某个类型可以执行“索引”写操作,就需要该类型实现std::ops::IndexMut trait。
为了防止索引操作导致程序崩溃,如果我们不确定使用的“索引”是否合法,应该使用get()方法调用来获取数组中的元素,这个方法不会引起panic!,它的返回类型是Option
1 | fn main(){ |
输出结果为:“Some(10)None”
对于明显的数组越界行为,在Rust中可以通过lint检查来发现。大家可以参考“clippy”这个项目,它可以检查出这种明显的常量索引越界的现象。
总体来说,在Rust里面,靠编译阶段静态检查是无法消除数组越界的行为的。
字符串
Rust的字符串涉及两种类型,一种是&str,另外一种是String。
&str
str是Rust的内置类型。
&str是对str的借用。Rust的字符串内部默认是使用utf-8编码格式的。
而内置的char类型是4字节长度的,存储的内容是Unicode Scalar Value。
所以Rust里面的字符串不能视为char类型的数组,而更接近u8类型的数组。
实际上str类型有一种方法:fn as_ptr(&self) -> *const u8
。它内部无须做任何计算,只需做一个强制类型转换即可
这样设计有一个缺点,就是不能支持O(1)时间复杂度的索引操作。如果我们要找一个字符串s内部的第n个字符,不能直接通过s[n]得到,这一点跟其他许多语言不一样。
在Rust中,这样的需求可以通过下面的语句实现:1
s.chars().nth(n)
它的时间复杂度是O(n),因为utf-8是变长编码,如果我们不从头开始过一遍,根本不知道第n个字符的地址在什么地方。
但是综合来看,选择utf-8作为内部默认编码格式是缺陷最少的一种方式了。
相比其他的编码格式,它有相当多的优点。比如:它是大小端无关的,它跟ASCII码兼容,它是互联网上的首选编码等等。
[T]是DST类型,对应的str是DST类型。
&[T]是数组切片类型,对应的&str是字符串切片类型。
1 | fn main(){ |
&str类型也是一个胖指针
1 | println!("{:?}", std::mem::size_of::<*const()>()); |
它内部实际上包含了一个指向字符串片段头部的指针和一个长度。
所以它跟C/C++的字符串不同:C/C++里面的字符串以’\0’结尾,而Rust的字符串是可以中间包含’\0’字符的。
String
它跟&str类型的主要区别是,它有管理内存空间的权力。关于“所有权”和“借用”的关系
&str类型是对一块字符串区间的借用,它对所指向的内存空间没有所有权,哪怕&mut str也一样
1 | fn main(){ |
String可以后面追加内容,这是因为String类型在堆上动态申请了一块内存空间,它有权对这块内存空间进行扩容,内部实现类似于std::Vec
所以我们可以把这个类型作为容纳字符串的容器使用。
容器与迭代器
容器
Vec
Vec是最常用的一个容器,对应C++里面的vector。
它就是一个可以自动扩展容量的动态数组。
它重载了Index运算符,可以通过中括号取下标的形式访问内部成员。
它还重载了Deref / DerefMut运算符,因此可以自动被解引用为数组切片
1 | fn main(){ |
一个Vec中能存储的元素个数最多为std::usize::MAX个,超过了会发生panic。
因为它记录元素个数,用的就是usize类型。
如果我们指定元素的类型是0大小的类型,那么这个Vec根本不需要在堆上分配任何空间。
1 | fn main(){ |
VecDeque
VecDeque是一个双向队列。在它的头部或者尾部执行添加或者删除操作,都是效率很高的。
它的用法和Vec非常相似,主要是多了pop_front() push_front()等方法
1 | use std::collections::VecDeque; |
HashMap
HashMap<K, V, S>是基于hash算法的存储一组键值对(key-value-pair)的容器。
其中泛型参数K是键的类型,V是值的类型,S是哈希算法的类型。
hash算法的关键是,将记录的存储地址和key之间建立一个确定的对应关系。
这样当想查找某条记录时,我们根据记录的key,通过一次函数计算,就可以得到它的存储地址,进而快速判断这条记录是否存在、存储在哪里。
因此Rust的HashMap要求,key要满足Eq + Hash的约束。
Eq trait代表这个类型可以作相等比较,并且一定满足下列三个性质:
- 自反性——对任意a,满足a == a;
- 对称性——如果a == b成立,则b == a成立;
- 传递性——如果a == b且b == c成立,则a == c成立。
1 | trait Hash{ |
编译器提供derive帮助实现
1 |
|
完整使用hashMap示例
1 |
|
BTreeMap
BTreeMap<K, V>是基于B树数据结构的存储一组键值对(key-value-pair)的容器。
它跟HashMap的用途相似,但是内部存储的机制不同。
B树的每个节点包含多个连续存储的元素,以及多个子节点。
BTreeMap对key的要求是满足Ord约束,即具备“全序”特征。
1 | use std::collections::BTreeMap; |
BTreeMap比HashMap多的一项功能是,它不仅可以查询单个key的结果,还可以查询一个区间的结果
1 | use std::collections::BTreeMap; |
迭代器
1 | trait Itertor{ |
它最主要的一个方法就是next(),返回一个Option
一般情况返回Some(Item);如果迭代完成,就返回None。
实现迭代器
1 | use std::iter::Iterator; |
迭代器的组合
Rust标准库有一个命名规范,从容器创造出迭代器一般有三种方法
- iter()创造一个Item是&T类型的迭代器;
- iter_mut()创造一个Item是&mut T类型的迭代器;
- into_iter()创造一个Item是T类型的迭代器。
1 | fn main(){ |
Rust的迭代器有一个重要特点,那它就是可组合的(composability)
Iterator trait里面还有一大堆的方法,比如nth、map、filter、skip_while、take等等,这些方法都有默认实现,它们可以统称为adapters(适配器)
1 | fn main(){ |
for循环
之前都是手工直接调用迭代器的next()方法,然后使用while let语法来做循环。
实际上Rust里面更简洁、更自然地使用迭代器的方式是使用for循环
1 | use std::collections::HashMap; |
Rust的for <item> in <container> { <body> }
语法结构就是一个语法糖。
这个语法的原理其实就是调用<container>.into_iter()
方法来获得迭代器,然后不断循环调用迭代器的next()方法,将返回值解包,赋值给
只要某个类型实现了IntoIterator,那么调用into_iter()方法就可以得到对应的迭代器。
这个into_iter()方法的receiver是self,而不是&self,执行的是move语义。
这么做可以同时支持Item类型为T、&T或者&mut T,用户有选择的权力。
1 | trait IntoIterator{ |
对于一个容器类型,标准库里面对它impl了三次IntoIterator。
- 当Self类型为BTreeMap的时候,Item类型为(K, V),这意味着,每次next()方法都是把内部的元素move出来了;
- 当Self类型为&BTreeMap的时候,Item类型为(&K, &V),每次next()方法返回的是借用;
- 当Self类型为&mut BTreeMap的时候,Item类型为(&K,&mut V),每次next()方法返回的key是只读的,value是可读写的。
所以如果有个变量m,其类型为BTreeMap,那么用户可以选择使用m.into_iter()或者(&m).into_iter()或者(&mut m).into_iter(),分别达到不同的目的。
1 | // container在循环之后生命周期就结束了,循环过程中的每个item是从container中move出来的 |
生成器
在Rust里面,协程(Coroutine)是编写高性能异步程序的关键设施,生成器(Generator)是协程的基础。
简介
生成器的语法很像前面讲过的闭包,但它与闭包有一个区别,即yield关键字。
当闭包中有yield关键字的时候,它就不是一个闭包,而是一个生成器。
项目和模块
Rust用了两个概念来管理项目:一个是crate,一个是mod
- crate简单理解就是一个项目。crate是Rust中的独立编译单元。每个crate对应生成一个库或者可执行文件(如lib .dll .so .exe等)。
官方有一个crate仓库https://crates.io/,可以供用户发布各种各样的库,用户也可以直接使用这里面的开源库。 - mod简单理解就是命名空间。mod可以嵌套,还可以控制内部元素的可见性。
crate和mod有一个重要区别是:crate之间不能出现循环引用;而mod是无所谓的,mod1要使用mod2的内容,同时mod2要使用mod1的内容,是完全没问题的。
在Rust里面,crate才是一个完整的编译单元(compile unit)。
也就是说rustc编译器必须把整个crate的内容全部读进去才能执行编译,rustc不是基于单个的.rs文件或者mod来执行编译的。
作为对比C/C++里面的编译单元是单独的.c/.cpp文件以及它们所有的include文件。
每个.c/. cpp文件都是单独编译,生成.o文件,再把这些.o文件链接起来。
cargo
Cargo是Rust的包管理工具,是随着编译器一起发布的。
在使用rustup安装了官方发布的Rust开发套装之后,Cargo工具就已经安装好了,无须单独安装。
我们可以使用cargo命令来查看它的基本用法。
Cargo的官方使用文档在这个地址:https://doc.rust-lang.org/cargo/。
Cargo可以用于创建和管理项目、编译、执行、测试、管理外部下载的包和可执行文件等。
我们创建一个新的工程,这个工程会生成一个可执行程序。
步骤如下。
进入项目文件夹后,使用如下命令:
1
cargo new hello_world --bin
使用tree,查看到当前文件夹结构
1
2
3
4---hello_world
-- cargo.toml
-- src
-- main.rshello_world文件夹内,使用cargo build命令编译项目
生成的可执行文件在./target/debug/文件夹内; 使用cargo build –release命令,则可以生成release版的可执行文件,比debug优化更好使用./target/debug/hello_world命令,或者cargo run命令,可以执行我们刚生成的这个可执行程序
进入hello_world的上一层文件夹,新建一个library项目1
cargo new good_bye
lib.rs文件是库项目的入口,打开这个文件,写入以下代码1
2
3pub fn say(){
println!("good bye");
}
使用cargo build,编译通过。现在我们希望hello_world项目能引用good_bye项目。
打开hello_world项目的Cargo.toml文件,在依赖项下面添加对good_bye的引用
1 | [dependencies] |
这个写法是引用本地路径中的库。
如果要引用官方仓库中的库更简单
1 | [dependencies] |
现在在应用程序中调用这个库。打开main.rs源文件,修改代码为1
2
3
4
5
6extern create good_bye;
fn main() {
println!("Hello world!");
good_bye::say();
}
再次使用cargo run编译执行,就可以看到我们正确调用了good_bye项目中的代码。
cargo只是一个包管理工具,并不是编译器。Rust的编译器是rustc,使用cargo编译工程实际上最后还是调用的rustc来完成的。
如果我们想知道cargo在后面是如何调用rustc完成编译的,可以使用cargo build –verbose选项查看详细的编译命令。
我们可以用cargo -h来查看其他用法
- checkcheck命令可以只检查编译错误,而不做代码优化以及生成可执行程序,非常适合在开发过程中快速检查语法、类型错误。
- clean清理以前的编译结果。
- doc生成该项目的文档。
- test执行单元测试。
- bench执行benchmark性能测试。
- update升级所有依赖项的版本,重新生成Cargo.lock文件。
- install安装可执行程序。
- uninstall删除可执行程序。
其中cargo install是一个非常有用的命令,它可以让用户自己扩展cargo的子命令,为它增加新功能。
比如我们可以使用cargo install cargo-tree
,安装一个新的cargo子命令,接下来就可以使用cargo tree
项目依赖
在Cargo.toml文件中,我们可以指定一个crate依赖哪些项目。
这些依赖既可以是来自官方的crates.io,也可以是某个git仓库地址,还可以是本地文件路径。
1 | [dependencies] |
下面详细讲一下在[dependencies]里面的几种依赖项的格式
来自crates.io的依赖
只需指定它的名字及版本号即可1
2[dependencies]
lazy_static = "1.0"- ^符号,如^1.2.3代表1.2.3<=version < 2.0.0;
- ~符号,如~1.2.3代表1.2.3<=version < 1.3.0;
*
符号,如1.*
代表1.0.0 <= version < 2.0.0;- 比较符号, 比如>=1.2.3, >1.2.3多个限制条件合起来用逗号分开
来自git仓库的依赖
除了最简单的git="…"
指定repository之外,我们还可以指定对应的分支1
rand = {git=https://github.com/rust-lang-nursery/rand, branch="next"}
或者指定当前的commit号
1
rand = {git=https://github.com/rust-lang-nursery/rand, branch="master", rev="31f2663"}
还可以指定对应的tag名字
1
rang = {git=https://github.com/rust-lang-nursery/rand, tag="0.3.15"}
来自本地文件路径的依赖
指定本地文件路径,既可以使用绝对路径也可以使用相对路径。
当我们使用cargo build编译完项目后,项目文件夹内会产生一个新文件,名字叫Cargo.lock。
它实际上是一个纯文本文件,同样也是toml格式。它里面记录了当前项目所有依赖项目的具体版本。
每次编译项目的时候,如果该文件存在,cargo就会使用这个文件中记录的版本号编译项目;
如果该文件不存在,cargo就会使用Cargo.toml文件中记录的依赖项目信息,自动选择最合适的版本。
对于依赖项,我们不仅要在Cargo.toml文件中写出来,还要在源代码中写出来。
目前版本中,必须在crate的入口处(对库项目就是lib.rs文件,对可执行程序项目就是main.rs文件)写上1
2extern crate hello; // 声明外部依赖
extern crate hello as hi; // 可以重命名
配置
cargo也支持配置文件。
配置文件可以定制cargo的许多行为,就像我们给git设置配置文件一样。
类似的cargo的配置文件可以存在多份,它们之间有优先级关系。
你可以为某个文件夹单独提供一份配置文件,放置到当前文件夹的.cargo/config位置
也可以提供一个全局的默认配置,放在$HOME/.cargo/config位置。
1 | [cargo-new] |
workspace
cargo的workspace概念,是为了解决多crate的互相协调问题而存在的。
假设现在我们有一个比较大的项目。我们把它拆分成了多个crate来组织,就会面临一个问题:不同的crate会有各自不同的Cargo.toml,编译的时候它们会各自产生不同的Cargo.lock文件,我们无法保证所有的crate对同样的依赖项使用的是同样的版本号。
为了让不同的crate之间能共享一些信息,cargo提供了一个workspace的概念。一个workspace可以包含多个项目;所有的项目共享一个Cargo.lock文件,共享同一个输出目录;一个workspace内的所有项目的公共依赖项都是同样的版本,输出的目标文件都在同一个文件夹内。
workspace同样是用Cargo.toml来管理的。我们可以把所有的项目都放到一个文件夹下面。在这个文件夹下写一个Cargo.toml来管理这里的所有项目。
Cargo.toml文件中要写一个[workspace]的配置1
2
3
4
5[workspace]
members = [
"project1", "lib1"
]
整个文件夹的目录结构如下1
2
3
4
5
6
7
8
9
10
11--Cargo.lock
--Cargo.toml
--project1
--Cargo.toml
--src
--main.rs
--lib1
--cargo.toml
--src
--lib.rs
--target
我们可以在workspace的根目录执行cargo build等命令。
请注意虽然每个crate都有自己的Cargo.toml文件,可以各自配置自己的依赖项,但是每个crate下面不再会各自生成一个Cargo.lock文件,而是统一在workspace下生成一个Cargo.lock文件。
如果多个crate都依赖一个外部库,那么它们必然都是依赖的同一个版本。
build.rs
cargo工具还允许用户在正式编译开始前执行一些自定义的逻辑。方法是在Cargo.toml中配置一个build的属性
1 | [package] |
关键字
- 变量绑定\拷贝(浅拷贝只发生在栈上)\克隆(深拷贝:.clone)
- 获取变量的引用,称之为借用(borrowing)
- 引用与解引用
- 引用/可变引用
- 可变引用同时只能存在一个
- 可变引用与不可变引用不能同时存在
- Rust 专门起了一个名字 —— Non-Lexical Lifetimes(NLL),专门用于找到某个引用在作用域(})结束前就不再被使用的代码位置。
- 悬垂引用(Dangling References):意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在
- #![allow(unused_variables)] 属性标记,该标记会告诉编译器忽略未使用的变量,不要抛出 warning 警告
- unimplemented!() 告诉编译器该函数尚未实现,类似的标记还有 todo!()
- 如果我们使用 {} 来格式化输出,那对应的类型就必须实现 Display 特征,以前学习的基本类型,都默认实现了该特征
- 如果实现Debug特征, 那么对应的输出格式是{:?},#[derive(Debug)]
- 结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?} 来替代 {:?}
- dbg! 输出到标准错误输出 stderr,而 println! 输出到标准输出 stdout
- 任何类型的数据都可以放入枚举成员中: 例如字符串、数值、结构体甚至另一个枚举。
- Option 枚举用于处理空值
- Option 枚举包含两个成员,一个成员表示含有值:Some(T), 另一个表示没有值:None
- 使用 Option
值,需要编写处理每个成员的代码。你想要一些代码只当拥有 Some(T) 值时运行,允许这些代码使用其中的 T。也希望一些代码在值为 None 时运行,这些代码并没有一个可用的 T 值。match 表达式就是这么一个处理枚举的控制流结构:它会根据枚举的成员运行不同的代码,这些代码可以使用匹配到的值中的数据。 - Option类型可以用unwrap, 但是会遇到panic
- 切片的长度可以与数组不同,并不是固定的,而是取决于你使用时指定的起始和结束位置
- 创建切片的代价非常小,因为切片只是针对底层数组的一个引用
- 切片类型[T]拥有不固定的大小,而切片引用类型&[T]则具有固定的大小,因为 Rust 很多时候都需要固定大小数据类型,因此&[T]更有用,&str字符串切片也同理
- for 元素 in 集合 {
// 使用元素干一些你懂我不懂的事情
} - for item in &mut collection {
// 修改该元素,可以使用 mut 关键字
// …
}
- match 的匹配必须要穷举出所有可能,因此这里用 _ 来代表未列出的所有可能性
- match 的每一个分支都必须是一个表达式,且所有分支的表达式最终返回值的类型必须相同
X | Y,类似逻辑运算符 或,代表该分支可以匹配 X 也可以匹配 Y,只要满足一个即可
模式绑定:模式匹配的另外一个重要功能是从模式中取出绑定的值,例如下面双重match
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29enum Direction{
East,
West,
North,
South,
Data(UsState)
}
enum UsState {
Alabama,
Alaska,
// --snip--
}
let dire = Direction::Data(Alabama);
match dire {
Direction::Data(state) =>{
match state {
UsState::Alabama => {
println!("Alabama")
},
_ =>{
println!("Alabama")
}
}
}
_=>println!("Other")
}matches!:它可以将一个表达式跟模式进行匹配,然后返回匹配的结果 true or false。
变量覆盖: Some(age) = age, age代表的是内部解构的
全模式列表
- 通过序列 ..= 匹配值的范围: ..= 语法允许你匹配一个闭区间序列内的值。当模式匹配任何在此序列内的值时,该分支会执行
- 解构并分解值: let p = Point { x: 0, y: 7 }; let Point { x: a, y: b } = p;
- 解构枚举
- @绑定: @运算符允许为一个字段绑定另外一个变量
1 | enum Message{ |
- @前绑定后解构(Rust 1.56 新增)
1 |
|
- 关联函数:在impl中且没有self的函数被称之为关联函数:因为它没有self,不能用f.read()的形式调用,因此它是一个函数而不是方法,它又在impl中,与结构体紧密关联,因此称为关联函数。
self关键字
- self 表示 Rectangle 的所有权转移到该方法中,这种形式用的较少
- &self 表示该方法对 Rectangle 的不可变借用
- &mut self 表示可变借用
泛型
- 泛型 Generics:用同一功能的函数处理不同类型的数据
- 编译器建议我们给 T 添加一个类型限制:使用 std::cmp::PartialOrd 特征(Trait)对 T 进行限制,使其所有的类型都能进行比较
- 同样不是所有 T 类型都能进行相加操作,因此我们需要用 std::ops::Add
结构体泛型
1
2
3
4struct Point<T,U> {
x: T,
y: U,
}枚举泛型
1
2
3
4
5
6
7
8
9enum Option<T> {
Some(T),
None,
}
enum Result<T, E> {
Ok(T),
Err(E),
}方法中使用泛型
1
2
3
4
5
6
7
8
9struct Point3<T>{
x:T,
y:T,
}
impl <T> Point3<T> {
fn x(&self) ->&T{
&self.x
}
}函数中使用泛型
1
2
3pub fn notify<T:Talk>(item:&T){
item.say2();
}const 泛型(Rust 1.51 版本引入的重要特性)
特征
基本概念
- 特征 Trait
- #[derive(Debug)],它在我们定义的类型(struct)上自动派生 Debug 特征
默认实现
1
2
3
4
5
6
7pub trait Summary {
fn summarize_author(&self) -> String;
fn summarize(&self) -> String {
format!("(Read more from {}...)", self.summarize_author())
}
}特征约束(trait bound): T: Summary 被称为特征约束
1
2
3pub fn notify<T:Talk>(item:&T){
item.say2();
}多重约束
1
2pub fn notify2(data:&(impl Talk + Look));
pub fn notify3<T:Talk + Look>(data:&T);Where 约束
1
2
3
4
5
6
7
8
9pub fn notify4<T:Display + Clone, U:Clone + Debug>(data1:T, data2:U){
println!("{}, {:?}", data1, data2);
}
pub fn notify5<T,U>(data1:T, data2:U)
where T:Display + Clone, U:Clone + Debug
{
println!("{}, {:?}", data1, data2);
}使用特征约束有条件地实现方法或特征
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
struct Pair5<T>{
x:T,
y:T,
}
impl <T>Pair5<T> {
fn new(x:T, y:T) ->Self{
Self{
x,y
}
}
}
impl <T:Display+Debug+PartialOrd> Pair5<T> {
fn cmp_display(&self){
if self.x > self.y {
println!("{}", self.x);
} else {
println!("{:?}", self.y);
}
}
}函数返回中的 impl Trait,必须有imple当前的Trait
1
2
3
4
5
6
7
8
9
10
11
12
fn return1(switch:bool) -> impl Look{
if switch {
Person3{
age:10,
}
} else {
Person3{
age:20,
}
}
}通过 derive 派生特征: derive 派生出来的是 Rust 默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现。
调用方法需要引入特征
1
2
3
4
5
6
7
8
9
10
11
12
13use std::convert::TryInto;
fn main() {
let a: i32 = 10;
let b: u16 = 100;
let b_ = b.try_into()
.unwrap();
if a < b_ {
println!("Ten is less than one hundred.");
}
}特征对象:特征对象指向实现了 xxx 特征的类型的实例
1 | impl Draw for u8{ |
1 | 在动态类型语言中,有一个很重要的概念:鸭子类型(duck typing), |
特征的分发
特征对象的动态分发
编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发(static dispatch)
因为是在编译期完成的,对于运行期性能完全没有任何影响。
与静态分发相对应的是动态分发(dynamic dispatch),直到运行时才能确定需要调用什么方法。
之前代码中的关键字 dyn 正是在强调这一“动态”的特点。静态分发 Box
和动态分发 Box
动态的除了ptr还有vptr
Self 与 self
在 Rust 中,有两个self,一个指代当前的实例对象,一个指代特征或者方法类型的别名:
闭包
1 | |param1, param2,...| { |
- 捕获作用域中的值
- 闭包对内存的影响
当闭包从环境中捕获一个值时,会分配内存去存储这些值。对于有些场景来说,这种额外的内存分配会成为一种负担。
与之相比函数就不会去捕获这些环境值,因此定义和使用函数不会拥有这种内存负担。
三种 Fn 特征
- FnOnce,该类型的闭包会拿走被捕获变量的所有权。
Once 顾名思义,说明该闭包只能运行一次 - FnMut,它以可变借用的方式捕获了环境中的值,因此可以修改该值
- Fn 特征,它以不可变借用的方式捕获环境中的值 让我们把上面的代码中 exec 的 F 泛型参数类型修改为 Fn(&’a str):
三种 Fn 的关系
实际上,一个闭包并不仅仅实现某一种 Fn 特征,规则如下:
所有的闭包都自动实现了 FnOnce 特征,因此任何一个闭包都至少可以被调用一次
没有移出所捕获变量的所有权的闭包自动实现了 FnMut 特征
不需要对捕获变量进行改变的闭包自动实现了 Fn 特征
move 和 Fn
实际上使用了 move 的闭包依然可能实现了 Fn 或 FnMut 特征。
生命周期
三条消除规则
- 每一个引用参数都会获得独自的生命周期
- 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期
- 若存在多个输入生命周期,且其中一个是 &self 或 &mut self,则 &self 的生命周期被赋给所有的输出生命周期
智能指针
智能指针往往是基于结构体实现,它与我们自定义的结构体最大的区别在于它实现了 Deref 和 Drop 特征:
- Deref 可以让智能指针像引用那样工作,这样你就可以写出同时支持智能指针和引用的代码,例如 *T
- Drop 允许你指定智能指针超出作用域后自动执行的代码,例如做一些数据清除等收尾工作
智能指针在 Rust 中很常见,我们在本章不会全部讲解,而是挑选几个最常用、最有代表性的进行讲解:
- Box
,可以将值分配到堆上 - Rc
,引用计数类型,允许多所有权存在 - Ref
和 RefMut ,允许将借用规则检查从编译期移动到运行期进行
Box
在 Rust 中,main 线程的栈大小是 8MB,普通线程是 2MB,在函数调用时会在其中创建一个临时栈空间,调用结束后 Rust 会让这个栈空间里的对象自动进入 Drop 流程
Deref解引用
1 | struct MyBox<T>(T); |
- 函数和方法中的隐式 Deref 转换:仅引用类型的实参才会触发自动解引用
连续的隐式 Deref 转换
使用了之前自定义的智能指针 MyBox,并将其通过连续的隐式转换变成 &str 类型
- 首先 MyBox 被 Deref 成 String 类型,结果并不能满足 display 函数参数的要求
- 编译器发现 String 还可以继续 Deref 成 &str,最终成功的匹配了函数参数。
引用归一化
Rust 会在解引用时自动把智能指针和 &&&&v 做引用归一化操作,转换成 &v 形式,最终再对 &v 进行解引用:
- 把智能指针(比如在库中定义的,Box、Rc、Arc、Cow 等)从结构体脱壳为内部的引用类型,也就是转成结构体内部的 &v
- 把多重&,例如 &&&&&&&v,归一成 &v
三种Deref转换
- 当 T: Deref<Target=U>,可以将 &T 转换成 &U,也就是我们之前看到的例子
- 当 T: DerefMut<Target=U>,可以将 &mut T 转换成 &mut U
- 当 T: Deref<Target=U>,可以将 &mut T 转换成 &U
Drop 释放资源
Drop 的顺序
- 变量级别,按照逆序的方式,
_x
在_foo
之前创建,因此_x
在_foo
之后被 drop- 结构体内部,按照顺序的方式,结构体
_x
中的字段按照定义中的顺序依次 drop
1 | Rust 自动为几乎所有类型都实现了 Drop 特征 |
手动回收
对于 Rust 而言,不允许显式地调用析构函数
1 |
|
应订正为
1 |
|
他们的区别在于
- Drop::drop 只是借用了目标值的可变引用,所以,就算你提前调用了 drop,后面的代码依然可以使用目标值,但是这就会访问一个并不存在的值,非常不安全
- std::mem::drop完美拿走了所有权,而且这种实现保证了后续的使用必定会导致编译错误,因此非常安全
1 | 手动drop调用的是std::mem::drop(),自动drop调用的是std::ops::Drop::drop()。 |
Drop 使用场景
对于 Drop 而言,主要有两个功能:
- 回收内存资源
- 执行一些收尾工作
我们都无需手动去 drop 以回收内存资源,因为 Rust 会自动帮我们完成这些工作,
但是确实有极少数情况,需要你自己来回收资源的,例如文件描述符、网络 socket 等
互斥的 Copy 和 Drop
无法为一个类型同时实现 Copy 和 Drop 特征。
因为实现了 Copy 的特征会被编译器隐式的复制,因此非常难以预测析构函数执行的时间和频率。
因此这些实现了 Copy 的类型无法拥有析构函数。
Rc 与 Arc
Rust 所有权机制要求一个值只能有一个所有者,在大多数情况下,都没有问题,但是考虑以下情况:
- 在图数据结构中,多个边可能会拥有同一个节点,该节点直到没有边指向它时,才应该被释放清理
- 在多线程中,多个线程可能会持有同一个数据,但是你受限于 Rust 的安全机制,无法同时获取该数据的可变引用
Rust 在所有权机制之外又引入了额外的措施来简化相应的实现:通过引用计数的方式,允许一个数据资源在同一时刻拥有多个所有者。
Rc
Rc 正是引用计数的英文缩写。当我们希望在堆上分配一个对象供程序的多个部分使用且无法确定哪个部分最后一个结束时,就可以使用 Rc 成为数据值的所有者,例如之前提到的多线程场景就非常适合。
1 | fn case60(){ |
Rc::clone:只是引用计数增加到2,并没有克隆数据
通过Rc::strong_count(&a)查看引用计数
不可变引用
事实上,Rc
Rc 简单总结
- Rc/Arc 是不可变引用,你无法修改它指向的值,只能进行读取,如果要修改,需要配合后面章节的内部可变性 RefCell 或互斥锁 Mutex
- 一旦最后一个拥有者消失,则资源会自动被回收,这个生命周期是在编译期就确定下来的
- Rc 只能用于同一线程内部,想要用于线程之间的对象共享,你需要使用 Arc
- Rc
是一个智能指针,实现了 Deref 特征,因此你无需先解开 Rc 指针,再使用里面的 T,而是可以直接使用 T,例如上例中的 gadget1.owner.name
Arc
Rc
当然还有更深层的原因:由于 Rc
好在天无绝人之路,一起来看看 Rust 为我们提供的功能类似但是多线程安全的 Arc。
Arc 是 Atomic Rc 的缩写,顾名思义:原子化的 Rc
Arc 的性能损耗
原子化或者其它锁虽然可以带来的线程安全,但是都会伴随着性能损耗,而且这种性能损耗还不小。因此 Rust 把这种选择权交给你,毕竟需要线程安全的代码其实占比并不高,大部分时候我们开发的程序都在一个线程内。
Cell与RefCell内部可变性
Rust 规则 | 智能指针带来的额外规则 |
---|---|
一个数据只有一个所有者 | Rc/Arc让一个数据可以拥有多个所有者 |
要么多个不可变借用,要么一个可变借用 | RefCell实现编译期可变、不可变引用共存 |
违背规则导致编译错误 | 违背规则导致运行时panic |
RefCell 简单总结
- 与 Cell 用于可 Copy 的值不同,RefCell 用于引用
- RefCell 只是将借用规则从编译期推迟到程序运行期,并不能帮你绕过这个规则
- RefCell 适用于编译期误报或者一个引用被在多处代码使用、修改以至于难于管理借用关系时
- 使用 RefCell 时,违背借用规则会导致运行期的 panic
选择 Cell 还是 RefCell
- Cell 只适用于 Copy 类型,用于提供值,而 RefCell 用于提供引用
- Cell 不会 panic,而 RefCell 会
1 | // code snipet 1 |
虽然性能一致,但代码 1 拥有代码 2 不具有的优势:它能编译成功:)
总之当非要使用内部可变性时,首选 Cell,只有你的类型没有实现 Copy 时,才去选择 RefCell。
1 | fn main() { |
不能对一个不可变的值进行可变借用,这会破坏 Rust 的安全性保证
相反你可以对一个可变值进行不可变借用。
原因是:当值不可变时,可能会有多个不可变的引用指向它,此时若将修改其中一个为可变的,会造成可变引用与不可变引用共存的情况;
而当值可变时,最多只会有一个可变引用指向它,将其修改为不可变,那么最终依然是只有一个不可变的引用指向它。
循环引用与自引用
Weak与循环引用
Weak | Rc |
---|---|
不计数 | 引用计数 |
不拥有所有权 | 拥有值的所有权 |
不阻止值被释放(drop) | 所有权计数归零,才能 drop |
引用的值存在返回 Some,不存在返回 None | 引用的值必定存在 |
通过 upgrade 取到 Option<Rc |
通过 Deref 自动解引用,取值无需任何操作 |
宏
在 Rust 中宏分为两大类:声明式宏( declarative macros ) macro_rules! 和三种过程宏( procedural macros ):
- [derive],在之前多次见到的派生宏,可以为目标结构体或枚举派生指定的代码,例如 Debug 特征
- 类属性宏(Attribute-like macro),用于为目标添加自定义的属性
- 类函数宏(Function-like macro),看上去就像是函数调用
国内查看评论需要代理~