0%

2025OS训练营一阶段记录

2025OS训练营一阶段记录

说明一下,我是提前为训练营做准备,所以记录时间比开营要早,毕竟以我这种基础不笨鸟先飞怕跟不上哈哈。

3.12

今天开始学Rust,之前对这个语言也没咋接触过,但是之前学过点go,都是函数式编程,应该有相通处吧。

我找了经验贴都说看course.rs的Rust圣经,于是按照提示搭建了下环境,我用的是ubuntu20.04,装起环境没有碰到什么问题。还跟着作者用vscode,装了几个推荐的插件。

了解了cargo 跑了下helloworld,之前写c++的,每次都被环境折腾的不行,找不到的包还要自己手动编译,而且是win环境下…不会写makefile还要用cmake,这东西更新还快,每种库外部文件导入方式还不一样,可能被折磨习惯了觉得这很正常。原来现在的编程语言能做到这么厉害,不仅不用自己编译,手动导入,连库的可用性也会检测。而且rust还有媲美c++的性能,太厉害了!编译的方式也非常简单,toml的依赖方式也比任何语言的依赖方式都要简单。Rust 原生支持 UTF-8 编码的字符串,可以很容易的使用世界各国文字作为字符串内容,再也不用被msvc的字符集问题折腾了。{}作为占位符,数字、字符串、结构体都能打印。rust有很好的链式编程特性,标准库的函数熟练运用起来应该非常方便优雅。

第一章变量绑定与解构。变量默认不可变 :Rust变量默认不能修改,必须加mut才能变成可变变量。这个设计挺特别的,一开始不习惯但确实能避免很多意外修改的bug。变量遮蔽:可以用同名变量覆盖之前的变量,实际上是创建了新变量。和mut的区别在于会创建新内存空间,还能用来改变变量类型。解构赋值 :可以从元组、结构体等复杂类型中提取值赋给变量,写法很简洁。常量 :用const声明,必须指定类型,命名习惯是全大写加下划线。命名规范 :下划线开头的变量名可以避免未使用变量的警告。

整体感觉Rust的变量系统设计得很严谨,虽然开始有点不习惯,但这些特性确实能写出更安全的代码。特别是默认不可变这个设计,强迫开发者想清楚哪些变量真的需要修改。

第二章基本类型。基本类型与c++没什么差别,特别的就是单元类型 () ,其唯一的值也是 ()。rust编译器必须在编译期知道我们所有变量的类型,但这不意味着需要为每个变量指定类型,它可以根据变量的值和上下文中的使用方式来自动推导出变量的类型,在某些情况下,它无法推导出变量类型,需要手动去给予一个类型标注。在整数上,在release 模式构建时,Rust 不检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理,也有一些函数用来检查溢出。在处理浮点数计算时要相当注意。NaN为数学上为定义,与之交互都会成为NaN。

我注意到range for i in 1..=5这样的方式非常有意思和方便,还可以用字母。Rust 拥有相当多的数值类型. 需要熟悉这些类型所占用的字节数,这样就知道该类型允许的大小范围以及选择的类型是否能表达负数。类型转换必须是显式的. Rust 永远也不会偷偷把你的 16bit 整数转换成 32bit 整数。

Rust 的函数体是由一系列语句组成,最后由一个表达式来返回值,需要区分语句和表达式,表达式总要返回值,分号结尾的是语句。

当用 ! 作函数返回类型的时候,表示该函数永不返回,这种语法往往用做会导致程序崩溃的函数。

3.13

今天学习基础第三章所有权和借用。为了解决内存安全问题,rust提出所有权概念,所有权三条规则:Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者;一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者;当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)。

1
2
let x = 5;
let y = x;

这段代码并没有发生所有权的转移,原因很简单: 代码首先将 5 绑定到变量 x,接着拷贝 x 的值赋给 y,最终 xy 都等于 5,因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。

1
2
let s1 = String::from("hello");
let s2 = s1;

