rust 语法

1. 一道题开始

1.1 为什么无法运行

1
2
3
4
let mut data = vec![1, 2, 3];
let first_ref = &data[0]; // 获得一个不可变引用(“复制钥匙”)
data.push(4); // 尝试修改数据(需要“主钥匙”)
println!("{}", first_ref);

这个代码为什么编译不成功?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 1. let mut data = vec![1, 2, 3];
// 你告诉管家:“这是我的一箱珍宝(data),未来可能会往里面加东西(mut)。”
// 管家点头,他现在是这箱珍宝的唯一管理者。

// 2. let first_ref = &data[0];
// 你对管家说:“我想欣赏一下第一个珍宝,给我一个临时的‘只读’凭证(first_ref)。”
// 管家把凭证给你,并严肃地告诉你:“先生,在您欣赏期间,为了保证您看到的就是最真实的样子,
// 我绝不会移动或改变箱子里的任何东西。”
// 此时,一个“只读凭证”被发出去了。

// 3. data.push(4);
// 你突然又想往箱子里加一个新珍宝。你对管家下令:“加个东西进去!”
// 管家立刻阻止你(编译器报错):“不行!我之前给您的‘只读凭证’还没收回来呢。
// 万一我现在加东西,导致整个箱子(内存)需要换个更大的地方存放,
// 您手上的那个凭证就会指向一个空荡荡的旧位置,这太危险了!”

// 4. println!("{}", first_ref);
// 如果第三步成功了,这一步就可能去访问一个已经被释放的内存地址,导致程序崩溃。

Rust 的编译器(管家)执行一条铁律:你不能在存在 “ 只读借用 “(&data)的期间,对原始数据进行 “ 可变操作 “(data.push)。这就是 Rust 如何在编译时就彻底杜绝 “ 悬垂指针 “ 这类内存安全问题的。

1.2 let mut data = vec![1, 2, 3]

我们把它拆开来看,就像庖丁解牛一样:

  • let: 这是 Rust 中声明一个变量的关键字。
  • mut: 这是 “mutable”(可变的)的缩写。在 Rust 中,所有变量默认都是不可变的(immutable)。如果你想让一个变量的值可以被修改,就必须明确地使用 mut 关键字。这是一种安全设计,鼓励你编写更清晰、副作用更少的代码。
    • let x = 5; x = 6; // 编译错误!因为 x 不可变。
    • let mut y = 5; y = 6; // 正确!因为 y 被声明为可变。
  • data: 你为变量起的名字。
  • vec![]: 这个看起来有点奇怪。vec! 不是一个函数,而是一个宏(Macro)。
    • 什么是宏? 你可以把它理解为 “ 代码的代码 “。它是一种在编译器把你的代码翻译成机器码之前运行的特殊代码段,它的作用是根据你给的模式生成你想要的代码。
    • ! 符号就是用来区分宏和普通函数的。
    • vec![1, 2, 3] 这个宏的作用就是方便地创建一个向量(Vector),也就是一个可以在运行时增长或缩小的动态数组,并用 1, 2, 3 来初始化它。它最终生成的代码类似于 Vec::new() 然后再把元素一个个 push 进去,但用宏写起来方便多了。

所以,这整行代码的意思是:” 声明一个名为 data 的可变变量,它的类型是一个整数向量,并初始化内容为 1, 2, 3。”

2. 语法基础

2.1 变量声明与数据类型

  • 声明: let (不可变) 和 let mut (可变)。
  • 基本类型: i32 (32 位有符号整数), u64 (64 位无符号整数), f64 (64 位浮点数), bool (true/false), char (字符)。Rust 通常能自动推断类型。
1
2
let x = 10;           // 编译器推断为 i32 
let name: &str = "BotGem"; // 有时需要显式标注类型
  • 复合类型 - 元组 (Tuple): 固定长度,可以包含不同类型的元素。
1
2
let user_info: (String, i32) = (String::from("Alice"), 28); 
let user_name = user_info.0; // 通过索引访问
  • 复合类型 - 数组 (Array): 固定长度,所有元素必须是相同类型。
1
let numbers: [i32; 3] = [1, 2, 3];

2.2 函数 (Functions)

  • 使用 fn 关键字定义。
  • 参数和返回值的类型必须标注。
  • -> 符号用于指定返回值类型。
  • Rust 是一个 “ 基于表达式 “ 的语言。函数的最后一个表达式的值就是返回值,可以省略 return 关键字和分号。
1
2
3
4
// 这是一个函数,接收一个 i32,返回一个 i32
fn add_one(x: i32) -> i32 {
x + 1 // 没有分号,这是一个表达式,它的值将作为函数的返回值
}

