02_Rust学习笔记

Rust基本概念

变量绑定与解构

  • 为什么要手动设置变量的可变性?

支持声明可变的变量为编程提供了灵活性,支持声明不可变的变量为编程提供了安全性。Rust两者都有。

除此之外,还有一个优点就是运行性能上的提升,因为将本身无需改变的变量声明为不可变在运行期间会避免一些多余的runtime检查。

变量绑定

let a = "hello world"

为什么不使用赋值而是用绑定呢?

Rust 最核心的原则——所有权,简单来讲,任何内存对象都是有主人的,而且一般情况完全属于他的主人。绑定就是把这个对象绑定给一个变量,让这个变量成为他的主人。

变量可变性

Rust 的变量在默认情况下是不可变的。这样可以使我们的编写的代码更加安全,性能也更好。可以通过 mut 关键字让变量变为可变的,让设计更灵活。
如果变量a不可变,那么一旦为它绑定值,就不能再修改a。

  • 使用下划线开头忽略未使用的变量。

变量解构

let 表达式不仅仅用于变量的绑定,还能进行复杂变量的解构:从一个相对复杂的变量中,匹配出该变量的一部分内容

变量与常量之间的差异

常量(constant)。与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:

  • 常量不允许使用 mut常量不仅仅默认不可变,而且自始至终不可变,因为常量在编译完成后,已经确定它的值。
  • 常量使用 const 关键字而不是 let 关键字来声明,并且值的类型必须标注。

变量遮蔽(shadowing)

Rust 允许声明相同的变量名,在后面声明的变量会遮蔽掉前面声明的。

变量遮蔽的用处在于,如果你在某个作用域内无需再使用之前的变量(在被遮蔽后,无法再访问到之前的同名变量),就可以重复的使用变量名字,而不用绞尽脑汁去想更多的名字。

基本类型

数值类型

整数

长度 有符号类型 无符号类型
8 位 i8 u8
16 位 i16 u16
32 位 i32 u32
64 位 i64 u64
128 位 i128 u128
视架构而定 isize usize
数字字面量 示例
十进制 98_222
十六进制 0xff
八进制 0o77
二进制 0b1111_0000
字节 (仅限于 u8) b'A'

整数溢出

当在 debug 模式编译时,Rust 会检查整型溢出,若存在这些问题,则使程序在编译时 panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。

在当使用 --release 参数进行 release 模式构建时,Rust 检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理。简而言之,大于该类型最大值的数值会被补码转换成该类型能够支持的对应数字的最小值。

要显式处理可能的溢出,可以使用标准库针对原始数字类型提供的这些方法:

  • 使用 wrapping_* 方法在所有模式下都按照补码循环溢出规则处理,例如 wrapping_add
  • 如果使用 checked_* 方法时发生溢出,则返回 None
  • 使用 overflowing_* 方法返回该值和一个指示是否存在溢出的布尔值
  • 使用 saturating_* 方法,可以限定计算后的结果不超过目标类型的最大值或低于最小值,例如:
assert_eq!(100u8.saturating_add(1), 101);
assert_eq!(u8::MAX.saturating_add(127), u8::MAX);

浮点类型

Rust 中浮点类型数字也有两种基本类型: f32f64,默认浮点类型是 f64

  • 避免在浮点数上测试相等性
  • 当结果在数学上可能存在未定义时,需要格外的小心

NaN

对于数学上未定义的结果,例如对负数取平方根 -42.1.sqrt() ,会产生一个特殊的结果:Rust 的浮点数类型使用 NaN (not a number) 来处理这些情况。

所有跟 NaN 交互的操作,都会返回一个 NaN,而且 NaN 不能用来比较

数字运算

fn main() {
  // 编译器会进行自动推导,给予twenty i32的类型
  let twenty = 20;
  // 类型标注
  let twenty_one: i32 = 21;
  // 通过类型后缀的方式进行类型标注:22是i32类型
  let twenty_two = 22i32;

  // 只有同样类型,才能运算
  let addition = twenty + twenty_one + twenty_two;
  println!("{} + {} + {} = {}", twenty, twenty_one, twenty_two, addition);

  // 对于较长的数字,可以用_进行分割,提升可读性
  let one_million: i64 = 1_000_000;
  println!("{}", one_million.pow(2));

  // 定义一个f32数组,其中42.0会自动被推导为f32类型
  let forty_twos = [
    42.0,
    42f32,
    42.0_f32,
  ];

  // 打印数组中第一个值,并控制小数位为2位
  println!("{:.2}", forty_twos[0]);
}

位运算

运算符 说明
& 位与 相同位置均为1时则为1,否则为0
| 位或 相同位置只要有1时则为1,否则为0
^ 异或 相同位置不相同则为1,相同则为0
! 位非 把位中的0和1相互取反,即0置为1,1置为0
<< 左移 所有位向左移动指定位数,右位补0
>> 右移 所有位向右移动指定位数,带符号移动(正数补0,负数补1)

序列(range)

Rust 提供了一个非常简洁的方式,用来生成连续的数值,例如 1..5,生成从 1 到 4 的连续数字,不包含 5 ;1..=5,生成从 1 到 5 的连续数字,包含 5,它的用途很简单,常常用于循环中

有理数和复数

Rust 的标准库相比其它语言,准入门槛较高,因此有理数和复数并未包含在标准库中:

  • 有理数和复数
  • 任意大小的整数和任意精度的浮点数
  • 固定精度的十进制小数,常用于货币相关的场景

好在社区已经开发出高质量的 Rust 数值库:num

按照以下步骤来引入 num 库:

  1. 创建新工程 cargo new complex-num && cd complex-num
  2. Cargo.toml 中的 [dependencies] 下添加一行 num = "0.4.0"
  3. src/main.rs 文件中的 main 函数替换为下面的代码
  4. 运行 cargo run
use num::complex::Complex;

 fn main() {
   let a = Complex { re: 2.1, im: -1.2 };
   let b = Complex::new(11.1, 22.2);
   let result = a + b;

   println!("{} + {}i", result.re, result.im)
 }

总结

  • Rust 拥有相当多的数值类型. 因此你需要熟悉这些类型所占用的字节数,这样就知道该类型允许的大小范围以及你选择的类型是否能表达负数
  • 类型转换必须是显式的. Rust 永远也不会偷偷把你的 16bit 整数转换成 32bit 整数
  • Rust 的数值上可以使用方法. 例如你可以用以下方法来将 13.14 取整:13.14_f32.round(),在这里我们使用了类型后缀,因为编译器需要知道 13.14 的具体类型

字符、布尔、单元类型

Rust 的字符只能用 '' 来表示, "" 是留给字符串的。

Rust 的字符不仅仅是 ASCII,所有的 Unicode 值都可以作为 Rust 字符,包括单个的中文、日文、韩文、emoji 表情符号等等,都是合法的字符类型。Unicode 值的范围从 U+0000 ~ U+D7FFU+E000 ~ U+10FFFF。不过“字符”并不是 Unicode 中的一个概念,所以人在直觉上对“字符”的理解和 Rust 的字符概念并不一致。

Rust 中的布尔类型有两个可能的值:truefalse,布尔值占用内存的大小为 1 个字节。使用布尔类型的场景主要在于流程控制。

单元类型就是 () ,唯一的值也是 ()。例如常见的 println!() 的返回值也是单元类型 ()

再比如,可以用 () 作为 map 的值,表示我们不关注具体的值,只关注 key

语句和表达式

完成了一个具体的操作,但是并没有返回值,因此是语句。
表达式会进行求值,然后返回一个值。 表达式不能包含分号。这一点非常重要,一旦在表达式后加上分号,它就会变成一条语句,再也不会返回一个值!

函数

  • 函数名和变量名使用蛇形命名法(snake case),例如 fn add_two() -> {}
  • 函数的位置可以随便放,Rust 不关心我们在哪里定义了函数,只要有定义即可
  • 每个函数参数都需要标注类型
    Rust 是静态类型语言,因此需要你为每一个函数参数都标识出它的具体类型。
    函数的返回值就是函数体最后一条表达式的返回值,当然我们也可以使用 return 提前返回

