基本介绍

Rust 是由 Mozilla 团队于 2010 年推出的系统级编程语言,专注于 安全性性能并发性。它通过独特的编译时检查机制(如所有权系统),在无需垃圾回收(GC)的前提下保障内存安全,同时性能媲美 C/C++,被 Stack Overflow 评为最受开发者喜爱的语言之一(2016-2023 连续多年)。

我们来通过一段代码来简单浏览一下Rust语法

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
// Rust 程序入口函数,跟其它语言一样,都是 main,该函数目前无返回值
fn main() {
// 使用let来声明变量,进行绑定,a是不可变的
// 此处没有指定a的类型,编译器会默认根据a的值为a推断类型:i32,有符号32位整数
// 语句的末尾必须以分号结尾
let a = 10;
// 主动指定b的类型为i32
let b: i32 = 20;
// 这里有两点值得注意:
// 1. 可以在数值中带上类型:30i32表示数值是30,类型是i32
// 2. c是可变的,mut是mutable的缩写
let mut c = 30i32;
// 还能在数值和类型中间添加一个下划线,让可读性更好
let d = 30_i32;
// 跟其它语言一样,可以使用一个函数的返回值来作为另一个函数的参数
let e = add(add(a, b), add(c, d));

// println!是宏调用,看起来像是函数但是它返回的是宏定义的代码块
// 该函数将指定的格式化字符串输出到标准输出中(控制台)
// {}是占位符,在具体执行过程中,会把e的值代入进来
println!("( a + b ) + ( c + d ) = {}", e);
}

// 定义一个函数,输入两个i32类型的32位有符号整数,返回它们的和
fn add(i: i32, j: i32) -> i32 {
// 返回相加值,这里可以省略return
i + j
}

注意:

  • 字符串使用双引号 "" 而不是单引号 '',Rust 中单引号是留给单个字符类型(char)使用的
  • Rust 使用 {} 来作为格式化输出占位符,其它语言可能使用的是 %s%d%p 等,由于 println! 会自动推导出具体的类型,因此无需手动指定

变量绑定与解构

变量命名

rust和其它语言一样,都需要遵循命名规范

下面是一些例子:

类型 命名风格 示例
变量、函数、模块 蛇形命名法(snake_case) calculate_length, user_name
结构体、枚举、特性 大驼峰式(PascalCase) String, HttpRequest, FromStr
常量和静态变量 全大写蛇形(SCREAMING_SNAKE_CASE) MAX_CONNECTIONS, DEFAULT_PORT
生命周期参数 短小写字母 + 单引号 'a, 'ctx, 'static
泛型类型参数 简明的大驼峰式或单字母 T, K, V, Context

变量绑定

在其他的语言里,我们使用var a="hello world"的方式给a复制,也就是把等式右边的字符串赋给了变量a,而在rust中,我们使用let a="hello world",我们在rust中称这个过程为变量绑定

为什么使用变量绑定忙着哩设计了Rust最核心的原则——所有权,简单来讲,任何内存对象都是有主人的,而且一般情况完全属于它的主人,绑定就是把这个对象绑定给一个变量,让这个变量成为它的主人(在这种情况下,该对象之前的主人就会丧失对该对象的所有权)

绑定就意味着不可变了吗?

变量的可变性

Rust一般情况下是不可变的,但如果实在想变,可以使用**mut**关键字来使变量可变

如果我们不使用mut,那么变量一旦绑定一个数,就不能再绑定另一个数了

例如我们不使用mut ,在新建的 variables 目录下,编辑 src/main.rs ,改为下面代码:

1
2
3
4
5
6
fn main() {
let x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}

保存文件,再用cargo run运行

image-20250326193653127

报了一个错,具体的错误原因是 cannot assign twice to immutable variable x(无法对不可变的变量进行重复赋值),因为我们想为不可变的 x 变量再次赋值。

这种错误是为了避免无法预期的错误发生在我们的变量上:一个变量往往被多处代码所使用,其中一部分代码假定该变量的值永远不会改变,而另外一部分代码却无情的改变了这个值,在实际开发过程中,这个错误是很难被发现的,特别是在多线程编程中。

如果我们使用mut,代码就能成功执行

1
2
3
4
5
6
fn main() {
let mut x = 5;
println!("The value of x is: {}", x);
x = 6;
println!("The value of x is: {}", x);
}

image-20250326193919766

所以选择可变还是不可变,取决于你的使用场景,例如不可变可以带来安全性,但是丧失了灵活性和性能(如果你要改变,就要重新创建一个新的变量,这里涉及到内存对象的再分配)。而可变变量最大的好处就是使用上的灵活性和性能上的提升。

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

如果你创建了一个变量却不在任何地方使用它,Rust就会给出一个警告,因为这可能会是个 BUG,如果不希望rust给出警告,就可以在rust前面加一个下划线来避免它

1
2
3
4
fn main(){
let _x=10;
let y=10;
}

image-20250326195013988

可以看到,两个变量都是只有声明,没有使用,但是编译器却独独给出了 y 未被使用的警告,充分说明了 _ 变量名前缀在这里发挥的作用。并且rust给出了修复的建议

变量解构

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

1
2
3
4
5
6
7
8
fn main() {
let (a, mut b): (bool,bool) = (true, false);
// a = true,不可变; b = false,可变
println!("a = {:?}, b = {:?}", a, b);

b = true;
assert_eq!(a, b);
}
解构式赋值

解构式赋值是指将一个复合数据类型(如元组、数组、结构体等)的内部值提取并赋值给多个变量的操作。在 Rust 中,解构赋值通常用于将一个复杂的数据结构的各个部分提取到单独的变量中。

解构式赋值在 Rust 中并不直接使用“赋值”的形式(如传统编程语言中的解构赋值),而是通过模式匹配来实现的。在 Rust 中,这种解构通常是通过 let 语句和匹配模式(如元组模式、数组模式、结构体模式等)来完成的。

Rust 1.59 版本后,我们可以在赋值语句的左式中使用元组、切片和结构体模式了。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Struct {
e: i32
}

fn main() {
let (a, b, c, d, e);

(a, b) = (1, 2);
// _ 代表匹配一个值,但是我们不关心具体的值是什么,因此没有使用一个变量名而是使用了 _
[c, .., d, _] = [1, 2, 3, 4, 5];
Struct { e, .. } = Struct { e: 5 };

assert_eq!([1, 2, 1, 4, 5], [a, b, c, d, e]);
}

这种使用方式跟之前的 let 保持了一致性,但是 let 会重新绑定,而这里仅仅是对之前绑定的变量进行再赋值。

需要注意的是,使用 += 的赋值语句还不支持解构式赋值。

变量和常量之间的差异

变量的值不能更改可能让你想起其他另一个很多语言都有的编程概念:常量(constant)。与不可变变量一样,常量也是绑定到一个常量名且不允许更改的值,但是常量和变量之间存在一些差异:

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

下面是一个常量声明的例子,其常量名为 MAX_POINTS,值设置为 100,000。(Rust 常量的命名约定是全部字母都使用大写,并使用下划线分隔单词,另外对数字字面量可插入下划线以提高可读性):

1
const MAX_POINTS: u32 = 100_000;

常量可以在任意作用域内声明,包括全局作用域,在声明的作用域内,常量在程序运行的整个过程中都有效。对于需要在多处代码共享一个不可变的值时非常有用,例如游戏中允许玩家赚取的最大点数或光速。

在实际使用中,最好将程序中用到的硬编码值都声明为常量,对于代码后续的维护有莫大的帮助。如果将来需要更改硬编码的值,你也只需要在代码中更改一处即可。

变量的遮蔽

rust允许声明相同的变量名,但后面的变量名会遮蔽掉前面的变量名

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let x = 5;
// 在main函数的作用域内对之前的x进行遮蔽
let x = x + 1;

{
// 在当前的花括号作用域内,对之前的x进行遮蔽
let x = x * 2;
println!("The value of x in the inner scope is: {}", x);
}