当 s1 被赋予 s2 后,Rust 认为 s1 不再有效,因此也无需在 s1 离开作用域后 drop 任何东西,这就是把所有权从 s1 转移给了 s2,s1 在被赋予 s2 后就马上失效了。其实类似于c++里的move。如果不想夺取原有的所有权可以使用clone(),rust基本类型自带clone属性。

Rust通过借用(Borrowing) 获取变量的引用,称之为借用。引用分为可变引用和不可变引用,区别就是可不可变,这种限制的好处就是使 Rust 在编译期就避免数据竞争,两个或更多的指针同时访问同一数据,至少有一个指针被用来写入数据,没有同步数据访问的机制。可变引用和不可变引用不可以同时存在,可变引用可以同时存在一个,不可变引用可以同时存在多个。

基础第四章复合类型。&s[0..len]切片操作很方便得用到string,字节序等,0..len为range类,左闭右开,在处理字节流时要注意,汉字在utf8占三个字节。字符串字面量也是个切片。

元组是由多种类型组合到一起形成的,因此它是复合类型,元组的长度是固定的,元组中元素的顺序也是固定的。可以通过以下语法创建一个元组:

1
let tup: (i32, f64, u8) = (500, 6.4, 1);

变量 tup 被绑定了一个元组值 (500, 6.4, 1),该元组的类型是 (i32, f64, u8),元组是用括号将多个类型组合到一起,可以使用模式匹配或者 . 操作符来获取元组中的值。

用模式匹配解构元组:

1
let (x, y, z) = tup;

struct结构体和c语言类似,访问字段用 . ,初始化实例时,每个字段都需要进行初始化,初始化时的字段顺序不需要和结构体定义时的顺序一致。结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段,

结构体必须要有名称,但是元组结构体的字段可以没有名称,例如:

1
2
struct Color(i32, i32, i32);
let origin = Point(0, 0, 0);

元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。

如果想在结构体里包含一个引用,必须声明生命周期。可以为结构体添加debug属性,实现fmt函数用来自定义打印结构体信息。

枚举类比大多语言要强大,每个元素可以是结构体包含不同信息,

1
2
3
4
5
6
enum Message {
Quit,
Move { x: i32, y: i32 },
Write(String),
ChangeColor(i32, i32, i32),
}

Option 枚举用于处理空值,在其它编程语言中,往往都有一个 null 关键字,当null 异常导致程序的崩溃时我们需要处理这些 null 空值。Rust 吸取了众多教训,决定抛弃 null,而改为使用 Option 枚举变量来表述这种结果。Option 枚举包含两个成员,一个成员表示含有值:Some(T), 另一个表示没有值:None,定义如下:

1
2
3
4
enum Option<T> {
Some(T),
None,
}

其中 T 是泛型参数,Some(T)表示该枚举成员的数据类型是 T,换句话说,Some 可以包含任何类型的数据。

在 Rust 中,最常用的数组有两种,第一种是速度很快但是长度固定的 array,第二种是可动态增长的但是有性能损耗的 Vector,在本书中,我们称 array 为数组,Vector 为动态数组。数组的三要素:长度固定、元素必须有相同的类型、依次线性排列。声明方式:

1
let a: [i32; 5] = [1, 2, 3, 4, 5];

3.14

今天学习基础第五章流程控制和第六章模式匹配。流程控制就是for … in while loop很容易。模式匹配还是对c++er比较新颖。match的用法和switch相似,强大的点在于可以同时解构内容如:

1
2
3
4
5
6
match action {
Action::Say(s) => {
println!("{}", s);
},
_ => {},
}

if let 相当于单个匹配的match,同样用于获取并解构。

matches!,它可以将一个表达式跟模式进行匹配,然后返回匹配的结果 true or false

match 和 if let 都可以触发变量遮蔽,就是可以同名变量优先使用当前作用于下的,在处理时很方便。