特殊返回函数

  • 无返回值()
      • 函数没有返回值,那么返回一个 ()
    • 通过 ; 结尾的语句返回一个 ()
  • 永不返回的发散函数!
    • 当用 ! 作函数返回类型的时候,表示该函数永不返回( diverge function )

所有权和借用

内存回收机制

如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重。

  • 垃圾回收机制(GC),在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
  • 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++
  • 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查
     Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何性能上的损失。
先学习一段不安全的代码 (C)
int* fun(){
    int a = 100;
    char *b = "abc";
    return &a; // 返回变量a的指针。(作用域结束后,局部变量a的生命周期结束,将要被释放,此时就变成了悬空指针。)
}

局部变量 a 存在于栈上面,离开工作域后,a 所申请的栈上内存都会被系统回收,造成悬空指针。
变量 b 是常量字符串,他只有在整个程序结束后系统才会回收这片内存。如此就造成了内存安全问题。

堆和栈

对于 Rust 这样的系统编程语言,值是位于栈上还是堆上非常重要,因为这会影响程序的行为和性能。

对于性能上的区别如下:在栈上分配内存比在堆上面更快,因为在入栈时候操作系统无需进行函数调用或者更慢的系统调用来分配新的空间,只需要将数据放入栈顶。相比较而言,在堆上分配内存需要更多的工作,因为操作系统必须找到一块内存充足的内存空间,接着做一些记录为下一次分配做准备,如果当前进程分配的内存页不足,还需要进行系统调用来申请更多的内存。

当你的代码调用一个函数时,传递给函数的参数(包括可能指向堆上数据的指针和函数的局部变量)依次被压入栈中,当函数调用结束时,这些值将被从栈中按照相反的顺序依次移除。

因为堆上的数据缺乏组织,因此跟踪这些数据何时分配和释放是非常重要的,否则堆上的数据将产生内存泄漏 —— 这些数据将永远无法被回收。这就是 Rust 所有权系统为我们提供的强大保障。

栈和堆的核心目标就是为程序在运行时提供可供使用的内存空间。

所有权原则
  1. Rust 中每一个值都被一个变量所拥有,该变量被称为值的所有者
  2. 一个值同时只能被一个变量所拥有,或者说一个值只能拥有一个所有者
  3. 当所有者(变量)离开作用域范围时,这个值将被丢弃(drop)

变量绑定后的数据交互

转移所有权
fn main() {
    let x = 3;
    let y = x;
}

这段代码没有产生所有权的转移, Rust 基本类型都是通过自动拷贝的方式来赋值的。因为整数是 Rust 基本数据类型,是固定大小的简单值,因此这两个值都是通过自动拷贝的方式来赋值的,都被存在栈中,完全无需在堆上分配内存。

    let s = String::from("lgqbinbin");
    let y = s;

String 类型是一个复杂类型,由存储在栈中的堆指针字符串长度字符串容量共同组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆内存。当 s1 被赋予 s2 后,Rust 认为 s1 不再有效,因此也无需在 s1 离开作用域后 drop 任何东西,这就是把所有权从 s1 转移给了 s2s1 在被赋予 s2 后就马上失效了

克隆(深拷贝)

Rust 永远也不会自动创建数据的 “深拷贝”

    let s1 = String::from("lgqbinbin");
    let s2 = s1.clone(); //深拷贝
    println!("s1 = {},s2 = {}", s1, s2);

如果代码性能无关紧要,例如初始化程序时或者在某段时间只会执行寥寥数次时,你可以使用 clone 来简化编程。但是对于执行较为频繁的代码(热点路径),使用 clone 会极大的降低程序性能,需要小心使用!

拷贝(浅拷贝)

浅拷贝只发生在上,因此性能很高,在日常编程中,浅拷贝无处不在。

引用和借用

如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。 Rust 能否像其它编程语言一样,使用某个变量的指针或者引用呢?答案是可以。
Rust 通过 借用(Borrowing) 这个概念来达成上述的目的,获取变量的引用,称之为借用(borrowing)。正如现实生活中,如果一个人拥有某样东西,你可以从他那里借来,当使用完毕后,也必须要物归原主。

引用和解引用

fn main() {
    let x = 5;
    let y = &x;
    assert_eq!(5, x);
    assert_eq!(5, *y);
    assert_eq!(5, y); //将会报错,因为只能比较相同类型
}

不可变引用

不可变引用:通过 &s1 语法,我们创建了一个指向 s1 的引用,但是并不拥有它。因为并不拥有这个值,当引用离开作用域后,其指向的值也不会被丢弃。

可变引用

可变引用:首先,声明 s 是可变类型,其次创建一个可变的引用 &mut s 和接受可变引用参数 some_string: &mut String 的函数。

  • 可变引用只能同时存在一个。 同一作用域,特定数据只能有一个可变引用

这种限制的好处就是使 Rust 在编译期就避免数据竞争,数据竞争可由以下行为造成:

  • 两个或更多的指针同时访问同一数据
  • 至少有一个指针被用来写入数据
  • 没有同步数据访问的机制

可变引用和不可变引用不能同时存在。
 引用作用域的结束位置从花括号变成最后一次使用的位置
 悬垂引用。
 在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。

总结

- 同一时刻,你只能拥有要么一个可变引用,要么任意多个不可变引用

  • 引用必须总是有效的

复合类型

字符串

字符串是由字符组成的连续集合,Rust 中的字符是 Unicode 类型,因此每个字符占据 4 个字节内存空间,但是在字符串中不一样,字符串是 UTF-8 编码,也就是字符串中的字符所占的字节数是变化的(1 - 4),这样有助于大幅降低字符串所占用的内存空间。

Rust 在语言级别,只有一种字符串类型: str,它通常是以引用类型出现 &str,也就是上文提到的字符串切片。虽然语言级别只有上述的 str 类型,但是在标准库里,还有多种不同用途的字符串类型,其中使用最广的即是 String 类型。

str 类型是硬编码进可执行文件,也无法被修改,但是 String 则是一个可增长、可改变且具有所有权的 UTF-8 编码字符串,当 Rust 用户提到字符串时,往往指的就是 String 类型和 &str 字符串切片类型,这两个类型都是 UTF-8 编码

切片

切片是为了允许引用集合中部分连续的元素序列,而不是引用整个集合。
对于字符串而言,切片就是对 String 类型中某一部分的引用。
字符串切片的类型标识是 &str
字符串字面量是切片。

从 &str 类型生成 String 类型的操作:

  • String::from("hello,world")
  • "hello,world".to_string()
    如何将 String 类型转为 &str 类型呢?
    取引用即可:
fn main() {
    let my_name = String::from("lgqbinbin");
    let s = &my_name;
    println!("{}", s);
}

字符串切片是非常危险的操作,因为切片的索引是通过字节来进行,但是字符串又是 UTF-8 编码,因此你无法保证索引的字节刚好落在字符的边界上.

操作字符串

追加
fn main() {
    let mut s = String::from("hello ");
    s.push_str("rust");
    println!("push_str()追加字符串 -> {}", s);
    s.push('!');
    println!("push()追加字符 -> {}", s);
}
插入
fn main() {
    let mut s = String::from("hello ");
    s.insert_str(6, "rust!");
    println!("insert_str()插入字符串 -> {}", s);
    s.insert(5, ',');
    println!("insert()插入字符 -> {}", s);
}
替换
  • replace ()
    适用于 String 和 &str 类型。replace() 方法接收两个参数,第一个参数是要被替换的字符串,第二个参数是新的字符串。该方法会替换所有匹配到的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串
    let s1 = String::from("I love rust!");
    let s2 = s1.replace("rust", "RUST");
    dbg!(s2);

该方法是返回一个新的字符串,而不是操作原来的字符串

  • Replacen ()
    let s1 = String::from("I love rust!  rust  rust");
    let s2 = s1.replacen("rust", "RUST", 2);
    dbg!(s2);

该方法是返回一个新的字符串,而不是操作原来的字符串

  • replace_range () 仅适用于 String 类型
    let mut s1 = String::from("I love rust!  rust  rust");
    s1.replace_range(7..11, "RUST");
    dbg!(s1);
删除