println!("The value of x is: {}", x);
}

输出

image-20250326200735503

这个程序首先将数值 5 绑定到 x,然后通过重复使用 let x = 来遮蔽之前的 x,并取原来的值加上 1,所以 x 的值变成了 6。第三个 let 语句同样遮蔽前面的 x,取之前的值并乘上 2,得到的 x 最终值为 12

这和 mut 变量的使用是不同的,第二个 let 生成了完全不同的新变量,两个变量只是恰好拥有同样的名称,涉及一次内存对象的再分配 ,而 mut 声明的变量,可以修改同一个内存地址上的值,并不会发生内存对象的再分配,性能要更好。

基本类型

Rust 每个值都有其确切的数据类型,总的来说可以分为两类:基本类型和复合类型。 基本类型意味着它们往往是一个最小化原子类型,无法解构为其它类型(一般意义上来说),由以下组成:

  • 数值类型:有符号整数 (i8, i16, i32, i64, isize)、 无符号整数 (u8, u16, u32, u64, usize) 、浮点数 (f32, f64)、以及有理数、复数
  • 字符串:字符串字面量和字符串切片 &str
  • 布尔类型:truefalse
  • 字符类型:表示单个 Unicode 字符,存储为 4 个字节
  • 单元类型:即 () ,其唯一的值也是 ()

数值类型

整数类型

整数是没有小数部分的数字,之前使用过的i32类型,表示有符号的32为整数(i是英文单词integer的首字母,与之相反的是u,代表无符号的unsigned类型),下表显示了Rust中的内置函数整数类型:

长度 有符号类型 无符号类型
8 位 i8 u8
16 位 i16 u16
32 位 i32 u32
64 位 i64 u64
128 位 i128 u128
视架构而定 isize usize

类型定义的形式统一为:有无符号 + 类型大小(位数)无符号数表示数字只能取正数和 0,而有符号则表示数字可以取正数、负数还有 0。就像在纸上写数字一样:当要强调符号时,数字前面可以带上正号或负号;然而,当很明显确定数字为正数时,就不需要加上正号了。有符号数字以补码形式存储。

每个有符号类型规定的数字范围是 -(2n - 1) ~ 2n - 1 - 1,其中 n 是该定义形式的位长度。因此 i8 可存储数字范围是 -(27) ~ 27 - 1,即 -128 ~ 127。无符号类型可以存储的数字范围是 0 ~ 2n - 1,所以 u8 能够存储的数字为 0 ~ 28 - 1,即 0 ~ 255。

此外,isizeusize 类型取决于程序运行的计算机 CPU 类型: 若 CPU 是 32 位的,则这两个类型是 32 位的,同理,若 CPU 是 64 位,那么它们则是 64 位。

整型字面量可以用下表的形式书写:

数字字面量 示例
十进制 98_222
十六进制 0xff
八进制 0o77
二进制 0b1111_0000
字节 (仅限于 u8) b'A'
整型溢出

假设我们有个u8类型的数,它可以存放0到255的数,如果我们修改为256或更大,就会发生整型溢出,关于这一行为 Rust 有一些有趣的规则:当在 debug 模式编译时,Rust 会检查整型溢出,若存在这些问题,则使程序在编译时 panic(崩溃,Rust 使用这个术语来表明程序因错误而退出)。

在当使用 --release 参数进行 release 模式构建时,Rust 检测溢出。相反,当检测到整型溢出时,Rust 会按照补码循环溢出(two’s complement wrapping)的规则处理。简而言之,大于该类型最大值的数值会被补码转换成该类型能够支持的对应数字的最小值。比如在 u8 的情况下,256 变成 0,257 变成 1,依此类推。程序不会 panic,但是该变量的值可能不是你期望的值。依赖这种默认行为的代码都应该被认为是错误的代码。

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

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

//尝试将 255 加上 127 时,结果 382 超出了 u8 能表示的最大值(255)。但是,saturating_add 会确保不会发生溢出,而是返回 u8 类型的最大值 255。
assert_eq!(u8::MAX.saturating_add(127), u8::MAX);


下面是一个演示wrapping_*方法的示例

1
2
3
4
5
fn main() {
let a : u8 = 255;
let b = a.wrapping_add(20);
println!("{}", b); // 19
}

输出是19,相当于是275mod256=19

浮点类型

浮点类型数字是带有小数点的数字,在rust中浮点类型也有两种基本类型:f32和f64,分别为32位和64位大小。默认浮点类型是f64,在线代的CPU中它的速度与f32几乎相同,但精度更高

1
2
3
4
5
fn main() {
let x = 2.0; // f64

let y: f32 = 3.0; // f32
}

f32 类型是单精度浮点型,f64 为双精度。

注意:1.浮点数往往是你想要数字的近似表达

​ 2.浮点数在某些特性上是反直觉的

所以有些浮点数虽然看上去相等,但由于精度问题,并不相等

NaN

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

**所有跟 NaN 交互的操作,都会返回一个 NaN**,而且 NaN 不能用来比较,下面的代码会崩溃:

1
2
3
4
fn main() {
let x = (-42.0_f32).sqrt();
assert_eq!(x, x);
}

出于防御性编程的考虑,可以使用 is_nan() 等方法,可以用来判断一个数值是否是 NaN

1
2
3
4
5
6
fn main() {
let x = (-42.0_f32).sqrt();
if x.is_nan() {
println!("未定义的数学行为")
}
}

所以NaN的用处大概是用来抛出计算过程中的异常的

数字运算
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
fn main() {
// 加法
let sum = 5 + 10;

// 减法
let difference = 95.5 - 4.3;

// 乘法
let product = 4 * 30;

// 除法
let quotient = 56.7 / 32.2;

// 求余
let remainder = 43 % 5;
}

这些语句中的每个表达式都使用了数学运算符,并且计算结果绑定到一个变量上,附录 B 中给出了 Rust 提供的所有运算符的列表。

再来看一个综合性的示例:

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
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]);
}
位运算

Rust 的位运算基本上和其他语言一样

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

..来表示范围,例如 1..5,生成从 1 到 4 的连续数字,不包含 5 ;1..=5,生成从 1 到 5 的连续数字,包含 5,它的用途很简单,常常用于循环中:

1
2
3
4
for i in 1..=5 {
println!("{}",i);
}

最终程序输出1到5

注意:序列只允许用于数字或字符类型,原因是:它们可以连续,同时编译器在编译期可以检查该序列是否为空,字符和数字值是 Rust 中仅有的可以用于判断是否为空的类型。

使用 As 完成类型转换

Rust 中可以使用 As 来完成一个类型到另一个类型的转换,其最常用于将原始类型转换为其他原始类型,但是它也可以完成诸如将指针转换为地址、地址转换为指针以及将指针转换为其他指针等功能。你可以在这里了解更多相关的知识。

1
2
3
4
5
6
7
fn main() {
let a = 3.1 as i8;
let b = 100_i8 as i32;
let c = 'a' as u8; // 将字符'a'转换为整数,97

println!("{},{},{}",a,b,c)
}
有理数和复数

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
1
2
3
4
5
6
7
8
9
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)
}

字符、布尔、单元类型

字符类型(char)

在rust中,不仅仅是ASCII,所有的Unicode、単个中文,日文、韩文、emoji 表情符号等等,都是合法的字符类型,占4个字节

1
2
3
4
5
6
fn main() {
let c = 'z';
let z = 'ℤ';
let g = '国';
let heart_eyed_cat = '😻';
}
布尔(bool)

拥有true和fals,占1个字节

1
2
3
4
5
6
7
8
9
fn main() {
let t = true;

let f: bool = false; // 使用类型标注,显式指定f的类型

if f {
println!("这是段毫无意义的代码");
}
}
单元类型

单元类型就是 ()

语句及表达式

Rust 的函数体是由一系列语句组成,最后由一个表达式来返回值,例如:

1
2
3
4
5
6
fn add_with_extra(x: i32, y: i32) -> i32 {
let x = x + 1; // 语句
let y = y + 5; // 语句
x+y //表达式
}

语句会执行一些操作但是不会返回一个值,而表达式会在求值后返回一个值,因此在上述函数体的三行代码中,前两行是语句,最后一行是表达式。

对于 Rust 语言而言,这种基于语句(statement)和表达式(expression)的方式是非常重要的,你需要能明确的区分这两个概念,但是对于很多其它语言而言,这两个往往无需区分。基于表达式是函数式语言的重要特征,表达式总要返回值

语句
1
2
3
4
let a = 8;
let b: Vec<f64> = Vec::new();
let (a, c) = ("hi", false);

以上都是语句,它们完成一个具体的操作,但是并没有返回值,因此是语句

由于let是语句,那当然不能把一个语句赋给其他值

1
let b = (let a = 8);

上述操作会报错

表达式

表达式会进行求职,然后返回一个值,例如5+6在求值后会返回11,因此它是一个表达式

调用一个函数是表达式,因为会返回一个值,调用宏也是表达式,用花括号包裹最终返回一个值的语句块也是表达式,总之,能返回值,它就是表达式:

1
2
3
4
5
6
7
8
fn main() {
let y = {
let x = 3;
x + 1
};

println!("The value of y is: {}", y);
}

上面使用一个语句块表达式将值赋给 y 变量,语句块长这样:

1
2
3
4
{
let x=3;
x+1
}

注意:表达式不能包含分号。这一点非常重要,一旦你在表达式后加上分号,它就会变成一条语句,再也不会返回一个值

函数

1
2
3
4
fn add(i: i32, j: i32) -> i32 {
i + j
}

声明函数的关键字 fn,函数名 add(),参数 ij,参数类型和返回值类型都是 i32

image-20250327220537722

函数要点
  • 函数名和变量名使用蛇形命名法(snake case),例如 fn add_two() -> {}
  • 函数的位置可以随便放,Rust 不关心我们在哪里定义了函数,只要有定义即可
  • 每个函数参数都需要标注具体类型
1
2
3
4
5
6
7
8
fn main() {
another_function(5, 6.1);
}

fn another_function(x: i32, y: f32) {
println!("The value of x is: {}", x);
println!("The value of y is: {}", y);
}

x:i32的i32是必要的,去掉的话会报错

函数返回

在rust中,函数就是表达式,因此我们可以把函数的返回值直接给调用者。

函数的返回值就是函数体最后一条表达式的返回值,当然我们也可以使用 return 提前返回,下面的函数使用最后一条表达式来返回一个值:

1
2
3
4
5
6
7
8
9
fn plus_five(x:i32) -> i32 {
x + 5
}

fn main() {
let x = plus_five(5);

println!("The value of x is: {}", x);
}

x + 5 是一条表达式,求值后,返回一个值,因为它是函数的最后一行,因此该表达式的值也是函数的返回值。

再来看两个重点:

  1. let x = plus_five(5),说明我们用一个函数的返回值来初始化 x 变量,因此侧面说明了在 Rust 中函数也是表达式,这种写法等同于 let x = 5 + 5;
  2. x + 5 没有分号,因为它是一条表达式,所以函数最终返回的结果是x+5的结果

再来看一段代码,同时使用 return 和表达式作为返回值:

1
2
3
4
5
6
7
8
9
10
11
12
13
fn plus_or_minus(x:i32) -> i32 {
if x > 5 {
return x - 5
}

x + 5
}

fn main() {
let x = plus_or_minus(5);

println!("The value of x is: {}", x);
}

plus_or_minus 函数根据传入 x 的大小来决定是做加法还是减法,若 x > 5 则通过 return 提前返回 x - 5 的值,否则返回 x + 5 的值。

Rust 中的特殊返回类型
无返回值()

对于 Rust 新手来说,有些返回类型很难理解,而且如果你想通过百度或者谷歌去搜索,都不好查询,因为这些符号太常见了,根本难以精确搜索到。

例如单元类型 (),是一个零长度的元组。它没啥作用,但是可以用来表达一个函数没有返回值:

  • 函数没有返回值,那么返回一个 ()
  • 通过 ; 结尾的语句返回一个 ()

例如下面的 report 函数会隐式返回一个 ()

1
2
3
4
5
6
use std::fmt::Debug;

fn report<T: Debug>(item: T) {
println!("{:?}", item);

}

与上面的函数返回值相同,但是下面的函数显式的返回了 ()

1
2
3
fn clear(text: &mut String) -> () {
*text = String::from("");
}

在实际编程中,你会经常在错误提示中看到该 () 的身影出没,假如你的函数需要返回一个 u32 值,但是如果你不幸的以 表达式; 的语句形式作为函数的最后一行代码,就会报错:

1
2
3
fn add(x:u32,y:u32) -> u32 {
x + y;
}

错误如下:

1
2
3
4
5
6
7
8
9
error[E0308]: mismatched types // 类型不匹配
--> src/main.rs:6:24
|
6 | fn add(x:u32,y:u32) -> u32 {
| --- ^^^ expected `u32`, found `()` // 期望返回u32,却返回()
| |
| implicitly returns `()` as its body has no tail or `return` expression
7 | x + y;
| - help: consider removing this semicolon

注意:只有表达式能返回值,而 ; 结尾的是语句,在 Rust 中,一定要严格区分表达式语句的区别,这个在其它语言中往往是被忽视的点。

永不返回的发散函数 !

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

1
2
3
fn dead_end() -> ! {
panic!("你已经到了穷途末路,崩溃吧!");
}

下面的函数创建了一个无限循环,该循环永不跳出,因此函数也永不返回:

1
2
3
4
5
fn forever() -> ! {
loop {
//...
};
}

所有权和借用

所有权

所有的程序都必须和计算机内存打交道,如何从内存中申请空间来存放程序的运行内容,如何在不需要的时候释放这些空间,成了重中之重,也是所有编程语言设计的难点之一。在计算机语言不断演变过程中,出现了三种流派:

  • **垃圾回收机制(GC)**,在程序运行时不断寻找不再使用的内存,典型代表:Java、Go
  • 手动管理内存的分配和释放, 在程序中,通过函数调用的方式来申请和释放内存,典型代表:C++
  • 通过所有权来管理内存,编译器在编译时会根据一系列规则进行检查

其中 Rust 选择了第三种,最妙的是,这种检查只发生在编译期,因此对于程序运行期,不会有任何上的性能损失

栈和堆

栈和堆是编程语言最核心的数据结构,在rust中,值是位于栈还是堆上非常重要,这会影响程序的行为和性能

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

栈按照顺序存储值并以相反顺序取出值,这中操作方式也被称作后进先出

增加数据叫做进栈,减少数据叫做出栈

但是,栈中所有的数据都必须占用已知固定大小的内存空间,假设数据大小未知,那么在取出数据时,你讲无法取到你想要的数据

与栈不同的是,当我们遇见大小未知或者可能变化的数据,我们就需要将其存储在堆上

当向堆上放入数据时,需要请求一定大小的内存空间。操作系统在对的没出找到一块足够大的空位,把它标记为已使用,不返回一个表示该位置地址的指针,该过程被称为在堆上分配内存

接着,该指针会被推入栈中,因为指针大小固定,在后续使用过程中,将通过栈中的指针,来获取数据在堆上的时机内存位置, 进而访问该数据

由上可知,堆是一种缺乏组织的数据结构

性能区别

在栈上分配内存比在堆上分配内存要快,因为入栈是操作系统无需调用函数来分配现代科技,只需要将新数据放入栈顶即可。相比之下,在堆上分配内存则需要更多的工作,这是因为操作系统必须首先找到一块足够存放数据的内存空间,接着做一些记录为下一次分配空间做准备,如果当前进程分配的内存页不足时,还需要进行系统调用来申请更多内存。 因此,处理器在栈上分配数据会比在堆上分配数据更加高效。

所有权和堆栈

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

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

对于其他很多编程语言,你确实无需理解堆栈的原理,但是在 Rust 中,明白堆栈的原理,对于我们理解所有权的工作原理会有很大的帮助

所有权原则

注意几点:

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

作用域是一个变量在程序中有效的范围,假如有这样一个 变量:

1
let s = "hello";

变量 s 绑定到了一个字符串字面值,该字符串字面值是硬编码到程序代码中的。s 变量从声明的点开始直到当前作用域的结束都是有效的:

1
2
3
4
5
{                      // s 在这里无效,它尚未声明
let s = "hello"; // 从此处起,s 是有效的

// 使用 s
} // 此作用域已结束,s不再有效

简而言之,s 从创建开始就有效,然后有效期持续到它离开作用域为止,可以看出,就作用域来说,Rust 语言跟其他编程语言没有区别。

变量绑定背后的数据交互
转移所有权

先来看一段代码

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

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

整个过程中的赋值都是通过值拷贝的方式完成(发生在栈中),因此并不需要所有权转移。

我们在来看下面代码:

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

对于基本类型(存储在栈上),Rust 会自动拷贝,但是 String 不是基本类型,而且是存储在堆上的,因此不能自动拷贝。

String类型是一个字符串类型,由存储在栈中的堆指针、字符串长度、字符串容器组成,其中堆指针是最重要的,它指向了真实存储字符串内容的堆指针

关于上面let s2=s1,分成两种方式讨论

  1. 拷贝 String 和存储在堆上的字节数组 如果该语句是拷贝所有数据(深拷贝),那么无论是 String 本身还是底层的堆上数据,都会被全部拷贝,这对于性能而言会造成非常大的影响
  2. 只拷贝 String 本身 这样的拷贝非常快,因为在 64 位机器上就拷贝了 8字节的指针8字节的长度8字节的容量,总计 24 字节,但是带来了新的问题,还记得我们之前提到的所有权规则吧?其中有一条就是:一个值只允许有一个所有者,而现在这个值(堆上的真实字符串数据)有了两个所有者:s1s2

好吧,就假定一个值可以拥有两个所有者,会发生什么呢?

当变量离开作用域后,Rust 会自动调用 drop 函数并清理变量的堆内存。不过由于两个 String 变量指向了同一位置。这就有了一个问题:当 s1s2 离开作用域,它们都会尝试释放相同的内存。这是一个叫做 二次释放(double free) 的错误,也是之前提到过的内存安全性 BUG 之一。两次释放(相同)内存会导致内存污染,它可能会导致潜在的安全漏洞。

因此,Rust 这样解决问题:s1 被赋予 s2 后,Rust 认为 s1 不再有效,因此也无需在 s1 离开作用域后 drop 任何东西,这就是把所有权从 s1 转移给了 s2s1 在被赋予 s2 后就马上失效了

所以在上面代码中,当s1的所有权转移到了s2之后,s1就没有用了

克隆(深拷贝)

首先,Rust 永远也不会自动创建数据的 “深拷贝”。因此,任何自动的复制都不是深拷贝,可以被认为对运行时性能影响较小。

如果我们实在想要胜读复制String堆上的数据,可以使用clone方法

1
2
3
4
5
let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {}, s2 = {}", s1, s2);

能正常运行,没报错

拷贝(浅拷贝)

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

再回到之前看过的例子:

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

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

但这段代码似乎与我们刚刚学到的内容相矛盾:没有调用 clone,不过依然实现了类似深拷贝的效果 —— 没有报所有权的错误。

因为任何基本类型的组合可以 Copy ,不需要分配内存或某种形式资源的类型是可以 Copy。如下是一些 Copy 的类型:

  • 所有整数类型,比如 u32
  • 布尔类型,bool,它的值是 truefalse
  • 所有浮点数类型,比如 f64
  • 字符类型,char
  • 元组,当且仅当其包含的类型也都是 Copy 的时候。比如,(i32, i32)Copy 的,但 (i32, String) 就不是
  • 不可变引用 &T ,例如转移所有权中的最后一个例子,但是注意:可变引用 &mut T 是不可以 Copy的

引用与借用

如果仅仅支持通过转移所有权的方式获取一个值,那会让程序变得复杂。因此,Rust通过借用来获取一个值,获取变量的引用,称之为借用

借用分为两种类型:

  1. 不可变引用:允许多个借用者同时读取该值,但不允许修改。
  2. 可变引用:只允许一个借用者修改该值,但在借用期间不能有其他借用者。
引用和解引用

常规引用时一个指针类型,指向了对象存储的内存地址。在下面代码中,我们创建了一个i32的值引用y,然后使用解引用运算符来解出y所使用的值:

1
2
3
4
5
6
7
8
9
fn main() {
let x = 5;
let y = &x; //引用类型
println!("{}",x);
println!("{}",*y);

assert_eq!(5, x);
assert_eq!(5, *y);
}

我们使用&来引用一个变量,然后使用*来解引用这个变量

不可变引用

下面的代码,我们用 s1 的引用作为参数传递给 calculate_length 函数,而不是把 s1 的所有权转移给该函数:

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let s1 = String::from("hello");

let len = calculate_length(&s1);

println!("The length of '{}' is {}.", s1, len);
}

fn calculate_length(s: &String) -> usize {
s.len()
}

我们注意到:

  1. 无需像上章一样:先通过函数参数传入所有权,然后再通过函数返回来传出所有权,代码更加简洁
  2. calculate_length 的参数 s 类型从 String 变为 &String

在这里,&符号即是引用,他们允许你使用值,但是不获取所有权

image-20250328210117010

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

同理,函数 calculate_length 使用 & 来表明参数 s 的类型是一个引用:

1
2
3
4
5
fn calculate_length(s: &String) -> usize { // s 是对 String 的引用
s.len()
} // 这里,s 离开了作用域。但因为它并不拥有引用值的所有权,
// 所以什么也不会发生

注意:借用的变量不可修改

1
2
3
4
5
6
7
8
9
fn main() {
let s = String::from("hello");

change(&s);
}

fn change(some_string: &String) {
some_string.push_str(", world");
}

这里尝试在s里添加,world,但是会报错

可变引用

我们知道用let直接定义的变量的值不可修改,但我们使用mut后,就可修改了

我们修改上面报错代码

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut s = String::from("hello");

change(&mut s);
println!("{}", s);
}

fn change(some_string: &mut String) {
some_string.push_str(", world");
}

输出hello,world

可变引用同时只能存在一个

不过可变引用并不是随心所欲、想用就用的,它有一个很大的限制: 同一作用域,特定数据只能有一个可变引用

1
2
3
4
5
6
7
let mut s = String::from("hello");

let r1 = &mut s;
let r2 = &mut s;

println!("{}, {}", r1, r2);

会报一个错

1
2
3
4
5
6
7
8
9
10
11
error[E0499]: cannot borrow `s` as mutable more than once at a time 同一时间无法对 `s` 进行两次可变借用
--> src/main.rs:5:14
|
4 | let r1 = &mut s;
| ------ first mutable borrow occurs here 首个可变引用在这里借用
5 | let r2 = &mut s;
| ^^^^^^ second mutable borrow occurs here 第二个可变引用在这里借用
6 |
7 | println!("{}, {}", r1, r2);
| -- first borrow later used here 第一个借用在这里使用

这段代码出错的原因在于,第一个可变借用 r1 必须要持续到最后一次使用的位置 println!r1 创建和最后一次使用之间,我们又尝试创建第二个可变借用 r2

我们改写成下面这种形式

1
2
3
4
5
6
7
8
9
fn main() {
let mut s = String::from("hello");

let r1 = &mut s;
println!("{}", r1);
let r2 = &mut s;
println!("{}", r2);

}

这就避免了在同一时间有多个可变引用指向数据,r1变量在println!后就离开作用域了,后面只存在r2一个可变引用指向数据

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

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

