Rust-base-learning-3

·

Rust基础学习–(三)

六、流程控制

1、分支控制:if-else

用if-else赋值:

1
2
3
4
5
6
7
8
9
10
fn main() {
let condition = true;
let number = if condition {
5
} else {
6//用if-else语句赋值的时候,不能在不同分支使用不同的变量类型,否则报错
};

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

用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");
}
}

2、循环控制

(1)for循环

示例:

1
2
3
4
5
fn main() {
for i in 1..=5 {//在in后面往往使用集合的引用格式,如果不使用引用,会导致所有权被转移到for语句块中,后续不可以再使用该集合
println!("{}", i);
}
}

【对于实现了 copy 特征的数组(例如 [i32; 10])而言, for item in arr 并不会把 arr 的所有权转移,而是直接对其进行了拷贝,因此循环之后仍然可以使用 arr 。】

如果不需要使用声明的i,也可以用如下方式控制循环:

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

总结如下:

使用方法 等价使用方式 所有权
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);
}
}

continue:跳过本次循环继续,break:跳过当前整个循环(break 可以单独使用,也可以带一个返回值,有些类似 return)。

(2)while循环

eg;

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!("我出来了!");
}

【for比while更安全,功能更强大,这是因为编译器增加了运行时代码来对每次while循环的每个元素进行条件检查,而,for 并不会使用索引去访问数组,因此更安全也更简洁,同时避免 运行时的边界检查,性能更高】

(3)loop循环

又称无条件循环,使用if和break跳出循环。

eg:

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

七、模式匹配

1、match

eg;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
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");
},
_ => println!("West"),
};
}
  • match 的匹配必须要穷举出所有可能,因此这里用 _ 来代表未列出的所有可能性
  • match 的每一个分支都必须是一个表达式,且所有分支的表达式最终返回值的类型必须相同
  • X | Y,类似逻辑运算符 ,代表该分支可以匹配 X 也可以匹配 Y,只要满足一个即可

(其实 match 跟其他语言中的 switch 非常像,_ 类似于 switch 中的 default。)

2、if let

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

eg:

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

3、matches!宏

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

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

fn main() {
let v = vec![MyEnum::Foo,MyEnum::Bar,MyEnum::Foo];
v.iter().filter(|x| matches!(x, MyEnum::Foo));
//对 v 进行过滤,只保留类型是 MyEnum::Foo 的元素
}

其他例子:

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

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

【注:无论是match 还是 if let,这里都是一个新的代码块,而且这里的绑定相当于新变量,如果你使用同名变量,会发生变量遮蔽】

4、模式及其适用场景

模式是 Rust 中的特殊语法,它用来匹配类型中的结构和数据,它往往和 match 表达式联用,以实现强大的模式匹配能力。一般包括:

  • 字面值
  • 解构的数组、枚举、结构体或者元组
  • 变量
  • 通配符
  • 占位符

适用场景:

  • match 分支

  • if let分支

  • while let条件循环

  • for循环

  • let语句

  • 函数参数

  • let-else语句(新增)

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

    模式:

    1
    2
    3
    4
    let PATTERN = EXPRESSION else {
    // 模式匹配失败时执行的代码
    // 必须以发散表达式(如 panic!、return、break)结束
    };

    PATTERN:要匹配的模式(如 Some(x)(a, b) 等)。

    EXPRESSION:要匹配的值。

    else 块:模式匹配失败时执行的代码,必须以 发散表达式(diverging expression)结束(即程序控制流不会继续执行后续代码)。

    eg:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    use std::str::FromStr;

    fn get_count_item(s: &str) -> (u64, &str) {
    let mut it = s.split(' ');
    let (Some(count_str), Some(item)) = (it.next(), it.next()) else {
    panic!("Can't segment count item pair: '{s}'");
    };
    let Ok(count) = u64::from_str(count_str) else {
    panic!("Can't parse integer: '{count_str}'");
    };
    // error: `else` clause of `let...else` does not diverge
    // let Ok(count) = u64::from_str(count_str) else { 0 };
    (count, item)
    }

    fn main() {
    assert_eq!(get_count_item("3 chairs"), (3, "chairs"));
    }

    (与 matchif let 相比,let-else 的一个显著特点在于其解包成功时所创建的变量具有更广的作用域。在 let-else 语句中,成功匹配后的变量不再仅限于特定分支内使用)