2.3 控制流 (Control Flow)

  • if-else:和其它语言类似,但它可以作为表达式返回值。

    1
    2
    let condition = true; 
    let number = if condition { 5 } else { 6 }; // number 的值会是 5
  • 循环:

    • loop: 无限循环,用 break 退出。
    • while: 条件循环。
    • for: 最常用、最地道的循环。用于遍历任何可以迭代的集合。
1
2
3
4
let a = [10, 20, 30];
for element in a.iter() { // 遍历数组的引用
println!("the value is: {}", element);
}

2.4 结构体 (Structs) - 自定义数据结构

  • 类似于 C 的 struct 或其他语言中没有方法的 class。用来把相关的数据打包在一起。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct User {
username: String,
email: String,
active: bool,
}

// 创建实例
let mut user1 = User {
email: String::from("some@example.com"),
username: String::from("someuser123"),
active: true,
};
// 访问和修改
user1.email = String::from("another@example.com");

2.5 枚举 (Enums) 与 match - Rust 的超能力

  • 枚举允许你定义一个可以是若干种不同变体之一的类型。
  • Rust 的枚举特别强大,每个变体都可以关联数据。
1
2
3
4
5
enum WebEvent {
PageLoad, // 无关联数据
KeyPress(char), // 关联一个字符
Click { x: i64, y: i64 }, // 关联一个匿名结构体
}
  • match 是一个控制流运算符,像一个超强的 switch。它必须穷尽(exhaustive)所有可能的情况,编译器会帮你检查。这消除了大量潜在的 bug。
1
2
3
4
5
6
7
8
9
fn inspect(event: WebEvent) {
match event {
WebEvent::PageLoad => println!("page loaded"),
WebEvent::KeyPress(c) => println!("pressed '{}'.", c),
WebEvent::Click { x, y } => { // 使用 {} 解构
println!("clicked at x={}, y={}.", x, y);
},
}
}

match 和 Enum 的组合是 Rust 中表达复杂逻辑、保证程序健壮性的核心工具之一。

3. 字符串

字符串怎么定义

从根本上说,Rust 的字符串处理围绕着两种主要的类型:

  1. String:一个拥有所有权的、可变的、在堆上分配内存的字符串。
  2. &str (读作 “string slice”,字符串切片):一个借用的、不可变的、指向字符串数据的视图。

我们可以用一个形象的比喻来理解:

  • String 就像你拥有的一本实体书。它是你的,你可以在上面写字、撕掉几页或者增加新页。它有自己明确的存放位置(在内存的 “ 堆 “ 上)。
  • &str 就像这本书某一章节的一张复印件。它只是对原始内容的一个 “ 查阅凭证 “。你无法通过涂改复印件来修改原始的书。而且,这张复印件可以来自你的私有书籍(String),也可以来自图书馆的永久馆藏(程序里写死的字符串)。

1. String:所有者

  • 它是什么:String 是一个拥有其内容的数据结构。它的内容存储在堆(Heap)上,这意味着它的大小可以在程序运行时增长或缩小。例如,你可以往一个 String 后面追加更多文本。
  • 核心特征:
    • 拥有所有权:它有唯一的 “ 所有者 “。当所有者离开其作用域时,String 会被自动销毁,其占用的内存也会被释放。
    • 可变:如果用 mut 声明,你就可以修改它的内容。
    • 堆分配:它很灵活,可以改变大小。
  • 如何创建:
    • String::from("你的文本"):这是最常见、最明确的方式。它清楚地表达了 “ 请从这段文本数据创建一个新的 String 对象 “。
    • "你的文本".to_string():这个方法和 String::from 的作用完全一样。使用哪个纯粹是个人偏好。

2. &str:借用者 (字符串切片)

  • 它是什么:&str 是一个引用,它指向一段由 _ 别人 _ 拥有的 UTF-8 文本序列。它被称为 “ 切片 “,因为它常常只是一个 String 中 “ 一部分 “ 的视图。它实际上是一个 “ 胖指针 “,因为它包含两条信息:一个指向数据起始位置的指针,以及这个切片的长度。
  • 核心特征:
    • 借用:它不拥有它所指向的数据,只是一个临时的视图。
    • 不可变(默认):你不能通过 &str 来修改它指向的底层数据。
    • 大小固定:&str 本身的大小是固定的(一个指针 + 一个长度),它指向的数据序列长度也是固定的。
  • 它指向哪里?
    1. 指向字符串字面量 (String Literal):当你写 let name: &str = "BotGem"; 时,"BotGem" 这段文本是一个字符串字面量。它被直接编译进你的程序二进制文件的只读数据区。name 这个变量就是一个指向那个内存地址的引用。这种方式非常快,在程序运行时完全没有内存分配的开销。
    2. 指向一个 String:你可以创建一个 &str,让它指向一个 String 的全部或一部分。