pop()remove()truncate()clear()。这四个方法仅适用于 String 类型。

元组

元组是由多种类型组合到一起形成的,因此它是复合类型,元组的长度是固定的,元组中元素的顺序也是固定的。

定义元组

let tup: (i32, f64, u8) = (3, 3.0, 3);

用模式匹配解构元组

    let tup: (i32, f64, u8) = (3, 333.0, 3);
    let (x, y, z) = tup;
    println!("y = {}", y);

. 来访问元组

    let tup: (i32, f64, u8) = (3, 333.0, 33);
    println!("x = {}", tup.0);
    println!("y = {}", tup.1);
    println!("z = {}", tup.2);

结构体

结构体和元组都是由多种类型组合而成。但是与元组不同的是,结构体可以为内部的每个字段起一个富有含义的名称。因此结构体更加灵活更加强大,你无需依赖这些字段的顺序来访问和解析它们。

结构体语法

定义,创建实例,简化结构体创建实例

一个结构体由几部分组成:

  • 通过关键字 struct 定义
  • 一个清晰明确的结构体 名称
  • 几个有名字的结构体 字段
fn main() {
    let mut user1 = user {
        email: String::from("3045291617@qq.com"),
        username: String::from("lgqbinbin"),
        active: true,
        sign_in_count: 3,
    }; // 创建结构体实例。
    user1.email = String::from("lingyigeyouxiang"); //访问结构体字段,如果要修改,实例必须是可变的。
}
// 定义结构体
struct user {
    active: bool,
    username: String,
    email: String,
    sign_in_count: u64,
}
// 简化结构体创建实例
fn build_user(email: String, name: String) -> user {
    user {
        active: true,
        username: name,
        email: email,
        sign_in_count: 1,
    }
}
  1. 初始化实例时,每个字段都需要进行初始化
  2. 初始化时的字段顺序不需要和结构体定义时的顺序一致
结构体更新语法

根据已有的结构体实例,创建新的结构体实例,例如根据已有的 user1 实例来构建 user2

    let user2 = user {
        active: user1.active,
        username: user1.username,
        email: String::from("another@example.com"),
        sign_in_count: user1.sign_in_count,
    };
    let user3 = user {
        email: String::from("another@example.com"),
        ..user2
    };

username 所有权被转移给了 user2,导致了 user1 无法再被使用,但是并不代表 user1 内部的其它字段不能被继续使用.
把结构体中具有所有权的字段转移出去后,将无法再访问该字段,但是可以正常访问其它的字段

元组结构体

单元结构体就跟它很像,没有任何字段和属性,但是好在,它还挺有用。
如果你定义一个类型,但是不关心该类型的内容,只关心它的行为时,就可以使用 单元结构体

struct AlwaysEqual;
let subject = AlwaysEqual;
// 我们不关心 AlwaysEqual 的字段数据,只关心它的行为,因此将它声明为单元结构体,然后再为它实现某个特征
impl SomeTrait for AlwaysEqual {

}

如果想在结构体中使用一个引用,就必须加上生命周期.

使用 #[drive (debug)] 来打印结构体的信息

如果我们使用 {} 来格式化输出,那对应的类型就必须实现 Display 特征,以前学习的基本类型,都默认实现了该特征.
结构体为什么不默认实现 Display 特征呢?原因在于结构体较为复杂,例如考虑以下问题:你想要逗号对字段进行分割吗?需要括号吗?加在什么地方?所有的字段都应该显示?类似的还有很多,由于这种复杂性,Rust 不希望猜测我们想要的是什么,而是把选择权交给我们自己来实现:如果要用 {} 的方式打印结构体,那就自己实现 Display 特征。

println!("rect1 is {:?}", rect1);

可是依然无情报错了:

error[E0277]: `Rectangle` doesn't implement `Debug`

首先,Rust 默认不会为我们实现 Debug,为了实现,有两种方式可以选择:

  • 手动实现
  • 使用 derive 派生实现
#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}
fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };
    println!("rect1 is {:?}", rect1);
}