5、全模式列表

见链接

八、方法Method

定义:使用impl来定义方法

eg:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
struct Circle {
x: f64,
y: f64,
radius: f64,
}
impl Circle {
// new是Circle的关联函数,因为它的第一个参数不是self,且new并不是关键字
// 这种方法往往用于初始化当前结构体的实例
fn new(x: f64, y: f64, radius: f64) -> Circle {
Circle {
x: x,
y: y,
radius: radius,
}
}
// Circle的方法,&self表示借用当前的Circle结构体
fn area(&self) -> f64 {
std::f64::consts::PI * (self.radius * self.radius)
}
}

self、&self 和 &mut self:

(python里的self其实相当于这里的&mut self)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#[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()
);
}

area 的签名中,我们使用 &self 替代 rectangle: &Rectangle&self 其实是 self: &Self 的简写(注意大小写)。在一个 impl 块内,Self 指代被实现方法的结构体类型,self 指代此类型的实例,换句话说,self 指代的是 Rectangle 结构体实例,这样的写法会让我们的代码简洁很多,而且非常便于理解:我们为哪个结构体实现方法,那么 self 就是指代哪个结构体的实例。

self 依然有所有权的概念:

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

在上面的例子中,选择 &self 的理由跟在函数中使用 &Rectangle 是相同的:我们并不想获取所有权,也无需去改变它,只是希望能够读取结构体中的数据。如果想要在方法中去改变当前的结构体,需要将第一个参数改为 &mut self。仅仅通过使用 self 作为第一个参数来使方法获取实例的所有权是很少见的,这种使用方式往往用于把当前的对象转成另外一个对象时使用,转换完后,就不再关注之前的对象,且可以防止对之前对象的误调用。

总结,使用方法代替函数有以下好处:

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

【在 Rust 中,允许方法名跟结构体的字段名相同。eg,当我们使用 rect1.width() 时,Rust 知道我们调用的是它的方法,如果使用 rect1.width,则是访问它的字段。一般来说,方法跟字段同名,往往适用于实现 getter 访问器,例如:

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
mod my {
pub struct Rectangle {
width: u32,
pub height: u32,
}

impl Rectangle {
pub fn new(width: u32, height: u32) -> Self {
Rectangle { width, height }
}
pub fn width(&self) -> u32 {
return self.width;
}
pub fn height(&self) -> u32 {
return self.height;
}
}
}

fn main() {
let rect1 = my::Rectangle::new(30, 50);

println!("{}", rect1.width()); // OK
println!("{}", rect1.height()); // OK
// println!("{}", rect1.width); // Error - the visibility of field defaults to private
println!("{}", rect1.height); // OK
}

当从模块外部访问结构体时,结构体的字段默认是私有的,其目的是隐藏信息(封装)。我们如果想要从模块外部获取 Rectangle 的字段,只需把它的 newwidthheight 方法设置为公开可见,那么用户就可以创建一个矩形,同时通过访问器 rect1.width()rect1.height() 方法来获取矩形的宽度和高度。

因为 width 字段是私有的,当用户访问 rect1.width 字段时,就会报错。注意在此例中,Self 指代的就是被实现方法的结构体 Rectangle

特别的是,这种默认的可见性(私有的)可以通过 pub 进行覆盖,这样对于模块外部来说,就可以直接访问使用 pub 修饰的字段而无需通过访问器。这种可见性仅当从定义结构的模块外部访问时才重要,并且具有隐藏信息(封装)的目的。

(暴论总结:感觉和c++的class的封装差不多)】

【->运算符在rust的替代方式:(rust并没有该运算符)

在 C/C++ 语言中,有两个不同的运算符来调用方法:. 直接在对象上调用方法,而 -> 在一个对象的指针上调用方法,这时需要先解引用指针。换句话说,如果 object 是一个指针,那么 object->something()(*object).something() 是一样的。