1
2
3
4
5
let s: String = String::from("hello world");

let hello: &str = &s[0..5]; // 一个指向 "hello" 的切片
let world: &str = &s[6..11]; // 一个指向 "world" 的切片
let whole_thing: &str = &s; // 一个指向整个 String 的切片
场景推荐类型为什么?
在 struct 结构体中存储数据String结构体应该是自包含的,应该拥有它自己的数据。如果你用 &str,你就必须处理复杂的生命周期问题,这意味着该结构体的存活时间不能超过它所借用的数据的存活时间。
作为函数参数&str这是黄金法则。使用 &str 会让你的函数更灵活。一个接受 &str 的函数,既可以接收一个拥有的 String (&my_string),也可以接收一个字符串字面量 ("hello"),或者另一个切片。这是一种无所有权的 “ 出借 “。
从函数返回值通常是 String如果函数创建了一个新的字符串(例如,通过拼接两个字符串),它必须返回一个拥有的 String,这样才能把所有权转移给调用者。
…特例:返回输入字符串的一部分&str (带生命周期)如果一个函数只是在它的输入中查找并返回一个子串,它可以返回一个 &str 切片。这更高效(没有新的内存分配),但需要使用生命周期来向编译器证明,返回的切片不会比原始数据活得更久。(这是一个更高级的话题)。
需要被修改的变量let mut s = String::…只有 String 是可以增长和修改的。
1
2
3
4
5
6
7
8
9
10
11
12
13
// 好例子:这个函数非常灵活
fn print_message(message: &str) {
println!("{}", message);
}

fn main() {
let owned_string = String::from("我是一个拥有的字符串。");
let literal_string = "我是一个字符串字面量。";

// 两种类型的字符串都可以传给这个函数!
print_message(&owned_string); // 传递 String 的引用
print_message(literal_string); // 直接传递字面量
}

4. 引用和可变变量

在 C++ 里,引用的主要目的之一就是修改值,但在 Rust 中,简单的 & 引用似乎不行。

首先,Rust 的引用分为两种,这与 C++ 的单一引用类型有本质区别:Rust 的引用体系:共享 Vs 独占

  1. 共享引用 (Shared Reference): &T
    • 权限:只读。你只能通过它来读取数据,不能修改。
    • 特点:可以同时存在多个。就像很多人可以同时阅读同一本书。
    • 这就是 &name。它是一个共享引用,因此默认不可修改值。
  2. 可变引用 (Mutable Reference): &mut T
    • 权限:可读可写。你可以通过它来修改数据。
    • 特点:具有独占性。在它的作用域内,不能存在任何其他对该数据的引用(无论是共享的还是可变的)。就像一个人要修改一本书,他必须先把书拿走,不允许任何其他人同时阅读或修改。

在 Rust 中,” 可变性 “ 有两个层面,必须将它们分开理解:🔥

  1. 变量绑定的可变性 (let mut):决定一个变量是否可以被重新赋值,指向另一个东西。
  2. 数据的可变性 (&mut):决定是否可以通过引用去修改所指向的数据。
1
2
3
4
5
6
7
let mut data = 10;
let ref_b = &mut data; // ref_b 是一个可变引用

*ref_b = 20; // 正确!✅ 通过可变引用修改了 `data` 的值
println!("修改后的 data: {}", data); // 会输出 20

// ref_b = &mut another_variable; // 错误!❌ `ref_b` 本身是不可变的,不能让它指向别的东西
1
2
3
4
5
6
7
8
9
10
11
let mut data1 = 10;
let mut data2 = 50;

let mut ref_d = &mut data1; // ref_d 是一个可变的绑定,指向一个可变引用

*ref_d = 20; // 正确!✅ 通过它修改 data1
println!("修改后的 data1: {}", data1); // 输出 20

ref_d = &mut data2; // 正确!✅ 让 ref_d 重新指向 data2
*ref_d = 60; // 正确!✅ 通过它修改 data2
println!("修改后的 data2: {}", data2); // 输出 60
组合变量能否重新赋值?能否通过引用修改数据?示例
let x = &y;否 ❌否 ❌let x = &y;
let mut x = &y;是 ✅否 ❌x = &z;
let x = &mut y;否 ❌是 ✅*x = 100;
let mut x = &mut y;是 ✅是 ✅x = &mut z; *x = 100;

上面为什么都有 &

直接回答:& 并不是必须的。 & 是一个独立的操作符,它的作用是 “ 创建一个引用 “。