Option解构在rust经常用到,返回结果要么是Some(T),要么是None,在处理时解构处理不同情况非常强大。

解构方式还有个while let ,可以循环处理解构,数组和元组也可以用对应的结构来解构,_可以用来忽略这个位置的变量,匹配的范围同样可以使用range。

基础第七章方法。可以使用impl 对struct enum实现方法,方法公开需前置pub,方法的函数如若调用结构体的内容需要第一个参数位self,一般情况时&self,在需要对结构体内容更改时为&mut self,当然也可以不是引用,self转让所有权到方法里。在函数里没有结构体的引用时(关联函数)需要使用结构体::来访问,否则一概使用 . 来访问。impl 内部也可以声明变量、常量等。impl 可以多次进行实现。

第八章泛型与特征。在 Rust 中,泛型参数的名称可以任意起,但是出于惯例都用 T ,使用泛型参数,有一个先决条件,必需在使用前对其进行声明:

1
fn largest<T>(list: &[T]) -> T {

该泛型函数的作用是从列表中找出最大的值,其中列表中的元素类型为 T。首先 largest<T> 对泛型参数 T 进行了声明,然后才在函数参数中进行使用该泛型参数 list: &[T] ,函数 largest 有泛型类型 T,它有个参数 list,其类型是元素为 T 的数组切片,最后,该函数返回值的类型也是 T

T 可以是任何类型,但不是所有的类型都能进行比较,编译器建议我们给 T 添加一个类型限制:使用 std::cmp::PartialOrd 特征(Trait)对 T 进行限制。

有时候,编译器无法推断你想要的泛型参数,这时候需要显式地来声明。

1
2
3
4
5
6
7
fn create_and_print<T>() where T: From<i32> + Display {
let a: T = 100.into(); // 创建了类型为 T 的变量 a,它的初始值由 100 转换而来
println!("a is: {}", a);
}
fn main() {
create_and_print::<i64>();
}

结构体和枚举的泛型

1
2
3
4
5
6
7
8
9
10
11
12
13
enum Option<T> {
Some(T),
None,
}
struct Point<T,U> {
x: T,
y: U,
}
impl<T> Point<T> {
fn x(&self) -> &T {
&self.x
}
}

方法中使用泛型:使用泛型参数前要提前声明:impl<T>,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。注意,这里的 Point<T> 不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T> 而不再是 Point。除了结构体中的泛型参数,还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:

1
2
3
4
5
6
7
8
impl<T, U> Point<T, U> {
fn mixup<V, W>(self, other: Point<V, W>) -> Point<T, W> {
Point {
x: self.x,
y: other.y,
}
}
}

const泛型:[i32; 3][i32; 2] 确实是两个完全不同的类型,因此无法用同一个函数调用,const 泛型,也就是针对值的泛型可以用于处理数组长度的问题:

1
2
3
4
5
6
7
8
9
fn display_array<T: std::fmt::Debug, const N: usize>(arr: [T; N]) {
println!("{:?}", arr);
}
fn main() {
let arr: [i32; 3] = [1, 2, 3];
display_array(arr);
let arr: [i32; 2] = [1, 2];
display_array(arr);
}

const fn,即常量函数。通常情况下,函数是在运行时被调用和执行的。然而,在某些场景下,我们希望在编译期就计算出一些值,以提高运行时的性能或满足某些编译期的约束条件。例如,定义数组的长度、计算常量值等。有了 const fn,我们可以在编译期执行这些函数,从而将计算结果直接嵌入到生成的代码中。

特征trait:如果不同的类型具有相同的行为,那么我们就可以定义一个特征,然后为这些类型实现该特征。定义特征是把一些方法组合在一起,目的是定义一个实现某些目标所必需的行为的集合。

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
pub trait Summary {
fn summarize(&self) -> String;
}
pub struct Post {
pub title: String, // 标题
pub author: String, // 作者
pub content: String, // 内容
}

impl Summary for Post {
fn summarize(&self) -> String {
format!("文章{}, 作者是{}", self.title, self.author)
}
}

pub struct Weibo {
pub username: String,
pub content: String
}

impl Summary for Weibo {
fn summarize(&self) -> String {
format!("{}发表了微博{}", self.username, self.content)
}
}

关于特征实现与定义的位置,有一条非常重要的原则:如果你想要为类型 A 实现特征 T,那么 A 或者 T 至少有一个是在当前作用域中定义的! 例如可以为上面的 Post 类型实现标准库中的 Display 特征,这是因为 Post 类型定义在当前的作用域中。同时也可以在当前包中为 String 类型实现 Summary 特征,因为 Summary 定义在当前作用域中.

特征中定义具有默认实现的方法,这样其它类型无需再实现该方法,或者也可以选择重载该方法.

使用特征作为函数参数:

1
2
3
pub fn notify(item: &impl Summary) {
println!("Breaking news! {}", item.summarize());
}

impl Summary顾名思义,它的意思是 实现了Summary特征item 参数。

如果想要强制函数的两个参数是同一类型只能使特征约束来实现:

1
pub fn notify<T: Summary>(item1: &T, item2: &T) {}

泛型类型 T 说明了 item1item2 必须拥有同样的类型,同时 T: Summary 说明了 T 必须实现 Summary 特征。

多重约束:除了单个约束条件,可以指定多个约束条件,

1
pub fn notify(item: &(impl Summary + Display)) {}

除了上述的语法糖形式,还能使用特征约束的形式:

1
pub fn notify<T: Summary + Display>(item: &T) {}

特征约束,可以让我们在指定类型 + 指定特征的条件下去实现方法,也可以有条件地实现特征,例如,标准库为任何实现了 Display 特征的类型实现了 ToString 特征。

可以通过 impl Trait 来说明一个函数返回了一个类型,该类型实现了某个特征:

1
2
3
4
5
fn returns_summarizable() -> impl Summary {
Weibo {
。。。
}
}

这种 impl Trait 形式的返回值,在一种场景下非常非常有用,那就是返回的真实类型非常复杂,你不知道该怎么声明时。

#[derive(Debug)] :是一种特征派生语法,被 derive 标记的对象会自动实现对应的默认特征代码,继承相应的功能。例如 Debug 特征,它有一套自动实现的默认代码,当你给一个结构体标记后,就可以使用 println!("{:?}", s) 的形式打印该结构体的对象。

再如 Copy 特征,它也有一套自动实现的默认代码,当标记到一个类型上时,可以让这个类型自动实现 Copy 特征,进而可以调用 copy 方法,进行自我复制。

总之,derive 派生出来的是 Rust 默认给我们提供的特征,在开发过程中极大的简化了自己手动实现相应特征的需求,当然,如果你有特殊的需求,还可以自己手动重载该实现

特征对象

1
2
3
pub trait Draw {
fn draw(&self);
}

只要组件实现了 Draw 特征,就可以调用 draw 方法来进行渲染。假设有一个 ButtonSelectBox 组件实现了 Draw 特征:

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
29
30
31
pub struct Button {
pub width: u32,
pub height: u32,
pub label: String,
}

impl Draw for Button {
fn draw(&self) {
// 绘制按钮的代码
}
}

struct SelectBox {
width: u32,
height: u32,
options: Vec<String>,
}

impl Draw for SelectBox {
fn draw(&self) {
// 绘制SelectBox的代码
}
}
fn draw1(x: Box<dyn Draw>) {
// 由于实现了 Deref 特征,Box 智能指针会自动解引用为它所包裹的值,然后调用该值对应的类型上定义的 `draw` 方法
x.draw();
}

fn draw2(x: &dyn Draw) {
x.draw();
}

此时,还需要一个动态数组来存储这些 UI 对象:

1
2
3
pub struct Screen {
pub components: Vec<?>,
}

特征对象**指向实现了 Draw 特征的类型的实例,也就是指向了 Button 或者 SelectBox 的实例,这种映射关系是存储在一张表中,可以在运行时通过特征对象找到具体调用的类型方法。

  • draw1 函数的参数是 Box<dyn Draw> 形式的特征对象,该特征对象是通过 Box::new(x) 的方式创建的
  • draw2 函数的参数是 &dyn Draw 形式的特征对象,该特征对象是通过 &x 的方式创建的
  • dyn 关键字只用在特征对象的类型声明上,在创建时无需使用 dyn
1
2
3
pub struct Screen {
pub components: Vec<Box<dyn Draw>>,
}

其中存储了一个动态数组,里面元素的类型是 Draw 特征对象:Box<dyn Draw>,任何实现了 Draw 特征的类型,都可以存放其中。

再来为 Screen 定义 run 方法,用于将列表中的 UI 组件渲染在屏幕上:

1
2
3
4
5
6
7
impl Screen {
pub fn run(&self) {
for component in self.components.iter() {
component.draw();
}
}
}

泛型是在编译期完成处理的:编译器会为每一个泛型参数对应的具体类型生成一份代码,这种方式是静态分发,因为是在编译期完成的,对于运行期性能完全没有任何影响。与静态分发相对应的是动态分发(dynamic dispatch),直到运行时,才能确定需要调用什么方法。

3.15

第九章集合类型

使用 Vec::new 创建动态数组

1
let v: Vec<i32> = Vec::new();

这里,v 被显式地声明了类型 Vec<i32>,这是因为 Rust 编译器无法从 Vec::new() 中得到任何关于类型的暗示信息,因此也无法推导出 v 的具体类型:

1
2
let mut v = Vec::new();
v.push(1);

此时,v 就无需手动声明类型,因为编译器通过 v.push(1),推测出 v 中的元素类型是 i32,因此推导出 v 的类型是 Vec<i32>

如果预先知道要存储的元素个数,可以使用 Vec::with_capacity(capacity) 创建动态数组,这样可以避免因为插入大量新数据导致频繁的内存分配和拷贝,提升性能

还可以使用宏 vec! 来创建数组,与 Vec::new 有所不同,前者能在创建同时给予初始化值:

1
let v = vec![1, 2, 3];

同样,此处的 v 也无需标注类型,编译器只需检查它内部的元素即可自动推导出 v 的类型是 `Vec

跟结构体一样,Vector 类型在超出作用域范围后,会被自动删除:

1
2
3
4
{
let v = vec![1, 2, 3];
// ...
} // <- v超出作用域并在此处被删除

Vector 被删除后,它内部存储的所有内容也会随之被删除。

同时借用多个数组元素 遇到同时借用多个数组元素的情况

1
2
3
4
let mut v = vec![1, 2, 3, 4, 5];
let first = &v[0];
v.push(6);
println!("The first element is: {first}");

此时编译器报错。数组的大小是可变的,当旧数组的大小不够用时,Rust 会重新分配一块更大的内存空间,然后把旧数组拷贝过来。这种情况下,之前的引用显然会指向一块无效的内存。

1
2
3
4
5
6
7
8
use std::collections::HashMap;

// 创建一个HashMap,用于存储宝石种类和对应的数量
let mut my_gems = HashMap::new();

// 将宝石类型和对应的数量写入表中
my_gems.insert("红宝石", 1);
my_gems.insert("蓝宝石", 2);

3.16

第十章生命周期。

借用检查:为了保证 Rust 的所有权和借用的正确性,Rust 使用了一个借用检查器(Borrow checker)来检查程序的借用正确性。

函数中的生命周期:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn longest(x: &str, y: &str) -> &str {
if x.len() > y.len() {
x
} else {
y
}
}
--------
--> src/main.rs:9:33
|
9 | fn longest(x: &str, y: &str) -> &str {
| ---- ---- ^ expected named lifetime parameter // 参数需要一个生命周期
|

编译器无法知道该函数的返回值到底引用 x 还是 y因为编译器需要知道这些,来确保函数调用后的引用生命周期分析。在存在多个引用时,编译器有时会无法自动推导生命周期,此时就需要手动去标注,通过为参数标注合适的生命周期来帮助编译器进行借用检查的分析。

生命周期标注:生命周期的语法以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。 如果是引用类型的参数,那么生命周期会位于引用符号 & 之后,并用一个空格来将生命周期和引用参数分隔开

1
2
3
&i32        // 一个引用
&'a i32 // 具有显式生命周期的引用
&'a mut i32 // 具有显式生命周期的可变引用

一个生命周期标注,它自身并不具有什么意义,因为生命周期的作用就是告诉编译器多个引用之间的关系。例如,有一个函数,它的第一个参数 first 是一个指向 i32 类型的引用,具有生命周期 'a,该函数还有另一个参数 second,它也是指向 i32 类型的引用,并且同样具有生命周期 'a。此处生命周期标注仅仅说明,这两个参数 firstsecond 至少活得和’a 一样久,至于到底活多久或者哪个活得更久,我们都无法得知

1
fn useless<'a>(first: &'a i32, second: &'a i32) {}

函数签名中的生命周期标注

1
2
3
4
5
6
7
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() {
x
} else {
y
}
}
  • 和泛型一样,使用生命周期参数,需要先声明 <'a>
  • xy 和返回值至少活得和 'a 一样久(因为返回值要么是 x,要么是 y

该函数签名表明对于某些生命周期 'a,函数的两个参数都至少跟 'a 活得一样久,同时函数的返回引用也至少跟 'a 活得一样久。实际上,这意味着返回值的生命周期与参数生命周期中的较小值一致:虽然两个参数的生命周期都是标注了 'a,但是实际上这两个参数的真实生命周期可能是不一样的(生命周期 'a 不代表生命周期等于 'a,而是大于等于 'a)。在通过函数签名指定生命周期参数时,并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过

因此 longest 函数并不知道 xy 具体会活多久,只要知道它们的作用域至少能持续 'a 这么长就行。

该例子证明了 result 的生命周期必须等于两个参数中生命周期较小的那个:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
fn main() {
let string1 = String::from("long string is long");
let result;
{
let string2 = String::from("xyz");
result = longest(string1.as_str(), string2.as_str());
}
println!("The longest string is {}", result);
}
error[E0597]: `string2` does not live long enough
--> src/main.rs:6:44
|
6 | result = longest(string1.as_str(), string2.as_str());
| ^^^^^^^ borrowed value does not live long enough
7 | }

在上述代码中,result 必须要活到 println!处,因为 result 的生命周期是 'a,因此 'a 必须持续到 println!

结构体中的生命周期:

1
2
3
struct ImportantExcerpt<'a> {
part: &'a str,
}

ImportantExcerpt 结构体中有一个引用类型的字段 part,因此需要为它标注上生命周期。结构体的生命周期标注语法跟泛型参数语法很像,需要对生命周期参数进行声明 <'a>。该生命周期标注说明,结构体 ImportantExcerpt 所引用的字符串 str 生命周期需要大于等于该结构体的生命周期

生命周期消除:尽管我们没有显式的为其标注生命周期,编译依然可以通过。其实原因不复杂,编译器为了简化用户的使用,运用了生命周期消除大法

三条消除规则:

编译器使用三条消除规则来确定哪些场景不需要显式地去标注生命周期。其中第一条规则应用在输入生命周期上,第二、三条应用在输出生命周期上。若编译器发现三条规则都不适用时,就会报错,提示你需要手动标注生命周期。

  1. 每一个引用参数都会获得独自的生命周期

    例如一个引用参数的函数就有一个生命周期标注: fn foo<'a>(x: &'a i32),两个引用参数的有两个生命周期标注:fn foo<'a, 'b>(x: &'a i32, y: &'b i32), 依此类推。

  2. 若只有一个输入生命周期(函数参数中只有一个引用类型),那么该生命周期会被赋给所有的输出生命周期,也就是所有返回值的生命周期都等于该输入生命周期

    例如函数 fn foo(x: &i32) -> &i32x 参数的生命周期会被自动赋给返回值 &i32,因此该函数等同于 fn foo<'a>(x: &'a i32) -> &'a i32

  3. 若存在多个输入生命周期,且其中一个是 &self&mut self,则 &self 的生命周期被赋给所有的输出生命周期

    拥有 &self 形式的参数,说明该函数是一个 方法,该规则让方法的使用便利度大幅提升。

3.17

方法中的生命周期:

1
2
3
4
5
6
7
8
9
struct ImportantExcerpt<'a> {
part: &'a str,
}

impl<'a> ImportantExcerpt<'a> {
fn level(&self) -> i32 {
3
}
}
  • impl 中必须使用结构体的完整名称,包括 <'a>,因为生命周期标注也是结构体类型的一部分
  • 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则

静态生命周期:生命周期 'static,拥有该生命周期的引用可以和整个程序活得一样久。

字符串字面量是被硬编码进 Rust 的二进制文件中,因此这些字符串变量全部具有 'static 的生命周期:

1
let s: &'static str = "就是活得久,嘿嘿";

Rust 中的错误主要分为两类:

  • 可恢复错误,通常用于从系统全局角度来看可以接受的错误,例如处理用户的访问、操作等错误,这些错误只会影响某个用户自身的操作进程,而不会对系统的全局稳定性产生影响
  • 不可恢复错误,刚好相反,该错误通常是全局性或者系统性的错误,例如数组越界访问,系统启动时发生了影响启动流程的错误等等,这些错误的影响往往对于系统来说是致命的

Rust 提供了 panic! 宏,当调用执行该宏时,程序会打印出一个错误信息,展开报错点往前的函数调用堆栈,最后退出程序

当出现 panic! 时,程序提供了两种方式来处理终止流程:栈展开直接终止

其中,默认的方式就是 栈展开,这意味着 Rust 会回溯栈上数据和函数调用,因此也意味着更多的善后工作,好处是可以给出充分的报错信息和栈调用信息,便于事后的问题复盘。直接终止,顾名思义,不清理数据就直接退出程序,善后工作交与操作系统来负责。

对于绝大多数用户,使用默认选择是最好的,但是当你关心最终编译出的二进制可执行文件大小时,那么可以尝试去使用直接终止的方式,例如下面的配置修改 Cargo.toml 文件,实现在 release 模式下遇到 panic 直接终止:

1
2
[profile.release]
panic = 'abort'

panic后如果是 main 线程,则程序会终止,如果是其它子线程,该线程会终止,但是不会影响 main 线程。

*Result<T, E> *是一个枚举类型,定义如下:

1
2
3
4
enum Result<T, E> {
Ok(T),
Err(E),
}

泛型参数 T 代表成功时存入的正确值的类型,存放方式是 Ok(T)E 代表错误时存入的错误值,存放方式是 Err(E)

不想使用 match 去匹配 Result<T, E>以获取其中的 T 值,因为 match 的穷尽匹配特性,你总要去处理下 Err 分支。有个办法简化这个过程就是 unwrapexpect。它们的作用就是,如果返回成功,就将 Ok(T) 中的值取出来,如果失败,就直接 panic。expectunwrap 很像,也是遇到错误直接 panic, 但是会带上自定义的错误提示信息,相当于重载了错误打印的函数.

1
2
let f = File::open("hello.txt").unwrap();
let f = File::open("hello.txt").expect("Failed to open hello.txt");

错误传播:

1
fn read_username_from_file() -> Result<String, io::Error>
  • 该函数返回一个 Result<String, io::Error> 类型,当读取用户名成功时,返回 Ok(String),失败时,返回 Err(io:Error)
  • File::openf.read_to_string 返回的 Result<T, E> 中的 E 就是 io::Error

3.18

  • 项目(Package):可以用来构建、测试和分享包
  • 工作空间(WorkSpace):对于大型项目,可以进一步将多个包联合在一起,组织成工作空间
  • 包(Crate):一个由多个模块组成的树形结构,可以作为三方库进行分发,也可以生成可执行文件进行运行
  • 模块(Module):可以一个文件多个模块,也可以一个文件一个模块,模块可以被认为是真实项目中的代码组织单元

包 Crate

对于 Rust 而言,包是一个独立的可编译单元,它编译后会生成一个可执行文件或者一个库。

一个包会将相关联的功能打包在一起,使得该功能可以很方便的在多个项目中分享。例如标准库中没有提供但是在三方库中提供的 rand 包,它提供了随机数生成的功能,只需要将该包通过 use rand; 引入到当前项目的作用域中,就可以在项目中使用 rand 的功能:rand::XXX

同一个包中不能有同名的类型,但是在不同包中就可以。例如,虽然 rand 包中,有一个 Rng 特征,可是依然可以在自己的项目中定义一个 Rng,前者通过 rand::Rng 访问,后者通过 Rng 访问,不会存在引用歧义。

项目 Package

由于 Package 就是一个项目,因此它包含有独立的 Cargo.toml 文件,以及因为功能性被组织在一起的一个或多个包。一个 Package 只能包含一个库(library)类型的包,但是可以包含多个二进制可执行类型的包。

库 Package

1
2
3
4
5
6
7
$ cargo new my-lib --lib
Created library `my-lib` package
$ ls my-lib
Cargo.toml
src
$ ls my-lib/src
lib.rs

如果你试图运行 my-lib,会报错:

1
2
$ cargo run
error: a bin target must be available for `cargo run`

原因是库类型的 Package 只能作为三方库被其它项目引用,而不能独立运行,只有之前的二进制 Package 才可以运行。

src/main.rs 一样,Cargo 知道,如果一个 Package 包含有 src/lib.rs,意味它包含有一个库类型的同名包 my-lib,该包的根文件是 src/lib.rs

模块

  • 使用 mod 关键字来创建新模块,后面紧跟着模块名称
  • 模块可以嵌套,这里嵌套的原因是招待客人和服务都发生在前厅,因此我们的代码模拟了真实场景
  • 模块中可以定义各种 Rust 类型,例如函数、结构体、枚举、特征等
  • 所有模块均定义在同一个文件中

模块树为模块的嵌套结构,他们都有一个根模块 crate。如果模块 A 包含模块 B,那么 AB 的父模块,BA 的子模块。

想要调用一个函数,就需要知道它的路径,在 Rust 中,这种路径有两种形式:

  • 绝对路径,从包根开始,路径名以包名或者 crate 作为开头
  • 相对路径,从当前模块开始,以 selfsuper 或当前模块的标识符作为开头

Rust 出于安全的考虑,默认情况下,所有的类型都是私有化的,包括函数、方法、结构体、枚举、常量,是的,就连模块本身也是私有化的。父模块完全无法访问子模块中的私有项,但是子模块却可以访问父模块、父父..模块的私有项

模块可见性不代表模块内部项的可见性,模块的可见性仅仅是允许其它模块去引用它,但是想要引用它内部的项,还得继续将对应的项标记为 pub

当外部的模块项 A 被引入到当前模块中时,它的可见性自动被设置为私有的,如果你希望允许其它外部代码引用我们的模块项 A,那么可以对它进行再导出。使用 pub use 即可实现。

引入第三方包中的模块,关于如何引入外部依赖:

修改 Cargo.toml 文件,在 [dependencies] 区域添加一行:rand = "0.8.3"

3.21-3.24 rustlings

关于rustlings,系统学过rust后几乎没有难题,所以我在6号一天就完成了70多道,前两天在熟悉github classroom和rustlings的自检方式。