Rust 并没有一个与 -> 等效的运算符;相反,Rust 有一个叫 自动引用和解引用的功能。方法调用是 Rust 中少数几个拥有这种行为的地方。

他是这样工作的:当使用 object.something() 调用方法时,Rust 会自动为 object 添加 &(视可见性添加&mut)、 * 以便使 object 与方法签名匹配。也就是说,这些代码是等价的:

1
2
p1.distance(&p2);
(&p1).distance(&p2);

第一行看起来简洁的多。这种自动引用的行为之所以有效,是因为方法有一个明确的接收者———— self 的类型。在给出接收者和方法名的前提下,Rust 可以明确地计算出方法是仅仅读取(&self),做出修改(&mut self)或者是获取所有权(self)。事实上,Rust 对方法接收者的隐式借用让所有权在实践中更友好。】

关联函数:

定义在 impl 中且没有 self 的函数被称之为关联函数: 因为它没有 self,不能用 f.read() 的形式调用,因此它是一个函数而不是方法,它又在 impl 中,与结构体紧密关联,因此称为关联函数。

eg:

1
2
3
4
5
6
impl Rectangle {
fn new(w: u32, h: u32) -> Rectangle {
Rectangle { width: w, height: h }
}
}

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

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

多个 impl 定义

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

为枚举实现方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#![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

简介:

泛型是一种多态。泛型主要目的是为程序员提供编程的便利,减少代码的臃肿,同时可以极大地丰富语言本身的表达能力。换句话说,就是一个函数,可以代替几十个,甚至数百个函数。

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 add_i8(a:i8, b:i8) -> i8 {
a + b
}
fn add_i32(a:i32, b:i32) -> i32 {
a + b
}
fn add_f64(a:f64, b:f64) -> f64 {
a + b
}

fn main() {
println!("add i8: {}", add_i8(2i8, 3i8));
println!("add i32: {}", add_i32(20, 30));
println!("add f64: {}", add_f64(1.23, 1.23));
}
//使用泛型
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 中,泛型参数的名称你可以任意起,但是出于惯例,我们都用 TTtype 的首字母)来作为首选,这个名称越短越好,除非需要表达含义,否则一个字母是最完美的。

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

1
2
3
4
5
6
7
fn largest<T>(list: &[T]) -> T {
}
//但是,如果并不是所有的类型都能进行函数中的运算,就需要加以限制,否则会报错
fn add<T: std::ops::Add<Output = T>>(a:T, b:T) -> T {//eg
a + b
}

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

结构体中使用泛型

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

1
2
3
4
5
6
7
8
9
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 是相同的类型(如果想让 xy 既能类型相同,又能类型不同,就需要使用不同的泛型参数)

枚举中使用泛型

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

此外,还有一个重要的枚举类型:result<T,E>

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

这个枚举和 Option 一样,主要用于函数返回值,与 Option 用于值的存在与否不同,Result 关注的主要是值的正确性。

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

方法中使用泛型

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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

除了结构体中的泛型参数,我们还能在该结构体的方法中定义额外的泛型参数,就跟泛型函数一样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
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 是定义在结构体 Point 上的泛型参数,V,W 是单独定义在方法 mixup 上的泛型参数,它们并不冲突,说白了,你可以理解为,一个是结构体泛型,一个是函数泛型。

此外,对于 Point<T> 类型,不仅能定义基于 T 的方法,还能针对特定的具体类型,进行方法定义:

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

const 泛型:针对值的泛型

(rust1.51版本引入)

在数组中,[i32; 2][i32; 3] 是不同的数组类型。因此我们可以使用const泛型处理数组长度问题,使其可以打印出任意长度的数组。

1
2
3
4
5
6
7
8
9
10
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 泛型表达式

假设我们某段代码需要在内存很小的平台上工作,因此需要限制函数参数占用的内存大小,此时就可以使用 const 泛型表达式来实现:

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
// 目前只能在nightly版本下使用
#![allow(incomplete_features)]
#![feature(generic_const_exprs)]