数据竞争会导致未定义行为,这种行为很可能超出我们的预期,难以在运行时追踪,并且难以诊断和修复。而 Rust 避免了这种情况的发生,因为它甚至不会编译存在数据竞争的代码!

很多时候,大括号可以帮我们解决一些编译不通过的问题,通过手动限制变量的作用域:

1
2
3
4
5
6
7
8
9
let mut s = String::from("hello");

{
let r1 = &mut s;

} // r1 在这里离开了作用域,所以我们完全可以创建一个新的引用

let r2 = &mut s;

可变引用与不可变引用不能同时存在

1
2
3
4
5
6
7
8
let mut s = String::from("hello");

let r1 = &s; // 没问题
let r2 = &s; // 没问题
let r3 = &mut s; // 大问题

println!("{}, {}, and {}", r1, r2, r3);

总的来说,借用规则如下:

  • 同一时刻,你只能拥有要么一个可变引用,要么任意多个不可变引用
  • 引用必须总是有效的
悬垂引用

悬垂引用也叫做悬垂指针,意思为指针指向某个值后,这个值被释放掉了,而指针仍然存在,其指向的内存可能不存在任何值或已被其它变量重新使用。在 Rust 中编译器可以确保引用永远也不会变成悬垂状态:当你获取数据的引用后,编译器可以确保数据不会在引用结束前被释放,要想释放数据,必须先停止其引用的使用。

复合类型

字符串与切片

切片

切片并不是Rust独有的,其他语言都有,它允许你引用集合中部分连续的元素序列

对于字符串来说,切片就是对String类型某一部分的引用

1
2
3
4
5
let s = String::from("hello world");

let hello = &s[0..5];
let world = &s[6..11];

hello没有引用整个String s,而是引用s的一部分内容,通过[0..5]的方式来指定

这就是创建切片的语法,使用方括号包括的一个序列:**[开始索引..终止索引]**

对于 let world = &s[6..11]; 来说,world 是一个切片,该切片的指针指向 s 的第 7 个字节(索引从 0 开始, 6 是第 7 个字节),且该切片的长度是 5 个字节。

在使用 Rust 的 .. range 序列语法时,如果你想从索引 0 开始,可以使用如下的方式,这两个是等效的:

1
2
3
4
let s = String::from("hello");

let slice = &s[0..2];
let slice = &s[..2];

同样的,如果你的切片想要包含 String 的最后一个字节,则可以这样使用:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[4..len];
let slice = &s[4..];

你也可以截取完整的 String 切片:

1
2
3
4
5
6
let s = String::from("hello");

let len = s.len();

let slice = &s[0..len];
let slice = &s[..];

在对字符串使用切片语法时需要格外小心,切片的索引必须落在字符之间的边界位置,也就是 UTF-8 字符的边界,例如中文在 UTF-8 中占用三个字节,下面的代码就会崩溃:

1
2
3
let s = "中国人";
let a = &s[0..2];
println!("{}",a);

因为我们只取 s 字符串的前两个字节,但是本例中每个汉字占用三个字节,因此没有落在边界处,也就是连 字都取不完整,此时程序会直接崩溃退出,如果改成 &s[0..3],则可以正常通过编译。 因此,当你需要对字符串做切片索引操作时,需要格外小心这一点,关于该如何操作 UTF-8 字符串,参见这里

当然,数组也可以切片

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

let slice = &a[1..3];

assert_eq!(slice, &[2, 3]);

String和&str转换

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

  • String::from("hello,world")
  • "hello,world".to_string()

那如何将String类型转换成&str

1
2
3
4
5
6
7
8
9
10
fn main() {
let s = String::from("hello,world!");
say_hello(&s);
say_hello(&s[..]);
say_hello(s.as_str());
}

fn say_hello(s: &str) {
println!("{}",s);
}
String和&str区别
特性 &str String
内存分配 通常不涉及堆分配,指向现有内存或字符串字面量 在堆上分配内存,存储和管理自己的数据
可变性 不可变的字符串切片 可变字符串,可以修改其内容
生命周期 &str 的生命周期依赖于引用的源 String 是所有权类型,生命周期与所有权相关
性能 更高效,不需要堆分配内存 相比 &str 有额外的堆分配和内存管理开销
常见用途 只读字符串,不需要修改 需要修改或动态生成字符串
字符串索引
1
2
3
let s1 = String::from("hello");
let h = s1[0];

会报错

注意:rust不存在字符串索引

字符串操作
追加(push)

push追加字符

push_str追加字符串

这两个方法都是在原有的字符串上追加,并不会返回新的字符串。由于字符串追加操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut 关键字修饰

1
2
3
4
5
6
7
8
9
10
11
12
fn main() {
let mut s = String::from("Hello ");

s.push_str("rust");
println!("追加字符串 push_str() -> {}", s);

s.push('!');
println!("追加字符 push() -> {}", s);
}

//追加字符串 push_str() -> Hello rust
//追加字符 push() -> Hello rust!
插入(insert)

insert()插入単个字符

insert_str()插入字符串

这俩方法需要传入两个参数,第一个参数是字符(串)插入位置的索引,第二个参数是要插入的字符(串),索引从 0 开始计数,如果越界则会发生错误。由于字符串插入操作要修改原来的字符串,则该字符串必须是可变的,即字符串变量必须由 mut 关键字修饰

1
2
3
4
5
6
7
8
9
10
fn main() {
let mut s = String::from("Hello rust!");
s.insert(5, ',');
println!("插入字符 insert() -> {}", s);
s.insert_str(6, " I like");
println!("插入字符串 insert_str() -> {}", s);
}

//插入字符 insert() -> Hello, rust!
//插入字符串 insert_str() -> Hello, I like rust!
替换(replace)

1.replace

适用于String和&str类型,replace()方法接收两个参数,第一个是要被替换的字符,第二个是新的字符串,该方法会匹配到所有的字符串。该方法是返回一个新的字符串,而不是操作原来的字符串

示例代码如下:

1
2
3
4
5
6
7
fn main() {
let string_replace = String::from("I like rust. Learning rust is my favorite!");
let new_string_replace = string_replace.replace("rust", "RUST");
dbg!(new_string_replace);
}

//new_string_replace = "I like RUST. Learning RUST is my favorite!"

2.replacen

该方法可适用于 String&str 类型。replacen() 方法接收三个参数,前两个参数与 replace() 方法一样,第三个参数则表示替换的个数。该方法是返回一个新的字符串,而不是操作原来的字符串

1
2
3
4
5
6
7
fn main() {
let string_replace = "I like rust. Learning rust is my favorite!";
let new_string_replacen = string_replace.replacen("rust", "RUST", 1);
dbg!(new_string_replacen);
}

//new_string_replacen = "I like RUST. Learning rust is my favorite!"

3.replace_range

该方法仅适用于String类型。replace_range接受两个参数,第一个参数是要替换字符串的范围(Range),第二个参数是新的字符。

该方法是直接操作原来的字符串,不会返回新的字符串。该方法需要使用 mut 关键字修饰

示例代码如下:

1
2
3
4
5
6
fn main() {
let mut string_replace_range = String::from("I like rust!");
string_replace_range.replace_range(7..8, "R");
dbg!(string_replace_range);
}
//string_replace_range = "I like Rust!"
删除(delete)

与删除有关的方法有4个,pop(),remove(),truncate(),clear().这四个方法仅适用于String类型

1.pop删除并返回字符串的最后一个字符

该方法是直接操作原来的字符串。但是存在返回值,其返回值是一个 Option 类型,如果字符串为空,则返回 None

1
2
3
4
5
6
7
8
fn main() {
let mut string_pop = String::from("rust pop 中文!");
let p1 = string_pop.pop();
let p2 = string_pop.pop();
dbg!(p1);
dbg!(p2);
dbg!(string_pop);
}

2.remove –删除并返回字符串中指定位置的字符

