第三章:复合数据类型与循环
在掌握了标量类型之后,我们需要构建更复杂的数据结构来表达真实世界的逻辑。本章将介绍 Rust 中的复合数据类型:元组(Tuple)和数组(Array),以及核心的字符串(String)类型。此外,我们还将学习如何通过循环(Loops)来遍历和操纵这些数据。
更重要的是,我们将在此接触到 Rust 语言的灵魂——所有权(Ownership)。这是 Rust 区别于其他所有编程语言的最关键特性,也是保证内存安全的终极武器。
3.1 元组 (Tuple)
元组是一种将多个不同类型的值组合进一个复合类型的主要方式。元组的长度在声明后是固定的,不能增长或缩小。这与 Python 的 Tuple 非常相似。
3.1.1 定义与解构
创建一个名为 tuple.rs 的文件,内容如下:
// 文件名:source_code/chapter_03_extracted/tuple.rs
fn main() {
// 创建一个包含整数、浮点数和字符的元组
let tup: (i32, f64, char) = (500, 6.4, 'A');
// 模式匹配解构(Destructuring):将元组拆解为单独的变量
let (x, y, z) = tup;
println!("x = {}, y = {}, z = {}", x, y, z);
// 通过索引访问(点号语法)
// 索引从 0 开始
println!("tup.0 = {}, tup.1 = {}, tup.2 = {}", tup.0, tup.1, tup.2);
}
编译与运行:
$ rustc tuple.rs
$ ./tuple
x = 500, y = 6.4, z = A
tup.0 = 500, tup.1 = 6.4, tup.2 = A
3.1.2 可变性与单元元组
默认情况下,元组是不可变的。如果需要修改其中的元素,必须使用 mut 关键字。
修改 tuple.rs 文件如下:
// 文件名:source_code/chapter_03_extracted/tuple.rs (追加或修改)
fn main() {
let mut mutable_tup = (100, 3.14, 'B');
println!("修改前: ({}, {}, {})", mutable_tup.0, mutable_tup.1, mutable_tup.2);
// 修改特定位置的元素
mutable_tup.0 = 200;
// 甚至可以整体重新赋值(只要类型匹配)
mutable_tup = (300, 2.71, 'C');
println!("修改后: ({}, {}, {})", mutable_tup.0, mutable_tup.1, mutable_tup.2);
}
编译与运行:
$ rustc tuple.rs
$ ./tuple
修改前: (100, 3.14, B)
修改后: (300, 2.71, C)
注:没有任何值的元组 () 被称为单元类型(Unit Type)。如果一个表达式不返回任何其他值,就隐式地返回单元类型。这类似于 C/Java 中的 void,但在 Rust 中它是一个真实存在的值。
3.2 数组 (Array)
数组也是多个值的集合,但与元组不同,数组中的每个元素必须是相同类型。Rust 中的数组长度是固定的,数据直接存储在栈(Stack)上。
3.2.1 声明与访问
数组的类型签名写作 [Type; Length]。
创建一个名为 array.rs 的文件:
// 文件名:source_code/chapter_03_extracted/array.rs
fn main() {
// 显式类型标注:包含 5 个 i32 元素的数组
letmut arr: [i32; 5] = [10, 20, 30, 40, 50];
// 另一种初始化语法:创建包含 5 个 3 的数组 [3, 3, 3, 3, 3]
let _zeros = [3; 5];
println!("原始数组: {:?}", arr); // 使用 {:?} 进行调试打印
// 索引访问
println!("arr[0] = {}", arr[0]);
// 修改元素
arr[0] = 100;
println!("修改后: {:?}", arr);
// 越界访问保护
// 如果尝试访问 arr[10],Rust 会在编译时(如果索引是常量)或运行时 Panic,防止缓冲区溢出。
let i = 10;
if i < arr.len() {
println!("arr[{}] = {}", i, arr[i]);
} else {
println!("索引 {} 越界!长度是 {}", i, arr.len());
}
}
编译与运行:
$ rustc array.rs
$ ./array
原始数组: [10, 20, 30, 40, 50]
arr[0] = 10
修改后: [100, 20, 30, 40, 50]
索引 10 越界!长度是 5
3.2.2 切片 (Slice)
数组虽然固定长度,但我们可以通过切片来引用数组的一部分。切片是一个胖指针,包含指向数据的指针和长度。这与 Python 的切片 arr[1:4] 概念非常类似。
// 引用数组中索引 1 到 3 的元素(不包含 4),即 [20, 30, 40]
let slice = &arr[1..4];
println!("切片: {:?}", slice);
数组与向量对比表
| 特性 | Array (数组) | Vector (向量) | Python List |
|---|---|---|---|
| 存储位置 | 栈 (Stack) | 堆 (Heap) | 堆 (Heap) |
| 长度 | 固定 (编译时确定) | 可变 (运行时增长) | 可变 |
| 类型限制 | 同构 (相同类型) | 同构 (相同类型) | 异构 (任意类型) |
| 性能 | 极快 (无分配开销) | 快 (有分配开销) | 较慢 (装箱开销) |
3.3 字符串 (String)
在 Rust 中,处理字符串比其他语言稍微复杂,因为 Rust 极其重视处理文本时的内存安全和 UTF-8 编码正确性。
主要有两种字符串类型:
String:存储在堆(Heap)上,可变,拥有所有权。类似于 C++ 的std::string。&str(字符串切片):通常是对String或字面量的引用,存储在栈上(或静态数据区),不可变。
创建一个 string.rs 文件:
// 文件名:source_code/chapter_03_extracted/string.rs
fn main() {
// String::from 创建一个堆分配的 String
letmut s = String::from("你好");
// push_str 追加字符串
s.push_str(",世界!");
println!("完整字符串: {}", s);
// 字符串切片 &str
// 注意:Rust 字符串索引必须切在有效的 UTF-8 字符边界上
// "你好" 占 6 个字节(每个汉字 3 字节)
let hello: &str = &s[0..6];
let world: &str = &s[9..18]; // 这里的索引需要根据实际字节计算
println!("切片: '{}' 和 '{}'", hello, world);
}
编译与运行:
$ rustc string.rs
$ ./string
完整字符串: 你好,世界!
切片: '你好' 和 '世界!'
3.4 循环 (Loops)
在处理数组、字符串等集合类型时,循环是必不可少的工具。Rust 提供了三种循环:loop、while 和 for。
3.4.1 loop 循环
loop 会无限次地执行代码块,直到显式使用 break 关键字停止。
fn main() {
let mut counter = 0;
let result = loop {
counter += 1;
if counter == 10 {
break counter * 2; // break 可以返回值
}
};
println!("结果是:{}", result);
}
3.4.2 while 循环
当条件为真时执行循环。这与 C 语言和 Python 中的 while 基本一致。
fn main() {
let mut number = 3;
while number != 0 {
println!("{}!", number);
number -= 1;
}
println!("发射!!!");
}
3.4.3 for 循环与遍历
for 循环是 Rust 中最常用、最安全的循环方式,特别是在遍历集合时。它避免了手动管理索引可能导致的越界错误。
创建一个名为 loops.rs 的文件:
// 文件名:source_code/chapter_03_extracted/loops.rs
fn main() {
let a = [10, 20, 30, 40, 50];
// 使用 iter() 方法获取迭代器
print!("数组遍历: ");
for element in a.iter() {
print!("{} ", element);
}
println!();
// 使用 Range (范围)
// (1..4) 生成 1, 2, 3 (不包含 4)
// rev() 方法用于反转序列
print!("倒计时: ");
for number in (1..4).rev() {
print!("{} ", number);
}
println!("出发!");
}
编译与运行:
$ rustc loops.rs
$ ./loops
数组遍历: 10 20 30 40 50
倒计时: 3 2 1 出发!
3.5 多维数组与矩阵运算
在掌握了基础数组和控制流之后,我们可以进一步探讨多维数组。Rust 支持多维数组,这在处理矩阵运算或图像数据时非常有用。虽然在科学计算中,我们通常使用 ndarray 这样的外部库来获得类似 Python NumPy 的体验,但理解原生多维数组是基础。
创建一个 matrix.rs 演示原生二维数组:
// 文件名:source_code/chapter_03_extracted/matrix.rs
fn main() {
// 创建两个 2x2 的二维数组
// 类型为 [[i32; 2]; 2]
let a = [[1, 2], [3, 4]];
let b = [[5, 6], [7, 8]];
println!("矩阵 A 第一行: {:?}", a[0]);
println!("矩阵 B 第二行: {:?}", b[1]);
// 使用嵌套循环遍历矩阵
println!("矩阵 A:");
for row in a.iter() {
for val in row.iter() {
print!("{} ", val);
}
println!();
}
}
编译与运行:
$ rustc matrix.rs
$ ./matrix
矩阵 A 第一行: [1, 2]
矩阵 B 第二行: [7, 8]
矩阵 A:
1 2
3 4
提示:Rust 标准库中没有内置动态增长的列表(List)或字典(Dict)。如果需要动态数组,请使用 Vec<T>(向量);如果需要键值对,请使用 HashMap<K, V>。
3.6 所有权 (Ownership):Rust 的灵魂
所有权是 Rust 最独特的功能。它让 Rust 无需垃圾回收(GC)即可保证内存安全,同时避免了手动内存管理的繁琐和风险。在深入所有权规则之前,我们必须先理解所有权所管理的“对象”究竟存放在哪里——这就引出了栈(Stack)与堆(Heap)的概念。
3.6.1 前置知识:栈与堆 (Stack vs Heap)
在系统编程中,栈和堆是两个核心的内存区域,它们的结构和用途截然不同。
栈 (Stack):快速有序
- 结构:遵循后进先出 (LIFO) 原则。就像在餐厅叠盘子,你只能在顶端放盘子,也只能从顶端拿盘子。
-
特点:
-
速度极快:分配内存只需移动栈指针(Stack Pointer),无需寻找空闲块。
- 大小固定:存放在栈上的数据必须在编译时拥有已知且固定的大小(例如
i32、[i32; 5])。 - 自动管理:当函数调用结束,对应的栈帧 (Stack Frame) 被弹出,其中的所有局部变量自动销毁。
堆 (Heap):灵活无序
- 结构:缺乏特定的组织,类似一个大仓库或杂乱的房间。
-
特点:
-
大小可变:用于存储编译时大小未知或运行时可能变化的数据(例如
String、Vec)。 - 成本较高:
-
管理复杂:这正是所有权系统大显身手的地方。在 C/C++ 中需要手动
free,在 Java/Go 中依赖 GC 扫描,而在 Rust 中,所有权规则负责自动管理堆内存的生命周期。 -
分配慢:内存分配器(Allocator)需要在堆中寻找足够大的空闲空间,标记为已用,并返回一个指针(Pointer)。
- 访问慢:访问堆数据需要通过指针进行间接寻址(Dereferencing),比直接访问栈数据要慢。
比喻:餐馆就餐
- 栈:像是在快餐店排队。队伍整齐有序,每个人处理完就离开,效率极高。
- 堆:像是去餐厅预订包间。你需要先问服务员(分配器):“有坐得下10个人的桌子吗?”服务员找到空桌子后,领你过去(返回指针)。如果之后又来了5个朋友,原来的桌子坐不下,可能还需要换个更大的房间(重新分配与数据迁移)。
3.6.2 所有权三大规则
- Rust 中的每一个值都有一个被称为其“所有者”(Owner)的变量。
- 值在任一时刻有且只有一个所有者。
- 当所有者(变量)离开作用域,这个值将被丢弃(Drop),内存被释放。
3.6.3 栈上数据:Copy 语义
对于实现了 Copy trait 的类型(如整数、浮点数、布尔值、字符、以及仅包含 Copy 类型的元组/数组),赋值操作是位的复制。
let x = 5;
let y = x; // x 的值被完全复制给 y
println!("x = {}, y = {}", x, y); // x 仍然有效,因为它在栈上
3.6.4 堆上数据:Move 语义
对于 String 这样存储在堆上的复杂类型,赋值操作意味着所有权的转移(Move)。这是为了避免二次释放(Double Free)错误。
创建一个 ownership.rs 文件深入理解:
// 文件名:source_code/chapter_03_extracted/ownership.rs
fn main() {
// === Copy 语义 ===
let x = 5;
let y = x;
println!("Copy: x={}, y={}", x, y); // 正常工作
// === Move 语义 ===
let s1 = String::from("hello");
let s2 = s1; // 所有权从 s1 移动到 s2
// println!("{}", s1); // 编译错误!s1 已失效,禁止访问
println!("Move: s2={}", s2); // 正确,s2 是新的所有者
// === Clone (深拷贝) ===
let s3 = s2.clone(); // 显式复制堆上的数据
println!("Clone: s2={}, s3={}", s2, s3); // s2 和 s3 指向两块独立的堆内存
// === 引用与借用 (Borrowing) ===
let len = calculate_length(&s3); // 传递引用,不转移所有权
println!("'{}' 的长度是 {}。", s3, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
} // s 离开作用域,但它只是一个引用,没有所有权,所以什么都不发生
编译与运行:
$ rustc ownership.rs
$ ./ownership
Copy: x=5, y=5
Move: s2=hello
Clone: s2=hello, s3=hello
'hello' 的长度是 5。
3.6.5 借用规则 (The Borrow Checker)
Rust 的借用检查器强制执行以下规则,以彻底根除数据竞争(Data Race):
- 在任意给定时间,你要么只能有一个可变引用(
&mut T),要么只能有多个不可变引用(&T)。 - 引用必须总是有效的。
这意味着你不能在有人仅仅只是“读取”数据时(持有不可变引用),去“修改”数据(创建可变引用)。这保证了数据的一致性。
通过严格遵守这些规则,Rust 在编译阶段就消灭了一整类内存安全问题,让程序在运行时快如闪电且坚如磐石。
预览时标签不可点