我们有三种东西需要区分:

  1. 值 (Value):内存中实际的数据,比如数字 10,字符串 String::from("abc")
  2. 变量绑定 (Variable Binding):给 “ 值 “ 起的一个名字,比如 let x = …
  3. 引用 (Reference):一个指向某个 “ 值 “ 的 “ 指针 “ 或 “ 地址 “,它本身也是一种值。

世界一:直接操作 “ 值 “ (没有 & 的世界)

1
2
3
4
5
6
7
8
9
// 场景A: 不可变绑定,持有值
let x = 10;
// x = 20; // 错误!❌ `x` 不是 mut 的,不能让它持有别的值。

// 场景B: 可变绑定,持有值
let mut y = 10;
println!("y 的初始值: {}", y); // 输出 10
y = 20; // 正确!✅ 因为 `y` 是 mut 的,所以可以改变它持有的值。
println!("y 的新值: {}", y); // 输出 20

世界二:创建并使用 “ 引用 “ (有 & 的世界)

现在,我们引入 & 操作符。& 的作用是:” 别动那个值,给我它的地址,我要创建一个引用。”

mut 在 & 后面,是修饰所创建的引用的类型,决定了这个引用是 “ 只读权限 “ 还是 “ 可写权限 “。

1
2
3
4
5
6
7
8
9
10
11
let mut data = 10; // 一个可变的变量,持有值 10

// --- 下面进入引用的世界 ---

// `ref_a` 持有的是 `data` 的【只读地址】
let ref_a = &data;
// *ref_a = 20; // 错误!❌ 这个地址是只读权限的,不能通过它修改 data

// `ref_b` 持有的是 `data` 的【可写地址】
let ref_b = &mut data;
*ref_b = 30; // 正确!✅ 这个地址是可写权限的,可以通过它修改 data
  • 如果你想直接操作值,就不需要 &
  • 如果你想创建一个引用(比如,为了避免数据所有权的转移,或者只是想 “ 借用 “ 一下数据),那么你就必须使用 &,因为 & 就是 “ 创建引用 “ 的语法。

如何修改一个变量

如果你想以任何方式修改一个变量的值(无论是直接修改还是通过可变引用),这个变量的原始绑定都必须是 let mut

为什么?这源于 Rust 的安全承诺。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 1. s 必须是可变的
let mut s: String = String::from("hello world");

// 打印修改前的状态
println!("原始的 s 是: '{}'", s);

{ // 使用一个作用域来限制可变引用的生命周期
// 2. 创建一个指向 s 的可变切片 (&mut str)
let hello_mut: &mut str = &mut s[0..5];

// 现在,你可以通过这个可变引用来修改数据了
hello_mut.make_ascii_uppercase(); // 这是一个 &mut str 的方法
} // 可变引用 hello_mut 在这里失效

// 打印修改后的结果
println!("修改后的 s 是: '{}'", s); // 输出: 'HELLO world'

5. 注意事项

  • 拥抱编译器:Rust 编译器非常严格,但它的错误信息极其友好和详尽。把它当作你的导师,而不是敌人。它指出的问题几乎都是潜在的严重 Bug。
  • 默认不可变:始终优先使用不可变变量和引用 (let x 和 &x)。只在确实需要修改时才使用 mut 关键字。这会引导你写出更清晰、更易推理的代码。
  • 优先使用迭代器:Rust 的迭代器是 “ 零成本抽象 “ 的典范。使用 for item in my_vec.iter() 而不是手写 for i in 0..my_vec.len(),代码更优雅,性能也同样出色。
  • 善用 Option 和 Result:Rust 没有 null。它使用 Option<T> (表示 Some(T) 或 None) 来处理可能缺失的值,使用 Result<T, E> (表示 Ok(T) 或 Err(E)) 来处理可能失败的操作。这强迫你在编译时就处理所有异常情况。
  • 不要过度使用 .clone(): 当所有权问题让你头疼时,一个简单的 “ 修复 “ 方法就是到处复制数据 (.clone()),但过度使用会带来性能损失。问问自己:” 我真的需要一份全新的数据副本,还是只需要 ‘ 借用 ‘ 一下?” 学习何时使用引用(&)是关键。
  • 混淆 String 和 &str:
    • String:一个在堆上分配、可增长的、拥有所有权的 UTF-8 字符串。(像 C++ 的 std::string)
    • &str (字符串切片):一个对 String 或字符串字面量某个部分的不可变借用。(像 C 的 const char*)
    • 记住,函数参数优先使用 &str,因为它更灵活,可以接受 String 和字符串字面量两种类型的输入,且避免了所有权转移。