该方法是直接操作原来的字符串。但是存在返回值,其返回值是删除位置的字符串,只接收一个参数,表示该字符起始索引位置。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
fn main() {
let mut string_remove = String::from("测试remove方法");
println!(
"string_remove 占 {} 个字节",
std::mem::size_of_val(string_remove.as_str())
);
// 删除第一个汉字
string_remove.remove(0);
// 下面代码会发生错误
// string_remove.remove(1);
// 直接删除第二个汉字
// string_remove.remove(3);
dbg!(string_remove);
}

3.truncate –删除字符串中从指定位置开始到结尾的全部字符

该方法是直接操作原来的字符串。无返回值。该方法 truncate() 方法是按照字节来处理字符串的

1
2
3
4
5
fn main() {
let mut string_truncate = String::from("测试truncate");
string_truncate.truncate(3);
dbg!(string_truncate);
}

4.clear –清空字符串

该方法是直接操作原来的字符串。调用后,删除字符串中的所有字符,相当于 truncate() 方法参数为 0 的时候。

1
2
3
4
5
fn main() {
let mut string_clear = String::from("string clear");
string_clear.clear();
dbg!(string_clear);
}
连接 (Concatenate)

使用+或者+=连接字符串

在使用 + 时, 必须传递切片引用类型。不能直接传递 String 类型。**+ 是返回一个新的字符串,所以变量声明可以不需要 mut 关键字修饰**。

元组

定义:

长度固定、元素顺序固定

1
2
3
fn main() {
let tup: (i32, f64, u8) = (500, 6.4, 1);
}
用模式匹配解构元组

将tup里的值分别赋值给x,y,z

1
2
3
4
5
6
7
fn main() {
let tup = (500, 6.4, 1);

let (x, y, z) = tup;

println!("The value of y is: {}", y);
}
用.来访问元组

如果我们想要访问某个特定的元素,我们使用.进行访问

1
2
3
4
5
6
7
8
9
fn main() {
let x: (i32, f64, u8) = (500, 6.4, 1);

let five_hundred = x.0;

let six_point_four = x.1;

let one = x.2;
}

和其他语言一样,元组的索引从0开始。

结构体

定义

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

  • 通过关键字 struct 定义
  • 一个清晰明确的结构体 名称
  • 几个有名字的结构体 字段

例如:

1
2
3
4
5
6
7
struct User {
active: bool,
username: String,
email: String,
sign_in_count: u64,
}

实例化

我们尝试实例化上面一个结构体

1
2
3
4
5
6
7
let user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};

有几点值得注意:

  1. 初始化实例时,每个字段都需要进行初始化
  2. 初始化时的字段顺序不需要和结构体定义时的顺序一致
访问结构体字段
1
2
3
4
5
6
7
8
9
let mut user1 = User {
email: String::from("someone@example.com"),
username: String::from("someusername123"),
active: true,
sign_in_count: 1,
};

user1.email = String::from("anotheremail@example.com");

我们用.来访问和修改结构体实例内部的字段值

需要注意的是,必须要将结构体实例声明为可变的,才能修改其中的字段

简化结构体构造

下面的函数类似一个构建函数,返回了 User 结构体的实例:

1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
email: email,
username: username,
active: true,
sign_in_count: 1,
}
}

它接收两个字符串参数: emailusername,然后使用它们来创建一个 User 结构体,并且返回。可以注意到这两行: email: emailusername: username,非常的扎眼,因为实在有些啰嗦,如果你从 TypeScript 过来,肯定会鄙视 Rust 一番,不过好在,它也不是无可救药:

1
2
3
4
5
6
7
8
fn build_user(email: String, username: String) -> User {
User {
email,
username,
active: true,
sign_in_count: 1,
}
}

如上所示,当函数参数和结构体字段同名时,可以直接使用缩略的方式进行初始化,跟 TypeScript 中一模一样。

结构体更新语法

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

1
2
3
4
5
6
let user2 = User {
active: user1.active,
username: user1.username,
email: String::from("another@example.com"),
sign_in_count: user1.sign_in_count,
};

我们发现,user1的三个字段居然手动被赋值给了user2,太麻烦了,Rust提供了结构体更新语法:

1
2
3
4
5
let user2 = User {
email: String::from("another@example.com"),
..user1
};

只需用一个..,就能将与user1一样的值赋给了user2

元组结构体(tuple struct)

结构体必须要有名称,但是结构体的字段可以没有名称,这种结构体长得像元组,因此称为元组结构体: struct Color(i32, i32, i32);
struct Point(i32, i32, i32);

let black = Color(0, 0, 0);
let origin = Point(0, 0, 0);

元组结构体在你希望有一个整体名称,但是又不关心里面字段的名称时将非常有用。例如上面的 Point 元组结构体,众所周知 3D 点是 (x, y, z) 形式的坐标点,因此我们无需再为内部的字段逐一命名为:x, y, z

单元结构体

单元结构体和单元类型很像,没有任何字段和属性

如果你定义一个类型,但是不关心该类型的内容,只关心它的行为时,就可以使用 单元结构体

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

如果我们想要对一个结构体实例进行打印,需要在代码最前方加上一个#[derive(Debug)] ,然后使用dbg!()或者println!("{:?}", s);来输出

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
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}

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

println!("rect1 is {:?}", rect1);
}
--------------------------------------------------------------------------------------------------------
#[derive(Debug)]
struct Rectangle {
width: u32,
height: u32,
}

fn main() {
let scale = 2;
let rect1 = Rectangle {
width: dbg!(30 * scale),
height: 50,
};

dbg!(&rect1);
}

枚举

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

1
2
3
4
5
6
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}

枚举类型是一个类型,它会包含所有可能的枚举成员,而枚举值是该类型中的具体某个成员的实例。

枚举值

我们通过::来访问枚举类型下的具体成员

1
2
let heart = PokerSuit::Hearts;
let diamond = PokerSuit::Diamonds;

接下来,我们想让扑克牌变得更加实用,那么需要给每张牌赋予一个值:A(1)-K(13),这样再加上花色,就是一张真实的扑克牌了,例如红心 A。

目前来说,枚举值还不能带有值,因此先用结构体来实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
enum PokerSuit {
Clubs,
Spades,
Diamonds,
Hearts,
}

struct PokerCard {
suit: PokerSuit,
value: u8
}

fn main() {
let c1 = PokerCard {
suit: PokerSuit::Clubs,
value: 1,
};
let c2 = PokerCard {
suit: PokerSuit::Diamonds,
value: 12,
};
}

这段代码很好的完成了它的使命,通过结构体 PokerCard 来代表一张牌,结构体的 suit 字段表示牌的花色,类型是 PokerSuit 枚举类型,value 字段代表扑克牌的数值。

可以吗?可以!好吗?说实话,不咋地,因为还有简洁得多的方式来实现:

1
2
3
4
5
6
7
8
9
10
11
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(u8),
Hearts(u8),
}

fn main() {
let c1 = PokerCard::Spades(5);
let c2 = PokerCard::Diamonds(13);
}

直接将数据信息关联到枚举成员上,省去近一半的代码,这种实现是不是更优雅?

不仅如此,同一个枚举类型下的不同成员还能持有不同的数据类型,例如让某些花色打印 1-13 的字样,另外的花色打印上 A-K 的字样:

1
2
3
4
5
6
7
8
9
10
11
enum PokerCard {
Clubs(u8),
Spades(u8),
Diamonds(char),
Hearts(char),
}

fn main() {
let c1 = PokerCard::Spades(5);
let c2 = PokerCard::Diamonds('A');
}
同一化类型

枚举(enum)是 Rust 中一种常用的类型,它可以将不同类型的数据统一为一个枚举类型。通过定义不同的枚举变体,可以将多种类型的数据封装在一个类型中,然后使用模式匹配来解构和统一处理它们。

例如我们有一个 WEB 服务,需要接受用户的长连接,假设连接有两种:TcpStreamTlsStream,但是我们希望对这两个连接的处理流程相同,也就是用同一个函数来处理这两个连接,代码如下:

1
2
3
4
5
6
7
8
9
10
11
fn new (stream: TcpStream) {
let mut s = stream;
if tls {
s = negotiate_tls(stream)
}

// websocket是一个WebSocket<TcpStream>或者
// WebSocket<native_tls::TlsStream<TcpStream>>类型
websocket = WebSocket::from_raw_socket(
s, ......)
}

此时,枚举类型就能帮上大忙:

1
2
3
4
enum Websocket {
Tcp(Websocket<TcpStream>),
Tls(Websocket<native_tls::TlsStream<TcpStream>>),
}

数组

在Rust中,最常用的数组有两种,第一种是速度很快但是长度固定的array,第二种是可动态增长的但是有性能损耗的Vector,我们将前面的array称之为数组,将后面的Vector称之为动态数组

数组的具体定义很简单:将多个类型相同的元素依次组合在一起,就是一个数组,数组具有以下三要素:

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

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

创建数组

1
2
3
4
5
6
7
8
9
fn main() {
let a = [1, 2, 3, 4, 5];
}

//为数组声明类型
let a: [i32; 5] = [1, 2, 3, 4, 5];

//用;进行分隔,前面是值,后面是出现的次数
let a=[3;5]

访问数组元素

更其他语言一样,用索引来访问

1
2
3
4
fn main() {
let a = [1, 2, 3, 4, 5];
println!("{}",a[0]);
}

访问数组的第一个元素

注意:数组元素是非基本类型

数组切片

1
2
3
4
5
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 字符串切片也同理

流程控制(语句学习)

1.if语句

1
2
3
4
5
if condition == true {
// A...
} else {
// B...
}

2.if-else语句

1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let n = 6;

if n % 4 == 0 {
println!("number is divisible by 4");
} else if n % 3 == 0 {
println!("number is divisible by 3");
} else if n % 2 == 0 {
println!("number is divisible by 2");
} else {
println!("number is not divisible by 4, 3, or 2");
}
}

3.for循环

1
2
3
4
5
fn main() {
for i in 1..=5 {
println!("{}", i);
}
}

1..=5的意思是1到5(包括5),1..5意思是1到5(不包括5)

注意,使用 for 时我们往往使用集合的引用形式,除非你不想在后面的代码中继续使用该集合(比如我们这里使用了 container 的引用)。如果不使用引用的话,所有权会被转移(move)到 for 语句块中,后面就无法再使用这个集合了):

1
2
3
for item in &container {
// ...
}

如果想在循环中,修改该元素,可以使用 mut 关键字:

1
2
3
for item in &mut collection {
// ...
}

总结如下:

使用方法 等价使用方式 所有权
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() 可变借用

如果想在循环中获取元素的索引

1
2
3
4
5
6
7
fn main() {
let a = [4, 3, 2, 1];
// `.iter()` 方法把 `a` 数组变成一个迭代器
for (i, v) in a.iter().enumerate() {
println!("第{}个元素是{}", i + 1, v);
}
}

当然如果我们想用 for 循环控制某个过程执行 10 次,但是又不想单独声明一个变量来控制这个流程

我们用_来代替那个变量

1
2
3
4
for _ in 0..10 {
// ...
}

4.continue

使用continue可以跳过当前循环,开始下一次循环

1
2
3
4
5
6
7
for i in 1..4 {
if i == 2 {
continue;
}
println!("{}", i);
}

5.break

使用break跳出当前整个循环

1
2
3
4
5
6
for i in 1..4 {
if i == 2 {
break;
}
println!("{}", i);
}

6.while循环

跟c的差不多

1
2
3
4
5
6
7
8
9
10
11
fn main() {
let mut n = 0;

while n <= 5 {
println!("{}!", n);

n = n + 1;
}

println!("我出来了!");
}

7.loop循环

简单的无限循环,我们可以在其内部设置break决定何时结束循环

1
2
3
4
5
6
7
fn main() {
loop {
println!("again!");
}
}

//无限的again!,知道crtl+c跳出循环
1
2
3
4
5
6
7
8
9
10
11
12
13
fn main() {
let mut counter = 0;

let result = loop {
counter += 1;

if counter == 10 {
break counter * 2;
}
};

println!("The result is {}", result);
}

这里有几点值得注意:

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

模式匹配

match和if let

match匹配

先看看match的通用形式:

1
2
3
4
5
6
7
8
9
10
match target {
模式1 => 表达式1,
模式2 => {
语句1;
语句2;
表达式2
},
_ => 表达式3
}

match 允许我们将一个值与一系列的模式相比较,并根据相匹配的模式执行对应的代码

我们来看一个例子

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
enum Coin {
Penny,
Nickel,
Dime,
Quarter,
}

fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => {
println!("Lucky penny!");
1
},
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter => 25,
}
}

fn main() {
// 调用 value_in_cents 函数,传入一个 Coin::Penny 枚举值
let penny_value = value_in_cents(Coin::Penny);
println!("The value of a penny is: {} cents", penny_value);

// 你也可以调用其他硬币类型的 value_in_cents
let nickel_value = value_in_cents(Coin::Nickel);
println!("The value of a nickel is: {} cents", nickel_value);
}

value_in_cents 函数根据匹配到的硬币,返回对应的美分数值。match 后紧跟着的是一个表达式,跟 if 很像,但是 if 后的表达式必须是一个布尔值,而 match 后的表达式返回值可以是任意类型,只要能跟后面的分支中的模式匹配起来即可,这里的 coin 是枚举 Coin 类型

接下来是match的分支。一个分支有两个部分:一个模式和针对该模式的处理代码。第一个分支的模式是 Coin::Penny,其后的 => 运算符将模式和将要运行的代码分开。这里的代码就仅仅是表达式 1,不同分支之间使用逗号分隔。

match 表达式执行时,它将目标值 coin 按顺序依次与每一个分支的模式相比较,如果模式匹配了这个值,那么模式之后的代码将被执行。如果模式并不匹配这个值,将继续执行下一个分支。

每个分支相关联的代码是一个表达式,而表达式的结果值将作为整个 match 表达式的返回值。如果分支有多行代码,那么需要用 {} 包裹,同时最后一行代码需要是一个表达式。

简单来说就是在下面main中的传入value_in_cents的值,匹配到啥,就输出啥

模式绑定

模式匹配的另外一个重要功能是从模式中取出绑定的值,例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum UsState {
Alabama,
Alaska,
// --snip--
}

enum Coin {
Penny,
Nickel,
Dime,
Quarter(UsState), // 25美分硬币
}
}

其中 Coin::Quarter 成员还存放了一个值:美国的某个州(因为在 1999 年到 2008 年间,美国在 25 美分(Quarter)硬币的背后为 50 个州印刷了不同的标记,其它硬币都没有这样的设计)。

接下来,我们希望在模式匹配中,获取到 25 美分硬币上刻印的州的名称:

1
2
3
4
5
6
7
8
9
10
11
fn value_in_cents(coin: Coin) -> u8 {
match coin {
Coin::Penny => 1,
Coin::Nickel => 5,
Coin::Dime => 10,
Coin::Quarter(state) => {
println!("State quarter from {:?}!", state);
25
},
}
}

上面代码中,在匹配 Coin::Quarter(state) 模式时,我们把它内部存储的值绑定到了 state 变量上,因此 state 变量就是对应的 UsState 枚举类型。

例如有一个印了阿拉斯加州标记的 25 分硬币:Coin::Quarter(UsState::Alaska),它在匹配时,state 变量将被绑定 UsState::Alaska 的枚举值。

穷尽匹配

之前说过match的匹配必须穷尽所有情况,下面来距离说明。例如:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
enum Direction {
East,
West,
North,
South,
}

fn main() {
let dire = Direction::South;
match dire {
Direction::East => println!("East"),
Direction::North | Direction::South => {
println!("South or North");
},
};
}

