rust 学习笔记
1. 入门指南
- 安装 & 更新 & 卸载
- 安装指令
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
- 默认启动
export PATH="$HOME/.cargo/bin:$PATH
- 更新
rustup update
- 卸载
rustup self uninstall
- 安装指令
- 文件编译 & 执行
- 编译
rustc xxx.rs
生成二进制文件直接执行
- 编译
- 项目编译 & 执行
- 项目构建
cargo new xxx
- 项目编译
cargo build
在 debug 文件下生成二进制文件 - 项目执行
cargo run
可以直接执行 - 项目检查
cargo check
比 build 快,方便检查 - release 版本
cargo build --release
以及cargo run --release
正式编译
- 项目构建
2. 猜数游戏
1 | use std::io; |
- rust 变量默认是不可变的
- 不可变变量声明
let
,可变变量声明let mut
- 不可变变量声明
- rust 引用默认也是不可变的,引用访问同一份代码,没有拷贝开销
- 不可变引用
&sth
,可变引用$mut sth
- 不可变引用
expect
为了处理io::Result
的结果Err
println!
中通过{}
实现占位Cargo
最主要的功能是管理、使用第三方库- 在
Cargo.toml
中的[dependencies]
下可以添加依赖 cargo build
执行太慢换源
- 在
1 | [source.crates-io] |
- 没有修改重复使用
cargo build
很快,因为Cargo.lock
文件确保构建可重现 - 升级包版本使用
cargo update
,但是遵从语义化版本规则,跨版本升级需要手动修改[dependencies]
- rust 是静态强类型语言,能自动类型推导,也可以显示声明
- rust 允许同名变量,这一行为被称为隐藏
1 | use std::io; |
3. 通用编程概念
- 标量类型:整数、浮点数、布尔值、字符
- 复合类型:元组、数组
- rust 使用蛇形命名snake case作为函数、变量名称的风格,全小写、下划线分割
- rust 中语句 statement 和表达式 expression 有区别
- 语句执行操作但是没有返回值,比如
let x = 5;
- 表达式会计算并产生一个值作为结果,比如
{}
- 语句执行操作但是没有返回值,比如
1 | fn main() { |
- rust 在函数中通过
->
表明返回类型,函数中返回值等同于函数中最后一个表达式的值 - rust 中语句没有没有返回值,所以上面的大括号里的
x + 1
如果带上;
反而会报错 - rust 中的条件表达式会返回一个 bool 结果
1 | fn main() { |
- rust 中的
break
后可以跟返回结果
1 | fn main() { |
for
循环遍历 list 时,可以通过list.iter()
遍历集合
1 | fn main() { |
- 可以通过
..
补充集合,其中rev()
反转集合
4. 所有权
- rust 通过包含特定规则的所有权系统来管理系统,在编译过程中完成,不会产生运行时开销
- rust 中的每一个值都有一个对应的变量作为它的所有者
- 在同一时间内,值有且仅有一个所有者
- 当所有者离开自己的作用域时,它持有的值就会被释放掉
- rust 中为了避免二次释放内存,堆上如果有两个指针指向这片内存,被复制的指针(栈)会被废弃,因此以下代码会报错,这个不是浅拷贝,而是 move
1 | fn main() { |
- rust 永远不会自动地创建数据的深拷贝,确实需要深拷贝堆数据时,可以使用
clone()
方法,以下代码不会报错
1 | fn main() { |
- 拥有
Copy
这种 trait 的类型可以在赋值其他变量后保持可用性,但是如果有Drop
这种 trait 不能实现Copy
- 整数、bool、char、浮点、所有字段都可
Copy
的元组
- 整数、bool、char、浮点、所有字段都可
- 堆变量进入函数以后,就离开了作用域,不再有效
1 | fn main() { |
- rust 中将一个值赋值给另一个变量时就会转移所有权,当一个持有堆数据的变量离开作用域时,它的数据就会被
Drop
清理回收,除非这些数据的所有权移动到了另一个变量上 - rust 中可以通过
&
引用变量,而同时不交出所有权,但是同时也不能在引用中修改变量本身,因为引用默认是不可变的
1 | fn main() { |
- rust 中可变引用,需要变量本身是可变的,同时在作用域内,一个变量只能声明一个可变引用,因此在作用域里一个变量超过一个可变引用会报错
- 此外,在拥有不可变引用的情况下,不能创建可变引用
- 假设当前有某个引用,在这个引用被销毁前,编译器会确保堆空间不被销毁,也就不会出现悬垂引用
1 | fn main() { |
- rust 的字符串切片是专们用于字符串的,还有其他通用切片,比如数组切片
5. 结构体
- 在变量名与字段名相同时可以使用简化版的字段初始化方法
1 | fn build_user(emial: String, user_name: String) -> User { |
- 使用其他实例创建新的实例,
..
可以表达剩下的未被显式赋值的字段都使用相同的值
1 | let user2 = User { |
- 元组结构体依然使用
struct
关键字开头,并由结构体名称及元组类型定义组成
1 | struct Color(i32, i32, i32); |
{:#?}
告知println!
当前的结构体需要使用名为Debug
的格式化输出,#[derive(Debug)]
添加注解来派生 Debug trait
1 |
|
- 通过
impl
块可以增加方法实现,&self
代替了&Rectangle
1 | impl Rectangle { |
- Rust 会自动为调用者 object 添加
&
&mut
*
以符合方法的签名 impl
块中允许定义不接收self
作为参数的函数,他们被称为 关联函数,常被用作构造器,可以通过::
调用- Rust 中允许有多个
impl
块
6. 枚举
- rust 中枚举和结构体都可以通过
impl
定义结构体的方法
1 |
|
- rust 的
if
与match
的区别在于if
后只能是 bool,但是match
后可以是任何类型 - rust 中的
match
支持绑定被匹配对象的部分值,我们可以据此从枚举变体中提取值
1 | fn plus_one(x: Option<i32>) -> Option<i32> { |
- rust 中的匹配是穷尽的,我们需要确保代码是合法的
- rust 中可以使用
if let
作为match
的语法糖,它只在值满足某一特定模式时运行,忽略其他可能,此外可以配合else
使用
1 | fn main() { |
7. 包&模块
- rust 提供了一系列功能管理代码
- package: 用于构建、测试单元包的 cargo 功能
- crate: 用于生成库或可执行文件的树形模块
- module & use: 控制文件结构、作用域、路径的私有性
- path: 一种用于命名条目的方法
- 一个包中只能拥有最多一个单元包(库单元包或二进制单元包)
- cargo 默认将
src/main.rs
视作一个二进制单元包的根节点,将src/lib.rs
视作库单元包的根节点,它们拥有与包相同的名称 - 我们可以在路径
src/bin
下添加源文件来创建更多二进制单元包,这个路径下每个源文件被视为单独的二进制单元包 - 使用关键字
mod
可以定义一个模块,其内可以继续定义其他模块、结构体、枚举、常量、trait 或函数 - 模块结构组成模块树,模块间有父子关系,整个模块树被放在名为
crate
的隐式根模块下 - rust 中路径有两种形式,路径的标识符之间使用
::
分隔- 绝对路径:使用单元包或字面量 crate 从根节点开始
- 相对路径:使用 self、super 或内部标识符开始从当前模块开始
- rust 中所有条目默认是私有的,通过
pub
可以暴露路径
1 | mod front_of_house { |
super
关键字可以找到父模块
1 | fn serve_order() {} |
- 结构体为公共时,字段保持了私有,需要逐一决定是否公开;枚举为公共时,所有的变体都自动变为公共状态
- 当结构体有非公共字段时,需要提供一个公共的关联函数,否则无法构建实例
- 使用
use
可以引入作用域,在user
后使用相对路径需要从self
开始,而不是从当前作用域开始 - 使用
as
配合use
可以给指定内容取别名 - 可以使用
pub use
将当前引入内容重导出
8. 通用集合
- vector 连续存储多个值,string 字符集合,hash map 关联特定的键
- 可以通过
let v: Vec<i32> = Vec::new();
创建动态数组,还可以通过宏创建动态数组let v = vec![1, 2, 3];
- 可以通过
push
将元素放入 vector 中 - vector 被销毁时,其内元素也会被销毁
- 使用
&
与[]
会直接获得元素的引用,使用get
会获得Option<&T>
- vector 会遵循『不能同时拥有可变引用与不变边引用』的规则
- 需要使用解引用
*
来获得 vector 绑定的值
1 | fn main() { |
- 可以通过
+
、format!
、push_str()
、push()
拼接 String,其中push_str()
接收的是一个字符串切片,push
接收单个字符 - 字符串使用
+
会默认调用add
方法,fn add(self, s: &str) -> String
1 | let s1 = String::from("Hello, "); |
- 这里 s2 的类型是
&String
,但是符合方法&str
是因为使用了强制转换,即&s2[..]
,由于self
没有引用,所以调用结束后 s1 失效 String
是基于Vec<u8>
的封装,随着编码不同所需长度不同,所以len
不能表达内容长度,实际表达的是字节长度,使用下标会报错String
的chars()
会遍历各个 char 值,bytes()
会返回每个字节,合法的 Unicode 可能会占用 1 字节以上
1 | use std::collections::HashMap; |
- 实现了
Copy
trait 的类型,他们的值会被复制到哈希映射中,但是比如String
这种持有所有权的值,其值、所有权都会转移到哈希映射 HashMap
提供了entry()
,它返回一个枚举检查键值是否存在,如果不存在可以使用or_insert()
将参数作为新值插入,并把新值的可变引用返回
9. 错误
- Rust 错误分为『可恢复』与『不可恢复』,针对『可恢复』错误提供了
Result<T, E>
,『不可恢复错误』提供了中止运行的panic!
宏 - 当
panic!
发生时,Rust 沿着调用栈的反向顺序遍历所有函数并清理数据,也可以立即中止程序不进行清理,需要在配置中使用中止模式 - 使用
RUST_BACKTRACE=1 cargo run
可以查看回溯表 - 可以使用
match
匹配错误不同的情况
1 | use std::fs::File; |
- 这与下面的方法等价,可以少写不少
match
1 | use std::fs::File; |
unwrap
当 Result 是 Ok 时,自动解析结果,否则调用panic!
expect
在unwrap
的基础上附带错误信息,推荐使用这个- Rust 中
?
是传播错误的快捷方式,此外?
只适用于返回 Result 的函数
1 | use std::fs::File; |
- Rust 中的
?
还可以链式处理
1 | use std::io; |
- 除了以上这些直接,还可以使用
std::fs
下的fs::read_to_string()
它已经做好了封装 main
的返回类型除了()
还可以是Result<T, E>
,Box<dyn Error>
被称为trait
对象,表示任何可能的错误类型
1 | use std::error::Error; |
panic!
对外抛出异常,应该优选选择Result
- 测试时,应该使用
unwrap
与expect
作为占位符,测试时以panic!
作为标记 - 如果代码处于『损坏』状态,应该使用
panic!
;在自定义类型中,也可以配合panic!
防止代码出现『损坏』状态的情况
10. 泛型&trait&生命周期
- 抽取相同功能作为函数,抽象逻辑
1 | fn main() { |
- 与此同时希望抽象类型,引入泛型,如果需要对所有类型实现判断
>
判断还需要实现std::cmp::PartialOrd
这个 trait
1 | struct Point<T> { |
- 在方法中使用泛型,需要在方法名后立即使用泛型的定义,如上方法
x
所示,注意这里的<T>
是为了后续的使用
1 | struct Point<T, U> { |
- 如上的泛型方法中,方法
mixup
的类型V, W
仅与当前方法有关,而结果显然是T, W
,这里被类、方法的泛型共同定义 - rust 中泛型方法与具体类型的代码不会有任何速度上的差异,这是编译时完成的单态化,泛型被编译器生成了特定的定义,下面两段代码,第二段是编译器自动解析的
1 | let integer = Some(5); |
1 | enum Option_i32{ |
trait
是 rust 编译器描述某些特定类型拥有的且能够被其他类型共享的功能。它与interface
类似,但是不尽相同
1 | pub trait Summary { |
- 上面是一个实现 trait 的例子,只有当 trait 与类型定义在我们的库中时,才能实现对应的 trait,但是我们不能在『外部类』中实现『外部 trait』,这被称为孤儿规则
- trait 方法可以有默认的实现
1 | pub trait Summary { |
- trait 可以作为参数传递,比如
pub fn notify(item: impl Summary)
,这里的impl Summary
表示传入的参数必须实现Summary
这个 trait;同时它仅仅是一个语法糖,换一种写法pub fn notify<T: Summary>(item: T)
效果相同,这里的T
可以被多次使用,比如fn notify(item1: T, item2: T)
,这里的T
是同一个类型 - 通过
+
可以指定多个 trait 约束,比如pub fn notify(item: impl Summary + Display)
,这里的item
必须实现Summary
和Display
这两个 trait;同样,它也可以被写作pub fn notify<T: Summary + Display>(item: T)
,这里的T
必须实现Summary
和Display
这两个 trait - 这样的指定比较繁琐,可以使用
where
关键字,比如pub fn notify<T>(item: T) where T: Summary + Display
,这里的T
必须实现Summary
和Display
这两个 trait - 返回值也可以是 trait,比如
fn returns_summarizable() -> impl Summary
,这里的返回值必须实现Summary
这个 trait,但是注意只能返回一个类型,不能返回两个不同的类型,比如fn returns_summarizable(flag: bool) -> impl Summary { if flag { NewsArticle{} } else { Tweet{} } }
这样是不行的,因为NewsArticle
和Tweet
是不同的类型
1 | fn largest<T: PartialOrd + Copy>(list: &[T]) -> T { |
- 通过 trait 的方式实现泛型,可以避免代码重复,比如上面的
largest
函数,如果不使用泛型,那么就需要写两个函数,一个是largest_i32
,一个是largest_char
,这样就会有很多重复的代码
1 | use std::fmt::Display; |
- 上面的例子中,
Pair
结构体有一个泛型T
,它有一个new
方法,这个方法可以创建一个Pair
实例,同时Pair
还有一个cmp_display
方法,这个方法可以比较x
和y
的大小,但是这个方法有一个限制,就是T
必须实现Display
和PartialOrd
这两个 trait,这样的限制可以保证cmp_display
方法可以使用x
和y
的Display
方法,同时可以使用x
和y
的PartialOrd
方法 - 普通泛型可以确保类型拥有期望的行为,『生命周期』可以确保引用在我们的使用过程中一直有效
1 | { |
- 上面的代码将会报错,因为
x
的生命周期比r
的生命周期短,所以r
引用的x
已经被释放了,所以r
引用的是一个无效的值
1 | { |
- 上面的代码是可以正常运行的,因为
x
的生命周期比r
的生命周期长,所以r
引用的x
一直有效
1 | fn longest<'a>(x: &'a str, y: &'a str) -> &'a str { |
- 上面的代码中使用了
'a
显式声明声明周期,这里的'a
表示x
和y
的生命周期一样长,所以返回值的生命周期也是'a
,这样就可以保证返回值的引用一直有效(如果不加,我们就不知道x
与y
哪一个被返回,也就不知道他们的生命周期的长度,将会报错),上面的代码'a
的意思是返回值生命周期会使用x
与y
中生命周期较短的那个
1 | fn main() { |
- 上面的代码将会报错,因为
string2
的生命周期比result
的生命周期短,所以result
引用的string2
已经被释放了,所以result
引用的是一个无效的值 - 『生命周期』语法就是用来关联一个函数中不同参数及返回值的生命周期的。一旦它们形成了某种联系,Rust 就获得了足够的信息来支持保障内存安全的操作,并阻止那些可能会导致悬垂指针或其他违反内存安全的行为的代码
- 在么有显示标注的情况下,编译器目前有 3 种规则计算引用的生命周期:
- 一,每一个引用参数都会拥有自己的生命周期参数
- 二,当只有一个输入生命周期参数时,这个生命周期会被赋给所有输出生命周期参数
- 三,当拥有多个输入生命周期参数,其中一个是
&self
或&mut self
时,self
的生命周期会被赋给所有输出生命周期参数
- 有一种特殊的生命周期
'static
,表示整个程序的执行期
1 | fn longest_with_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str |
- 上面的代码中,
longest_with_announcement
函数有三个参数,其中ann
参数是泛型,这个泛型必须实现Display
trait,这样才能使用Display
的fmt
方法,这个泛型没有生命周期,所以不需要加'a
,但是x
和y
参数都有生命周期,所以需要加'a
,这样才能保证返回值的引用一直有效
11. 测试
- rust 中将需要测试的函数头中增加
#[test]
标注,然后使用cargo test
命令进行测试,测试函数中使用assert!
宏进行断言,如果断言失败,测试将会失败,如果断言成功,测试将会成功 assertt_eq!
与assert_ne!
宏可以用来比较两个值是否相等或不相等,后者在不相等时通过;如果是自定义类型,需要自行实现PartialEq
和Debug
这两个 trait,可以再定义的上方添加#[derive(PartialEq, Debug)]
注解来自动实现这两个 trait- 在
assert
中还可以增加自定义的错误信息,当断言失败时,可以根据错误信息来判断是哪里出错了 - 可以在
#[test]
下方使用#[shold_panic]
表示预期出现 panic,如果没有出现 panic,测试将会失败,同时参数expect
表示预期中的 panic 信息,如果 panic 信息不一致,测试也将会失败 - 此外还可以使用
Result<T, E>
编写测试,此时不要加上#[shold_panic]
,而是应该直接返回一个 Err 值,同时可以使用?
运算符来简化代码 cargo test
默认使用并行执行,可能因此出现测试错误,可以使用cargo test -- --test-threads=1
控制执行线程数- 使用
cargo test -- --nocapture
可以显示测试函数中的打印信息 - 可以使用
cargo test name
中只要包含name
的测试函数将会被执行 - 可以在代码中通过
#[ignore]
来忽略某些测试函数,此外可以通过cargo test -- --ignored
来执行被忽略的测试函数 - 集成测试代码被放在
src
目录同级的tests
文件中,集成测试中需要使用use xxx
这是因为集成测试中每一个文件都是一个独立的包,同时不需要使用[cfg(test)]
因为 rust 对tests
目录做了特殊处理 - 因为集成测试中的代码都是独立的包,所以需要一个库来引入其他包,如果没有
src/lib.rs
,我们可以创建一个空的src/lib.rs
文件,然后在Cargo.toml
中增加[[bin]]
来创建一个二进制包,这样就可以在tests
中引入这个二进制包了
12. 命令行程序
std::env
可以获取输入信息,入参&x[0]
存储的是程序的名称,&x[1]
存储的是第一个参数,以此类推- 将程序拆分成
main.rs
和lib.rs
做到关注点分离,main.rs
中只有一些简单的逻辑,而lib.rs
中包含所有的业务逻辑,这样可以方便测试