当结构体较大时,我们可能希望能够有更好的输出表现,此时可以使用 {:#?} 来替代 {:?},输出如下:

rect1 is Rectangle {
    width: 30,
    height: 50,
}

还有一个简单的输出 debug 信息的方法,那就是使用 dbg! 宏,它会拿走表达式的所有权,然后打印出相应的文件名、行号等 debug 信息,当然还有我们需要的表达式的求值结果。除此之外,它最终还会把表达式值的所有权返回!
dbg! 输出到标准错误输出 stderr,而 println! 输出到标准输出 stdout
dbg!(&rect1);

枚举

枚举(enum 或 enumeration)允许你通过列举可能的成员来定义一个枚举类型,例如扑克牌花色:

    enum PokerSuit {
        Clubs,    //梅花
        Spades,   //黑桃
        Diamonds, //方片
        Hearts,   //红心
    }

任何一张扑克,它的花色肯定会落在四种花色中,而且也只会落在其中一个花色上,这种特性非常适合枚举的使用,因为枚举值只可能是其中某一个成员。抽象来看,四种花色尽管是不同的花色,但是它们都是扑克花色这个概念,因此当某个函数处理扑克花色时,可以把它们当作相同的类型进行传参。
 枚举类型是一个类型,它会包含所有可能的枚举成员,而枚举值是该类型中的具体某个成员的实例。

枚举值
fn main() {
    let heart = PokerSuit::Hearts;
    let diamond = PokerSuit::Diamonds;
    print_suit(heart);
    print_suit(diamond);
}
#[derive(Debug)]
enum PokerSuit {
    Clubs,    //梅花
    Spades,   //黑桃
    Diamonds, //方片
    Hearts,   //红心
}
fn print_suit(card: PokerSuit) {
    // 需要在定义 enum PokerSuit 的上面添加上 #[derive(Debug)],否则会报 card 没有实现 Debug
    println!("{:?}", card);
}

任何类型的数据都可以放入枚举成员中:例如字符串、数值、结构体甚至另一个枚举。

enum PokerCard {
    Clubs(u8),
    Spades(u8),
    Diamonds(char),
    Hearts(char),
}
fn main() {
    let c1 = PokerCard::Spades(5);
    let c2 = PokerCard::Diamonds('A');
}
Option 枚举用于处理空值

Rust 吸取了众多教训,决定抛弃 null,而改为使用 Option 枚举变量来表述这种结果。
Option 枚举包含两个成员,一个成员表示含有值:Some(T), 另一个表示没有值:None,定义如下:

enum Option<T> {
    Some(T),
	None, 
}

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

let absent_number: Option<i32> = None;

数组

array 为数组,Vector 为动态数组。
数组 array 。数组的具体定义很简单:将多个类型相同的元素依次组合在一起,就是一个数组。结合上面的内容,可以得出数组的三要素:

  • 长度固定
  • 元素必须有相同的类型
  • 依次线性排列

我们这里说的数组是 Rust 的基本类型,是固定长度的,这点与其他编程语言不同,其它编程语言的数组往往是可变长度的,与 Rust 中的动态数组 Vector 类似

创建数组

	let a = [3,3,3];
	let a: [i32; 5] = [1, 2, 3, 4, 5];//i32是元素类型,后面的5是长度。
	let a = [3,  5];//初始化一个某个值重复出现 N 次的数组

由于它的元素类型大小固定,且长度也是固定,因此数组 array 是存储在栈上,性能也会非常优秀。与此对应,动态数组 Vector 是存储在堆上,因此长度可以动态改变。当你不确定是使用数组还是动态数组时,那就应该使用后者,

访问数组元素

因为数组是连续存放元素的,因此可以通过索引的方式来访问存放其中的元素:

    let a = [3,2,1];
    let first = a[0];
    let second = a[1];

当尝试使用索引访问元素时,Rust 将检查你指定的索引是否小于数组长度。如果索引大于或等于数组长度,Rust 会出现 panic。这种检查只能在运行时进行,比如编译器无法在编译期知道用户运行代码时将输入什么值。

数组元素为非基础类型

基本类型在 Rust 中赋值是以 Copy 的形式,这时候你就懂了吧,let array=[3;5] 底层就是不断的Copy出来的,但很可惜复杂类型都没有深拷贝,只能一个个创建。
正确的写法,应该调用std::array::from_fn

	let array: [String; 8] = std::array::from_fn(|_i| String::from("rust is good!")); 
	println!("{:#?}", array);

数组切片

    let a: [i32; 5] = [1, 2, 3, 4, 5];
    let slice: &[i32] = &a[1..3];
    assert_eq!(slice, &[2, 3]);

上面的数组切片 slice 的类型是&[i32],与之对比,数组的类型是[i32;5],简单总结下切片的特点:

  • 切片的长度可以与数组不同,并不是固定的,而是取决于你使用时指定的起始和结束位置
  • 创建切片的代价非常小,因为切片只是针对底层数组的一个引用
  • 切片类型 [T] 拥有不固定的大小,而切片引用类型 &[T] 则具有固定的大小,因为 Rust 很多时候都需要固定大小数据类型,因此 &[T] 更有用,&str 字符串切片也同理

总结

  • 数组类型容易跟数组切片混淆,[T;n] 描述了一个数组的类型,而 [T] 描述了切片的类型, 因为切片是运行期的数据结构,它的长度无法在编译期得知,因此不能用 [T;n] 的形式去描述
  • [u8; 3][u8; 4]是不同的类型,数组的长度也是类型的一部分
  • 在实际开发中,使用最多的是数组切片[T],我们往往通过引用的方式去使用&[T],因为后者有固定的类型大小

流程控制

使用 if 来做分支控制

if()   else

if()   else if()   else

循环控制

For 循环

使用方法 等价使用方式 所有权
for item in collection for item in IntoIterator::into_iter(collection) 转移所有权
for item in &collection for item in collection.iter() 不可变借用
for item in &mut collection for item in collection.iter_mut() 可变借用
如果我们想用 for 循环控制某个过程执行 10 次,但是又不想单独声明一个变量来控制这个流程,该怎么写?
for _ in 0..10 {   // ... }

可以用 _ 来替代 i 用于 for 循环中,在 Rust 中 _ 的含义是忽略该值或者类型的意思,如果不使用 _,那么编译器会给你一个 变量未使用的 的警告。

Continue,break, while

我们也能用 while 来实现 for 的功能, for 并不会使用索引去访问数组,因此更安全也更简洁,同时避免 运行时的边界检查,性能更高。

Loop 循环

loop 是一个简单的无限循环,你可以在内部实现逻辑通过 break 关键字来控制循环何时结束。

  • break 可以单独使用,也可以带一个返回值,有些类似 return
  • loop 是一个表达式,因此可以返回一个值

模式匹配

matchif let

Match

    match target {
        模式1 => 表达式1,
        模式2 => {
            语句1;
            语句2;
            表达式2
        },
        _ => 表达式3
    }

If let

遇到只有一个模式的值需要被处理,其它值直接忽略的场景.

if let Some(3) = v { 
	println!("three"); 
}

当你只要匹配一个条件,且忽略其他条件时就用 if let ,否则都用 match

Matches! 宏

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

    let foo = 'f';
    assert!(matches!(foo, 'A'..='Z' | 'a'..='z'));

变量遮蔽例子

fn main() {
    let age = Some(30);
    if let Some(age) = age {
        // 创建一个新的变量,该变量与之前的 `age` 变量同名
        assert_eq!(age, 30);
    } // 新的 `age` 变量在这里超出作用域
    match age {
        // `match` 也能实现变量遮蔽
        Some(age) => println!("age 是一个新的变量,它的值是 {}", age),
        _ => (),
    }
}

解构 option

enum Option<T> {
    None,
    Some(T),
}

一个变量要么有值:Some(T), 要么为空:None

匹配 Option<T>

使用 Option<T>,是为了从 Some 中取出其内部的 T 值以及处理没有值的情况.

fn plus_one(x: Option<i32>) -> Option<i32> {
    match x {
        None => None,
        Some(i) => Some(i + 1),
    }
}
let five = Some(5);
let six = plus_one(five);
let none = plus_one(None);

其他

@ 操作符可以让我们将一个与模式相匹配的值绑定到新的变量上

匹配守卫match guard)是一个位于 match 分支模式之后的额外 if 条件,它能为分支模式提供更进一步的匹配条件。

对于有多个部分的值,可以使用 .. 语法来只使用部分值而忽略其它值,这样也不用再为每一个被忽略的值都单独列出下划线。.. 模式会忽略模式中剩余的任何没有显式匹配的值部分。

使用模式 &mut V 去匹配一个可变引用时,你需要格外小心,因为匹配出来的 V 是一个值,而不是可变引用

方法 Method

Rust 的方法往往跟结构体、枚举、特征(Trait)一起使用.如读取一个文件写入缓冲区,如果用函数的写法 read(f, buffer),用方法的写法 f.read(buffer)。不过与其它语言 class 跟方法的联动使用不同,Rust 的方法往往跟结构体、枚举、特征(Trait)一起使用。

定义方法

Rust 使用 impl 来定义方法。
其它语言中所有定义都在 class 中,但是 Rust 的对象定义和方法定义是分离的,这种数据和使用分离的方式,会给予使用者极高的灵活度。

#[derive(Debug)]
struct Rectangle {
    width: u32,
    height: u32,
}
impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }
}
fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    println!(
        "The area of the rectangle is {} square pixels.",
        rect1.area()
    );
}

impl Rectangle {} 表示为 Rectangle 实现方法(impl 是实现 implementation 的缩写),这样的写法表明 impl 语句块中的一切都是跟 Rectangle 相关联的。

self, &self&mut self

在上面的那段代码中,使用 &self 代替 rectangle:&Rectangle; &self 其实是 self:&Self 的简写。在一个 impl 块中,Self 指代被实现方法的结构体类型,self 指代该类型的实例。

self 依然有所有权的概念:

  • self 表示 Rectangle 的所有权转移到该方法中,这种形式用的较少
  • &self 表示该方法对 Rectangle 的不可变借用
  • &mut self 表示可变借用

就像是函数参数一样,一样需要严格遵守 Rust 的所有权规则。
使用方法代替函数有以下好处:

  • 不用在函数签名中重复书写 self 对应的类型
  • 代码的组织性和内聚性更强,对于代码维护和阅读来说,好处巨大。

方法名和结构体字段名相同

在 Rust 中,允许方法名跟结构体的字段名相同:

impl Rectangle {
    fn width(&self) -> bool {
        self.width > 0
    }
}

fn main() {
    let rect1 = Rectangle {
        width: 30,
        height: 50,
    };

    if rect1.width() {
        println!("The rectangle has a nonzero width; it is {}", rect1.width);
    }
}

上面的代码中,如果调用 rect1.width() 使用的是方法,如果是 rect1.width 则直接访问的他的字段。

带有多个参数的方法

impl Rectangle {
    fn area(&self) -> u32 {
        self.width * self.height
    }

    fn can_hold(&self, other: &Rectangle) -> bool {
        self.width > other.width && self.height > other.height
    }
}

fn main() {
    let rect1 = Rectangle { width: 30, height: 50 };
    let rect2 = Rectangle { width: 10, height: 40 };
    let rect3 = Rectangle { width: 60, height: 45 };

    println!("Can rect1 hold rect2? {}", rect1.can_hold(&rect2));
    println!("Can rect1 hold rect3? {}", rect1.can_hold(&rect3));
}

上面这个例子,方法带有多个参数,也是可以的。看起来和 C++的语法差不多。

关联函数

如何为一个结构体定义一个构造器方法?也就是接受几个参数,然后构造并返回该结构体的实例。
答案是参数中不包含 self 即可。
示例如下:

impl Rectangle {
    fn new(w: u32, h: u32) -> Rectangle {
        Rectangle {
            width: w,
            height: h,
        }
    }
}

在这个例子中,fn 后面这个 new函数有两个参数,返回到结构体变量,函数的内容则是结构体实例化的写法。详细信息看笔记上面的结构体部分。

Rust 中有一个约定俗成的规则,使用 new 来作为构造器的名称,出于设计上的考虑,Rust 特地没有用 new 作为关键字。