上述代码中,我们匹配了East,North,South,但没有匹配West,程序就会报一个错

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
error[E0004]: non-exhaustive patterns: `West` not covered // 非穷尽匹配,`West` 没有被覆盖
--> src/main.rs:10:11
|
1 | / enum Direction {
2 | | East,
3 | | West,
| | ---- not covered
4 | | North,
5 | | South,
6 | | }
| |_- `Direction` defined here
...
10 | match dire {
| ^^^^ pattern `West` not covered // 模式 `West` 没有被覆盖
|
= help: ensure that all possible cases are being handled, possibly by adding wildcards or more match arms
= note: the matched value is of type `Direction`

所以我们在写模式匹配时,需要将所有枚举的值都赋上值

_通配符

当我们不想在匹配时列出所有值的时候,可以使用Rust提供的一个特殊模式,例如,u8 可以拥有 0 到 255 的有效的值,但是我们只关心 1、3、5 和 7 这几个值,不想列出其它的 0、2、4、6、8、9 一直到 255 的值。那么, 我们不必一个一个列出所有值, 因为可以使用特殊的模式 _ 替代:

1
2
3
4
5
6
7
8
9
let some_u8_value = 0u8;
match some_u8_value {
1 => println!("one"),
3 => println!("three"),
5 => println!("five"),
7 => println!("seven"),
_ => (),
}

通常,将_防止其他分支后,_将会匹配所有遗漏的值。()表示返回单元类型与所有分支返回值的类型相同,所以当匹配到_后,什么也不会发生

除了_通配符,用一个变量来承载其他情况也是可以的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#[derive(Debug)]
enum Direction {
East,
West,
North,
South,
}

fn main() {
let dire = Direction::South;
match dire {
Direction::East => println!("East"),
other => println!("other direction: {:?}", other),
};
}

然而,在某些场景下,我们其实只关心某一个值是否存在,此时 match 就显得过于啰嗦。

if let匹配

在 Rust 中,SomeOption 枚举的一个变体。Option 是一个非常常用的枚举类型,它用于表示一个可能存在或不存在的值。Option 有两个变体:

  1. Some(T):表示一个包含类型 T 的值。Some 用来包装一个具体的值,表示该值存在。
  2. None:表示没有值,也就是值不存在。

有时候会遇到只有一个模式的值需要被处理,其他值被忽略的情况,如果使用match就要写成一下模式

1
2
3
4
5
6
let v = Some(3u8);
match v {
Some(3) => println!("three"),
_ => (),
}

这样写太过于繁冗,我们使用if let的方式来实现

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

matches!宏

Rust标准库中提供了一个非常实用的宏:matches!,他可以将一个表达式跟模式进行匹配,然后返回匹配的结果 true or false。

例如,有一个动态数组,里面存有以下枚举

1
2
3
4
5
6
7
8
enum MyEnum {
Foo,
Bar
}

fn main() {
let v = vec![MyEnum::Foo,MyEnum::Bar,MyEnum::Foo];
}

现在如果想对v进行过滤,只保留类型是MyEnum::Foo的元素,按经验一般来说是这样写的

1
v.iter().filter(|x| x == MyEnum::Foo);

但是,实际上这行代码会报错,因为你无法将x直接跟一个枚举成员进行比较。我们使用matches!进行比较

1
v.iter().filter(|x| matches!(x, MyEnum::Foo));

我们来看看其他例子

1
2
3
4
5
6
let foo = 'f';
assert!(matches!(foo, 'A'..='Z' | 'a'..='z'));

let bar = Some(4);
assert!(matches!(bar, Some(x) if x > 2));

解构Option

定义:

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

简单解释就是,应该变量要么有值:Some(T),要么为空:None.

那现在我们该如何去使用这个Option枚举类型,根据经验,可以通过match来实现

因为 OptionSomeNone 都包含在 prelude 中,因此你可以直接通过名称来使用它们,而无需以 Option::Some 这种形式去使用,总之,千万不要因为调用路径变短了,就忘记 SomeNone 也是 Option 底下的枚举成员!

匹配Option<T>

使用Option<T>,是为了从Some中取出起内部的T值以及处理没有值的情况,为了演示这一点,下面编写一个函数,它获取一个Option<i32>,如果其中含有一个值,将其加一;如果其中没有值,则返回None值;

1
2
3
4
5
6
7
8
9
10
11
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);

plus_one接受一个Option<i32>类型的参数,提示返回一个Option<i32>类型的值(这种形式的函数在标准库类随处可见),在该函数的内部处理中,如果传入的是一个None,则返回一个None且不做任何处理;如果传入的是一个Some(i32),则通过模式绑定,把其中的值绑定到变量i上,然后返回i+1的值,同时用Some进行包裹

当传入Some(5)时,首先匹配None分支,由于值不满足,继续匹配下一个分支:

1
Some(i) => Some(i + 1)

Some(5)与Some(i)匹配上了,i绑定了Some包含的值,因此i在这里i的值为5,接着匹配分支的代码被执行,最后将i的值加一并返回一个含有值6的新Some。

当传入None时,直接就匹配到了match的第一个分支,后续分支将不再匹配

模式匹配适用场景

match分支
1
2
3
4
5
6
match VALUE {
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
}

如上所示,match的每一个分支就是一个模式,因为match是无穷尽,因此我们需要一个_通配符来匹配剩余所有情况:

1
2
3
4
5
6
match VALUE {
PATTERN => EXPRESSION,
PATTERN => EXPRESSION,
_ => EXPRESSION,
}

if let分支

if let 分支往往用于匹配一个模式,而忽略剩下所有模式的场景:

1
2
3
if let PATTERN = SOME_VALUE {

}
while let条件循环

它只允许条件满足,模式匹配就能一直进行while循环。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
#![allow(unused)]
fn main() {
// Vec是动态数组
let mut stack = Vec::new();

// 向数组尾部插入元素
stack.push(1);
stack.push(2);
stack.push(3);

// stack.pop从数组尾部弹出元素
while let Some(top) = stack.pop() {
println!("{}", top);
}
}
for循环
1
2
3
4
5
6
let v = vec!['a', 'b', 'c'];

for (index, value) in v.iter().enumerate() {
println!("{} is at index {}", value, index);
}

这里使用enumerate方法生成了一个迭代器,该迭代器每次迭代都会返回一个(索引,值)形式的元组,然后用(index,value)来匹配

let语句
1
2
let PATTERN = EXPRESSION;

该语句也是一种模式匹配

1
let x = 5;

这其中,x是一种模式绑定,代表将匹配的值绑定到变量上,因此,在Rust中,变量名也是一种模式,只不过它比较朴素很不起眼罢了

函数参数

函数参数也是模式:

1
2
3
4
fn foo(x: i32) {
// 代码
}

其中x就是一个模式,你还可以在参数中匹配元组:

1
2
3
4
5
6
7
8
fn print_coordinates(&(x, y): &(i32, i32)) {
println!("Current location: ({}, {})", x, y);
}

fn main() {
let point = (3, 5);
print_coordinates(&point);
}

&(3,5)会匹配模式&(x,y),因此x得到了3,y得到了5

let和if let

对于以下代码,编译器会报错:

1
2
let Some(x) = some_option_value;

因为右边的值可能不为Some,而是None,这种时候就不能进行匹配,也就是上面的代码遗漏None的匹配

类似let,for和match都必须要求完全覆盖匹配,才能通过编译(不可驳模式匹配)

但是对于if let,就可以这样使用:

1
2
3
if let Some(x) = some_option_value{
println!("{}",x);
}

因为if let允许匹配一种模式,而忽略区域的模式(可驳模式匹配)。

let-else(Rust 1.65 新增)

使用 let-else 匹配,即可使 let 变为可驳模式。它可以使用 else 分支来处理模式不匹配的情况,但是 else 分支中必须用发散的代码块处理(例如:breakreturnpanic

全模式列表(总结)

由于不同类型的模式匹配的例子比较多,为了方便查询,总结一下

匹配字面值
1
2
3
4
5
6
7
8
9
let x = 1;

match x {
1 => println!("one"),
2 => println!("two"),
3 => println!("three"),
_ => println!("anything"),
}