fn something<T>(val: T)
where
Assert<{ core::mem::size_of::<T>() < 768 }>: IsTrue,
// ^-----------------------------^ 这里是一个 const 表达式,换成其它的 const 表达式也可以
{
//
}

fn main() {
something([0u8; 0]); // ok
something([0u8; 512]); // ok
something([0u8; 1024]); // 编译错误,数组长度是1024字节,超过了768字节的参数长度限制
}

// ---

pub enum Assert<const CHECK: bool> {
//
}

pub trait IsTrue {
//
}

impl IsTrue for Assert<true> {
//
}

const fn

在讨论完 const 泛型后,不得不提及另一个与之密切相关且强大的特性:const fn,即常量函数。const fn 允许我们在编译期对函数进行求值,从而实现更高效、更灵活的代码设计。

要定义一个常量函数,只需要在函数声明前加上 const 关键字。例如:

1
2
3
4
5
6
7
8
9
const fn add(a: usize, b: usize) -> usize {
a + b
}

const RESULT: usize = add(5, 10);

fn main() {
println!("The result is: {}", RESULT);
}

虽然 const fn 提供了很多便利,但是由于其在编译期执行,以确保函数能在编译期被安全地求值,因此有一些限制,例如,不可将随机数生成器写成 const fn

无论在编译时还是运行时调用 const fn,它们的结果总是相同,即使多次调用也是如此。唯一的例外是,如果你在极端情况下进行复杂的浮点操作,你可能会得到(非常轻微的)不同结果。因此,不建议使 数组长度 (arr.len())Enum判别式 依赖于浮点计算。

结合 const fn 与 const 泛型

const fnconst 泛型 结合,可以实现更加灵活和高效的代码设计。例如,创建一个固定大小的缓冲区结构,其中缓冲区大小由编译期计算确定:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
struct Buffer<const N: usize> {
data: [u8; N],
}

const fn compute_buffer_size(factor: usize) -> usize {
factor * 1024
}

fn main() {
const SIZE: usize = compute_buffer_size(4);
let buffer = Buffer::<SIZE> {
data: [0; SIZE],
};
println!("Buffer size: {} bytes", buffer.data.len());
}

在这个例子中,compute_buffer_size 是一个常量函数,它根据传入的 factor 计算缓冲区的大小。在 main 函数中,我们使用 compute_buffer_size(4) 来计算缓冲区大小为 4096 字节,并将其作为泛型参数传递给 Buffer 结构体。这样,缓冲区的大小在编译期就被确定下来,避免了运行时的计算开销。

泛型的性能保证

Rust 通过在编译时进行泛型代码的 单态化(monomorphization)来保证效率。单态化是一个通过填充编译时使用的具体类型,将通用代码转换为特定代码的过程。

编译器所做的工作正好与我们创建泛型函数的步骤相反,编译器寻找所有泛型代码被调用的位置并针对具体类型生成代码。

让我们看看一个使用标准库中 Option 枚举的例子:

1
2
let integer = Some(5);
let float = Some(5.0);

当 Rust 编译这些代码的时候,它会进行单态化。编译器会读取传递给 Option<T> 的值并发现有两种 Option<T>:一种对应 i32 另一种对应 f64。为此,它会将泛型定义 Option<T> 展开为 Option_i32Option_f64,接着将泛型定义替换为这两个具体的定义。

编译器生成的单态化版本的代码看起来像这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
enum Option_i32 {
Some(i32),
None,
}

enum Option_f64 {
Some(f64),
None,
}

fn main() {
let integer = Option_i32::Some(5);
let float = Option_f64::Some(5.0);
}

我们可以使用泛型来编写不重复的代码,而 Rust 将会为每一个实例编译其特定类型的代码。这意味着在使用泛型时没有运行时开销;当代码运行,它的执行效率就跟好像手写每个具体定义的重复代码一样。这个单态化过程正是 Rust 泛型在运行时极其高效的原因。

To Be Continue…


Rust-base-learning-3
http://example.com/2025/07/02/Rust-base-learning-3/
作者
oxygen
发布于
2025年7月2日
许可协议