因为关联函数是函数,所以不能使用 . 来进行调用,调用需要使用 ::,例如 let sq = Rectangle::new(3, 3);。这个方法位于结构体的命名空间中::: 语法用于关联函数和模块创建的命名空间。

多个 impl 定义

Rust 允许我们为一个结构体定义多个 impl 块,目的是提供更多的灵活性和代码组织性,例如当方法多了后,可以把相关的方法组织在同一个 impl 块中,那么就可以形成多个 impl 块,各自完成一块儿目标。

为枚举实现方法

枚举类型之所以强大,不仅仅在于它好用、可以同一化类型,还在于,我们可以像结构体一样,为枚举实现方法:

#![allow(unused)]
enum Message {
    Quit,
    Move { x: i32, y: i32 },
    Write(String),
    ChangeColor(i32, i32, i32),
}

impl Message {
    fn call(&self) {
        // 在这里定义方法体
    }
}

fn main() {
    let m = Message::Write(String::from("hello"));
    m.call();
}

除了枚举和结构体,我们还可以为特征(trait)实现方法。

泛型和特征

泛型 Generics

在编程中,经常有这样的需求:用同一功能的函数处理不同类型的数据,例如两个数的加法,无论是整数还是浮点数,甚至是自定义类型,都能进行支持。在不支持泛型的编程语言中,通常需要为每一种类型编写一个函数。

泛型就是一种多态。泛型主要目的是为程序员提供编程的便利,减少代码的臃肿,同时可以极大地丰富语言本身的表达能力.

示例

fn add<T>(a: T, b: T) -> T {
    a + b
}
fn main() {
    println!("add i8: {}", add(2i8, 3i8));
    println!("add i32: {}", add(20, 30));
    println!("add f64: {}", add(1.23, 1.23));
}

这段我自己来运行的时候却进行了报错。暂时没发现原因。 这是正常的,后面会慢慢学到。

泛型详解

上面代码的 T 就是泛型参数,实际上在 Rust 中,泛型参数的名称你可以任意起,但是出于惯例,我们都用 T (T 是 type 的首字母)来作为首选,这个名称越短越好,除非需要表达含义,否则一个字母是最完美的。

使用泛型参数,有一个先决条件,必需在使用前对其进行声明。

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

这个函数定义如下:函数 largest 有泛型类型 T,它有个参数 list,其类型是元素为 T 的数组切片,最后,该函数返回值的类型也是 T

这时候,回到刚刚的示例,报错有这样的提示:
error[E0369]: cannot add T to T ``
不是所有 T 类型都能进行相加操作,因此我们需要用 std::ops::Add<Output = T> 对 T 进行限制:

fn add<T>: std::ops::Add<Output = T>>(a: T, b: T) -> T {
    a + b
}

修改后,就可以正常运行。

显式地指定泛型的类型参数

有时候,编译器无法推断你想要的泛型参数。例子如下所示:

use std::fmt::Display;

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();
}

报错如下

12 |     create_and_print();
   |     ^^^^^^^^^^^^^^^^ cannot infer type of the type parameter `T` declared on the function `create_and_print`
   |
   = note: multiple `impl`s satisfying `_: From<i32>` found in the `core` crate:
           - impl From<i32> for AtomicI32;
           - impl From<i32> for f64;
           - impl From<i32> for i128;
           - impl From<i32> for i64;
note: required by a bound in `create_and_print`
  --> src/main.rs:5:8
   |
3  | fn create_and_print<T>()
   |    ---------------- required by a bound in this function
4  | where
5  |     T: From<i32> + Display,
   |        ^^^^^^^^^ required by this bound in `create_and_print`
help: consider specifying the generic argument
   |
12 |     create_and_print::<T>();
   |                     +++++

修改代码

fn main() {
    create_and_print::<i64>();
}

修改后即可运行。

结构体中使用泛型

结构体中的字段类型也可以用泛型来定义,下面代码定义了一个坐标点 Point,它可以存放任何类型的坐标值:

struct Point<T> {
    x: T,
    y: T,
}

fn main() {
    let integer = Point { x: 5, y: 10 };
    let float = Point { x: 1.0, y: 4.0 };
}

有两点需要特别的注意:

  • 提前声明,跟泛型函数定义类似,首先我们在使用泛型参数之前必需要进行声明 Point<T>,接着就可以在结构体的字段类型中使用 T 来替代具体的类型
  • x 和 y 是相同的类型

第二点是非常的要注意,那么如果想要 x 和 y 的类型不同,可以怎样做呢?答案是使用不同的泛型参数:

struct Point<T,U> {
    x: T,
    y: U,
}
fn main() {
    let p = Point{x: 1, y :1.1};
}

枚举中使用泛型

Option 是枚举中第一个应该被想起来的,如下:

enum Option<T>{
	Some<T>,
	None,
}

这是一个拥有泛型 T的枚举类型,它的第一个成员是 Some<T>, 存放了一个类型为 T 的值。得益于泛型值的引入,我们可以在任何一个需要返回值的函数中去使用 Option<T> 枚举类型作为返回值,用于返回一个任意类型的值 Some<T>, 或者没有值 None

还有一个枚举叫做 Result 如下所示:

enum Result<T,E> {
    Ok<T>,
    Err<E>,
}

这个枚举和 Option 差不多,主要用于函数返回值,与 Option 不同的是他关注返回值的正确性,而不是返回是否有值。

如果函数正常运行,则最后返回一个 Ok(T)T 是函数具体的返回值类型,如果函数异常运行,则返回一个 Err(E)E 是错误类型。例如打开一个文件:如果成功打开文件,则返回 Ok(std::fs::File),因此 T 对应的是 std::fs::File 类型;而当打开文件时出现问题时,返回 Err(std::io::Error)E 对应的就是 std::io::Error 类型。

方法中使用泛型

例子如下:

struct Point<T> {
    x: T,
    y: T,
}

impl<T> Point<T> {
    fn x(&self) -> &T {
        &self.x
    }
}

fn main() {
    let p = Point { x: 5, y: 10 };
    println!("p.x = {}", p.x());
}

需要提前声明:impl<T>,只有提前声明了,我们才能在 Point<T> 中使用它,这样 Rust 就知道 Point 的尖括号中的类型是泛型而不是具体类型。需要注意的是,这里的 Point<T> 不再是泛型声明,而是一个完整的结构体类型,因为我们定义的结构体就是 Point<T> 而不再是 Point

然后再看另外一个例子,除了在结构体中泛型参数,我们还可以再结构体的方法中定义额外的泛型参数,跟泛型函数一样。

struct Point<T, U> {
    x: T,
    y: U,
}

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,
        }
    }
}

fn main() {
    let p1 = Point { x: 5, y: 10.4 };
    let p2 = Point { x: "Hello", y: 'c'};

    let p3 = p1.mixup(p2);

    println!("p3.x = {}, p3.y = {}", p3.x, p3.y);
}

T,U 是定义在结构体上的泛型参数,V,W 是单独定义在方法 mixup 上的泛型参数,他们并不冲突。前一个是结构体泛型,后一个是函数泛型。

为具体的泛型类型实现方法

对于 Point<T> 类型,不仅能够定义基于 T 的方法,还能针对特定的具体类型,进行方法定义:
如下所示:

impl Point<f32> {
    fn distance_from_origin(&self) -> f32 {
        (self.x.powi(2) + self.y.powi(2)).sqrt()
    }
}

这段代码意味着 Point<f32> 类型会有一个方法 distance_from_origin,而其他 T 不是 f32 类型的 Point<T> 实例则没有定义此方法。这个方法计算点实例与坐标 (0.0, 0.0) 之间的距离,并使用了只能用于浮点型的数学运算符。

const 泛型

上面学习的那些都是针对类型实现的泛型,所有的泛型都是为了抽象不同的类型。针对值的泛型呢?

[i32; 2] 和 [i32; 3] 是不同的数组类型。

集合类型

动态数组 Vector

动态数组用 Vec<T> 来表示。非常像 C++中的链表或者 vector 容器。

创建动态数组

有多种方式可以创建。

Vec::new

这种方法调用了 Vec 中的 new 关联函数:
下面是两种创建代码的示例:

fn main() {
    let v: Vec<i32> = Vec::new();

    let mut v2 = Vec::new();
    v2.push(3);
}
vec![] (注意是小写)

这种方法是使用宏 Vec! 来创建数组,和前一种方法不同的是,它能够在创建的同时进行初始化。

fn main() {
    let v = vec![1, 2, 3];
}

在这里的 v 则不用标注数据类型,编译器会根据它的内部元素自动推导出来。

更新 Vector

使用 push 方法向数组尾部添加元素:
同样的必须声明 mut 可变变量才可以对变量进行修改。

let mut v2 = Vec::new();
v2.push(3);

Vector 和他内部的元素共存亡

类似于结构体,Vector 在超出作用域后会被自动删除,他内部的元素也会被自动删除。但是当其中的元素被引用之后,就会发现事情变复杂了。

Vector 中读取元素

读取指定位置的元素有两种方法可以选择:

  • 通过下标索引访问
  • 通过 get 方法

认识生命周期

生命周期,简而言之就是引用的有效作用域。在大多数时候,我们无需手动的声明生命周期,因为编译器可以自动进行推导,用类型来类比下:

  • 就像编译器大部分时候可以自动推导类型 < - > 一样,编译器大多数时候也可以自动推导生命周期
  • 在多种类型存在时,编译器往往要求我们手动标明类型 < - > 当多个生命周期存在,且编译器无法推导出某个引用的生命周期时,就需要我们手动标明生命周期

垂悬指针和生命周期

{
    let r;
    {
        let x = 5;
        r = &x;
    }
    println!("r: {}", r);
}

此处 r 就是一个悬垂指针,它引用了提前被释放的变量 x
在这里 r 拥有更大的作用域,或者说活得更久。如果 Rust 不阻止该悬垂引用的发生,那么当 x 被释放后,r 所引用的值就不再是合法的,会导致我们程序发生异常行为,且该异常行为有时候会很难被发现。

借用检查

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

函数中的生命周期

fn main() {
    let string1 = String::from("abcd");
    let string2 = "xyz";
    let result = longest(string1.as_str(), string2);
    println!("The longest string is {}", result);
}
fn longest(x: &str, y: &str) -> &str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

编译阶段就会出错。
= help: this function's return type contains a borrowed value, but the signature does not say whether it is borrowed from x or y = 帮助: 该函数的返回值是一个引用类型,但是函数签名无法说明,该引用是借用自 x 还是 y

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

生命周期标注语法

生命周期标注并不会改变任何引用的实际作用域。

例如一个变量,只能活一个花括号,那么就算你给它标注一个活全局的生命周期,它还是会在前面的花括号结束处被释放掉,并不会真的全局存活。
生命周期的语法也颇为与众不同,以 ' 开头,名称往往是一个单独的小写字母,大多数人都用 'a 来作为生命周期的名称。如果是引用类型的参数,那么生命周期会位于引用符号 & 之后,并用一个空格来将生命周期和引用参数分隔开:

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

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

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

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}
  • 和泛型一样,使用生命周期参数,需要先声明 <'a>
  • xy 和返回值至少活得和 'a 一样久(因为返回值要么是 x,要么是 y
    在通过函数签名指定生命周期参数时,我们并没有改变传入引用或者返回引用的真实生命周期,而是告诉编译器当不满足此约束条件时,就拒绝编译通过

函数的返回值如果是一个引用类型,那么它的生命周期只会来源于

  • 函数参数的生命周期
  • 函数体中某个新建引用的生命周期

若是后者情况,就是典型的悬垂引用场景

result 在函数结束后就被释放,但是在函数结束后,对 result 的引用依然在继续。在这种情况下,没有办法指定合适的生命周期来让编译通过,因此我们也就在 Rust 中避免了悬垂引用。
那遇到这种情况该怎么办?最好的办法就是返回内部字符串的所有权,然后把字符串的所有权转移给调用者。

fn longest<'a>(_x: &str, _y: &str) -> String {
    String::from("really long string")
}
fn main() {
   let s = longest("not", "important");
}

生命周期语法用来将函数的多个引用参数和返回值的作用域关联到一起,一旦关联到一起后,Rust 就拥有充分的信息来确保我们的操作是内存安全的。

结构体中的生命周期

已经理解了生命周期,那么意味着在结构体中使用引用也变得可能:只要为结构体中的每一个引用标注上生命周期即可。
结构体的生命周期标注语法跟泛型参数语法很像,需要对生命周期参数进行声明 <'a>。该生命周期标注说明,结构体 ImportantExcerpt 所引用的字符串 str 生命周期需要大于等于该结构体的生命周期

生命周期的消除

对于 first_word 函数,它的返回值是一个引用类型,那么该引用只有两种情况:

  • 从参数获取
  • 从函数体内部新创建的变量获取

如果是后者,就会出现悬垂引用,最终被编译器拒绝,因此只剩一种情况:返回值的引用是获取自参数,这就意味着参数和返回值的生命周期是一样的。道理很简单,我们能看出来,编译器自然也能看出来,因此,就算我们不标注生命周期,也不会产生歧义。

注意:

  • 消除规则不是万能的,若编译器不能确定某件事是正确时,会直接判为不正确,那么你还是需要手动标注生命周期
  • 函数或者方法中,参数的生命周期被称为 输入生命周期,返回值的生命周期被称为 输出生命周期

三条消除规则

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

  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 形式的参数,说明该函数是一个 方法,该规则让方法的使用便利度大幅提升。

方法中的生命周期

方法的生命周期标注跟函数类似。

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

impl<'a> ImportantExcerpt<'a> {
    fn level(&self) -> i32 {
        3
    }
}

其中有几点需要注意的:

  • impl 中必须使用结构体的完整名称,包括 <'a>,因为_生命周期标注也是结构体类型的一部分_!
  • 方法签名中,往往不需要标注生命周期,得益于生命周期消除的第一和第三规则

静态生命周期

在 Rust 中有一个非常特殊的生命周期,那就是 'static,拥有该生命周期的引用可以和整个程序活得一样久。
总结下:

  • 生命周期 'static 意味着能和程序活得一样久,例如字符串字面量和特征对象
  • 实在遇到解决不了的生命周期标注问题,可以尝试 T: 'static,有时候它会给你奇迹

返回值和错误处理

