【Lang.0x01】Rust 学习笔记
施工中…
先从 Rust 手册 中的 猜数字游戏 开始,熟悉一下这门语言。
猜数字小游戏中遇到的东西
1 | |
关联函数
关联函数是针对某个类型实现的函数。如 String::new() 创建一个新的空字符串。
好复杂的输入
1 | |
io::stdin() 返回一个 std::io::Stdin 类的实例。
该实例的 read_line 方法将用户输入追加到一个字符串中。
read_line 方法返回一个 Result 类型的实例。Result是一个枚举类型,有两个值 Ok 和 Err。Result 类的 expect 方法检测实例的值,如果是 Err,则使程序崩溃并输出 expect 函数的参数。
println! 宏
把 {} 包裹的字符串识别为变量。也可以 {} 内留空占位,在后面补上相应的值。
Crate
按照手册的说法,crate 是一组 Rust 源代码文件(Remember that a crate is a collection of Rust source code files.)。类似 c 语言头文件?
应该有两种 crate,一种 binary crate,是可执行的,一种 library crate,只包含源文件,不可单独执行。
项目依赖由 Cargo.toml 文件和 Cargo.lock 文件管理。Cargo.toml 文件通过
1 | |
这样的语法管理依赖。
rand = "0.9.2" 实际上是 rand = "^0.9.2"的简写。 cargo 会寻找与 0.9.2 版本接口兼容的最新版本作为项目依赖。
第一次构建项目时, cargo 会根据 Cargo.toml 确定依赖的 crate 版本,然后生成一个 Cargo.lock 文件,保存项目实际使用的 crate 的版本及其依赖。之后 cargo 会优先根据 Cargo.lock 文件下载对应版本的依赖,不会再自动升级 crate 版本。
Cargo.lock 确保了项目可以被任何人重新构建,应当随源码一起发布。
match 关键字
类似于 switch 语句。但是 match .. {} 是一个表达式,根据其中分支(arm)内容确定表达式的值。
match 返回的类型必须是相同的,但是在循环中,一个分支中有 continue 时,可以不用管这个分支的返回值。
trait
trait 类似于接口,提供了一系列的方法签名,其他类型可以实现这些方法。
想使用 trait 声明的方法,必须使对应 trait 存在于作用域中(比如在全局作用域 use rand::Rng)。
Cargo 打开文档
1 | |
在浏览器中打开当前项目依赖的所有 crate 的文档,有、方便。
shadowing
rust 允许使用相同的变量名声明一个新变量,这样之前的变量会无效。
基础
变量与常量
1 | |
基本数据类型
rust 是静态类型语言,在编译时就应该确定数据类型。
标量类型
整数
| 长度 | 有符号 | 无符号 |
|---|---|---|
| 8-bit | i8 |
u8 |
| 16-bit | i16 |
u16 |
| 32-bit | i32 |
u32 |
| 64-bit | i64 |
u64 |
| 128-bit | i128 |
u128 |
| 架构相关 | isize |
usize |
| 数字字面值 | 例子 |
|---|---|
| Decimal (十进制) | 98_222 |
| Hex (十六进制) | 0xff |
| Octal (八进制) | 0o77 |
| Binary (二进制) | 0b1111_0000 |
Byte (单字节字符)(仅限于u8) |
b'A' |
debug 模式时整数溢出会引发 panic,而 release 模式不会。
浮点数
浮点数采用 IEEE-754 标准表示。包含 f32 和 f64 两种类型。
布尔类型
略
字符类型
和 c 语言略有不同,一个 char 占 4 字节,使用 UTF-8 编码。
复合类型
元组类型
1 | |
元组长度是 固定 的。
类型注解也是可选的。
可以通过 解构 操作获取元组中的元素:
1 | |
也可以用 . 来访问元组中的元素。
空的元组叫做 单元(uint)元组,是表达式没有显示返回内容时的默认返回值。
元组 没有 迭代器。
数组类型
1 | |
数组长度是 固定 的,所有元素都应该是同一种类型。类型和长度注解(: [i32, 5])也是可选的。
数组元素通过下标访问。编译器不允许下标超过实际的大小,编译时能检测出会报错,难以检测会在运行时产生 panic。
数组有迭代器。
函数
语法:
1 | |
最后一行不加 ; 可以直接当做函数返回值。
条件控制
if 表达式
rust 中的 if 语句是一个表达式,{} 内最后一行不加分号作为表达式的值。
if 后面只能跟一个 bool 类型的值。不支持自动类型转换。
loop 表达式
loop 代表无限循环,只有在遇到 break 语句时才会终止。
break val; 可以返回 val 作为 loop 表达式的值。
用 'label: loop {} 可以给循环打上标签,在内层循环中使用 break 'label val; 可以直接退出整个循环并返回 val。
while 循环
和其他语言没什么区别。while 循环理论上也是个表达式,但是无法用 break 返回值。只有默认返回值 ()。
for 循环
for 循环一般用来遍历。
可以直接遍历数组,但这时会产生移动行为,数组会失去对原本元素的所有权,之后这个数组就不可用了。
一般用
1 | |
来遍历。
使用 .iter() 之后发现 i 的自动类型推断由 String 变为了 &String。这样在循环之后还能继续使用该数组。
所有权系统
所有权(ownership)是 Rust 用于如何管理内存的一组规则。
所有权系统可以说是 rust 的立身之本,主要思想是通过编译器严格的检查使得 rust 在没有垃圾收集机制的情况下也不会产生垃圾。使得 rust 兼具安全与高性能的特点。
所有权系统主要针对在堆上申请了内存的变量。栈上的变量是不需要所有权系统的,因为在函数结束时栈上的空间就被释放了。但是 rusts 还是默认了所有类型都适用所有权规则,我们需要通过实现 Copy trait 来显式地告诉编译器:这个类型不会用到堆区内存,不用管它。
所有权规则
- Rust 中的每一个值都有一个 所有者(owner)。
- 值在任一时刻有且只有一个所有者。
- 当所有者离开作用域,这个值将被丢弃。
所有权规则详解
第二条规则
再次强调一点,所有权规则本质上是编译器约束 rust 书写的源代码,并不是在二进制文件中实现了这套规则。
形如
1 | |
这种代码被 rust 称为 移动(move)。本质上是浅拷贝(复制指向堆区的指针而不是重新申请一片堆区内存)加上 rust 编译器的检查机制。实际存储 "value" 的堆内存所有权被转移给了 s2,此后 s1 无权再使用这片内存(在编译阶段检查)。
BTW,在 rust 中,深拷贝由 .clone() 函数实现。
第三条规则
每种类型都可以实现两种特殊的 trait,Copy和 Drop。
如果一种类型实现了 Drop trait, rust 编译器将会在这个类型的所有实例离开其所在作用域时调用 drop 函数。
一个类型不能同时实现 Copy 和 Drop。
当一个 Rust 结构体既没有实现 Copy trait 又没有实现 Drop trait时,Rust 会在其作用域结束时自动调用析构器并释放其内存。
如果一种类型实现了 Copy trait,移动行为又变回了浅拷贝行为。
引用与借用
使用所有权系统会产生一个问题,就是只要发生移动行为,原变量就不再可用了。一般语言函数传参习惯用浅拷贝,而在 rust 中所有浅拷贝都被编译器监督变成了所谓的移动。所以在 rust 中,函数传参只能传引用。(其实也可以移动,把参数当做返回值还回去就行了。)
指针(引用类型)实现了 Copy trait,因此可以用于安全的传参。
rust 有一个自动解引用的特性,像下面这样,不用自己手动解引用。
1 | |
可变引用与不可变引用
默认的引用不能修改被引用变量的值,只有加上 mut 关键字才行。
1 | |
关于可变引用与不可变引用,有下面几条规则:
- 在同一个作用域中,可变引用与不可变引用不能同时存在;
- 在同一个作用域中,可以存在多个不可变引用,但只能存在一个可变引用;
- 编译器会检测变量最后被使用的地方,在那之后视为其作用域结束。
对于第三点
1 | |
这样会编译失败,而像
1 | |
这样可以编译成功。像下面
1 | |
这样也不能编译。
Slice 类型
简单来说,就是引用一部分。
像这样:
1 | |
字符串 slice 的类型是 &str,i32 数组的 slice 类型是 &[i32]。
结构体
感觉更像是类(class)。
定义
1 | |
最后不用加分号。
声明
1 | |
参数和字段名相同可以简写:
1 | |
结构体更新语法:
1 | |
1 | |
这种方法也会使原来的实例失去相应的所有权。
对于所有字段类型都实现了 Copy trait,但自己没有实现的结构体,
1 | |
第一个 println! 是可以的,第二个报错。
元组结构体
没有字段名,只有字段类型。
1 | |
也可以解构,但是必须加上结构体名:
1 | |
类单元结构体
类单元结构体(unit-like structs)没有任何字段。一般用于实现 trait。
1 | |
为结构体实现 Debug trait
直接用 println! 打印结构体是不行的,因为没有实现 Display trait。
Display trait 需要手动实现,有点麻烦。
rust 还有一个方便的打印调试信息的功能,可以用 println!("{:?}") 打印调试信息,这个功能需要实现 Debugtrait,但不用手动实现,只要为结构体加上外部属性 #[derive(Debug)] 就行。
还可以通过把 {:?} 替换成 {:#?} 美化输出。也可以用 dbg! 宏,dbg! 宏接受一个表达式的所有权,再返回该值的所有权。
为结构体定义方法
1 | |
方法的第一个参数必须是 self 或者 &self。用 . 调用。
impl 块中定义的函数称为 关联函数。也可以定义第一个参数不是 self 的函数,用 :: 调用。
最常见的不是方法的关联函数是 new 函数,返回一个结构体的实例。
1 | |
关键字 Self 在函数的返回类型和函数体中,都是对 impl 关键字后所示类型的别名,这里是 Rectangle。
枚举和模式匹配
结构体给予你将字段和数据聚合在一起的方法,像 Rectangle 结构体有 width 和 height 两个字段。而枚举给予你一个途径去声明某个值是一个集合中的一员。
C 语言的枚举可以看作一种宏替换,把一个字符串替换成一个数字。rust 的枚举要强大很多很多。
rust 的枚举类型的每一个成员称为 变体(variants)。变体是可以携带数据的,而且允许不同变体数据类型不同。
1 | |
也可以为枚举类型定义方法,和结构体差不多。
Option 枚举
rust 中没有空值,取而代之的 Option 枚举。定义为:
1 | |
match 控制流结构
Rust 有一个叫做
match的极为强大的控制流运算符,它允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行相应代码。
上文已有 [[#match 关键字| match 关键字]]。
match 要求分支必须覆盖所有可能。可以用一个随便什么名字的变量匹配没有被前面匹配的所有值,这个分支必须放在最后,它后面的分支都是不可到达的。这种方式会把匹配的值绑定到我们自己命名的变量。也可以用 _ 来匹配但不接受值。
if let 和 let else
换句话说,可以认为
if let是match的一个语法糖,它当值匹配某一模式时执行代码而忽略所有其他值。
1 | |
可以在后面加一个 else 代码块,效果和 match 中的 _ 分支一样。
我们有时候需要利用 if let 返回值。可以像这样:
1 | |
可以用 let else 让它更优雅:
1 | |
常见集合
Rust 标准库中包含一系列被称为 集合(collections)的非常有用的数据结构。
Rust’s collections can be grouped into four major categories:
- Sequences:
Vec,VecDeque,LinkedList - Maps:
HashMap,BTreeMap - Sets:
HashSet,BTreeSet - Misc:
BinaryHeap
Vec
新建 vector
空的:
1 | |
必须标注类型。
带值的可以用 Vec! 宏:
1 | |
会自动推断类型。
(TODO)
神秘的生命周期
awc实现个链表怎么这么难
写代码时有一个常见的需求是定义一个有指针类型成员变量的结构体。在 Rust 中,直接这么定义是不行的:
1 | |
报错为:
1 | |
这是因为,我们不知道 a 指向的值在什么时候被释放,如果 a 执行的值被释放而 a 依然存在,就会产生危险的悬垂引用(dangling references)。在 pwn 题中,这种 Use After Free 漏洞是常见且有用的。
为了解决悬垂引用的问题,Rust 引入了生命周期(lifetime)的概念。
生命周期是一种泛型。不同于确保类型有期望的行为,生命周期用于保证引用在我们需要的整个期间内都是有效的。
Rust 中的每一个引用都有其生命周期,也就是引用保持有效的作用域。大部分时候生命周期是隐含并可以推断的,有时候需要手动标注。
1 | |
这个生命周期标注表示 part 指向的值必须起码和 ImportantExcerpt 实例活得一样久。换言之,只要该实例还没有被释放,Rust 就不会让 part 指向的值被释放。
像下面这种写法是无法编译的:
1 | |
报错:
1 | |
看起来我们好像解决了在 结构体中使用指针 的问题,实际上并没有。以下代码是无法通过编译的:
1 | |
报错如下:
1 | |
虽然 inst1.part 被重新赋值为了 s2_slice,但 Rust 借用检查器依旧会认为 s1 生命周期不应该在 inst1 前结束。
这就很尴尬了,假设我们要实现一个链表,需要支持插入删除操作。删除节点时必然会发生类似上面的行为,而这样是不能通过编译的。要彻底解决这个问题,还得看下一章的智能指针。
从函数返回引用
当从函数返回一个引用,返回值的生命周期参数需要与一个参数的生命周期参数相匹配。如果返回的引用没有指向任何一个参数,那么唯一的可能就是它指向一个函数内部创建的值。然而它将会是一个悬垂引用,因为它将会在函数结束时离开作用域。
神秘的智能指针
(TODO)
