《Rust语言程序设计基础》第三章:复合数据类型与循环

第三章:复合数据类型与循环

在掌握了标量类型之后,我们需要构建更复杂的数据结构来表达真实世界的逻辑。本章将介绍 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 编码正确性。

主要有两种字符串类型:

  1. String:存储在堆(Heap)上,可变,拥有所有权。类似于 C++ 的 std::string
  2. &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 提供了三种循环:loopwhile 和 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):灵活无序

  • 结构:缺乏特定的组织,类似一个大仓库或杂乱的房间。
  • 特点:

  • 大小可变:用于存储编译时大小未知或运行时可能变化的数据(例如 StringVec)。

  • 成本较高:
  • 管理复杂:这正是所有权系统大显身手的地方。在 C/C++ 中需要手动 free,在 Java/Go 中依赖 GC 扫描,而在 Rust 中,所有权规则负责自动管理堆内存的生命周期。

  • 分配慢:内存分配器(Allocator)需要在堆中寻找足够大的空闲空间,标记为已用,并返回一个指针(Pointer)。

  • 访问慢:访问堆数据需要通过指针进行间接寻址(Dereferencing),比直接访问栈数据要慢。

比喻:餐馆就餐

  • :像是在快餐店排队。队伍整齐有序,每个人处理完就离开,效率极高。
  • :像是去餐厅预订包间。你需要先问服务员(分配器):“有坐得下10个人的桌子吗?”服务员找到空桌子后,领你过去(返回指针)。如果之后又来了5个朋友,原来的桌子坐不下,可能还需要换个更大的房间(重新分配与数据迁移)。

3.6.2 所有权三大规则

  1. Rust 中的每一个值都有一个被称为其“所有者”(Owner)的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃(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):

  1. 在任意给定时间,你要么只能有一个可变引用(&mut T),要么只能有多个不可变引用(&T)。
  2. 引用必须总是有效的。

这意味着你不能在有人仅仅只是“读取”数据时(持有不可变引用),去“修改”数据(创建可变引用)。这保证了数据的一致性。

通过严格遵守这些规则,Rust 在编译阶段就消灭了一整类内存安全问题,让程序在运行时快如闪电且坚如磐石。

预览时标签不可点