包和模块

  1. 单个文件过大,导致打开、翻页速度大幅变慢
  2. 查询和定位效率大幅降低,类比下,你会把所有知识内容放在一个几十万字的文档中吗?
  3. 只有一个代码层次:函数,难以维护和协作,想象一下你的操作系统只有一个根目录,剩下的都是单层子目录会如何:disaster
  4. 容易滋生 Bug
  • 项目(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

前面的学习创建的 package 项目就是二进制项目。
文件里面并没有提到 src/main.rs 作为程序的入口,原因是 Cargo 有一个惯例:src/main.rs 是二进制包的根文件,该二进制包的包名跟所属 Package 相同,在这里都是 my-project,所有的代码执行都从该文件中的 fn main() 函数开始。
使用 cargo run 可以运行该项目,输出。

库 package

$ cargo new my-lib --lib
     Created library `my-lib` package
$ ls my-lib
Cargo.toml
src
$ ls my-lib/src
lib.rs

运行 my-lib,会报错:

$ 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

典型的项目结构

一个真实项目中典型的 Package,会包含多个二进制包,这些包文件被放在 src/bin 目录下,每一个文件都是独立的二进制包,同时也会包含一个库包,该包只能存在一个 src/lib.rs

.
├── Cargo.toml
├── Cargo.lock
├── src
│   ├── main.rs
│   ├── lib.rs
│   └── bin
│       └── main1.rs
│       └── main2.rs
├── tests
│   └── some_integration_tests.rs
├── benches
│   └── simple_bench.rs
└── examples
    └── simple_example.rs
  • 唯一库包:src/lib.rs
  • 默认二进制包:src/main.rs,编译后生成的可执行文件与 Package 同名
  • 其余二进制包:src/bin/main1.rs 和 src/bin/main2.rs,它们会分别生成一个文件同名的二进制可执行文件
  • 集成测试文件:tests 目录下
  • 基准性能测试 benchmark 文件:benches 目录下
  • 项目示例:examples 目录下

模块

这个部分的学习,我通过实际的操作一遍来进行学习,代码量会偏多。

使用 cargo new --lib restaurant 创建一个小餐馆,注意,这里创建的是一个库类型的 Package,然后将以下代码放入 src/lib.rs 中:

// 餐厅前厅,用于吃饭
mod front_of_house {
    mod hosting {//招待客人
        fn add_to_waitlist() {} //加入候补
        fn seat_at_table() {}   //坐席
    }
    mod serving {//服务
        fn take_order() {}      //接受订购
        fn serve_order() {}     //服务订单
        fn take_payment() {}    //接受付款
    }
}

以上的代码创建了三个模块,有几点需要注意的:

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

模块树

src/main.rs 和 src/lib.rs 被称为包根(crate root),是由于这两个文件的内容形成了一个模块 crate,该模块位于包的树形结构(由模块组成的树形结构)的根部。
   crate └── front_of_house ├── hosting │ ├── add_to_waitlist │ └── seat_at_table └── serving ├── take_order ├── serve_order └── take_payment  
这颗树展示了模块之间彼此的嵌套关系,因此被称为模块树。其中 crate 包根是 src/lib.rs 文件,包根文件中的三个模块分别形成了模块树的剩余部分。

父子模块

如果模块 A 包含模块 B,那么 A 是 B 的父模块,B 是 A 的子模块。在上例中,front_of_house 是 hosting 和 serving 的父模块,反之,后两者是前者的子模块。

每个文件都有自己的路径,用户可以通过这些路径使用它们,在 Rust 中,我们也通过路径的方式来引用模块。

用路径引用模块

路径有两种形式:

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

为上述代码增加实现一个小功能: 文件名:src/lib.rs

mod front_of_house {
    mod hosting {
        fn add_to_waitlist() {}
    }
}
pub fn eat_at_restaurant() {
    // 绝对路径
    crate::front_of_house::hosting::add_to_waitlist();
    // 相对路径
    front_of_house::hosting::add_to_waitlist();
}//试过会报错,加上pub就好了。
绝对路径引用

因为 eat_at_restaurant 和 add_to_waitlist 都定义在一个包中,因此在绝对路径引用时,可以直接以 crate 开头,然后逐层引用,每一层之间使用 :: 分隔:

crate::front_of_house::hosting::add_to_waitlist();
crate
└── eat_at_restaurant
└── front_of_house
    ├── hosting
    │   ├── add_to_waitlist
    │   └── seat_at_table
    └── serving
        ├── take_order
        ├── serve_order
        └── take_payment

绝对路径的调用,完全符合了模块树的层级递进,非常符合直觉,如果类比文件系统,就跟使用绝对路径调用可执行程序差不多:/front_of_house/hosting/add_to_waitlist,使用 crate 作为开始就和使用 / 作为开始一样。

相对路径引用

再回到模块树中,因为 eat_at_restaurant 和 front_of_house 都处于包根 crate 中,因此相对路径可以使用 front_of_house 作为开头:

front_of_house::hosting::add_to_waitlist();

代码可见性

现在到了解释刚刚的代码为什么会报错了。

错误信息很清晰:hosting 模块是私有的,无法在包根进行访问,那么为何 front_of_house 模块就可以访问?因为它和 eat_at_restaurant 同属于一个包根作用域内,同一个模块内的代码自然不存在私有化问题。

父模块完全无法访问子模块中的私有项,但是子模块却可以访问父模块、父父..模块的私有项

Pub 关键字

类似其它语言的 public 或者 Go 语言中的首字母大写,Rust 提供了 pub 关键字,通过它你可以控制模块和模块中指定项的可见性。

由于之前的解释,我们知道了只需要将 hosting 模块标记为对外可见即可:

mod front_of_house {
    pub mod hosting {
        fn add_to_waitlist() {}
    }
}

然后还是报同样的错误,
模块可见性不代表模块内部项的可见性,模块的可见性仅仅是允许其它模块去引用它,但是想要引用它内部的项,还得继续将对应的项标记为 pub。在实际项目中,一个模块需要对外暴露的数据和 API 往往就寥寥数个,如果将模块标记为可见代表着内部项也全部对外可见,那你是不是还得把那些不可见的,一个一个标记为 private?反而是更麻烦的多。为函数也标记上 pub 之后就顺利解决了。

使用 super 引用模块

super 代表的是父模块为开始的引用方式,非常类似于文件系统中的 .. 语法:../a/b 文件名:src/lib.rs

fn serve_order() {}
// 厨房模块
mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::serve_order();
    }
    fn cook_order() {}
}

也可以使用 crate::serve_order(), 这样的话就是上面所说的绝对路径。

使用 self 引用模块

self 其实就是引用自身模块中的项,也就是说和我们之前章节的代码类似,都调用同一模块中的内容,区别在于之前章节中直接通过名称调用即可,而 self,你得多此一举:因为完全可以直接调用 back_of_house,但是 self 还有一个大用处,在下一个部分我在学习。

结构体和枚举的可见性

  • 将结构体设置为 pub,但它的所有字段依然是私有的
  • 将枚举设置为 pub,它的所有字段也将对外可见

原因在于,枚举和结构体的使用方式不一样。如果枚举的成员对外不可见,那该枚举将一点用都没有,因此枚举成员的可见性自动跟枚举可见性保持一致,这样可以简化用户的使用。

而结构体的应用场景比较复杂,其中的字段也往往部分在 A 处被使用,部分在 B 处被使用,因此无法确定成员的可见性,那索性就设置为全部不可见,将选择权交给程序员。

模块与文件分离

在之前的例子中,我们所有的模块都定义在 src/lib.rs 中,但是当模块变多或者变大时,需要将模块放入一个单独的文件中,让代码更好维护。

现在,把 front_of_house 前厅分离出来,放入一个单独的文件中 src/front_of_house.rs

// 餐厅前厅,用于吃饭
mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
        fn seat_at_table() {}
    }
    mod serving {
        fn take_order() {}
        fn serve_order() {}
        fn take_payment() {}
    }
}

把 back_of_house 前厅分离出来,放入一个单独的文件中 src/back_of_house.rs

// 厨房模块
mod back_of_house {
    fn fix_incorrect_order() {
        cook_order();
        super::serve_order();
    }
    fn cook_order() {}
}
fn serve_order() {}

然后,将以下代码留在 src/lib.rs 中:

mod back_of_house;
mod front_of_house;
pub use crate::front_of_house::front_of_house::hosting;//十分注意这里的路径,这样做之后会有两层::front_of_house。
pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
}//怎样变成一个呢,只需要在那个文件夹中去掉定义的部分即可。
  • mod front_of_house; 告诉 Rust 从另一个和模块 front_of_house 同名的文件中加载该模块的内容
  • 使用绝对路径的方式来引用 hosting 模块:crate::front_of_house::hosting;

需要注意的是,和之前代码中 mod front_of_house{..} 的完整模块不同,现在的代码中,模块的声明和实现是分离的,实现是在单独的 front_of_house.rs 文件中,然后通过 mod front_of_house; 这条声明语句从该文件中把模块内容加载进来。因此我们可以认为,模块 front_of_house 的定义还是在 src/lib.rs 中,只不过模块的具体内容被移动到了 src/front_of_house.rs 文件中。

在这里出现了一个新的关键字 use,联想到其它章节我们见过的标准库引入 use std::fmt;,可以大致猜测,该关键字用来将外部模块中的项引入到当前作用域中来,这样无需冗长的父模块前缀即可调用:hosting::add_to_waitlist();

当一个模块有许多子模块时,我们也可以通过文件夹的方式来组织这些子模块。
可以创建一个目录 front_of_house,然后在文件夹里创建一个 hosting.rs 文件,hosting.rs 文件现在就剩下:

pub fn add_to_waitlist() {}
fn seat_at_table() {}

尝试编译程序,结果会报错:

error[E0583]: file not found for module `front_of_house`
 --> src/lib.rs:3:1
  |
1 | mod front_of_house;
  | ^^^^^^^^^^^^^^^^^^
  |
  = help: to create the module `front_of_house`, create file "src/front_of_house.rs" or "src/front_of_house/mod.rs"

