The Rust programming language is fundamentally about empowerment.
书籍基本信息
英文版: https://doc.rust-lang.org/stable/book/
中文版: https://kaisery.github.io/trpl-zh-cn/
Chapter1 Rust部署
安装
见原文https://kaisery.github.io/trpl-zh-cn/ch01-01-installation.html
笔者使用的IDE为vscode, 推荐配置rust-analyzer插件
使用Cargo管理Rust项目
cargo new [proj_name]
, 创建一个新的Rust项目cargo init
, 将当前目录初始化为一个Rust项目- 需要注意的是, 需要手动创建src目录并转移项目文件
- 因此更建议从一开始就采用cargo进行项目管理
cargo build
, 进行编译, 生成buildcargo build --release
, 花费更长的时间在编译时进行优化, 适用于release版本构建
cargo run
, 运行可执行文件, 默认以main作为入口cargo check
, 在不生成可执行文件的情况下检验代码是否可编译
Chapter2 快速入门
跟着书中教程走即可
Chapter3 编程概念
关键字(Keywords): 由语言保留使用而不允许变量或函数定义的名称, 对于Rust, 请参见附录A
3.1 变量和可变性
3.1.1 变量
- 使用关键字
let
声明变量(Variable) - 变量默认是不可改变的(Immutable)
- 直观上来说, 这样的变量不允许二次赋值
- 使用
mut
关键字修饰变量使其可变
1 | let a = 5; |
3.1.2 常量
- 使用关键字
const
声明常量(Constant) - 常量无法被
mut
修饰 - 常量必须在声明时指定类型
- 常量只能被常量表达式赋值
- 与常量相比, 不可变变量允许使用一个不确定值(运行前)进行赋值; 二者的共同点是均不允许二次赋值.
1 | let HOURS_IN_SECONDS: u32 = 60 * 60; |
Additional: Rust中的常量求值
https://doc.rust-lang.org/reference/const_eval.html#constant-evaluation
3.1.3 遮蔽
遮蔽(Shadowing): 定义一个与之前变量同名的新变量, 在作用域范围内, 此时的变量均视为后定义的变量
1 | fn main() { |
Compare: shadowing & mut
遮蔽从原来上是新建了一个变量, 因此允许覆盖定义的变量类型不同; 而可变变量从始至终都是对同一个变量操作, 因此不允许赋值不同的变量类型.
1
2
3
4
5
6 //pass
let spaces = " "; // Type: String
let spaces = spaces.len(); // Type: Integer
//error
let mut spaces = " ";
let spaces = spaces.len(); // mismatched types
3.2 数据类型
数据类型(Data Type): 包括标量(scalar)和复合(compound)
Tips: Rust是静态类型(statically typed)语言, 在编译时就必须知道所有变量的类型; 当有多种类型可能时, 需要显式指定变量类型.
3.2.1 标量类型
包括整型, 浮点型, 布尔类型和字符类型
整型
长度 | 有符号 | 无符号 |
---|---|---|
8 | i8 | u8 |
16 | i16 | u16 |
32 | i32 | u32 |
64 | i64 | u64 |
128 | i128 | u128 |
32/64 | isize | usize |
对于iszie
/usize
, 在32位机器上是32位,
在64位机器上是64位
数字字面值:
数字字面值 | 例子 |
---|---|
Decimal | 98_222(允许使用'_'作为分隔符) |
Hex | 0xff |
Octal | 0o77 |
Binary | 0b1111_0000 |
Byte(仅用于u8) | b'A' |
Additional: Rust中的整型溢出
- debug模式下Rust面对整型溢出的情况会使程序panic
--release
模式下Rust默认采用和C++相同的处理方式(补码溢出)Rust标准库提供了处理溢出的方法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15 //wrapping_*: 正常溢出
let a: u8 = 255;
assert_eq!(a.wrapping_add(1), 0); // a = 0
//checked_*: 返回None
let b: u8 = 200;
assert_eq!(b.checked_mul(2), None); // 200*2=400 > u8::MAX(255)
assert_eq!(b.checked_div(0), None); // 除零错误
//overflowing_*: 返回布尔值表示是否溢出
let (result, overflowed) = 255u8.overflowing_add(1);
assert_eq!(result, 0); // 环绕结果
assert_eq!(overflowed, true); // 标记溢出
//saturating_*: 最大/最小值处做饱和处理
assert_eq!(255u8.saturating_add(1), 255); // 上饱和到 u8::MAX
assert_eq!((-128i8).saturating_sub(1), -128); // 下饱和到 i8::MIN
assert_eq!(100u8.saturating_mul(3), 255); // 300 > 255 → 255
浮点型
遵循IEEE-754标准, 包括f32
和f64
两种类型
布尔类型
包括ture
和false
,
允许使用bool
关键字标识布尔类型
字符类型
使用关键字char
标识字符类型
Rust中字符类型长度为4Bytes(32bits), 表示Unicode标量值(Unicode
Scalar Value),
有效值为U+0000 ~ U+D7FF, U+E000 ~ U+10FFFF
3.2.2 复合类型
包括元组(tuple)和数组(array)
元组
元组的长度在声明后不会变化, 元组中元素类型允许不同
1 | let tup: (i32, f64, u8) = (500, 6.4, 1); |
数组
1 | // 数组的声明 |
Tips: Rust中的数组越界访问
rust中面临数组的越界访问, 程序会panic而非继续执行, 这避免了某些可能会出现在c/c++中的非预期结果(例如在linux下越界访问可能会引起
segmentation fault
)
3.3 函数
使用关键字fn
定义函数.
与C/C++不同, rust不关注函数定义的位置, 因此也不需要进行函数声明
1 | fn main(){ |
Additional: 语句(Statement)和表达式(Expression)
简单来说, 语句执行动作但无返回值, 表达式执行计算产生值
例如:
x + y
是一个表达式, 但let z = x + y
是一个语句在Rust中, 语句不允许作为右值出现(
let a = (let b = 3)
是非法的)
3.4 注释
- 用
//
标识的内容会被注释, 这与C/C++的用法是一致的 - 用
/*
和*/
包围的内容会被注释, 同样与C/C++一致 - 用
///
标识的内容称为文档注释, 此处暂且不做展开
3.5 控制流
3.5.1 条件控制
包括if
, else
, if else
需要注意的是, 与C/C++不同,
rust的布尔表达式不会做整型到布尔类型的自动转化,
需要显式给出number != 0
作为判断条件
1 | if number > 0{ |
3.5.2 循环控制
1 | ------ |
Chapter4 所有权
4.1 所有权
- 所有权(ownership): 所有权是Rust管理内存的一组规则
- Rust通过所有权系统管理内存, 编译器在编译时会根据一系列的规则进行检查. 如果违反了任何这些规则, 程序将不能编译
- 在运行时, 所有权系统的任何功能都不会减慢程序的运行
Tips: 想要理解所有权系统需要对基本的内存管理模型堆(heap), 栈(stack) 有所了解
4.1.1 所有权规则
- Rust中的每一个值都有一个所有者(owner)
- 值在任一时刻有且只有一个所有者
- 当所有者离开作用域, 这个值将被丢弃
4.1.2 作用域
- 作用域(scope): 作用域是一个项(item)在程序中的有效范围
1 | { // s 在这里无效,它尚未声明 |
Tips: Rust的作用域与C/C++一致, 笔者在此不再赘述
4.1.3 所有权转移
接下来以String
为例解释所有权的概念
1 | { |
Rust提供了
drop
函数用于释放内存, 当变量离开作用域时会自动调用drop
函数(也即}
处)
由于String类型在堆上分配内存, 因此其行为会变得更加复杂和"有趣", 我们来看下面的例子
1 | { |
这段代码最终会在栈上分配两个i32类型的x,y, 它们的值都是5.
1 | { |
如果String类型数据的行为和之前的i32类型相同, 那么我们期望其行为也是一致的, 也就是说,
y = x
时应当会拷贝一个String绑定到y上, 但事实并非如此被上述"特性"坑过的读者应当反应过来了, 这就是浅拷贝和深拷贝带来的问题, 在内存中的模型如下图所示:
![]()
接着来考虑一下Rust在离开某个作用域后会自动drop的机制, s1和s2在离开作用域时都会被释放, 但字符串"hello"会被二次释放(double free)!
Rust所有权可以解决这个问题, 在实际的Rust逻辑中, 当s1被赋值给s2时, s1的所有权会转移给s2, 此时s1不再有效, 如果此时试图使用s1, Rust编译器会报错
1 | { |
4.1.4 作用域与赋值
作用域, 所有权, drop释放内存之间的关系反过来也成立, 当给已有变量重新赋值时, 会调用drop释放原先的内存.
1 | { |
4.1.5 rust深拷贝
1 | { |
此时代码的行为和之前的i32类型一致
![]()
Additional: Copy类型
Rust中有一些类型是Copy类型, 这意味着这些类型的值在赋值时会被复制而不是转移所有权. 这是因为这些类型在编译时就可以知道其大小, 对其拷贝是快速且简单的. 以下是Rust中支持的Copy类型:
- 所有整数类型(i32, u32, i64, u64等)
- 布尔类型(bool)
- 浮点数类型(f32, f64)
- 字符类型(char)
- 元组类型(tuple), 只要它们的元素都是Copy类型
- 其他实现了Copy trait的类型(Rust注解, 将在后文介绍)
4.1.6 所有权与函数
- 函数参数的所有权转移
1 | fn main() { |
这里的所有权转移符合之前的预期, 当 s
被移动到
takes_ownership
函数时, s
不再有效, 而
x
由于是 Copy
类型, 在函数调用后仍然有效.
- 函数返回值的所有权转移
1 | fn main() { |
4.2 引用与借用
4.1节的所有权"似乎"解决了全部的问题, 但在某些情况下我们并不希望转移所有权, 比如说函数传参时: 如果我们想在执行完函数后继续使用原有变量, 就需要将参数的所有权从函数参数传回调用者. 这未免有些麻烦, 因此, Rust提出了引用与借用.
引用(Reference): 引用像是一个指针, 允许在不获取所有权的情况下访问数据. 但与指针不同, 引用在其生命周期中保证指向某个特定类型的有效值.
借用(Borrowing): 我们将创建引用的行为称之为借用. 默认情况下, 借用的变量无法进行修改.
1 | fn main() { |
注意到函数参数的类型是
&String
而不是String
, 这意味着s1的所有权没有转移到calculate_length
函数中, 而是通过引用传递. 这使得s1
在函数调用后仍然有效.![]()
Additional: 解引用(dereferencing)
Rust同时支持解引用操作
*
, 暂且先留到下文介绍.
4.2.1 可变引用
只读的引用显然无法满足程序员的需求, 因此Rust提供了可变引用(mutable reference).
1 | fn main() { |
可变引用有一个很大的限制:如果你有一个对该变量的可变引用,你就不能再创建对该变量的引用.
1 | fn main() { |
这一限制同时包括了不可变引用:
1 | let mut s = String::from("hello"); |
不过, Rust是允许同时拥有多个不可变引用的
Tips: 为什么要这么设计?
这个限制的好处是可以在编译时便避免数据竞争(data race).
- 两个或多个指针同时访问同一数据
- 至少有一个指针进行写操作
- 没有数据同步机制
数据竞争会导致未定义行为,难以在运行时追踪,并且难以诊断和修复;Rust 通过拒绝编译存在数据竞争的代码来避免此问题!
作用域在可变引用上也是照常生效的, 因此下面的代码是正确的:
1 | let mut s = String::from("hello"); |
1 | let mut s = String::from("hello"); |
不可变引用 r1 和 r2 的作用域在 println! 最后一次使用之后结束,这发生在可变引用 r3 被创建之前。因为它们的作用域没有重叠,所以代码是可以编译的。编译器可以在作用域结束之前判断不再使用的引用。
4.2.2 悬垂引用
- 悬垂指针(Dangling pointer):
悬垂指针是指向一个已经被释放或无效的内存地址的指针
- 在C/C++中, 悬垂指针可能会导致未定义行为, 因为程序可能会尝试访问已经被释放的内存.
Rust的所有权可以在编译期间识别出悬垂引用(Dangling reference), 从而避免此类问题.
1 | fn main() { |
正确的写法是直接将所有权转移出去
1 | fn dangle() -> String { |
4.3 Slice
- 切片(slice)允许引用集合中一段连续的元素序列,而不用引用整个集合. 切片并不拥有所有权
4.3.1 字符串 slice
- 字符串 slice (string slice): 字符串slice是String中一部分值的引用
1 | let s = String::from("hello"); |
其示意图如下:
![]()
slice语法允许省略
1 | let s = String::from("hello"); |
Tips: 字符串 slice range 的索引必须位于有效的 UTF-8 字符边界内,如果尝试从一个多字节字符的中间位置创建字符串 slice,则程序将会因错误而退出
现在来看一个string slice的例子:
1 | fn main() { |
定义一个获取字符串 slice 而不是 String 引用的函数使得我们的 API
更加通用并且不会丢失任何功能(&s[..]
相当于整个字符串的切片,
等价于&s
)
4.3.2 其他类型 slice
1 | let a = [1, 2, 3, 4, 5]; |
Chapter5 结构体
结构体(struct): 结构体是一个自定义数据类型,允许你包装和命名多个相关的值,从而形成一个有意义的组合
字段(field): 结构体中的每个值称为一个字段
5.1 结构体的定义和实例化
5.1.1 结构体定义
1 | struct User { |
5.1.2 结构体实例化
通过为每个字段指定具体值来创建这个结构体的实例
1 | let user1 = User { |
5.1.3 结构体字段访问
与C/C++相同, Rust访问结构体字段使用.
1 | fn main() { |
Tips: Rust不允许单独指定结构体某个字段可变, 只有整个结构体实例可变时才能修改其中的字段.
5.1.4 一些语法糖
Rust提供了部分简写语法糖方便程序员进行开发
1 | fn build_user(email: String, username: String) -> User { |
1 | fn build_user(email: String, username: String) -> User { |
当字段名和参数名相同时, 允许进行省略
1 | fn main() { |
1 | fn main() { |
结构体更新语法允许你使用现有实例的值来创建新的实例, 可以省略无需改变的字段设置
5.1.5 元组结构体
- 元组结构体(Tuple Struct): 元组结构体是一个没有命名字段的结构体, 其定义类似于元组
1 | struct Color(i32, i32, i32); |
5.1.6 类单元结构体
- 类单元结构体(Class-like Unit Struct): 类单元结构体没有任何字段, 但仍然是一个结构体
1 | struct AlwaysEqual; |
Tips: 类单元结构体通常用于实现特定的 trait 或作为类型标记, 关于trait请参见下文
(为什么Rust教程总是会提到一些后续才知道的东西铺垫在前面呢)
Additional: 结构体所有权
上述举的例子中结构体字段没有引用类型, 此时结构体拥有所有字段的所有权; 在这并不意味着结构体不能定义引用类型的字段, 而是需要生命周期进行标注(关于生命周期, 烦请参见下文)
5.2 方法语法
- 方法(method): 使用 fn 关键字和名称声明,可以拥有参数和返回值,同时包含在某处调用该方法时会执行的代码. 方法可以在结构体, 枚举和 trait 中定义
1 | // 用于输出调试信息的trait |
Additional: 自动引用与解引用(automatic referencing and dereferencing)
Rust会在调用方法时自动添加引用或解引用, 这使得方法调用更加简洁. 例如, 当
object.something()
调用方法时, Rust 会自动为 object 添加 &、&mut 或 * 以便使 object 与方法签名匹配.
1
2 p1.distance(&p2);
(&p1).distance(&p2); //两者等价这种自动引用的行为之所以有效,是因为方法有一个明确的接收者———— self 的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self),做出修改(&mut self)或者是获取所有权(self)
5.2.1 关联函数
- 关联函数(Associated Function): 所有在 impl
块中定义的函数被称为关联函数.
- 关联函数可以通过结构体名直接调用, 而不需要实例化结构体.
let sq = Rectangle::square(3);
- 关联函数可以通过结构体名直接调用, 而不需要实例化结构体.
1 | impl Rectangle { |
Chapter6 枚举与模式匹配
6.1 枚举的定义与使用
6.1.1 枚举的定义
1 | enum IpAddrKind { |
6.1.2 枚举值
1 | fn main() { |
6.1.3 枚举类型绑定值
Rust中枚举类型允许绑定值
1 | enum IpAddr { |
1 | struct Ipv4Addr { |
1 | enum Message { |
Option
Option是一个枚举类型, 用于表示一个值可能存在也可能不存在. 它有两个变体: Some 和 None.
1 | enum Option<T> { // <T>表示泛型, 可以是任意类型 |
其中前缀Option::
可以省略, 例如
1 | let some_number = Some(5); |
需要注意的是T
和Option<T>
并不是同一个类型,
Option<T>
是一个包含Some(T)
或None
的枚举类型,
而T
是一个具体的类型.
因此当期望对Option<T>
进行处理时,
需要转化为T
, 从而一定程度上规避了空值造成的异常.
6.2 match
match类似C/C++的switch语句, 但功能更强大, 可以匹配任意类型的值, 并且可以进行模式匹配.
1 | enum Coin { |
6.2.1 绑定值的模式
1 | // 这样可以立刻看到州的名称 |
6.2.2 Option的匹配
1 | fn plus_one(x: Option<i32>) -> Option<i32> { |
Tips: 匹配是穷尽的(exhaustive)
如果在上述代码中漏写了None的匹配, 编译器会报错
6.2.3 通配模式和_占位符
1 | let dice_roll = 9; |
1 | let dice_roll = 9; |
6.3 if let 和 let else简洁控制流
使用模式匹配时需要匹配全部情况
1 | let config_max = Some(3u8); |
if let
可以简化控制流代码
1 | let mut count = 0; |
但有时if-else的情况也会十分恼人
1 | fn describe_state_quarter(coin: Coin) -> Option<String> { |
Rust提供了 let...else
用于简化
1 | fn describe_state_quarter(coin: Coin) -> Option<String> { |