如果需要将文件夹作为一个模块,我们需要进行显示指定暴露哪些子模块。按照上述的报错信息,我们有两种方法:

  • 在 front_of_house 目录里创建一个 mod.rs,如果你使用的 rustc 版本 1.30 之前,这是唯一的方法。
  • 在 front_of_house 同级目录里创建一个与模块(目录)同名的 rs 文件 front_of_house.rs,在新版本里,更建议使用这样的命名方式来避免项目中存在大量同名的 mod.rs 文件。
src
├── front_of_house
│   └── hosting.rs   //第二种方法的文件结构
├── front_of_house.rs
└── lib.rs

而无论是上述哪个方式创建的文件,其内容都是一样的,你需要在定义你(mod.rs 或 front_of_house.rs)的子模块(子模块名与文件名相同):

pub mod hosting;
// pub mod serving;

使用 use 引入模块以及受限可见性

基本引入方式

绝对路径引入模块

例子如下所示:

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

使用 use 和绝对路径的方式,将 hosting 模块引入到当前作用域中,然后只需通过 hosting::add_to_waitlist 的方式,即可调用目标模块中的函数,相比 crate::front_of_house::hosting::add_to_waitlist() 的方式要简单的多。

相对路径引入模块中的函数

例子如下所示:

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
use front_of_house::hosting::add_to_waitlist;
pub fn eat_at_restaurant() {
    add_to_waitlist();
    add_to_waitlist();
    add_to_waitlist();
}

与上面引入 hosting 模块不同,直接引入该模块中的 add_to_waitlist 函数。

引入模块还是函数

从使用简洁性来说,引入函数自然是更甚一筹,但是在某些时候,引入模块会更好:

  • 需要引入同一个模块的多个函数
  • 作用域中存在同名函数

例如,如果想使用 HashMap,那么直接引入该结构体是比引入模块更好的选择,因为在 collections 模块中,我们只需要使用一个 HashMap 结构体:

use std::collections::HashMap;
fn main() {
    let mut map = HashMap::new();
    map.insert(1, 2);
}

建议优先使用最细粒度(引入函数、结构体等)的引用方式,如果引起了某种麻烦(例如前面两种情况),再使用引入模块的方式

避免重名引用

只要保证同一个模块中不存在同名项就行,模块之间、包之间的同名则不需要注意。

模块::函数
use std::fmt;
use std::io;
fn function1() -> fmt::Result {
    // --snip--
}
fn function2() -> io::Result<()> {
    // --snip--
}

上面的例子给出了很好的解决方案,使用模块引入的方式,具体的 Result 通过 模块::Result 的方式进行调用。

可以看出,避免同名冲突的关键,就是使用父模块的方式来调用,除此之外,还可以给予引入的项起一个别名。

as 别名引用

例子如下:

use std::fmt::Result;
use std::io::Result as IoResult;
fn function1() -> Result {
    // --snip--
}
fn function2() -> IoResult<()> {
    // --snip--
}

首先通过 use std::io::Result 将 Result 引入到作用域,然后使用 as 给予它一个全新的名称 IoResult,这样就不会再产生冲突:

  • Result 代表 std::fmt::Result
  • IoResult 代表 std:io::Result

引入项再导出

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

mod front_of_house {
    pub mod hosting {
        pub fn add_to_waitlist() {}
    }
}
pub use crate::front_of_house::hosting;
pub fn eat_at_restaurant() {
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
    hosting::add_to_waitlist();
}

使用 pub use 即可实现。这里 use 代表引入 hosting 模块到当前作用域,pub 表示将该引入的内容再度设置为可见。

当希望将内部的实现细节隐藏起来或者按照某个目的组织代码时,可以使用 pub use 再导出,例如统一使用一个模块来提供对外的 API,那该模块就可以引入其它模块中的 API,然后进行再导出,最终对于用户来说,所有的 API 都是由一个模块统一提供的。

使用第三方包

引入外部依赖的步骤如下所示:(以 rand 为例子)

  1. 修改 Cargo.toml 文件,在 [dependencies] 区域添加一行:rand = "0.8.3"
  2. 此时,如果你用的是 VSCode 和 rust-analyzer 插件,该插件会自动拉取该库,你可能需要等它完成后,再进行下一步(VSCode 左下角有提示)

此时,rand 包已经被我们添加到依赖中,下一步就是在代码中使用:

use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1..101);
}

这里使用 use 引入了第三方包 rand 中的 Rng 特征,因为我们需要调用的 gen_range 方法定义在该特征中。

crates.io,lib.rs

Rust 社区已经为我们贡献了大量高质量的第三方包,你可以在 crates.io 或者 lib.rs 中检索和使用,从目前来说查找包更推荐 lib.rs,搜索功能更强大,内容展示也更加合理,但是下载依赖包还是得用 crates.io

使用{}简化引入方式

注释和文档

注释的种类

在 Rust 中,注释分为三类:

  • 代码注释,用于说明某一块代码的功能,读者往往是同一个项目的协作开发者
  • 文档注释,支持 Markdown,对项目描述、公共 API 等用户关心的功能进行介绍,同时还能提供示例代码,目标读者往往是想要了解你项目的人
  • 包和模块注释,严格来说这也是文档注释中的一种,它主要用于说明当前包和模块的功能,方便用户迅速了解一个项目

代码注释

和 c++一模一样

文档注释

文档行注释 ///
文档块注释 /** */

格式化输出

println!("Hello");                 // => "Hello"
println!("Hello, {}!", "world");   // => "Hello, world!"
println!("The number is {}", 1);   // => "The number is 1"
println!("{:?}", (3, 4));          // => "(3, 4)"
println!("{value}", value=4);      // => "4"
println!("{} {}", 1, 2);           // => "1 2"
println!("{:04}", 42);             // => "0042" with leading zeros

println! 宏接受的是可变参数,第一个参数是一个字符串常量,它表示最终输出字符串的格式,包含其中形如 {} 的符号是占位符,会被 println! 后面的参数依次替换。

print!,println!,format!

  • print! 将格式化文本输出到标准输出,不带换行符
  • println! 同上,但是在行的末尾添加换行符
  • format! 将格式化文本输出到 String 字符串

在实际项目中,最常用的是 println! 及 format!,前者常用来调试输出,后者常用来生成格式化的字符串:

eprint!,eprintln!

使用方式跟 print!println! 很像,但是它们输出到标准错误输出:

eprintln!("Error: Could not complete task")

它们仅应该被用于输出错误信息和进度信息,其它场景都应该使用 print! 系列。

{}

与 {} 类似,{:?} 也是占位符:

  • {} 适用于实现了 std::fmt::Display 特征的类型,用来以更优雅、更友好的方式格式化文本,例如展示给用户
  • {:?} 适用于实现了 std::fmt::Debug 特征的类型,用于调试场景

其实两者的选择很简单,当你在写代码需要调试时,使用 {:?},剩下的场景,选择 {}

Debug 特征

为了方便我们调试,大多数 Rust 类型都实现了 Debug 特征或者支持派生该特征:
对于数值、字符串、数组,可以直接使用 {:?} 进行输出,但是对于结构体,需要Debug特征后,才能进行输出,总之很简单。

Display 特征

实现了 Display 特征的 Rust 类型并没有那么多,往往需要我们自定义想要的格式化方式:
没有实现 Display 特征,但是你又不能像派生 Debug 一般派生 Display,只能另寻他法:

  • 使用 {:?} 或 {:#?}
  • 为自定义类型实现 Display 特征
  • 使用 newtype 为外部类型实现 Display 特征

为自定义类型实现 Display 特征

posted @   彬彬zhidao  阅读(40)  评论(0编辑  收藏  举报
相关博文:
阅读排行:
· DeepSeek “源神”启动!「GitHub 热点速览」
· 我与微信审核的“相爱相杀”看个人小程序副业
· 上周热点回顾(2.17-2.23)
· 如何使用 Uni-app 实现视频聊天(源码,支持安卓、iOS)
· spring官宣接入deepseek,真的太香了~
点击右上角即可分享
微信分享提示