UNSW-COMP6991-Rust-现代编程笔记-全-

UNSW COMP6991 Rust 现代编程笔记(全)

001:Rust语言与程序安全 🦀

概述

在本节课中,我们将学习Rust编程语言的基础知识,并探讨编程语言设计如何影响程序的安全性。我们将通过编写一个简单的“猜数字”游戏来实践Rust编程,并深入分析一个核心概念——Option类型,比较它在不同编程语言中的实现及其对程序健壮性的影响。

课程介绍

欢迎来到COMP6991课程。这是一门全新的课程,旨在引导你思考编程语言作为一种工具,如何深刻地影响我们的思维习惯和编程方式。本课程不仅教授Rust语言,更鼓励你批判性地思考不同语言的设计决策、权衡及其对编写健壮、安全程序的影响。

我们将避免灌输“唯一正确”的编程方式,而是呈现多种可能性,让你通过实践形成自己的见解。课程的核心是提问与讨论:你喜欢哪种方式?为什么?这种方式在其他语言中如何体现?

课程结构与要求

学习资源

  • 主要工具:Rust工具链(在CSE系统上使用6991 cargo等命令)。
  • 推荐阅读:《Rust编程语言》(“The Book”),可免费在线获取。
  • 学习方式:结合讲座、实践工作坊、每周练习和作业进行学习。

讲座与工作坊

  • 讲座:每周两次,侧重于介绍Rust特性背后的设计思想,并会涉及多种编程语言的比较。
  • 工作坊:每周一次,是实践性环节。通常以小组形式协作解决一个较大问题,之后进行集体讨论,反思代码设计、遇到的问题以及在不同语言中的可能实现。

评估方式

  • 每周练习:20%
  • 作业1(程序设计):20%
  • 作业2(并发编程):25%
  • 期末考试:35%

课程无不及格分数线,总分达到50即可通过。

Rust初体验:从Hello World到猜数字游戏

上一节我们介绍了课程的整体框架,本节中我们将动手编写第一个Rust程序。

Hello World

以下是创建一个Rust项目并编写Hello World程序的基本步骤:

  1. 使用Cargo创建新项目:6991 cargo new hello_world
  2. 项目目录中的src/main.rs是程序入口。
  3. main函数中使用println!宏输出文本。
fn main() {
    println!("Hello, world!");
}
  1. 使用6991 cargo run命令编译并运行程序。

关键点

  • fn 关键字用于声明函数。
  • main 函数是程序执行的起点。
  • println! 是一个宏(而非函数),用于输出带换行的文本。
  • 默认情况下,变量是不可变的(immutable)。使用 let mut 声明可变变量。

构建猜数字游戏

接下来,我们将构建一个更复杂的程序:猜数字游戏。计算机会生成一个随机数,用户输入猜测,程序会提示猜测是过高、过低还是正确。

以下是实现的核心步骤:

  1. 生成随机数:通过添加rand依赖库(6991 cargo add rand)并使用thread_rng().gen_range(1..=100)生成1到100之间的随机数。
  2. 读取用户输入:使用std::io::stdin().read_line(&mut guess)从标准输入读取一行文本。
  3. 字符串转换为整数:使用guess.trim().parse::<i32>()将输入的字符串转换为整数。parse方法返回一个Result类型,需要处理可能出现的错误(例如输入非数字字符)。初期我们可以使用.unwrap()在出错时简单使程序崩溃。
  4. 比较数字:使用match表达式与cmp方法优雅地比较猜测值与秘密数字。
use std::cmp::Ordering;
use std::io;
use rand::Rng;

fn main() {
    let secret_number = rand::thread_rng().gen_range(1..=100);
    loop {
        println!("Please input your guess.");
        let mut guess = String::new();
        io::stdin().read_line(&mut guess).unwrap();
        let guess: i32 = match guess.trim().parse() {
            Ok(num) => num,
            Err(_) => continue,
        };
        match guess.cmp(&secret_number) {
            Ordering::Less => println!("Too small!"),
            Ordering::Greater => println!("Too big!"),
            Ordering::Equal => {
                println!("You win!");
                break;
            }
        }
    }
}

代码解析

  • loop 关键字创建一个无限循环。
  • match 表达式是Rust强大的控制流运算符,它允许你将一个值与一系列模式比较,并执行匹配的模式对应的代码。它必须是穷尽的,即覆盖所有可能的情况。
  • cmp 方法比较两个值并返回一个 Ordering 枚举变体(Less, Equal, Greater)。
  • 使用 match 处理 parse 的结果,比直接使用 .unwrap() 更健壮,因为它允许我们在输入非数字时继续循环而不是崩溃。

深入核心概念:Option类型与空值安全

上一节我们通过实践熟悉了Rust的基本语法,本节中我们来看看一个对程序安全至关重要的抽象:Option类型。

什么是Option类型?

Option是一个枚举(enum),它表示一个值可能存在也可能不存在。其定义如下:

enum Option<T> {
    Some(T),
    None,
}
  • Some(T):表示存在一个类型为 T 的值。
  • None:表示不存在任何值。

为什么Option类型重要?

在许多编程语言(如C、Java、Python、Go)中,存在“空引用”(null)的概念。任何引用或对象类型都可以为null。这导致了著名的“十亿美元错误”,因为开发者很容易忘记检查null,从而在运行时引发崩溃或未定义行为。

Rust通过Option类型显式地处理值可能缺失的情况。关键优势在于:如果一个值的类型不是Option,那么它在Rust中就不可能为“空”。这由编译器在编译时保证,极大地减少了运行时错误。

不同语言中的“空值”处理对比

以下是使用不同语言处理“可能返回字符串也可能不返回”的函数示例:

C语言:使用指针和NULL

char* create(_Bool condition) {
    if (condition) return "hello";
    else return NULL;
}
// 调用者必须手动检查返回值是否为NULL,否则可能导致未定义行为。

C++:可以使用std::optional,但指针仍有空值问题。
Java:有Optional类型,但任何对象引用(包括Optional本身)仍可以为null
Python:任何变量都可以是None,需依赖运行时检查或类型提示(如MyPy)。
Go:指针可为nil,解引用nil指针会引发panic(运行时崩溃)。

Rust:使用Option,并通过模式匹配安全处理。

fn create(condition: bool) -> Option<String> {
    if condition {
        Some("hello".to_string())
    } else {
        None
    }
}

match create(true) {
    Some(s) => println!("{}", s), // s 在此分支内有效
    None => println!("nothing"),   // 此分支没有 s 变量
}
// 编译器强制处理Some和None两种情况,避免遗漏。

Option类型的意义

Option类型用于建模多种场景:

  • 函数可能没有输出:例如,字符串解析为整数可能失败。
  • 函数参数可选:例如,一个转换函数可以接受可选的进制参数。
  • 结构体字段可能缺失:例如,学生可能还没有平均成绩(WAM)。

通过将“可能缺失”这一事实编码进类型系统,Rust引导(或强制)程序员在编译期处理所有可能的缺失情况,从而编写出更健壮、更少运行时错误的代码。

总结

本节课我们一起学习了Rust编程语言的基础入门,并深入探讨了编程语言设计中的一个核心安全特性——通过Option类型消除空引用错误。

我们了解到:

  1. Rust通过所有权、借用检查器和丰富的类型系统(如OptionResult)在编译期保障内存安全和逻辑安全。
  2. Option枚举是处理值缺失情况的显式、安全的方式,与许多语言中隐式、易出错的空指针形成对比。
  3. 语言的设计选择(如是否允许隐式空值)会深刻影响程序员编写健壮代码的难易程度。

Rust并不一定是所有场景下的最佳选择,但它提供了一个优秀的样本,让我们思考如何通过语言设计来约束错误、表达意图并构建更可靠的软件。在接下来的课程中,我们将继续探索Rust的其他特性,并以此为契机,更广泛地思考和比较编程语言的设计哲学。

002:Rust基础与所有权

概述

在本节课中,我们将要学习Rust编程语言的基础知识,包括变量与可变性、数据类型、表达式、函数、控制流,并初步接触Rust的核心概念——所有权。


变量与可变性

在Rust中,我们使用 let 关键字来声明变量。默认情况下,所有绑定都是不可变的。

let x = 42; // 不可变绑定

如果你希望一个变量可以被修改,必须显式地使用 mut 关键字。

let mut x = 42;
x = x * 2; // 这是允许的

变量遮蔽

Rust允许你重新声明一个同名变量,这被称为“遮蔽”。新变量会“遮蔽”掉旧的同名变量,它们可以是完全不同的类型。

let x = 42; // x 是 i32
let x = 'c'; // x 现在是 char,遮蔽了之前的 x

常量

你可以使用 const 关键字定义常量。常量必须显式指定类型,并且其值在编译时必须是已知的。

const F: i32 = 42;

数据类型

Rust是一种静态类型语言,这意味着所有变量的类型在编译时都是已知的。编译器通常可以推断出类型,但有时也需要你显式标注。

标量类型

标量类型代表单个值。Rust有四种主要的标量类型:整数、浮点数、布尔值和字符。

  • 整数:有符号(i8, i16, i32, i64, i128)和无符号(u8, u16, u32, u64, u128)。i32u32 是常用类型。
  • 浮点数f32(单精度)和 f64(双精度)。
  • 布尔值bool,值为 truefalse
  • 字符char,表示一个Unicode标量值。

复合类型

复合类型可以将多个值组合成一个类型。Rust有两个原生的复合类型:元组和数组。

  • 元组:将多个不同类型的值组合在一起。长度固定。

    let tup: (i32, bool, char) = (42, true, 'z');
    // 通过模式匹配解构
    let (x, y, z) = tup;
    // 通过索引访问
    let first = tup.0;
    
  • 数组:将多个相同类型的值组合在一起。长度固定。

    let arr: [i32; 3] = [1, 2, 3];
    let first = arr[0]; // 索引从0开始
    

结构体与枚举

上一节我们介绍了基础的数据容器,本节中我们来看看如何定义自己的复合类型。

  • 结构体:为数据命名并打包,使其意义更清晰。

    struct Student {
        zid: String,
        age: u32,
        wam: Option<f64>,
    }
    let student = Student { zid: String::from("z1234567"), age: 20, wam: Some(85.5) };
    

  • 枚举:定义一个类型可能取值的集合。Rust的枚举非常强大,每个变体可以关联不同类型和数量的数据。

    enum Automobile {
        Car { make: String, model: String, doors: u8, driven_wheels: DriveType },
        Motorbike { make: String, model: String, horsepower: u32 },
        Train { capacity: u32 },
        Bus { capacity: u32 },
    }
    

单元类型

单元类型 () 是一个零长度的元组。它通常在不关心返回值,但又需要某种类型时使用,例如不返回任何有用值的函数。

let unit: () = ();

Rust作为表达式语言

在Rust中,几乎所有代码块都是表达式,这意味着它们会产生一个值。这与许多语言中的“语句”概念不同。

例如,if 在Rust中是一个表达式:

let x = if condition { 42 } else { 100 }; // x 会根据条件被赋值为 42 或 100

函数体中的最后一个表达式(没有分号)会自动成为该函数的返回值:

fn add(a: i32, b: i32) -> i32 {
    a + b // 没有分号,这是一个表达式,其值 `a+b` 被返回
}

函数

函数使用 fn 关键字定义。参数和返回值的类型必须显式声明。

fn greet(name: &str) -> String {
    format!("Hello, {}!", name)
}

控制流

Rust提供了常见的控制流结构:ifloopwhilefor 和强大的 match

条件与循环

  • if 表达式:条件必须是 bool 类型。
  • loop:无限循环,可以使用 break 退出,break 可以带一个返回值。
  • while:条件循环。
  • for:遍历集合(如数组、范围等)。

模式匹配

match 表达式允许你将一个值与一系列模式进行比较,并根据匹配的模式执行代码。它是穷尽的,意味着必须覆盖所有可能的情况。

match value {
    1 => println!("One"),
    2 | 3 | 4 | 5 => println!("Two through Five"),
    _ => println!("Something else"), // `_` 通配符匹配任何值
}

match 在处理枚举和 Option/Result 类型时尤其有用:

let some_number: Option<i32> = Some(5);
match some_number {
    Some(x) => println!("The number is: {}", x),
    None => println!("There is no number"),
}

所有权

所有权是Rust最独特的功能,它使Rust能够在没有垃圾回收的情况下保证内存安全。

核心规则

  1. Rust中的每个值都有一个被称为其所有者的变量。
  2. 值在任一时刻有且只有一个所有者。
  3. 当所有者(变量)离开作用域,这个值将被丢弃(drop)。

移动语义

对于非 Copy 类型(如 String, Vec),赋值或传参会导致所有权移动。移动后,原来的变量将失效,不能再被使用。

let s1 = String::from("hello");
let s2 = s1; // s1 的所有权移动到了 s2
// println!("{}", s1); // 错误!s1 不再有效

克隆

如果你确实需要深度复制堆上的数据,可以使用 clone 方法。这会产生数据的完整副本,两个变量彼此独立。

let s1 = String::from("hello");
let s2 = s1.clone(); // 显式克隆
println!("{} and {}", s1, s2); // 现在两者都有效

Copy 类型

对于存储在栈上的简单标量类型(如整数、布尔值、字符)以及完全由 Copy 类型组成的元组和结构体,赋值会进行拷贝,而不是移动。Copy 类型自动实现了 Copy trait。

let x = 5;
let y = x; // x 被拷贝到 y
println!("x = {}, y = {}", x, y); // 两者都有效

所有权与函数

将值传递给函数时,所有权可能会发生移动。函数返回值也可以转移所有权。

fn takes_ownership(s: String) -> String { // s 进入作用域
    println!("{}", s);
    s // s 作为返回值被移出
}

fn main() {
    let s1 = String::from("hello");
    let s2 = takes_ownership(s1); // s1 的所有权被移动到函数内,然后又被移动到 s2
    // s1 在这里不再有效
}

总结

本节课中我们一起学习了Rust的基础语法和核心概念。我们了解了变量声明、可变性、各种数据类型(标量、复合、结构体、枚举),体验了Rust强大的表达式特性,学习了函数定义和控制流(特别是模式匹配)。最后,我们初步接触了Rust独特的所有权系统,理解了移动、克隆和 Copy 类型的区别,这是写出安全、高效Rust代码的基石。在接下来的课程中,我们将深入探讨借用、生命周期等更高级的所有权相关主题。

003:集合与迭代器

概述

在本节课中,我们将学习Rust中的核心概念:集合与迭代器。我们将探讨如何使用向量(Vec)、数组等集合类型,并深入了解迭代器(Iterator)这一强大的抽象,它允许我们以声明式和函数式风格处理数据序列。


集合简介

我们已经见过数组(Array),它是一种固定大小的集合。例如:

let my_array = [1, 2, 3];

数组必须始终包含固定数量的元素,其类型为 [i32; 3]

向量(Vec)则提供了动态大小的集合。例如:

let my_vec = vec![1, 2, 3];

向量可以增长和收缩,其类型为 Vec<i32>

向量与数组的一个关键区别在于,向量将其数据存储在堆(Heap)上,而数组通常存储在栈(Stack)上。这使得向量更适合存储大量数据或需要动态调整大小的场景。


向量的动态特性

向量支持动态调整大小。我们可以向向量中添加或移除元素。

以下是向向量添加元素的方法:

let mut my_vec = vec![1, 2, 3];
my_vec.push(4);

要从向量中移除元素,可以使用 remove 方法:

my_vec.remove(2); // 移除索引为2的元素

向量包含三个关键信息:元素本身、当前长度(length)和容量(capacity)。容量是为未来元素预分配的空间,以避免频繁的内存重新分配。

为了提高性能,可以使用 Vec::with_capacity 预先分配足够的空间:

let mut doubled_vec = Vec::with_capacity(input_vec.len());


迭代器(Iterator)简介

迭代器是Rust中处理序列数据的核心抽象。任何实现了 Iterator trait 的类型都可以被迭代。

迭代器的核心方法是 next,它返回一个 Option<T>。当序列中还有元素时,返回 Some(element);当序列耗尽时,返回 None

以下是如何从向量创建迭代器:

let my_vec = vec![1, 2, 3];
let mut iter = my_vec.into_iter();
println!("{:?}", iter.next()); // Some(1)
println!("{:?}", iter.next()); // Some(2)
println!("{:?}", iter.next()); // Some(3)
println!("{:?}", iter.next()); // None

迭代器的一个主要优势是它抽象了底层数据结构的细节,为各种集合提供了统一的处理接口。


迭代器适配器与方法

迭代器提供了许多强大的适配器方法,允许我们以函数式风格转换和组合数据。

以下是一些常用的迭代器方法:

  • map:对每个元素应用一个函数,将其转换为另一种类型。

    let doubled: Vec<_> = vec![1, 2, 3].into_iter().map(|x| x * 2).collect();
    // 结果为 [2, 4, 6]
    
  • filter:只保留满足条件的元素。

    let positives: Vec<_> = vec![-1, 0, 1, 2].into_iter().filter(|&x| x > 0).collect();
    // 结果为 [1, 2]
    

  • zip:将两个迭代器“压缩”成一个迭代器,产生元素对。
    let a1 = vec![1, 2, 3];
    let a2 = vec![4, 5, 6];
    let zipped: Vec<_> = a1.into_iter().zip(a2.into_iter()).collect();
    // 结果为 [(1, 4), (2, 5), (3, 6)]
    

  • fold:将一个序列“折叠”成单个值。它接受一个初始值和一个闭包,闭包将当前累积值和下一个元素作为输入,返回新的累积值。
    let sum = vec![1, 2, 3, 4].into_iter().fold(0, |acc, x| acc + x);
    // 结果为 10
    


实践示例:计算平均值

上一节我们介绍了迭代器的基本概念和常用方法,本节中我们来看看如何应用它们解决实际问题。

让我们编写一个函数来计算一个整数向量的平均值(均值)。

首先,考虑函数签名。由于向量可能为空,且平均值可能是小数,我们返回一个 Option<f64>

以下是使用迭代器的函数式实现:

fn mean_functional(numbers: Vec<i32>) -> Option<f64> {
    if numbers.is_empty() {
        return None;
    }
    let sum: i32 = numbers.iter().sum();
    Some(sum as f64 / numbers.len() as f64)
}

这个实现简洁明了,利用了迭代器的 sum 方法。


实践示例:查找最长相等子序列

接下来,我们解决一个更复杂的问题:给定两个向量,找出它们对应位置上连续相等元素的最长子序列长度。

例如,对于向量 [1, 2, 3, 4, 5, 6, 7, 8, 9, 10][1, 0, 3, 4, 5, 6, 20, 8, 9, 10],最长相等子序列是 [3, 4, 5, 6],长度为4。

以下是结合迭代器适配器的解决方案:

fn longest_equal_run_functional(x: Vec<i32>, y: Vec<i32>) -> usize {
    x.into_iter()
        .zip(y.into_iter())
        .map(|(xe, ye)| xe == ye)
        .fold((0, 0), |(best, current), equal| {
            if equal {
                let new_current = current + 1;
                (best.max(new_current), new_current)
            } else {
                (best, 0)
            }
        })
        .0
}

这个实现使用了 zip 来配对元素,map 来比较是否相等(生成布尔序列),最后用 fold 来跟踪当前运行长度和最佳长度。


哈希映射(HashMap)简介

哈希映射是存储键值对的集合。它在Rust中非常常用,通常通过 std::collections::HashMap 使用。

以下是如何使用哈希映射来统计一个向量中每个元素出现的次数(频率):

use std::collections::HashMap;

fn frequency_map(numbers: Vec<i32>) -> HashMap<i32, i32> {
    let mut map = HashMap::new();
    for &num in &numbers {
        *map.entry(num).or_insert(0) += 1;
    }
    map
}

这里使用了 entry API,它提供了一种高效的方式来检查键是否存在并插入或更新值。


实践示例:查找众数(Mode)

最后,我们使用哈希映射来查找一个向量中的众数(出现次数最多的元素)。

以下是实现:

fn mode(numbers: Vec<i32>) -> Option<i32> {
    let mut frequency = HashMap::new();
    for &num in &numbers {
        *frequency.entry(num).or_insert(0) += 1;
    }
    frequency
        .into_iter()
        .max_by_key(|&(_, count)| count)
        .map(|(value, _)| value)
}

这个函数首先构建频率映射,然后使用迭代器的 max_by_key 方法找到计数最大的条目,最后使用 map 提取出该元素的值。如果向量为空,max_by_key 返回 None,函数也相应返回 None


字符串作为字符集合

字符串(String)在Rust中也可以被视为集合。由于Rust字符串是UTF-8编码的,直接索引字节可能得不到预期的字符。通常,我们使用 chars 方法来获取字符迭代器。

以下是遍历字符串字符的方法:

let s = String::from("Hello 🦀");
for ch in s.chars() {
    println!("{}", ch);
}
// 输出: H, e, l, l, o, 空格, 🦀

总结

本节课中我们一起学习了Rust中集合与迭代器的核心知识。

我们探讨了向量(Vec)的动态特性及其与数组的区别,深入了解了迭代器(Iterator)这一强大抽象,它通过 next 方法提供了处理序列的统一接口。我们实践了多种迭代器适配器,如 mapfilterzipfold,并使用它们以函数式风格解决了计算平均值、查找最长相等子序列等问题。最后,我们介绍了哈希映射(HashMap)的基本用法,并用它来查找数据中的众数。

掌握这些概念对于编写高效、地道的Rust代码至关重要。迭代器不仅使代码更简洁,而且由于其“零成本抽象”的特性,通常能编译出与手写循环性能相当的机器码。

004:错误处理 🛠️

在本节课中,我们将学习编程语言中处理错误的多种方法。我们将探讨哨兵值、异常(包括受检异常和非受检异常)以及Rust中利用类型系统进行错误处理的独特方式。通过对比不同语言的策略,我们将理解Rust选择其特定错误处理模型背后的原因。

概述

错误处理是编程的核心部分。不同的语言采用了不同的策略,从简单的哨兵值到复杂的异常系统。本节将介绍这些方法,并重点讲解Rust如何通过ResultOption类型以及?运算符,在编译时提供强大的错误处理能力,同时保持代码的简洁性。

哨兵值

上一节我们介绍了错误处理的基本概念,本节中我们来看看第一种常见方法:哨兵值。

哨兵值是指那些其特定值本身携带了额外含义(通常是错误信号)的值。例如,在C语言中,空指针(NULL)或特定的错误码(如-1)常被用作哨兵值来指示操作失败。

以下是哨兵值的一些例子:

  • 空指针:在C语言中,NULL(通常为地址0)表示指针不指向任何有效数据。
  • 字符串终止符:C语言字符串以\0字符结尾,该字符标志着字符串的结束。
  • 错误码:许多系统函数(如Linux的errno)返回整数,其中0表示成功,非零值映射到特定的错误类型。

这种方法的优点是实现简单、性能开销低。然而,它也存在显著缺点:

  • 容易忽略错误:程序员可能忘记检查返回值。
  • 值域冲突:如果哨兵值(如-1)本身也是一个有效的返回值,就会产生歧义。
  • 缺乏信息:简单的错误码可能无法提供足够的错误上下文。
  • 依赖文档:程序员必须查阅文档才能理解每个返回值或错误码的含义。

异常

上一节我们讨论了使用哨兵值的优缺点,本节中我们来看看另一种广泛使用的错误处理机制:异常。

异常机制允许程序在遇到错误时“抛出”一个异常对象,并沿着调用栈向上“传播”,直到被某个“捕获”块处理。这实现了错误发生点与错误处理点的分离。

非受检异常

在Python、Java(部分异常)、C++等语言中,异常通常是非受检的。这意味着函数可以抛出异常,但调用者不一定需要在代码中显式声明或处理它们。

# Python 示例
try:
    number = int(input("请输入一个数字: "))
except ValueError:
    print("输入的不是有效数字。")

非受检异常的优点是减少了代码的侵入性,允许在更高层级集中处理错误。但其主要缺点是:

  • 控制流不透明:任何函数调用都可能抛出异常,中断当前执行流,使得程序的控制流难以追踪。
  • 运行时才发现:如果异常未被捕获,程序将在运行时崩溃。
  • 文档依赖:同样需要查阅文档来了解函数可能抛出的异常类型。

受检异常

Java语言引入了受检异常的概念。如果一个方法可能抛出受检异常,它必须在签名中使用throws关键字声明。调用该方法的代码必须要么捕获这个异常,要么在其自己的签名中声明抛出它。

// Java 示例(概念)
public void readFile() throws IOException {
    // ... 可能抛出 IOException 的代码
}

受检异常强制程序员考虑和处理可能的错误,提高了代码的健壮性。然而,它也带来了显著的缺点:

  • 样板代码多:导致函数签名膨胀和大量的try-catch块。
  • 破坏抽象:底层实现的异常类型会泄露给上层调用者。
  • 与高阶函数不兼容:当与函数式编程特性(如传递函数参数)结合时,受检异常会变得非常笨拙,导致所谓的“语言阻抗不匹配”。

Rust的类型系统方法

上一节我们看到了异常模型的挑战,本节中我们来看看Rust如何利用其强大的类型系统来处理错误。

Rust没有传统意义上的异常。相反,它使用枚举类型Result<T, E>Option<T>来显式表示可能失败的操作。

  • Option<T>:表示一个值可能存在(Some(T))或不存在(None)。
  • Result<T, E>:表示操作可能成功(Ok(T))或失败(Err(E))。

// Rust 核心错误处理类型
enum Result<T, E> {
    Ok(T),
    Err(E),
}

enum Option<T> {
    Some(T),
    None,
}

这种方式将错误信息编码在类型中,迫使程序员在编译时就必须处理所有可能的错误路径。这结合了受检异常的安全性,但通过类型系统实现,避免了其与语言其他特性的冲突。

处理Result

最基本的处理方式是使用match表达式:

let result: Result<i32, &str> = some_fallible_function();
match result {
    Ok(value) => println!("成功: {}", value),
    Err(e) => println!("失败: {}", e),
}

传播错误

为了简化错误传播,避免深层嵌套的match,Rust提供了?运算符。如果Result的值是Ok,则解包出值;如果是Err,则从当前函数提前返回该错误。

fn read_and_parse() -> Result<i32, Box<dyn std::error::Error>> {
    let mut input = String::new();
    std::io::stdin().read_line(&mut input)?; // 如果出错,直接返回错误
    let num: i32 = input.trim().parse()?; // 如果解析出错,直接返回错误
    Ok(num)
}

?运算符会自动进行错误类型的转换,前提是实现了相应的From trait。你也可以使用map_err方法在传播前转换错误类型。

不可恢复错误:Panic

对于不可恢复的错误(如程序逻辑错误、严重系统错误),Rust提供了panic!宏。它会终止当前线程,并展开调用栈(unwind)。这类似于非受检异常,但在Rust中通常只用于处理不应发生的错误。

if some_condition_that_must_be_true {
    // ... 正常逻辑
} else {
    panic!("发生了不可恢复的逻辑错误!");
}

总结

本节课中我们一起学习了错误处理的多种范式。

  • 哨兵值简单直接,但容易出错且缺乏表现力。
  • 异常(尤其是非受检异常)提供了灵活的跨层错误传播,但使控制流变得不透明,并依赖运行时发现错误。
  • 受检异常强制错误处理,提高了安全性,但引入了大量样板代码并与语言的其他特性存在冲突。

Rust采用了利用类型系统的方法,通过ResultOption类型在编译时强制错误处理。?运算符极大地简化了错误传播的语法,使得代码既安全又简洁。对于不可恢复的错误,则使用panic。这种设计在提供强大安全保障的同时,避免了其他模型的主要缺点,是Rust语言可靠性的基石之一。

005:借用机制

概述

在本节课中,我们将要学习Rust语言中一个核心且强大的特性——借用机制。我们将从回顾所有权开始,理解其带来的挑战,然后深入探讨共享借用和独占借用如何帮助我们在保证内存安全的同时,编写更灵活、更高效的代码。

所有权回顾与挑战

上一节我们介绍了Rust的所有权系统。在Rust中,大多数值默认具有所有权语义。这意味着一个值在任意时刻只能有一个所有者,当进行赋值或传递给函数等操作时,所有权会发生转移,原变量将变得无效。

例如,对于一个自定义结构体:

struct Student {
    zid: i32,
    name: String,
    wam: f64,
}

变量 student 拥有 Student 值,而 Student 值又拥有其内部的 String 字段。所有权是层层递进的。

然而,Rust也为一些简单类型(如 i32boolchar 等)提供了“复制”语义。这些类型被称为复制类型。对于复制类型,赋值操作会复制其比特位,而不是转移所有权,因此原变量仍然有效。一个类型要成为复制类型,其所有内部字段也必须是复制类型。

尽管所有权系统能有效防止内存错误(如双重释放、使用已释放内存),但在实践中,它有时会带来不便。例如,当我们想对一个值(如一个图像)进行多次操作时,每次将其传入函数都会导致所有权转移,之后便无法再次使用。常见的解决方法是让函数操作完毕后将所有权返回,但这使得代码显得冗长和繁琐。

共享可变性的危险

为什么Rust要设计这样严格的所有权系统?核心原因是为了避免共享可变性带来的危险。

考虑多个线程同时操作同一个数据的情况:

  • 同时写入:如果多个线程同时修改同一数据,硬件层面的写入可能相互覆盖,导致数据损坏或产生任意值。
  • 同时读写:如果一个线程在读取数据的同时,另一个线程正在修改它,读取线程可能获得一个损坏的、不一致的值。

这些问题统称为数据竞争,是并发编程中常见且危险的错误来源。另一个经典例子是C++中的迭代器失效问题:在遍历一个容器(如向量)时,如果另一个操作修改了该容器(例如添加元素导致重新分配内存),迭代器可能会指向已释放的内存,从而引发内存安全问题。

关键在于,当数据被共享且可被修改时,危险就出现了。Rust的设计哲学正是基于此:共享与可变性不可兼得

Rust的借用机制

Rust通过借用机制来贯彻“共享 XOR 可变”的原则。借用允许你临时访问一个值,而无需获取其所有权。借用分为两种:

  1. 共享借用:允许你读取数据,但不能修改。可以同时存在多个共享借用。
  2. 独占借用:允许你修改数据,但在借用期间,不能有任何其他借用(无论是共享还是独占)存在。

这个决定是临时性的。对于一个值,你可以在不同时期切换是共享它还是独占它,但在任何特定时刻,编译器必须能明确知道是哪种模式。

共享借用示例

假设我们想编写一个函数来计算字符串的字符数,我们不需要修改字符串,也不需要获取其所有权。这时应该使用共享借用。

fn string_chars_len(s: &String) -> usize {
    s.chars().count()
}

函数签名 &String 表示接受一个对 String 的共享借用。调用时,我们使用 & 操作符来获取值的引用:

let my_string = String::from("hello");
let len = string_chars_len(&my_string); // 传递共享借用
// my_string 仍然有效,所有权未转移

共享借用的特点:

  • 超级能力:可以创建任意多个,并且是复制类型,传递成本低。
  • 限制:不能通过它修改数据,并且在共享借用存在期间,不能创建独占借用。

独占借用示例

现在假设我们想编写一个函数来将字符串中的ASCII字符转为大写并添加感叹号,我们需要修改字符串,但仍希望调用者保留所有权。这时应该使用独占借用。

fn emphasize(s: &mut String) {
    s.make_ascii_uppercase();
    s.push_str("!!!");
}

函数签名 &mut String 表示接受一个对 String 的独占借用。调用时,变量本身必须声明为 mut,并且使用 &mut 操作符:

let mut my_string = String::from("hello");
emphasize(&mut my_string); // 传递独占借用

独占借用的特点:

  • 超级能力:可以修改数据,且传递成本低。
  • 限制:在独占借用存在期间,不能创建任何其他借用(无论是共享还是独占)。

编译器会在编译时严格检查这些规则。如果代码违反了“共享 XOR 可变”原则,程序将无法通过编译。这就在编译期杜绝了数据竞争和迭代器失效等内存安全问题。

悬垂引用

在C/C++中,一个常见的错误是返回一个指向局部变量的指针(悬垂指针)。在Rust中,编译器通过生命周期检查来防止悬垂引用。如果你尝试返回一个对局部值的引用,编译器会报错,指出返回值“借用了一个值,但没有可供借用的所有者”。这确保了所有引用在编译时都是有效的。

切片简介

切片是对集合中一段连续元素的引用。它是一种特殊的借用,让你可以高效地操作数组或向量的一部分,而无需复制数据。

考虑数组和向量:

let array: [i32; 5] = [1, 2, 3, 4, 5]; // 栈上数组
let vec: Vec<i32> = vec![1, 2, 3, 4, 5]; // 堆上向量

你可以分别借用它们:&[i32; 5]&Vec<i32>。但是,对于许多操作(特别是只读操作),我们更希望有一种统一的类型来表示“一段i32序列的视图”。这就是切片 &[i32]

字符串切片 &str 也是同理,它是对 String 或字符串字面量中部分字节的引用。切片让函数能够更通用地处理不同来源的连续数据。

总结

本节课我们一起学习了Rust的借用机制,这是其内存安全的核心支柱之一。

  • 我们回顾了所有权系统及其带来的挑战。
  • 我们探讨了共享可变性的危险,如数据竞争和迭代器失效。
  • 我们深入学习了共享借用独占借用,理解了Rust如何通过“共享 XOR 可变”原则在编译期保证安全。
  • 我们看到了借用如何让代码更灵活(无需转移所有权)且高效(仅传递指针)。
  • 我们简要了解了切片的概念,它是对连续数据块的统一视图。

借用机制是Rust初学者需要克服的主要障碍之一,但一旦掌握,它将成为编写安全、高效并发代码的强大工具。记住,编译器是你的朋友,它的严格检查是为了帮助你避免运行时难以调试的严重错误。

006:生命周期与智能指针

在本节课中,我们将要学习Rust中两个核心且独特的概念:生命周期和智能指针。生命周期是Rust编译器用来跟踪引用有效期的系统,是保证内存安全的关键。智能指针则提供了对堆上数据的灵活管理方式。理解这些概念是掌握Rust所有权系统的关键一步。

所有权与借用回顾

上一节我们介绍了所有权和借用的核心规则,本节中我们来看看如何用一张表来总结这些概念。

对于任何非借用的类型 T(例如 String 或自定义结构体),其规则如下:

  • 拥有类型 (T)
    • 要求:有且仅有一个所有者。
    • 访问权限:拥有完整的读写和所有权转移权限。
  • 共享借用 (&T)
    • 要求:同一时间只能存在共享借用,不能与独占借用共存。
    • 访问权限:只读。不能修改值,也不能通过它获得所有权。
  • 独占借用 (&mut T)
    • 要求:同一时间只能存在一个独占借用,且不能与任何其他借用共存。
    • 访问权限:可读写,但依然不拥有该值。

一个关键点是,通过借用访问嵌套字段时,访问权限会传递。例如,如果你有一个 Food 结构体的共享借用,那么你只能获得其内部 String 字段的共享借用,而非所有权。

struct Food { s: String }
let f = Food { s: String::from("hello") };
let fb: &Food = &f; // fb 是 f 的共享借用
let s_ref: &String = &fb.s; // 正确:获得 String 的共享借用
// let s: String = fb.s; // 错误!试图从借用中获得所有权

Rust编译器会在你违反这些规则时给出明确的错误提示,这比在运行时发生崩溃要安全得多。

切片:统一访问连续数据

我们之前遇到了一个问题:编写一个函数时,是应该接收 &[i32; 5](数组的借用)还是 &Vec<i32>(向量的借用)?为了代码的通用性,Rust提供了切片

切片 [T] 是对一系列连续元素的引用视图。关键点在于,切片类型 [T] 本身是非固定大小类型。这意味着你不能直接将其用作变量类型或函数参数。

解决方案是增加一层间接性,通常通过引用实现:&[T]&mut [T]。一个 &[T] 在底层实际上包含一个指向数据的指针和一个表示长度的值。

切片的美妙之处在于,它可以很容易地从数组或向量创建,并且可以获取子序列。

let arr = [1, 2, 3, 4, 5];
let vec = vec![1, 2, 3, 4, 5];

// 创建整个数组/向量的切片
let slice_from_arr: &[i32] = &arr[..];
let slice_from_vec: &[i32] = &vec[..];

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/df16baaba13d0da1fc4ef9f4e463a28a_26.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/df16baaba13d0da1fc4ef9f4e463a28a_27.png)

// 创建子切片 (索引1到3,不包含4)
let sub_slice: &[i32] = &arr[1..4]; // 包含元素 [2, 3, 4]

字符串也有类似的对应关系:可增长的 String 类型对应只读的字符串切片 &str&str 也是一个指针加长度的组合,可以指向 String 的一部分或字符串字面量。

利用切片,我们可以编写一个更通用的“获取第一个单词”的函数:

fn first_word(s: &str) -> &str {
    for (i, &item) in s.as_bytes().iter().enumerate() {
        if item == b' ' {
            return &s[..i];
        }
    }
    &s[..]
}

这个函数接收 &str,并返回 &str,因此它可以处理 String 的借用、字符串字面量或任何字符串切片,非常灵活。

迭代器方法:into_iter, iter, iter_mut

在拥有借用概念后,我们可以更清晰地理解常见的三种迭代器方法:

  • into_iter(self):消耗集合本身,取得所有权。迭代产生的项是拥有类型 T
  • iter(&self):获取集合的共享借用。迭代产生的项是共享引用 &T
  • iter_mut(&mut self):获取集合的独占借用。迭代产生的项是独占引用 &mut T

选择哪种方法取决于你希望对原始集合和其元素进行何种操作。

生命周期:引用能活多久?

Rust编译器通过生命周期来跟踪所有引用的有效范围,防止出现悬垂引用。生命周期通常用像 'a'b 这样的符号标注。

每个引用都有一个生命周期,即它必须有效的代码区域。编译器(借用检查器)会检查所有生命周期的有效性。核心规则是:引用的生命周期不能超过其引用的数据的生命周期

{
    let r;                // ---------+-- 'a
    {                     //          |
        let x = 5;        // -+-- 'b |
        r = &x;           //  |       |
    }                     // -+       | // 错误:`x` 的生命周期 'b 太短
    println!("{}", r);    //          |
}                         // ---------+

在上面的例子中,引用 r(生命周期 'a)试图借用 x(生命周期 'b)。由于 'b 没有 outlive(存活得比) 'a 更长,代码无法编译。

函数中的生命周期注解

当函数返回一个引用时,编译器有时无法推断出返回引用的生命周期与哪个输入参数相关。这时就需要我们显式地添加生命周期注解

例如,一个返回较长字符串切片的函数:

fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() {
        x
    } else {
        y
    }
}

注解 <'a> 声明了一个生命周期参数。x: &'a stry: &'a str 表示这两个参数和返回的引用 &'a str 拥有相同的生命周期 'a。实际上,'a 会被具体化为 xy 生命周期中较短的那一个。这确保了返回的引用总是有效的。

生命周期注解是函数签名的一部分,用于告诉编译器不同参数与返回值之间的生命周期关系。在大多数情况下,编译器可以自动推断(生命周期省略),无需我们手动添加。

结构体与枚举中的生命周期

如果结构体或枚举的字段包含引用,那么该类型也必须被赋予一个生命周期参数,以表明该引用有效的时长。

struct StructWithBorrow<'a> {
    x: &'a str,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/df16baaba13d0da1fc4ef9f4e463a28a_74.png)

enum EnumWithBorrow<'a> {
    SomeVariant(&'a str),
}

这确保了 StructWithBorrow 的实例不会比它内部借用的 str 活得更久。

智能指针:拥有所有权的指针

有时,我们需要所有权,但又需要将数据存储在堆上,或者需要绕过编译时的大小限制。这时就需要智能指针

  • Box<T>:最简单的智能指针。它将类型 T 的值分配在堆上,而 Box 本身(一个指针)存储在栈上。当你拥有 Box<T>,你就拥有里面的 T。当 Box 离开作用域时,堆内存会被自动释放。
    • 用途:用于在堆上分配数据;创建递归类型;处理 trait 对象。

  • Rc<T>:引用计数指针。它允许数据有多个所有者。每次调用 Rc::clone 都会增加引用计数,每次 Rc 离开作用域则减少计数。当计数为零时,数据被清理。
    • 限制:只提供共享(不可变)访问。如果需要有多个所有者且需要可变性,需要配合内部可变性类型(如 RefCell)使用。
    • 注意Rc 不能用于多线程场景(线程安全版本是 Arc<T>)。

  • RefCell<T>:提供内部可变性。它允许你在只有不可变引用的情况下,也能修改其内部的值。这是通过在运行时强制执行借用规则来实现的:如果你试图在已有活跃可变引用时获取另一个可变引用,程序会 panic
    • 用途:当你确信代码逻辑是正确的,但 Rust 的编译时借用检查过于保守时使用。它将部分检查从编译时移到了运行时。

总结

本节课中我们一起学习了 Rust 中两个高级但至关重要的概念:生命周期和智能指针。

  • 生命周期是 Rust 保证引用安全的核心机制,它通过编译时检查确保引用不会变成悬垂引用。我们学习了如何阅读生命周期错误,以及如何在函数和结构体中注解生命周期以帮助编译器。
  • 切片 &[T]&str 是对连续数据的借用视图,它们统一了对数组、向量和字符串的只读或读写访问,提高了代码的通用性。
  • 智能指针(如 BoxRcRefCell)提供了对堆内存管理和特定所有权模式(如多所有权、内部可变性)的支持,是对 Rust 严格所有权系统的有力补充。

理解这些概念是掌握 Rust 内存安全模型的关键。虽然初期可能有些挑战,但随着实践,你会逐渐欣赏它们带来的安全性和表达能力。

007:集合处理

在本节课中,我们将要学习Rust中的模式匹配、可选值处理以及集合类型(如向量、字符串和哈希映射)的基本操作。我们将通过对比不同编程语言的处理方式,来理解Rust设计哲学的优势。

模式匹配

上一节我们介绍了控制流,本节中我们来看看Rust中一个非常强大的特性:模式匹配。

模式试图描述数据的形状。例如,Option<T>类型的数据形状要么是Some(T),要么是None

我们可以使用match表达式来根据数据的形状执行不同的代码分支。match表达式是穷尽的,这意味着编译器会强制你处理所有可能的情况。

以下是一个匹配Option<i32>的例子:

let my_option: Option<i32> = Some(100);
match my_option {
    Some(value) => println!("值存在,它是 {}", value),
    None => println!("值是 None"),
}

在这个例子中,value是一个在匹配到Some分支时被绑定的变量。

模式可以嵌套,并且可以使用通配符_来匹配任何值但忽略它。match本身也是一个表达式,可以返回值。

以下是使用match作为表达式以及使用通配符的例子:

let expression_value = match my_option {
    Some(42) => 999, // 特殊处理值为42的情况
    Some(_) => 42,   // 匹配任何其他Some值
    None => 0,
};

if let语法是match的一个简洁变体,用于只关心一种模式匹配的情况。

可选值对比

现在,让我们看看不同编程语言如何处理“值可能不存在”的情况,并理解Rust的可选类型设计。

在许多语言(如C、Java、Go、Python)中,表示可选值的惯用方式通常依赖于可为空的指针或引用。这要求程序员手动检查值是否为“空”(null、nil、None等),否则可能在运行时导致错误(如空指针异常)。

Rust使用Option<T>枚举来明确表示可选性。一个Option<T>的值只能是Some(T)None,没有隐式的“空”状态。这通过类型系统将检查从运行时转移到了编译时。

考虑一个从字符串解析整数的函数。在C语言中,你可能需要检查多个输出参数和全局错误变量。在Rust中,函数可以简单地返回Result<i32, ParseIntError>Option<i32>,调用者必须通过match或类似方法显式处理成功和失败的情况。

这种设计的优势在于:

  1. 编译时保障:编译器确保你不会意外地使用一个可能不存在的值。
  2. 清晰的意图:函数签名明确说明了失败的可能性。
  3. 便于重构:如果函数从“总是成功”变为“可能失败”,需要改变返回类型,这会在所有调用点引发编译错误,迫使你更新处理逻辑。

Tony Hoare将空引用的发明称为“十亿美元的错误”,因为它导致了无数的错误和系统崩溃。Rust的可选类型设计旨在避免这类问题。

当然,为了快速原型开发,Rust也提供了.unwrap().expect()等方法,它们会在值为None时使程序崩溃。这些方法就像危险操作的明显标记,当你希望将原型代码强化为生产代码时,可以轻松地找到并替换它们。

集合:向量(Vec)

接下来,我们开始学习Rust中最常用的集合类型。首先从向量(Vec)开始,它是一种可增长的数据列表。

以下是向量的基本操作:

let mut v = vec![1, 2, 3]; // 使用宏创建向量
v.push(4);
v.push(5);
println!("向量是: {:?}", v); // 调试打印

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_28.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_30.png)

// 索引访问(可能恐慌)
println!("第一个元素: {}", v[0]);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_32.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_34.png)

// 安全索引访问
println!("安全访问第五个元素: {:?}", v.get(4)); // Some(5)
println!("安全访问第十个元素: {:?}", v.get(9)); // None

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_36.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_38.png)

// 迭代
for num in &v { // 注意使用引用,避免所有权转移
    println!("{}", num);
}

让我们实现一个计算向量平均值的函数。需要注意处理空向量和整数除法的问题。

fn find_mean(numbers: &Vec<i32>) -> Option<f64> {
    if numbers.is_empty() {
        return None;
    }
    let sum: i32 = numbers.iter().sum();
    let length = numbers.len() as f64;
    Some(sum as f64 / length)
}

接着实现一个查找中位数的函数。中位数是将列表排序后位于中间的值。如果列表长度为偶数,则是中间两个数的平均值。

fn find_median(mut numbers: Vec<i32>) -> Option<f64> {
    if numbers.is_empty() {
        return None;
    }
    numbers.sort(); // 原地排序
    let len = numbers.len();
    let middle_index = len / 2;

    if len % 2 == 0 { // 偶数长度
        let lower_middle = numbers[middle_index - 1] as f64;
        let upper_middle = numbers[middle_index] as f64;
        Some((lower_middle + upper_middle) / 2.0)
    } else { // 奇数长度
        Some(numbers[middle_index] as f64)
    }
}

集合:字符串(String)

Rust中的字符串是UTF-8编码的,可以安全地处理各种语言的字符和表情符号。主要涉及两种类型:String(拥有所有权的字符串)和&str(字符串切片,通常是借用视图)。

以下是一些基本操作:

let s1 = "hello"; // &str
let s2 = String::from("world"); // String
let s3 = s1.to_string(); // &str 转 String

// 拼接
let combined = s2 + " " + s1; // 注意:s2的所有权被移动

让我们实现一个统计字符串中单词数量的函数,展示函数式编程风格的简洁性。

fn word_count(text: &str) -> usize {
    text.split_whitespace().count()
}

split_whitespace()方法返回一个迭代器,按任意空白字符分割字符串,count()方法计算迭代器中的元素数量。

集合:哈希映射(HashMap)

哈希映射存储键值对。要使用自定义类型作为键,该类型必须实现EqHash trait,通常可以通过#[derive(PartialEq, Eq, Hash)]自动派生。

以下是哈希映射的基本用法:

use std::collections::HashMap;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_73.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/3b63af0e858d6dc6e92f83ad721cef04_75.png)

#[derive(PartialEq, Eq, Hash)] // 使Student可作为键
struct Student {
    name: String,
    zid: u32,
}

let mut reviews = HashMap::new();
reviews.insert("Adventures of Huckleberry Finn", "My favorite book.");
reviews.insert("Grimms' Fairy Tales", "Masterpiece.");

// 检查键是否存在
if !reviews.contains_key("Les Misérables") {
    println!("We have {} reviews, but Les Misérables ain't one.", reviews.len());
}

// 获取值(返回Option)
let book_review = reviews.get("Grimms' Fairy Tales");

// 遍历
for (book, review) in &reviews {
    println!("{}: {}", book, review);
}

总结

本节课中我们一起学习了Rust的核心概念之一——模式匹配,它允许我们根据数据的形状安全地解构和分支代码。我们对比了Rust的Option类型与其他语言中处理可选值的方式,理解了其通过类型系统在编译期保障安全的优势。最后,我们初步探索了三种常用的集合类型:向量Vec、字符串String和哈希映射HashMap,并练习了它们的基本操作。在接下来的课程中,我们将更深入地学习迭代器和函数式编程技巧,以便更高效地处理集合数据。

008:模块与测试 📦🧪

概述

在本节课中,我们将要学习Rust中关于代码组织和验证的两个核心概念:模块系统测试。我们将了解如何将代码拆分为多个文件和模块,如何控制代码的可见性,以及如何为代码编写单元测试和文档测试,以确保其正确性。


模块化与可见性

上一节我们介绍了Rust的基本语法和所有权概念,本节中我们来看看如何组织和管理大型项目。

Rust的模块系统允许你将代码分割到不同的文件和目录中,这有助于管理大型项目。一个cargo项目(package)可以包含多个crate(可编译单元)。最常见的两种crate是:

  • 二进制crate (src/main.rs):生成可执行文件。
  • 库crate (src/lib.rs):生成供其他crate使用的代码库。

将核心逻辑放在库crate中,让二进制crate只负责调用,是一种非常常见且地道的做法,它提高了代码的复用性。

定义与使用模块

模块是代码组织的核心单元。你可以使用mod关键字来定义一个模块。

内联模块:直接在文件中用花括号定义。

pub mod inline_module {
    pub fn foo() {
        println!("Food");
    }
}

文件模块:通过mod module_name;声明,Rust会在同级目录下寻找module_name.rs文件作为模块内容。

目录模块:通过mod folder_name;声明,Rust会在folder_name/目录下寻找mod.rs文件作为模块入口。

要从其他模块或crate中访问模块内的项(函数、结构体等),需要使用use关键字将其引入作用域,或者使用完整路径。

// 使用完整路径
let result = my_crate::inline_module::foo();

// 使用use引入作用域
use my_crate::inline_module::foo;
let result = foo();

可见性修饰符

Rust中所有项默认是私有的。使用pub关键字可以控制其可见范围。

以下是主要的可见性修饰符:

  • pub: 完全公开,可以从任何地方访问。
  • pub(crate): 仅在当前crate内可见。
  • pub(in path): 仅在指定路径的模块内可见(例如 pub(in my_crate::some_module))。
  • pub(super): 仅在父模块中可见。
  • (无修饰符)或 pub(self): 仅在当前模块内可见(私有)。

使用 pub use 进行重导出

pub use 允许你将一个模块内部的项引入当前模块的作用域,并同时将其公开。这常用于创建一个清晰的公共API,隐藏内部复杂的模块结构。

// 在 src/lib.rs 中
pub mod complex {
    pub mod internal {
        pub fn useful_function() {}
    }
}
// 重导出,用户可以直接使用 `my_crate::useful_function`
pub use complex::internal::useful_function;

单元测试

上一节我们学习了如何组织代码,本节中我们来看看如何确保这些代码的正确性。

Rust对测试提供了原生支持。单元测试通常与它们要测试的代码放在同一个文件中。

编写测试

使用 #[test] 属性标记一个函数,它就会成为一个测试函数。在测试函数内部,使用 assert!assert_eq!assert_ne! 等宏来验证代码行为。

pub fn add_two(a: i32, b: i32) -> i32 {
    a + b
}

#[test]
fn test_add_two() {
    assert_eq!(add_two(2, 3), 5);
    assert_eq!(add_two(5, 10), 15);
}

运行测试

使用 cargo test 命令运行所有测试。你可以通过参数来过滤测试:

  • cargo test test_add_two:运行单个测试。
  • cargo test --lib:只运行库crate的单元测试。

测试组织与 should_panic

通常,我们会将测试集中在一个专门的模块中,并使用 #[cfg(test)] 属性来确保测试代码只在运行测试时被编译。

#[cfg(test)]
mod tests {
    use super::*; // 引入外部模块的所有项

    #[test]
    fn it_works() {
        assert_eq!(add_two(2, 2), 4);
    }

    #[test]
    #[should_panic] // 这个测试应该触发panic
    fn this_panics() {
        panic!("This test is supposed to fail!");
    }
}

集成测试

集成测试位于项目根目录的 tests/ 文件夹中,它们将你的库作为一个外部crate来测试,主要用于测试公共API。每个文件都是一个独立的crate。


文档与文档测试 📝

上一节我们介绍了验证代码逻辑的单元测试,本节中我们来看看另一种特殊的测试——文档测试,它同时保证了文档示例的正确性。

Rust的文档注释使用三斜杠 ///,支持Markdown格式。cargo doc 命令可以生成美观的HTML文档。

编写文档注释

/// 将两个数字相加。
///
/// # 示例
/// ```
/// use my_crate::add_two;
/// let sum = add_two(2, 3);
/// assert_eq!(sum, 5);
/// ```
pub fn add_two(a: i32, b: i32) -> i32 {
    a + b
}

文档测试

在文档注释的代码块中编写的示例,会被Rust自动识别为文档测试。当你运行 cargo test 时,这些示例也会被编译和执行,确保你的文档永远与代码同步。

你可以使用 # 开头的行在文档中隐藏一些辅助代码,这些代码在文档中不可见,但测试时会包含。

/// ```
/// # // 这行在生成的文档中会被隐藏
/// # use my_crate::setup_complex_system;
/// # let x = setup_complex_system();
/// let result = x.do_something();
/// assert!(result.is_ok());
/// ```

链接到其他项

在文档中,你可以使用 [...] 语法链接到其他类型或函数,Rustdoc会自动生成链接。

/// 这个函数和 [`Foo`] 类型一起使用效果更好。
/// 更多细节参见 [`crate::module::some_function`]。

强制文档

你可以在 lib.rs 顶部添加属性 #![warn(missing_docs)]#![deny(missing_docs)],让编译器对未文档化的公共项发出警告或直接报错,这有助于保持文档的完整性。


总结

本节课中我们一起学习了Rust项目组织的核心机制。

  1. 模块系统:我们了解了如何使用 modusepub 来拆分代码、管理作用域和构建清晰的API。
  2. 单元测试:我们掌握了如何使用 #[test] 编写测试,以及如何用 cargo test 运行它们,这是保证代码质量的基础。
  3. 文档与文档测试:我们学习了如何编写 /// 文档注释,并利用其内置的文档测试功能,确保示例代码永远正确,这极大地提升了库的可用性和可靠性。

将这些概念应用到你的项目中,尤其是第一个作业中,将帮助你构建出结构清晰、易于维护且值得信赖的Rust代码。

009:泛型与特质

概述

在本节课中,我们将要学习Rust中的多态性,特别是泛型类型参数和特质。我们将了解如何编写适用于多种类型的通用代码,以及如何通过特质来约束这些类型的行为。课程内容将从简单的泛型函数开始,逐步深入到更复杂的通用容器和迭代器概念,并解释Rust实现泛型的底层机制——单态化。


泛型类型参数

上一节我们介绍了课程概述,本节中我们来看看泛型类型参数。

假设我们需要一个函数来找出两个值中的较小者。最初,我们可能会为每种类型(如 i32f32char)编写单独的函数。

fn smallest_i32(x: i32, y: i32) -> i32 {
    if x < y { x } else { y }
}

fn smallest_f32(x: f32, y: f32) -> f32 {
    if x < y { x } else { y }
}

fn smallest_char(x: char, y: char) -> char {
    if x < y { x } else { y }
}

这三个函数的逻辑完全相同,只是类型不同。为了避免代码重复,我们希望编写一个适用于任何类型的通用函数。

引入泛型

我们可以使用泛型类型参数 T 来定义一个通用函数。

fn smallest<T>(x: T, y: T) -> T {
    if x < y { x } else { y }
}

然而,这段代码无法编译。编译器会提示二元操作 < 不能应用于类型 T。这是因为 T 可以是任何类型,但 < 操作符并非对所有类型都有效。

约束类型参数

为了使函数正常工作,我们需要约束 T,确保它支持比较操作。在Rust中,这是通过特质(Trait)实现的。对于比较操作,标准库提供了 PartialOrd 特质。

以下是使用特质约束的两种等效语法:

  1. 在泛型参数声明中直接约束:
    fn smallest<T: PartialOrd>(x: T, y: T) -> T {
        if x < y { x } else { y }
    }
    
  2. 使用 where 子句(更清晰,尤其当约束复杂时):
    fn smallest<T>(x: T, y: T) -> T
    where
        T: PartialOrd,
    {
        if x < y { x } else { y }
    }
    

现在,smallest 函数可以用于任何实现了 PartialOrd 特质的类型,例如 i32f32char,甚至是我们自定义的结构体(如果为其派生或实现了 PartialOrd)。


使函数更通用

上一节我们介绍了如何为简单比较函数添加泛型,本节中我们来看看如何使其更加通用。

当前的 smallest 函数只接受两个参数。如果我们想找出任意数量值中的最小值呢?我们可以让它接受一个集合。

处理集合

首先,我们可能会想到接受一个 Vec<T>

fn smallest<T: PartialOrd>(xs: Vec<T>) -> Option<T> {
    let mut iter = xs.into_iter();
    let mut smallest = iter.next()?; // 如果迭代器为空,返回 None
    for item in iter {
        if item < smallest {
            smallest = item;
        }
    }
    Some(smallest)
}

这个函数返回 Option<T>,以优雅地处理空集合的情况。

泛化容器类型

然而,将参数类型固定为 Vec<T> 是“不必要的具体化”。用户可能拥有 LinkedList<T>、数组或 HashSet<T>。我们真正需要的约束是:参数可以被转换为一个能产出 T 类型元素的迭代器。

Rust 的 IntoIterator 特质正是为此而生。我们可以进一步泛化函数:

fn smallest<T, I>(xs: I) -> Option<T>
where
    T: PartialOrd,
    I: IntoIterator<Item = T>, // I 可以被转换为产出 T 的迭代器
{
    let mut iter = xs.into_iter();
    let mut smallest = iter.next()?;
    for item in iter {
        if item < smallest {
            smallest = item;
        }
    }
    Some(smallest)
}

我们还可以使用更简洁的 impl Trait 语法来简化签名,特别是当 I 的唯一约束就是 IntoIterator 时:

fn smallest<T>(xs: impl IntoIterator<Item = T>) -> Option<T>
where
    T: PartialOrd,
{
    // 函数体相同
}

现在,smallest 函数可以接受任何可迭代的集合,例如 VecLinkedList,甚至是对它们的引用,代码的通用性大大增强。


泛型在结构体和枚举中的应用

上一节我们看到了函数中的泛型,本节中我们来看看泛型如何应用于结构体和枚举。

泛型类型参数不仅可以用于函数,也可以用于定义结构体和枚举,使其能够容纳多种类型的数据。

泛型结构体

一个常见的例子是 Point 结构体,其坐标可以是任何类型。

struct Point<T> {
    x: T,
    y: T,
}

泛型枚举

标准库中的 OptionResult 枚举是泛型枚举的经典例子。

enum Option<T> {
    Some(T),
    None,
}

enum Result<T, E> {
    Ok(T),
    Err(E),
}

为泛型结构体实现方法

当为泛型结构体实现方法时,需要在 impl 块中声明泛型参数。

impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point { x, y }
    }
}

我们还可以为特定的类型约束实现方法。例如,只为实现了 Default 特质的 Point<T> 实现一个返回默认点的方法:

impl<T: Default> Point<T> {
    fn default() -> Self {
        Point {
            x: T::default(),
            y: T::default(),
        }
    }
}

这意味着,只有当 T 实现了 Default 特质时,Point<T> 才会拥有 default 方法。这种模式非常强大,可以根据类型的特性来提供不同的行为。


单态化:泛型在Rust中的实现

上一节我们了解了泛型的语法和应用,本节中我们来看看Rust是如何在底层实现泛型的。

Rust 通过一种称为 单态化 的技术来实现泛型。这与 C++ 的模板展开类似。

工作原理

当编译器遇到一个泛型函数调用时,例如 smallest(42, 100),它会执行以下步骤:

  1. 确定调用时使用的具体类型(这里是 i32)。
  2. 为这个具体类型生成一个全新的、非泛型的函数副本。这个过程就像是把泛型函数 fn smallest<T: PartialOrd>(x: T, y: T) -> T 中的 T 全部替换成 i32,得到 fn smallest_i32(x: i32, y: i32) -> i32
  3. 编译这个新生成的函数。

如果同一个泛型函数被用于 i32f64char,那么最终的可执行文件中将包含三个不同版本的函数。

单态化的优缺点

以下是单态化的主要权衡:

优点:

  • 性能优异: 生成的代码是专门针对具体类型优化的,编译器可以进行激进的内联和其他优化。运行时没有动态查找的开销。
  • 静态分派: 所有函数调用在编译时即已确定,效率极高。

缺点:

  • 代码膨胀: 如果泛型函数被用于许多不同的类型,会导致最终二进制文件体积增大。
  • 编译时间增长: 编译器需要为每种类型生成并编译单独的代码。
  • 必须知晓所有类型: 所有使用泛型代码的类型都必须在编译时已知。这对于插件系统或动态加载库的场景不友好。

正是因为这些缺点,Rust 也提供了另一种实现多态的方式——动态分派,它使用 dyn 关键字和特质对象。我们将在后续课程中详细讨论。


标准库中的常见特质

上一节我们探讨了泛型的实现机制,本节中我们快速浏览一些标准库中常见且有用的特质。

特质定义了类型可以共享的行为。标准库定义了许多核心特质,这有助于在整个 Rust 生态系统中建立一致性。

以下是几个关键特质的简介:

  • PartialOrd / Ord 用于比较和排序。PartialOrd 允许部分比较(例如浮点数有 NaN),Ord 则要求全序。
  • PartialEq / Eq 用于相等性比较。EqPartialEq 要求更严格,它要求相等关系满足自反性(a == a)。
  • Default 为类型提供一个合理的“默认值”。例如,i32 的默认值是 0String 的默认值是空字符串。
  • Display / Debug 用于格式化输出。Display 是面向用户的、友好的输出,而 Debug 是面向开发者的、详细的输出,通常可以通过 #[derive(Debug)] 自动派生。
  • Clone / Copy Clone 允许显式地创建值的副本(深拷贝)。Copy 是一个标记特质,表示类型可以通过简单的位拷贝进行复制(浅拷贝),赋值等操作不会转移所有权。
  • Iterator / IntoIterator 定义迭代行为。IntoIterator 指定了类型如何被转换为迭代器,而 Iterator 特质则定义了迭代器本身的行为(如 next 方法)。

理解这些特质及其用途,是编写高效、符合习惯的 Rust 代码的关键。


总结

本节课中我们一起学习了 Rust 多态性的核心概念:泛型与特质。

我们首先从编写重复的类型特定函数开始,引入了泛型类型参数 T 来创建通用函数。我们了解到,为了在泛型函数中使用特定操作(如比较),必须使用特质(如 PartialOrd)来约束类型参数。

接着,我们探索了如何使函数更加通用,不仅泛化元素类型 T,还泛化了容器类型,通过 IntoIterator 特质使其能接受任何可迭代集合。

我们还看到了泛型在结构体、枚举及其 impl 块中的应用,并了解了如何根据类型约束条件性地实现方法。

最后,我们深入了解了 Rust 实现泛型的底层机制——单态化,分析了其性能优势与潜在的代码膨胀代价,并简要介绍了标准库中一些至关重要的核心特质。

掌握泛型和特质是解锁 Rust 强大表达力和性能潜力的关键。在接下来的课程中,我们将继续学习动态分派和特质对象,以完成对 Rust 多态性体系的全面了解。

010:特质对象

概述

在本节课中,我们将要学习Rust中一个核心概念:特质对象。我们将从回顾泛型参数和特质的基础知识开始,然后深入探讨如何将特质用作动态类型,以及如何克服在此过程中遇到的各种限制。课程将涵盖特质对象的定义、使用场景、性能权衡以及如何实现自定义迭代器。


回顾:泛型参数与单态化

上一节我们介绍了泛型类型参数,本节中我们来看看它们是如何在编译时被处理的。

Rust编译器通过一个称为单态化的过程来处理泛型。这意味着对于每个被具体类型使用的泛型函数,编译器都会生成一个该类型的专用版本。

例如,对于一个泛型函数 smallest<T>,如果它在代码中被用于 i32f32char 类型,编译器会生成三个独立的函数:smallest_i32smallest_f32smallest_char

核心公式:

单态化:泛型代码 -> 针对每个具体类型的专用代码

这种方法的优点是生成的代码运行速度快,可以进行深度优化,是一种零成本抽象。缺点是可能会增加编译时间和最终二进制文件的大小。


探索标准库中的特质

在深入特质对象之前,让我们先熟悉一些Rust标准库中常见的特质。

ToStringDisplay 特质

ToString 特质用于将值转换为 String。它定义了一个方法:

pub trait ToString {
    fn to_string(&self) -> String;
}

许多类型都实现了它。有趣的是,标准库为所有实现了 Display 特质的类型自动提供ToString 的实现。

Display 特质类似于 Debug,但用于用户友好的输出。它使用格式化器,可以避免为最终输出分配大字符串。

Clone 特质

Clone 特质允许创建值的副本。它要求实现 clone 方法,并提供了一个默认的 clone_from 方法。

pub trait Clone {
    fn clone(&self) -> Self;
    fn clone_from(&mut self, source: &Self) { ... } // 默认实现
}

使用 #[derive(Clone)] 属性可以自动为结构体生成 Clone 实现,前提是所有字段都实现了 Clone

Add 特质

Add 特质重载了 + 运算符。它使用泛型参数来指定右操作数和返回值的类型。

pub trait Add<Rhs = Self> {
    type Output;
    fn add(self, rhs: Rhs) -> Self::Output;
}

你可以为自定义类型(如 Point)实现 Add 特质,使其支持加法运算。

Iterator 特质

Iterator 特质是Rust迭代功能的核心。它要求定义关联类型 Itemnext 方法。

pub trait Iterator {
    type Item;
    fn next(&mut self) -> Option<Self::Item>;
    // ... 许多有默认实现的方法,如 map, filter, collect
}

IntoIterator 特质用于可以转换为迭代器的类型(如 Vec),而 FromIterator 特质则允许从迭代器构建集合(如使用 collect)。


定义和使用自定义特质

现在,让我们通过一个例子来实践如何定义和使用自己的特质。

假设我们有两种动物:Sheep(羊)和 Cow(牛)。我们定义一个 Animal 特质来抽象它们的共同行为。

以下是特质定义和结构体:

pub trait Animal {
    fn name(&self) -> String;
    fn age(&self) -> u32;
    fn speak(&self) -> String;
    fn say_hello_to<A: Animal>(&self, other: &A) -> String;
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/00b06c43e58fb56fa7b5743bd1f63f91_53.png)

struct Sheep {
    name: String,
    age: u32,
    at_party: bool,
}

struct Cow {
    name: String,
    age: u32,
    angry: bool,
}

接着,我们为 SheepCow 实现 Animal 特质。每个实现都需要提供特质中所有方法的定义。

实现后,我们就可以在具体的 SheepCow 实例上调用特质方法了。需要注意的是,要调用特质方法,该特质必须在当前作用域内(通常通过 use 语句引入)。

我们还可以编写泛型函数,它接受任何实现了 Animal 特质的类型:

fn greet<A: Animal>(animal: &A) {
    println!("{} says {}", animal.name(), animal.speak());
}

特质对象的引入与挑战

上一节我们使用了泛型参数,但有时我们需要在运行时处理多种不同类型的值,并将它们视为同一种东西。例如,我们想要一个包含不同种类动物的 Vec

直觉上,我们可能想写 Vec<Animal>,但这会立即遇到问题。

问题1:大小未知

Rust需要知道类型的大小来分配内存。Animal 是一个特质,不是具体类型,它的大小在编译时无法确定(因为 SheepCow 的大小不同)。

解决方案:使用指针

我们可以将特质放在指针后面,指针的大小是固定的。常用的指针是 Box(拥有所有权)或引用 &(借用)。

let animals: Vec<Box<dyn Animal>> = vec![
    Box::new(sheep),
    Box::new(cow),
];

这里的 dyn Animal 就是一个特质对象,它表示“任何实现了 Animal 特质的类型”。

问题2:对象安全性

并非所有特质都可以用作特质对象。我们的 Animal 特质目前就不行,因为 say_hello_to 方法包含泛型类型参数 A

编译器会报错:the trait Animal cannot be made into an object

为什么? 特质对象在运行时通过虚表查找方法。如果方法有泛型参数,就需要为每个可能的类型生成一个方法副本,这在运行时无法确定。

解决方案A:修改特质方法

say_hello_to 的参数也改为特质对象,移除泛型参数:

fn say_hello_to(&self, other: &dyn Animal) -> String;

解决方案B:使用 where Self: Sized 限定

如果某些方法不适用于特质对象,但你又不想把它们从特质中移除,可以使用 where Self: Sized 来限定。这意味着该方法只能在编译时知道具体类型的上下文中调用,而不能在特质对象上调用。

fn say_hello_to<A: Animal>(&self, other: &A) -> String
where
    Self: Sized;

这样,Animal 特质就可以用作特质对象了,但 say_hello_to 方法无法在 dyn Animal 上调用。

关于哪些特质和方法是“对象安全”的,具体规则可以参考Rust文档中的 object safety 章节。


性能权衡:单态化 vs 特质对象

现在我们已经看到了两种使用特质的方式,总结一下它们的权衡:

  • 泛型参数(单态化)

    • 编译时:为每个用到的类型生成专用代码,编译速度可能较慢,二进制文件可能较大。
    • 运行时:无动态分发开销,性能最优,可以进行内联等优化。
    • 适用场景:性能关键路径,类型在编译时已知。
  • 特质对象(动态分发)

    • 编译时:只生成一份代码,编译更快,二进制文件更小。
    • 运行时:通过虚表进行方法查找,有一次间接调用开销。
    • 适用场景:需要存储或处理多种不同类型的集合,类型在运行时才能确定。

实践:实现迭代器特质

最后,让我们通过实现 Iterator 特质来巩固所学知识。我们将实现两个迭代器:一个生成斐波那契数列,另一个迭代 Vec 的元素。

1. 斐波那契迭代器

struct Fibonacci {
    current: u32,
    next: u32,
}

impl Iterator for Fibonacci {
    type Item = u32;

    fn next(&mut self) -> Option<Self::Item> {
        let new_next = self.current + self.next;
        self.current = self.next;
        self.next = new_next;
        Some(self.current)
    }
}

这个迭代器是无限的,我们可以用 .take(n) 来获取前n项。

2. Vec迭代器

我们希望创建一个迭代器,可以迭代 Vec 的借用切片。

struct VecIter<'a, T> {
    vec: &'a Vec<T>,
    index: usize,
}

impl<'a, T> Iterator for VecIter<'a, T> {
    type Item = &'a T;

    fn next(&mut self) -> Option<Self::Item> {
        let item = self.vec.get(self.index)?; // 使用 ? 运算符在索引越界时返回 None
        self.index += 1;
        Some(item)
    }
}

这里我们使用了生命周期 'a 来确保迭代器返回的引用不会比 Vec 本身存活得更久。get 方法返回 Option<&T>? 运算符能很好地处理边界情况。


总结

本节课中我们一起学习了Rust中强大的特质对象概念。我们从回顾泛型和单态化开始,然后探索了标准库中的常见特质。我们定义了自己的 Animal 特质,并遇到了将其用作动态类型(特质对象)的挑战,即对象安全性和大小未知问题。我们探讨了使用 Box<dyn Trait>&dyn Trait 作为解决方案,并分析了泛型(单态化)与特质对象(动态分发)之间的性能权衡。最后,我们通过实现 FibonacciVecIter 迭代器,实践了 Iterator 特质的用法。掌握这些概念,你将能够更灵活地设计Rust程序,在静态安全性和运行时灵活性之间做出合适的选择。

011:宏与元编程 🦀

在本节课中,我们将要学习Rust中的宏与元编程。宏是一种强大的元编程工具,它允许我们编写生成代码的代码,从而提供便利性、模拟多态性,甚至扩展语言本身的语法。我们将从C语言中的宏开始,理解其基本概念和潜在问题,然后深入探讨Rust中声明式宏的编写方法,并最终了解过程宏的强大能力。

概述:什么是元编程?

元编程的核心是编写能够生成或操作其他代码的代码。在Rust中,宏是实现元编程的主要方式。宏本质上是一个函数,它接收代码(或称为令牌流)作为输入,并输出新的代码。

元编程主要有三个用途:

  1. 便利性:减少重复的样板代码。
  2. 模拟多态:在缺乏泛型的语言中,生成针对不同类型的多个函数版本。
  3. 语法扩展:创建新的、更清晰或更强大的语法结构。

上一节我们介绍了元编程的基本概念,本节中我们来看看宏在C语言中的具体表现。

C语言中的宏:基础与陷阱

在C语言中,宏通过预处理器实现。一个简单的宏可以用于定义常量或函数式代码替换。

简单的MAX

以下是一个求最大值的宏:

#define MAX(a, b) ((a) > (b) ? (a) : (b))

这个宏接收两个参数ab,并返回其中较大的一个。使用宏而非函数的好处在于:

  • 无函数调用开销:代码被直接内联替换。
  • 类型无关:可以用于任何支持>操作的类型(如int, float),而无需为每种类型编写单独的函数。

然而,C语言的宏只是进行简单的文本替换,这带来了许多陷阱。

宏的陷阱:运算符优先级与多次求值

陷阱1:运算符优先级
考虑一个将输入乘以7的宏:

#define TIMES_SEVEN(x) (x * 7)

如果调用TIMES_SEVEN(2 + 2),预处理器会将其展开为(2 + 2 * 7)。由于乘法优先级高于加法,结果是16而非预期的28。解决方法是为参数和整个表达式加上括号:

#define TIMES_SEVEN(x) ((x) * 7)

陷阱2:多次求值
对于MAX宏,如果参数是带有副作用的表达式(如函数调用):

int x = MAX(print_and_return(10), print_and_return(42));

宏展开后,print_and_return函数可能会被调用多次(具体次数取决于ab的比较结果),这通常不是我们期望的行为。函数则能保证每个参数只求值一次。

上一节我们看到了C宏的局限性,本节中我们来看看如何用Rust的宏更安全、更强大地实现类似功能。

Rust中的声明式宏

Rust的宏系统比C的预处理器更强大、更安全。我们主要学习“声明式宏”(也称为“宏示例”),它使用macro_rules!来定义。

工具:cargo expand

在深入之前,介绍一个有用的工具cargo expand。它可以展示宏展开后的代码,就像C语言的clang -E一样,是理解和调试宏的利器。

构建我们自己的vec!

Rust标准库中的vec!宏非常方便。让我们尝试自己实现一个简化版本。目标是能够这样调用:

let v = my_vec![1, 2, 3];

以下是实现步骤:

第一步:处理空向量

macro_rules! my_vec {
    () => {
        Vec::new()
    };
}

这条规则匹配空输入(),并展开为Vec::new()

第二步:处理单个元素
我们希望匹配一个表达式,并将其推入向量。

macro_rules! my_vec {
    () => {
        Vec::new()
    };
    ($item:expr) => {{
        let mut v = Vec::new();
        v.push($item);
        v
    }};
}
  • $item:expr:这是一个元变量$表示这是一个宏参数,item是其名称,:expr指定它必须是一个表达式(如42x + y、函数调用等)。
  • 展开代码块:我们使用双花括号{{ ... }}。外层的{}macro_rules!语法的一部分,内层的{}会生成一个块表达式,其中v作为最后一行被返回。

第三步:处理多个元素
我们需要匹配由逗号分隔的多个表达式。

macro_rules! my_vec {
    () => {
        Vec::new()
    };
    ($($item:expr),+ $(,)?) => {{
        let mut v = Vec::new();
        $(v.push($item);)+
        v
    }};
}
  • $(...),+:这是重复模式$()包裹要重复的模式,后面的,+表示“一个或多个,由逗号分隔”。
  • $(,)?:末尾的可选逗号。?表示“零个或一个”,这使得my_vec![1, 2, 3,](末尾带逗号)的写法也成为可能。
  • $(v.push($item);)+:在展开部分,我们使用相同的$(...)+语法来重复执行push操作。$item会依次对应每个传入的表达式。

现在,我们的my_vec!宏已经可以像标准库的vec!一样工作了!

上一节我们成功构建了一个实用的宏,本节中我们来看看如何用宏进行语法扩展。

语法扩展示例:cfor!

Rust没有C风格的三段式for循环。但我们可以用宏来实现它,展示语法扩展的能力。

我们希望实现这样的语法:

cfor!(let mut i = 0; i < 10; i += 1, {
    println!("{}", i);
});

实现如下:

macro_rules! cfor {
    ($init:stmt; $cond:expr; $step:stmt; $body:block) => {
        $init
        while $cond {
            $body
            $step
        }
    };
}
  • $init:stmt:匹配一个语句(如let mut i = 0)。
  • $cond:expr:匹配一个条件表达式。
  • $step:stmt:匹配一个步进语句。
  • $body:block:匹配一个代码块。
  • 展开逻辑:将C风格的for循环转换为等价的while循环。

这个宏虽然简单,但生动展示了如何将一种语言的语法引入另一种语言。

宏的练习与挑战

学习编写宏的最佳方式是实践。课程提供了名为 macrocarta 的练习集,它包含一系列由易到难的宏编写题目。

以下是练习的要点:

  • 课程中的每周练习对应macrocarta的第6题。
  • 第11题是一个挑战练习,涉及更复杂的概念。
  • 建议从头开始尝试macrocarta,以系统性地建立对宏的理解。

上一节我们探讨了声明式宏,本节中我们将一窥更强大的过程宏的世界。

过程宏的强大应用

声明式宏功能强大,但Rust还有另一类宏:过程宏。过程宏是作为Rust函数实现的,它接收TokenStream(令牌流)作为输入,进行任意复杂的计算和处理,然后输出新的TokenStream。这开启了无限的可能性。

以下是两个令人印象深刻的过程宏应用:

1. 前端Web开发:yew框架的html!

yew是一个用于创建WebAssembly前端应用的Rust框架。它的html!宏允许你在Rust代码中直接编写HTML:

html! {
    <div>
        <button onclick={|_| println!("Clicked!")}>
            {"Click me"}
        </button>
        <p>{"Hello, world!"}</p>
    </div>
}

这个宏不仅将HTML转换为Rust的虚拟DOM结构,还会在编译时检查HTML标签的匹配和有效性,将Rust的安全保证带入了前端开发。

2. 数据库查询:sqlx库的query!

sqlx是一个异步SQL数据库工具包。它的query!宏提供了编译时检查的SQL查询:

let record = sqlx::query!("SELECT * FROM users WHERE id = ?", user_id)
    .fetch_one(&pool)
    .await?;
println!("User: {} {}", record.first_name, record.last_name);

这个宏在编译时会:

  1. 连接到数据库(或使用离线模式)。
  2. 验证SQL语法是否正确。
  3. 验证查询中的列名和表名是否存在。
  4. 根据查询结果自动推断并生成对应的Rust结构体类型(如本例中的record)。
    这意味着类型错误(如访问不存在的字段)会在编译时被发现,而不是运行时。

总结

本节课中我们一起学习了Rust中的宏与元编程。

  • 我们从元编程的概念出发,理解了宏是用于生成代码的代码。
  • 我们回顾了C语言宏的便利性与陷阱,如运算符优先级和多次求值问题。
  • 我们深入学习了Rust的声明式宏,使用macro_rules!来定义,并通过构建my_vec!宏和cfor!宏,掌握了元变量、重复模式等核心语法。
  • 我们介绍了强大的过程宏,并看到了它们在yewsqlx等库中的革命性应用,它们如何将编译时检查和安全保障扩展到新的领域。

宏是Rust中一项强大而独特的特性。虽然需要谨慎使用,但它为减少重复代码、创建领域特定语言(DSL)以及构建极其可靠和高效的库提供了无可比拟的工具。

012:函数与闭包 🦀

在本节课中,我们将要学习Rust中函数与闭包的核心概念。我们将从基础的函数指针开始,逐步深入到闭包及其环境捕获机制,并探讨三种关键的函数特质(FnFnMutFnOnce)及其与所有权系统的深刻联系。最后,我们将通过重构一个自定义的map函数来应用这些知识。

函数指针

上一节我们介绍了课程概述,本节中我们来看看函数指针。函数指针允许我们将函数作为值来传递和使用,这是将函数视为“一等公民”的基础。

函数指针的类型语法如下:

fn(i32, i32) -> i32

这表示一个接收两个i32参数并返回一个i32的函数。

以下是如何使用函数指针的示例:

fn add(x: i32, y: i32) -> i32 {
    x + y
}

fn main() {
    let my_thing: fn(i32, i32) -> i32 = add;
    println!("2 + 2 = {}", my_thing(2, 2)); // 输出:2 + 2 = 4
}

闭包与环境捕获

上一节我们介绍了函数指针,本节中我们来看看闭包。闭包是函数指针的扩展,它可以捕获并访问其定义环境中的变量。

闭包使用||语法定义,其具体类型由编译器自动生成且无法显式命名。

let factor = 2.5;
let closure = |x: i32| -> f32 { x as f32 * std::f32::consts::PI * factor };

当闭包捕获环境变量时,它就不再是简单的函数指针。为了处理这种未知的具体类型,Rust提供了函数特质。

函数特质:FnFnMutFnOnce

上一节我们看到了闭包如何捕获环境,本节中我们来看看描述闭包行为的三种特质。这些特质定义了调用闭包所需的所有权权限。

以下是三种函数特质的核心区别:

  1. FnOnce: 闭包可以被调用一次。它可能获取其捕获变量的所有权(对应self)。
  2. FnMut: 闭包可以被多次调用,但不能并发调用。它可能可变地借用其捕获的变量(对应&mut self)。
  3. Fn: 闭包可以被多次且并发地调用。它只能不可变地借用其捕获的变量(对应`&self``)。

所有闭包都至少实现FnOnce。仅捕获不可变引用的闭包也实现Fn。捕获可变引用但不获取所有权的闭包实现FnMut

重构 my_map 函数

上一节我们理解了函数特质,本节中我们将应用这些知识来改进我们的my_map函数,使其能够接受闭包。

最初,我们的my_map函数签名使用函数指针,这限制了它只能使用不捕获环境的函数。

fn my_map<I, CurrentItem, NewItem>(iter: I, func: fn(CurrentItem) -> NewItem) -> MyMap<I, CurrentItem, NewItem>
where
    I: Iterator<Item = CurrentItem>,
{
    MyMap { iter, func }
}

为了支持闭包,我们需要使用泛型和特质绑定。我们将参数func的类型改为泛型F,并要求F实现FnMut特质(因为map需要多次调用该函数)。

fn my_map<I, F, NewItem>(iter: I, func: F) -> MyMap<I, F, NewItem>
where
    I: Iterator,
    F: FnMut(I::Item) -> NewItem, // 使用 FnMut 特质
{
    MyMap { iter, func }
}

对应的MyMap结构体和迭代器实现也需要更新泛型参数。

struct MyMap<I, F, NewItem> {
    iter: I,
    func: F,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/faca3d266a1f263ca20b61e3a71efe74_80.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/faca3d266a1f263ca20b61e3a71efe74_82.png)

impl<I, F, NewItem> Iterator for MyMap<I, F, NewItem>
where
    I: Iterator,
    F: FnMut(I::Item) -> NewItem, // 迭代时也需要 FnMut
{
    type Item = NewItem;

    fn next(&mut self) -> Option<Self::Item> {
        let current_item = self.iter.next()?;
        Some((self.func)(current_item)) // 调用闭包
    }
}

现在,我们的my_map可以处理可变捕获的闭包了。

let mut counter = 0;
let result: Vec<_> = my_map(vec![1, 2, 3].into_iter(), |x| {
    counter += 1; // 闭包可变地捕获了 counter
    x * counter
}).collect();
assert_eq!(result, vec![1, 4, 9]); // 1*1, 2*2, 3*3

API 设计考量

上一节我们成功使my_map支持了闭包,本节中我们来探讨一下API设计的最佳实践。选择使用哪种函数特质(FnOnceFnMutFn)是一种权衡。

选择特质的原则如下:

  • 尽可能使用限制最少的特质:这为调用者提供了最大的灵活性。例如,如果函数只需被调用一次,优先考虑FnOnce
  • 根据需求向右移动:如果API需要调用函数多次,则需使用FnMut。如果需要并发调用,则必须使用Fn
  • 平衡双方需求:API设计者希望给自己最大的灵活性(倾向于Fn),而API调用者希望有最大的表达能力(倾向于FnOnce)。需要在两者间找到平衡点。

对于我们的my_map,因为迭代器的next方法需要多次调用闭包,所以FnMut是正确的选择。

示例:计时函数

最后,我们来看一个使用FnOnce的实用示例:一个测量任何闭包执行时间的通用函数。

use std::time::{Instant, Duration};

fn time_closure<F, T>(closure: F) -> (T, Duration)
where
    F: FnOnce() -> T, // 只需调用一次,使用 FnOnce
{
    let start = Instant::now();
    let result = closure();
    let duration = start.elapsed();
    (result, duration)
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/faca3d266a1f263ca20b61e3a71efe74_127.png)

fn main() {
    let (answer, time_taken) = time_closure(|| {
        // 模拟一些工作
        std::thread::sleep(std::time::Duration::from_millis(100));
        42
    });
    println!("闭包返回了 {},耗时 {:?}", answer, time_taken);
}

总结

本节课中我们一起学习了Rust中函数与闭包的核心机制。我们从函数指针开始,了解了闭包如何捕获环境变量,并深入探讨了FnFnMutFnOnce三种函数特质及其与所有权系统(&self&mut selfself)的对应关系。我们应用这些知识重构了my_map函数,使其能够支持更灵活的闭包操作,并讨论了在API设计中如何权衡选择不同的函数特质。理解这些概念是掌握Rust函数式编程和后续学习并发编程的重要基础。

013:Rust并发编程

概述

在本节课中,我们将要学习Rust中的并发编程。我们将探讨并发的基本概念,理解为何并发编程容易出错,并学习Rust如何利用其类型系统在编译时防止常见的并发错误,例如数据竞争。我们将介绍线程、互斥锁、原子类型以及Rust特有的SendSync特质。

并发概念回顾

上一节我们介绍了本课程的目标。本节中,我们来看看并发的基本概念。

我们通常将代码视为顺序执行:程序从main函数开始,逐行运行,调用函数,再逐行运行。这是单线程程序的典型模型,一次只执行一个任务。

然而,这种模型存在局限性。考虑一个需要完成三个独立子任务的程序(例如,从不同网络源获取数据)。这些任务互不依赖,执行顺序无关紧要。在单线程模型中,CPU必须等待一个任务(尤其是涉及I/O的阻塞任务)完成后才能开始下一个,导致CPU在等待期间闲置。

并发提供了一种解决方案。其核心思想是:当一个任务因等待外部资源(如网络响应)而阻塞时,CPU可以保存当前任务状态,转而执行其他任务,从而提高整体效率。正如Rob Pike所言:“并发是关于同时处理许多事情,而并行是关于同时做许多事情。”我们主要关注并发。

阻塞函数(如读取用户输入或网络请求)会暂停当前线程的执行。在没有并发的情况下,程序无法在等待阻塞操作的同时执行其他工作。

线程:并发的基本单元

上一节我们了解了为何需要并发。本节中我们来看看实现并发的基本单元:线程。

线程是程序中独立的执行路径。在Rust中,可以使用std::thread::spawn创建新线程。每个线程运行一个闭包中定义的代码。

以下是使用线程处理阻塞操作的示例:

use std::thread;
use std::time::Duration;

fn main() {
    // 创建一个新线程来读取用户输入
    let handle = thread::spawn(|| {
        let mut input = String::new();
        std::io::stdin().read_line(&mut input).expect("Failed to read line");
        println!("Read line: {}", input.trim());
    });

    // 主线程继续执行,每秒打印一次
    for _ in 0..10 {
        println!("Hello there");
        thread::sleep(Duration::from_secs(1));
    }

    // 等待输入线程完成
    handle.join().unwrap();
}

通过创建新线程处理阻塞的read_line调用,主线程可以继续执行循环打印,实现了同时处理多个任务。

并发中的问题:数据竞争

上一节我们看到了如何使用线程实现并发。本节中我们来看看并发编程中一个经典且危险的问题:数据竞争。

当多个线程同时访问同一数据,且至少有一个线程进行写入操作时,就可能发生数据竞争,导致未定义行为。以下是一个在C、Java等语言中典型的错误示例(概念上):

// 注意:此Rust代码无法编译,旨在说明问题
static mut COUNTER: i32 = 0;

fn main() {
    let mut handles = vec![];
    for _ in 0..50 {
        let handle = thread::spawn(|| {
            for _ in 0..100000 {
                unsafe { COUNTER += 1; } // 多个线程同时修改,导致数据竞争
            }
        });
        handles.push(handle);
    }
    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", unsafe { COUNTER }); // 结果通常远小于 5,000,000
}

在允许数据竞争的语言中,上述程序的最终计数往往远小于预期值(50 * 100,000 = 5,000,000),因为许多增加操作在并发执行时“丢失”了。

Rust的编译时防护

上一节我们看到了数据竞争的危害。本节中我们来看看Rust如何利用所有权系统在编译时防止此类错误。

尝试在Rust中直接编写数据竞争代码会遇到编译器错误。Rust的所有权规则(特别是可变引用的独占性)天然阻止了多线程下对数据的非同步修改。

例如,尝试将可变引用传递给多个线程:

fn main() {
    let mut counter = 0;
    let mut handles = vec![];

    for _ in 0..10 {
        // 错误:不能多次可变借用 `counter`
        let handle = thread::spawn(|| {
            counter += 1;
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }
    println!("Result: {}", counter);
}

编译器会报错:“cannot borrow counter as mutable more than once at a time”。这是因为thread::spawn要求闭包满足'static生命周期,并且其捕获的变量必须是Send的。一个局部变量的可变引用无法安全地满足这些要求,因此编译器拒绝此代码。

Send 与 Sync 特质

上一节我们了解到Rust编译器阻止了不安全的共享。本节中我们来看看定义线程安全性的两个核心标记特质:SendSync

它们是自动特质(auto traits),编译器会根据类型的构成自动判断是否实现。

  • Send:表示类型的所有权可以安全地跨线程传递(移动)。绝大多数类型是Send的。像Rc<T>这样的非原子引用计数类型就不是Send的,因为它不是线程安全的。
  • Sync:表示类型的引用(&T)可以安全地跨线程共享。如果一个类型TSync的,那么&T就是Send的。基本类型(如i32)、不可变类型以及像Mutex<T>这样的同步原语都是Sync的。而像Cell<T>RefCell<T>这样的内部可变性类型(非线程安全版本)就不是Sync的。

thread::spawn要求闭包捕获的所有变量都是Send + 'static的。这确保了只有线程安全的数据才能被传递到新线程中。

使用互斥锁保护共享数据

上一节我们学习了SendSync特质。本节中我们来看看如何使用互斥锁来安全地实现多线程间的数据共享。

互斥锁(Mutex, Mutual Exclusion)确保一次只有一个线程可以访问受保护的数据。Rust标准库提供了std::sync::Mutex。其设计巧妙之处在于,数据被封装在Mutex内部,访问数据必须先通过lock方法获取锁。锁返回一个MutexGuard,它提供了对内部数据的访问,并在其离开作用域被drop时自动释放锁,避免了忘记解锁的问题。

以下是使用Mutex修正计数问题的示例:

use std::sync::{Arc, Mutex};
use std::thread;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/9aedf169c5ff7cb375a8cc0c27b3efb2_24.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/9aedf169c5ff7cb375a8cc0c27b3efb2_26.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/9aedf169c5ff7cb375a8cc0c27b3efb2_27.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/9aedf169c5ff7cb375a8cc0c27b3efb2_29.png)

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = vec![];

    for _ in 0..50 {
        let counter = Arc::clone(&counter);
        let handle = thread::spawn(move || {
            for _ in 0..100000 {
                let mut num = counter.lock().unwrap();
                *num += 1;
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", *counter.lock().unwrap()); // 正确输出 5,000,000
}

这里使用了Arc(原子引用计数)来在多线程间共享Mutex的所有权。ArcRc的线程安全版本,实现了SendSync

更优雅的解决方案:作用域线程

上一节我们使用Arc<Mutex<T>>解决了问题。本节中我们来看看一种更简洁的模式:作用域线程。

Rust标准库提供了std::thread::scope,它可以创建“作用域线程”。这些线程被限定在一个作用域内,编译器能保证所有线程都会在作用域结束前被汇合(join)。因此,作用域线程可以安全地借用外部作用域的非'static数据,而无需使用Arc

使用作用域线程重写上面的例子:

use std::sync::Mutex;
use std::thread;

fn main() {
    let counter = Mutex::new(0);

    thread::scope(|s| {
        for _ in 0..50 {
            s.spawn(|| {
                for _ in 0..100000 {
                    let mut num = counter.lock().unwrap();
                    *num += 1;
                }
            });
        }
    }); // 所有在此作用域内生成的线程都会在这里被自动等待

    println!("Result: {}", *counter.lock().unwrap());
}

代码更加清晰,且无需担心所有权和引用计数。

高性能替代:原子类型

上一节我们介绍了互斥锁。本节中我们来看看另一种用于并发访问的、通常性能更高的工具:原子类型。

对于简单的整数类型,使用互斥锁可能开销较大。Rust在std::sync::atomic模块中提供了原子类型(如AtomicUsizeAtomicBool等)。原子类型通过特殊的CPU指令保证其操作的不可分割性,无需锁即可安全地进行并发读写。

使用原子类型实现计数器:

use std::sync::atomic::{AtomicUsize, Ordering};
use std::thread;

static COUNTER: AtomicUsize = AtomicUsize::new(0);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/9aedf169c5ff7cb375a8cc0c27b3efb2_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/9aedf169c5ff7cb375a8cc0c27b3efb2_47.png)

fn main() {
    let mut handles = vec![];

    for _ in 0..50 {
        let handle = thread::spawn(|| {
            for _ in 0..100000 {
                COUNTER.fetch_add(1, Ordering::SeqCst);
            }
        });
        handles.push(handle);
    }

    for handle in handles {
        handle.join().unwrap();
    }

    println!("Result: {}", COUNTER.load(Ordering::SeqCst)); // 正确输出 5,000,000
}

原子类型的操作(如fetch_add)通常比互斥锁快得多,适用于高性能场景。Ordering参数用于指定内存顺序,在大多数情况下使用Ordering::SeqCst(顺序一致性)是安全且简单的选择。

总结

本节课中我们一起学习了Rust并发编程的核心内容。

我们首先回顾了并发的基本概念及其价值。然后,我们探讨了使用线程实现并发的基本方法。接着,我们深入了解了并发编程中典型的数据竞争问题,并看到了Rust如何通过其严格的所有权和借用规则在编译时阻止此类错误。我们学习了SendSync这两个关键的标记特质,它们定义了类型的线程安全属性。

为了安全地共享数据,我们介绍了互斥锁(Mutex)的使用,它通过将数据与锁绑定,强制要求先加锁后访问,并利用RAII机制自动释放锁。我们还了解了使用Arc进行多线程所有权共享的模式。随后,我们看到了更简洁的“作用域线程”(thread::scope)模式,它允许安全地借用外部数据。最后,我们介绍了高性能的原子类型作为简单数据并发操作的替代方案。

Rust的“无畏并发”理念在于,它通过强大的类型系统,将许多常见的并发错误(如数据竞争、死锁的一部分)转变为编译时错误,使得开发者能够以更高的信心编写并发代码。

014:并发编程进阶 🚀

在本节课中,我们将继续深入学习Rust的并发编程。我们将探讨移动闭包、读写锁与锁中毒、通道以及Send/Sync特质等核心概念,并介绍强大的并行计算库Rayon。这些知识将帮助你更安全、高效地编写并发程序。


移动闭包

上一节我们介绍了线程和作用域线程。本节中,我们来看看一个在并发编程中非常实用的工具:移动闭包。

在Rust中,闭包默认会通过引用来捕获其环境中的变量。然而,当我们将闭包传递给新线程时,通常需要将变量的所有权也转移到新线程中,以确保其生命周期足够长。这时,就需要使用move关键字来创建移动闭包。

以下是一个常见场景的示例。我们尝试在一个新线程中打印一个字符串:

let my_string = "hello".to_string();
std::thread::spawn(|| {
    println!("{}", my_string);
}).join().unwrap();

这段代码无法编译。编译器会提示闭包可能比当前函数存活得更久,但它借用了my_string,而my_string由当前函数所有。

问题在于,闭包默认尝试通过引用来捕获my_string。对于std::thread::spawn,它要求闭包不借用任何外部数据,因为无法保证外部数据的生命周期。解决方案是使用移动闭包,强制将my_string的所有权移入闭包:

let my_string = "hello".to_string();
std::thread::spawn(move || {
    println!("{}", my_string);
}).join().unwrap();

添加move关键字后,代码成功编译。my_string的所有权被移入闭包对象,线程可以安全地使用它。

核心概念move关键字改变了闭包的捕获方式。它强制闭包通过值(即移动所有权)来捕获环境中的变量,而不是默认的引用捕获。这对于需要将数据所有权转移到新线程的场景至关重要。


读写锁与锁中毒

在学习了互斥锁之后,我们来看看一个更灵活的同步原语:读写锁,并了解一个与之相关的概念——锁中毒。

读写锁

std::sync::RwLock(读写锁)允许多个读取者同时访问数据,但最多只允许一个写入者。这与互斥锁(Mutex)形成对比,互斥锁在任何时候都只允许一个线程访问(无论是读还是写)。

读写锁的API提供了两种获取锁的方式:

  • read(): 获取一个读锁,返回RwLockReadGuard。你可以通过它获得数据的共享(不可变)引用。
  • write(): 获取一个写锁,返回RwLockWriteGuard。你可以通过它获得数据的独占(可变)引用。

当你的数据结构读多写少时,使用读写锁可以显著提高并发性能。

潜在风险:读写锁可能导致一种特殊的死锁。例如,如果一个线程已经持有一个读锁,然后尝试再次获取读锁,而此时恰好有另一个线程在等待获取写锁,某些锁的实现策略可能会导致所有线程都无法前进。在设计代码时,应尽量避免在持有锁的情况下再次尝试获取同一把锁。

锁中毒

标准库中的MutexRwLock都有一个特性叫做“中毒”。如果一个线程在持有锁时发生恐慌(panic),那么这个锁就会被标记为“中毒”。

此后,当其他线程尝试获取这个锁时,lock()read()write()方法将返回一个Result,其中包含PoisonError,而不是直接返回守卫。这是为了通知其他线程:之前持有锁的线程发生了异常,被保护的数据可能处于不一致的状态。

在许多应用程序代码中,如果发生恐慌,通常希望整个程序终止,因此可能不关心中毒处理。你可以简单地使用.unwrap().expect()来忽略中毒错误。但库的作者可以利用这个机制来实现更健壮的错误恢复。

注意:一些第三方库(如parking_lot)提供的互斥锁和读写锁默认没有中毒机制,API更简洁,但在某些特殊平台或场景下,标准库的实现可能更保守、兼容性更好。


通道

线程间共享数据的另一种强大模式是消息传递,而通道是实现这一模式的核心工具。

多生产者,单消费者通道

Rust标准库在std::sync::mpsc模块中提供了通道实现。mpsc代表“多生产者,单消费者”。你可以创建多个发送端,但只能有一个接收端。

创建一个通道会返回一个元组:(Sender<T>, Receiver<T>)

use std::sync::mpsc;

let (tx, rx) = mpsc::channel(); // 创建一个可以发送i32的通道

发送端Sender可以克隆,因此可以分发给多个线程。每个线程都可以调用send(value)方法向通道发送消息。如果接收端已销毁,send会返回错误。

接收端Receiver调用recv()方法会阻塞当前线程,直到从通道中接收到一个消息。如果所有发送端都关闭了,recv()会返回错误。

以下是一个简单示例:

use std::sync::mpsc;
use std::thread;

let (tx, rx) = mpsc::channel();

// 克隆发送端以供线程使用
let tx1 = tx.clone();
thread::spawn(move || {
    thread::sleep(std::time::Duration::from_secs(1));
    tx1.send("Hello from thread 1").unwrap();
});

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/271be3da1e0887f3f604182c6e4a8546_30.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/271be3da1e0887f3f604182c6e4a8546_32.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/271be3da1e0887f3f604182c6e4a8546_34.png)

thread::spawn(move || {
    thread::sleep(std::time::Duration::from_secs(2));
    tx.send("Hello from thread 2").unwrap();
});

// 主线程接收消息
println!("{}", rx.recv().unwrap()); // 来自线程1
println!("{}", rx.recv().unwrap()); // 来自线程2

有界通道mpsc::sync_channel(n)可以创建有界通道,其内部缓冲区大小为n。当缓冲区满时,send操作会阻塞,直到有空间为止。这可以防止生产者生产速度远快于消费者处理速度而导致内存无限增长。将n设为0会创建一个“交会通道”,发送和接收操作必须同步发生。

多消费者通道:标准库只提供单消费者通道。如果需要多消费者,可以使用第三方库,如crossbeam,它提供了crossbeam::channel,其接收端也是可克隆的。

通道是实现线程间通信、解耦生产者和消费者的优雅方式,在构建如聊天服务器等系统时非常有用。


Send与Sync特质深入

我们之前介绍了SendSync这两个标记特质。现在我们来更深入地理解它们,特别是为什么某些类型不实现它们,以及标准库中的同步原语如何依赖它们。

  • Send: 表示类型的所有权可以安全地跨线程传递。
  • Sync: 表示类型的不可变引用可以安全地跨线程共享。

编译器会根据类型的内部结构自动推导这些特质。例如,包含Mutex的类型通常是SendSync的,因为Mutex内部提供了必要的同步机制。

为什么某些类型不是Send/Sync
考虑一个包含原始指针的类型。如果它被标记为Send,那么将其移动到另一个线程可能会导致数据竞争,因为原始指针没有Rust所有权系统的保护。因此,包含原始指针的类型通常不会自动实现Send

标准库同步原语的约束
查看Mutex<T>的文档,你会发现它的SendSync实现是有条件的:

  • Mutex<T>Send的,当且仅当TSend的。
  • Mutex<T>Sync的,当且仅当TSend的。

为什么需要这个约束?假设Mutex<T>无条件地是Send,即使T不是Send。那么,我们可以通过以下步骤破坏安全性:

  1. 在主线程创建Mutex<NotSendType>
  2. 将互斥锁的引用传递给另一个线程。
  3. 在另一个线程中锁定互斥锁,获得一个&mut NotSendType
  4. 在同一线程中创建一个本地的NotSendType,并获得其&mut引用。
  5. 使用std::mem::swap交换主线程和本线程的NotSendType值。

这样,我们就成功地将一个非Send类型的值“发送”到了另一个线程,绕过了Rust的类型安全保证。因此,Mutex<T>必须要求内部的TSend的,才能保证整个MutexSendSync的。

这个设计体现了Rust“安全抽象”的理念:即使底层使用了不安全代码,公开的API也必须通过类型系统保证安全。


Rayon:优雅的并行计算 ✨

最后,我们来看一个能极大简化并行编程的库:Rayon。Rayon是一个数据并行库,可以轻松地将顺序计算转换为并行计算。

Rust的标准迭代器是顺序执行的。Rayon提供了几乎完全相同的API,但它是并行的。你只需要将普通的.iter().into_iter()替换为.par_iter().into_par_iter(),Rayon就会自动利用所有可用的CPU核心来并行处理数据。

示例:并行求和

use rayon::prelude::*; // 引入必要的特质

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/271be3da1e0887f3f604182c6e4a8546_51.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/271be3da1e0887f3f604182c6e4a8546_53.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/271be3da1e0887f3f604182c6e4a8546_55.png)

fn main() {
    let sum: f64 = (1..=10_000_000_000u64) // 100亿个数
        .into_par_iter() // 改为并行迭代器
        .map(|x| x as f64) // 并行映射
        .sum(); // 并行求和

    println!("Sum: {}", sum);
}

仅仅将into_iter()改为into_par_iter(),程序就从单核顺序执行变成了多核并行执行,速度得到极大提升。Rayon在背后自动处理了工作窃取、任务分割等复杂细节。

安全性:Rayon的并行迭代器方法(如map)要求传入的闭包是Fn,而不是FnMut。因为Fn闭包可以被多次同时调用而不会产生冲突,这由Rust的类型系统保证,从而确保了并行操作的安全性。

Rayon的存在充分展示了Rust语言和类型系统的强大:它允许在库的层面实现如此高级、安全且易用的并行抽象。


总结

本节课中我们一起学习了Rust并发编程的几个高级主题:

  1. 移动闭包:使用move关键字将变量所有权移入闭包,以满足线程生命周期的要求。
  2. 读写锁与中毒RwLock提供了更灵活的并发读取控制;标准库的锁在持有线程panic时会“中毒”,以警示数据可能不一致。
  3. 通道mpsc通道实现了多生产者-单消费者的消息传递模式,是线程间通信的利器。
  4. Send与Sync:深入理解了这些自动特质的意义,以及同步原语如何依赖它们来构建安全抽象。
  5. Rayon:通过近乎零成本的API改变,将顺序迭代计算自动并行化,极大地提升了计算密集型任务的性能。

掌握这些工具和概念,你将能够更自如地应对各种并发编程场景,编写出既安全又高效的Rust程序。

015:Unsafe与内存安全 🛡️

在本节课中,我们将要学习Rust中的unsafe代码以及内存安全的概念。我们将探讨为什么需要unsafe,它能做什么,以及如何安全地使用它来构建底层抽象。

概述

到目前为止,我们在课程中使用的所有Rust代码都属于“安全Rust”(Safe Rust)的范畴。这意味着我们从未使用过unsafe这个关键字。安全Rust是一种内存安全的语言,它通过编译器的严格检查,帮助我们避免了诸如读取未初始化数据、悬垂指针、段错误、双重释放、释放后使用以及缓冲区溢出等严重的内存问题。

然而,有时我们需要进行系统级编程,需要更底层的控制,例如直接操作内存地址和指针。这时,我们就需要进入“不安全Rust”(Unsafe Rust)的领域。本节课我们将探讨安全与不安全Rust之间的关系,以及如何在不破坏Rust安全保证的前提下,使用unsafe来构建强大的底层抽象。

内存安全的重要性

内存安全意味着程序不会出现上述那些危险的内存错误。大多数现代高级语言(如Python、Java、JavaScript)在默认情况下都是内存安全的,或者通过运行时(如垃圾回收器)来管理内存安全。

像C和C++这样的低级语言本身并不保证内存安全。一个正确编写的C/C++程序可以是内存安全的,但语言本身将确保正确的责任交给了程序员。如果程序员犯错,编译器可能无法识别,从而导致程序编译通过但运行时出现未定义行为(Undefined Behavior)或内存安全问题。

我们可以用一个简单的图示来理解不同语言在“正确性”和“可编译性”上的权衡:

  • 理想情况:所有正确的程序都能编译,所有不正确的程序都不能编译。
  • Rust的取舍:Rust编译器非常严格,它会拒绝大量它认为可能不安全的程序(包括一些实际上正确的程序),以确保所有能编译的程序都是内存安全的。
  • C/C++的取舍:C/C++编译器相对宽松,会编译更多的程序(包括一些实际上不正确的程序),将验证正确性的责任留给了程序员。

对于大多数应用开发,安全Rust的严格性是优点。但对于系统编程(如编写操作系统内核、嵌入式设备驱动、高性能数据结构),有时需要绕过编译器的安全检查来直接操作内存,这就是unsafe存在的意义。

引入Unsafe Rust

Rust通过unsafe关键字来提供底层操作能力。关键理念是:不安全Rust是安全Rust的超集。任何有效的安全Rust代码在unsafe块中同样有效,但unsafe允许你执行一些额外的、编译器无法自动验证安全性的操作。

unsafe的主要目标是:用不安全的实现来构建安全的抽象。例如,标准库中的Vec<T>String等类型,其内部实现使用了unsafe代码来高效地管理内存,但它们对外暴露的API是完全安全的,用户无法通过安全代码导致内存错误。

Unsafe允许的操作

unsafe关键字只允许你进行以下五种操作,它并不会“关闭借用检查器”:

  1. 解引用裸指针 (*const T, *mut T)
  2. 调用不安全函数
  3. 访问或修改可变静态变量
  4. 实现不安全trait
  5. 访问联合体(Union)的字段

其中,解引用裸指针是最核心的能力,它让你能直接读写内存地址。创建裸指针是安全的,但使用(解引用)它们必须在unsafe块中。

安全抽象与不安全实现

这是使用unsafe的核心模式。一个函数或模块可以对外提供安全的API,但在其内部使用unsafe代码来实现功能。开发者必须确保,无论用户如何使用这个安全API,都不会触发未定义行为。

例如,String::from_utf8_unchecked函数就是一个不安全函数。它接受一个字节切片,并假设它已经是有效的UTF-8编码,然后直接将其转换为String。如果传入无效字节,将破坏String类型的内存安全不变性,导致未定义行为。因此,这个函数被标记为unsafe,调用者必须在unsafe块中调用它,并自己负责确保传入的字节是有效的。

// 安全代码中无法调用
// let s = String::from_utf8_unchecked(invalid_bytes);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/c899e11dafb20a212ff9d2ea1c721164_58.png)

unsafe {
    // 调用者必须确保 bytes 是有效的 UTF-8
    let s = String::from_utf8_unchecked(valid_bytes);
}

与之对应的safe版本是String::from_utf8,它会检查字节有效性并返回Result,更安全但略有开销。

未定义行为与责任

Rust同样有“未定义行为”的概念。如果代码触发了未定义行为(例如,解引用空指针、数据竞争),那么编译器可以假设你的程序永远不会做这些事,并基于此进行激进的优化,这可能导致任何不可预测的结果。

在安全Rust中,编译器会确保你不会意外触发未定义行为。但在unsafe块中,避免未定义行为的责任转移到了程序员身上。编写unsafe代码时,你必须像C/C++程序员一样仔细推理,确保代码在所有情况下都是正确的。

一段unsafe代码如果不可能被外部的安全代码以任何方式导致未定义行为,则被称为“健全的”(Sound)。反之,则是“不健全的”(Unsound),这是严重的错误。

如何检查Unsafe代码的正确性

编写正确的unsafe代码非常困难。以下是Rust生态中用于辅助验证的重要工具:

  • Miri:一个Rust解释器,可以在执行时检测多种未定义行为(如越界访问、使用未初始化内存)。它运行缓慢,但非常适合在测试和CI中使用。
    cargo +nightly miri run
    cargo +nightly miri test
    

  • Sanitizers:一系列运行时检测工具,可以集成到编译过程中。
    • AddressSanitizer (ASan):检测内存错误(如缓冲区溢出、释放后使用)。
    • MemorySanitizer (MSan):检测未初始化内存读取。
    • ThreadSanitizer (TSan):检测数据竞争。
      使用它们通常需要通过-Z sanitizer标志并在nightly工具链下编译。

  • Loom:一个用于测试并发代码的库。它可以系统地遍历线程操作的所有可能交错顺序,帮助发现隐藏的数据竞争和死锁。
    #[test]
    fn test_concurrent_thing() {
        loom::model(|| {
            // 你的并发代码
        });
    }
    

总结

本节课我们一起学习了Rust中unsafe与内存安全的核心概念。

  • 我们回顾了内存安全的重要性以及Rust通过编译器严格检查来保障它的设计哲学。
  • 我们理解了在某些系统编程场景下,需要绕过这些检查来获得底层控制能力,因此Rust引入了unsafe关键字。
  • 我们明确了unsafe只允许进行五种特定操作,其核心思想是用不安全的实现构建安全的抽象
  • 我们认识到编写unsafe代码将避免未定义行为的责任交给了程序员,必须极其谨慎。
  • 最后,我们介绍了几种强大的工具(Miri, Sanitizers, Loom)来帮助验证unsafe代码的正确性。

unsafe是Rust强大能力的基石,它使得用Rust自身实现标准库成为可能,但也是一把需要小心使用的双刃剑。在下一节课中,我们将尝试动手实践,使用unsafe来构建一个简单的Vec类型。

016:Unsafe封装与FFI

概述

在本节课中,我们将学习Rust中两个高级且强大的主题:如何安全地封装unsafe代码来构建抽象,以及如何使用外部函数接口(FFI)来调用其他语言(如C)编写的库。我们将通过动手实践来理解这些概念,包括尝试构建一个简易的Vec类型,以及从Rust中调用C语言编写的libcurl库来获取网页内容。

构建简易的 MyVec<T>

上一节我们介绍了unsafe代码块和原始指针的基本概念。本节中,我们来看看如何利用这些知识,尝试构建一个我们自己的、简易版本的动态数组 MyVec<T>。我们的目标是理解标准库中Vec背后的核心思想,并亲身体验封装unsafe操作时需要注意的种种细节。

定义 MyVec 结构

首先,我们需要定义MyVec的数据结构。一个动态数组需要管理一块在堆上分配的内存,并跟踪其当前元素数量以及总容量。

struct MyVec<T> {
    pointer: *mut T,      // 指向堆上分配内存的原始指针
    size: usize,          // 当前存储的元素数量
    capacity: usize,      // 当前分配的内存可以容纳的元素数量
}

这里,pointer 是一个 *mut T 类型的原始指针,它指向我们为 T 类型元素分配的内存块。sizecapacity 分别记录已用空间和总空间。

实现 new 方法

初始时,向量为空,没有分配任何内存。我们将指针设为空指针,并将大小和容量都设为0。

impl<T> MyVec<T> {
    pub fn new() -> Self {
        MyVec {
            pointer: std::ptr::null_mut(),
            size: 0,
            capacity: 0,
        }
    }
}

实现 push 方法

push 方法用于向向量末尾添加一个元素。在添加之前,我们需要确保有足够的空间。

以下是实现 push 方法的关键步骤:

  1. 检查容量:如果当前元素数量 size 等于容量 capacity,说明空间已满,需要扩容。
  2. 扩容:我们通过一个辅助函数 expand_capacity 来分配更多内存。
  3. 计算写入位置:使用 pointer_to_elem 辅助函数,根据当前 size 计算出新元素应该存放的内存地址。
  4. 写入数据:使用 std::ptr::write 将值写入计算出的地址。必须使用 write 而不是直接赋值(*ptr = value,因为直接赋值会尝试读取旧值并调用其析构器,而新分配的内存是未初始化的,这会导致未定义行为。
  5. 更新大小:成功写入后,将 size 加1。
pub fn push(&mut self, value: T) {
    if self.size == self.capacity {
        self.expand_capacity();
    }
    unsafe {
        let ptr = self.pointer_to_elem(self.size);
        std::ptr::write(ptr, value);
        self.size += 1;
    }
}

// 辅助函数:获取指向第 index 个元素的指针
unsafe fn pointer_to_elem(&self, index: usize) -> *mut T {
    // 安全条件:index 必须小于等于当前容量
    self.pointer.add(index)
}

// 辅助函数:扩容
fn expand_capacity(&mut self) {
    let new_capacity = if self.capacity == 0 {
        4 // 初始容量
    } else {
        self.capacity.checked_mul(2).expect("Capacity overflow")
    };

    let new_layout = std::alloc::Layout::array::<T>(new_capacity).unwrap();

    let new_ptr = if self.capacity == 0 {
        unsafe { std::alloc::alloc(new_layout) as *mut T }
    } else {
        let old_layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
        let old_ptr = self.pointer as *mut u8;
        unsafe { std::alloc::realloc(old_ptr, old_layout, new_layout.size()) as *mut T }
    };

    self.pointer = new_ptr;
    self.capacity = new_capacity;
}

实现 pop 方法

pop 方法移除并返回最后一个元素。如果向量为空,则返回 None

  1. 检查是否为空:如果 size 为0,返回 None
  2. 减小大小:将 size 减1,获取移除前最后一个元素的索引。
  3. 读取数据:使用 std::ptr::read 从该地址读取值。这会获取元素的所有权,但不会调用源地址的析构器(因为元素已被移出)。
  4. 返回值:将读取的值包装在 Some 中返回。
pub fn pop(&mut self) -> Option<T> {
    if self.size == 0 {
        return None;
    }
    self.size -= 1;
    let index = self.size;
    unsafe {
        let ptr = self.pointer_to_elem(index);
        Some(std::ptr::read(ptr))
    }
}

实现 get 方法

get 方法返回一个到索引处元素的不可变引用。如果索引越界,则返回 None

关键点get 应该返回一个引用(&T),而不是值(T)。因为调用者可能只是查看数据,而不想取得其所有权。多次调用 get 获得同一元素的多个副本在语义上是错误的,并且如果 T 没有实现 Copy,会导致问题。

  1. 检查索引:如果 index >= size,返回 None
  2. 获取指针并解引用:在 unsafe 块中获取指针,然后通过 &*ptr 将其转换为引用。这个引用的生命周期与传入的 &self 相关联。
pub fn get(&self, index: usize) -> Option<&T> {
    if index >= self.size {
        return None;
    }
    unsafe {
        let ptr = self.pointer_to_elem(index);
        Some(&*ptr)
    }
}

实现 Drop trait

MyVec 离开作用域时,我们需要释放其占用的堆内存,并确保所有存储的元素都被正确析构。

  1. 析构元素:遍历所有有效元素(0 到 size),使用 std::ptr::drop_in_place 显式调用每个元素的析构器。
  2. 释放内存:使用 std::alloc::dealloc 释放整个内存块。

impl<T> Drop for MyVec<T> {
    fn drop(&mut self) {
        if !self.pointer.is_null() {
            // 首先析构所有剩余元素
            for i in 0..self.size {
                unsafe {
                    std::ptr::drop_in_place(self.pointer_to_elem(i));
                }
            }
            // 然后释放内存
            let layout = std::alloc::Layout::array::<T>(self.capacity).unwrap();
            unsafe {
                std::alloc::dealloc(self.pointer as *mut u8, layout);
            }
        }
    }
}

封装 Unsafe 抽象

我们构建的 MyVec 内部包含 unsafe 代码,但它为外部提供了一个安全的接口(push, pop, get 都是安全的 pub 函数)。然而,为了维持内部不变式(invariant),我们必须将结构体的字段标记为私有(private)。如果 pointersizecapacity 是公有的,那么安全代码就可以随意修改它们,从而破坏我们 unsafe 代码所依赖的前提条件,导致未定义行为。

因此,最佳实践是将包含 unsafe 实现的模块或结构体的内部状态完全隐藏起来。

pub mod myvec {
    struct MyVec<T> { /* 私有字段 */ }
    impl<T> MyVec<T> {
        pub fn new() -> Self { ... }
        pub fn push(&mut self, value: T) { ... }
        // 其他公共安全接口
    }
}

外部函数接口(FFI)

上一节我们探讨了在Rust内部封装不安全操作。本节中,我们来看看如何跨越语言边界,使用外部函数接口(FFI)来调用其他语言编写的代码,这里以C语言为例。

FFI 允许 Rust 程序与 C 库在同一进程中交互,这非常强大但也非常危险,因为Rust的安全保证在跨越FFI边界后不再有效。

手动绑定到 C 库:以 libcurl 为例

我们将尝试从Rust调用著名的C网络库 libcurl 来获取一个网页。

首先,需要在Rust中声明C库提供的函数和类型。这通过 extern 块完成。

use std::ffi::CString;
use std::os::raw::{c_void, c_long, c_char};

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/f64999e9bb3be52c75cf9a34aa5850b1_41.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/f64999e9bb3be52c75cf9a34aa5850b1_43.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/f64999e9bb3be52c75cf9a34aa5850b1_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/f64999e9bb3be52c75cf9a34aa5850b1_47.png)

// 定义C库中使用的类型
type CURL = c_void;
enum CURLoption {
    CURLOPT_URL = 10002, // 来自 curl.h 的实际值
}
enum CURLcode {
    CURLE_OK = 0,
    // ... 其他代码
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/f64999e9bb3be52c75cf9a34aa5850b1_49.png)

// 声明外部C函数
#[link(name = "curl")]
extern "C" {
    fn curl_easy_init() -> *mut CURL;
    fn curl_easy_setopt(curl: *mut CURL, option: CURLoption, parameter: *const c_char) -> CURLcode;
    fn curl_easy_perform(curl: *mut CURL) -> CURLcode;
    fn curl_easy_cleanup(curl: *mut CURL);
}

#[link(name = "curl")] 属性告诉Rust链接器去寻找名为 libcurl 的库。

接下来,我们可以在一个 unsafe 块中调用这些函数:

fn fetch_url(url: &str) -> Result<(), CURLcode> {
    unsafe {
        let curl = curl_easy_init();
        if curl.is_null() {
            return Err(/* 错误处理 */);
        }

        let c_url = CString::new(url).unwrap();
        let url_param = c_url.as_ptr() as *const c_char;

        let setopt_result = curl_easy_setopt(curl, CURLoption::CURLOPT_URL, url_param);
        if setopt_result != CURLcode::CURLE_OK {
            curl_easy_cleanup(curl);
            return Err(setopt_result);
        }

        let perform_result = curl_easy_perform(curl);
        curl_easy_cleanup(curl);

        if perform_result == CURLcode::CURLE_OK {
            Ok(())
        } else {
            Err(perform_result)
        }
    }
}

使用工具简化 FFI

手动编写FFI绑定既繁琐又容易出错。幸运的是,有强大的工具可以自动化这个过程:

  • bindgen: 这是一个可以从C/C++头文件自动生成Rust FFI绑定代码的工具。你只需要提供头文件,它就会输出对应的 extern 块和类型定义。
  • 现成的绑定 Crate: 对于大多数流行的C库,Rust社区通常已经提供了维护良好的绑定crate。例如,对于 libcurl,有 curl-sys crate 提供了底层绑定,而 curl crate 则在 curl-sys 之上提供了更符合Rust习惯的、安全的抽象API。

使用这些工具或crate可以极大地提高开发效率并减少错误。

总结

本节课中我们一起学习了Rust中两个关键的高级主题。

首先,我们深入探讨了如何通过封装 unsafe 代码块来构建安全的抽象。我们以构建简易的 MyVec<T> 为例,实践了内存分配、扩容、数据读写、析构以及内存释放的全过程。我们特别强调了维持内部不变式的重要性(通过私有字段),以及在使用原始指针时必须注意的细微之处,例如使用 ptr::write 避免读取未初始化内存,以及正确地区分返回引用(get)和所有权(pop)。

其次,我们介绍了外部函数接口(FFI)。我们看到了如何通过 extern 块声明C函数和类型,并在 unsafe 块中调用它们,从而实现Rust与C库的交互。我们也了解了使用 bindgen 等自动化工具或社区维护的绑定crate可以极大地简化这一过程。

通过结合 unsafe 封装和 FFI,Rust 能够在不牺牲安全性的核心承诺的前提下,实现极致的性能控制并与庞大的现有C/C++生态系统无缝集成。

017:Rust异步编程

概述

在本节课中,我们将要学习Rust中的异步编程。我们将探讨异步编程的基本概念,了解Future特质如何作为并发任务的核心抽象,并比较其与传统线程模型的差异。本节内容旨在帮助你理解异步编程的原理,为后续编写高效、可组合的异步代码打下基础。

课程内容

课程管理与考试信息

作业1的评分已基本发布。大部分学生表现良好。作业2的截止日期是本周五下午5点。请确保按时提交。

第九周的练习题已发布,虽然代码量不大,但需要确保代码的正确性。第十周的练习是一次模拟考试,建议大家在考试条件下尝试完成,以熟悉最终考试的题型和难度。

最终考试将于12月6日星期二上午9点至12点在线进行。考试为开卷形式,允许查阅资料,但不允许在互联网上发布任何内容。考试内容更侧重于理论分析和观点论证,而非单纯的代码实现。

异步编程简介

异步编程的核心是处理多个任务同时进行,特别是那些需要等待外部操作(如网络请求)的任务,而非纯粹消耗CPU的计算任务。

例如,一个同步程序依次请求多个网站,总耗时是各个请求耗时的总和。而使用多线程,可以同时发起这些请求,总耗时仅取决于最慢的那个请求,从而显著提升性能。

然而,操作系统线程本身较为“重量级”,创建、切换和销毁都需要开销。对于大量I/O密集型任务,为每个任务创建一个线程可能造成资源浪费。

Future特质:任务的抽象

Rust通过Future特质提供了一个更通用的并发任务抽象。一个Future代表一个可能尚未完成的计算,它可以被轮询(poll),每次轮询会推动计算向前进展一点,或者告知调用者“尚未就绪,请稍后再试”。

Future特质的核心定义如下:

trait Future {
    type Output;
    fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
}

enum Poll<T> {
    Ready(T),
    Pending,
}

poll方法类似于迭代器的next方法。它返回Poll::Ready(T)表示任务完成并返回值,或返回Poll::Pending表示任务尚未完成。

我们可以将一个网络请求建模为一个Future。它包含几个状态:初始状态、已发送请求等待响应状态、请求完成状态。每次调用poll,状态机就向前推进一步。

Future与线程的对比

Future比线程更通用。一个Future可以用线程来实现(例如,在线程中执行阻塞调用,然后通过通道通知结果),但Future本身并不要求必须使用线程。

关键在于,Future允许我们将许多并发的、需要等待的任务复用到少数几个甚至一个操作系统线程上执行。这通过事件循环(如epollkqueue)等机制实现,操作系统可以通知应用程序某个I/O操作已就绪,从而唤醒对应的Future继续执行。

像Tokio这样的异步运行时,就提供了高效的执行器(Executor)来调度和运行大量的Future

异步编程的优势

使用Future和异步编程模型主要有以下优势:

  1. 资源高效:可以处理成千上万的并发连接,而无需创建等量的操作系统线程,极大地减少了内存和上下文切换开销。
  2. 可组合性Future是一个通用接口,使得组合不同的异步操作变得非常容易。可以使用join等待多个Future全部完成,或用select等待第一个完成的Future
  3. 灵活性:开发者可以选择在何时何地使用线程。CPU密集型任务仍然可以使用线程池,而I/O密集型任务则可以使用轻量级的Future

async/await 语法糖

手动编写Future的状态机非常繁琐。Rust提供了asyncawait语法来简化异步代码的编写。

一个用async标记的函数在编译时会返回一个实现了Future的类型。在函数内部,可以使用await来等待另一个Future的完成,编译器会自动生成相应的状态机代码。

例如,一个异步的获取网站函数可以这样写:

async fn get_website(url: &str, name: &str) -> String {
    println!("Sending request to {}", name);
    let response = reqwest::get(url).await.unwrap().text().await.unwrap();
    println!("Received response from {}", name);
    response
}

这段代码看起来和同步代码几乎一样清晰,但它是非阻塞的。

总结

本节课我们一起学习了Rust异步编程的基础。我们了解到,Future特质是Rust中表示异步计算的核心抽象,它比线程更轻量、更通用。通过将任务建模为状态机,并在就绪时被轮询推进,Future使得在少量线程上高效处理大量并发I/O操作成为可能。async/await语法进一步简化了异步代码的编写。理解这些概念是使用Tokio等异步运行时和构建高性能网络应用的关键。

018:Rust Async实战

在本节课中,我们将学习Rust异步编程的实际应用,特别是如何使用tokio运行时和rocket框架构建一个简单的Web服务。我们将创建一个类似Pastebin的文本分享服务,涵盖创建和检索文本片段的功能。


课程回顾

上一节我们介绍了Future特质,它是Rust中表示并发操作的抽象。与JavaScript的Promise不同,Rust的Future需要被主动轮询(poll)才能推进工作。这种设计体现了Rust对性能成本的显式控制理念。

本节中,我们将看看如何在实际项目中应用这些概念,而不必直接与底层的Future特质交互。


异步运行时:Tokio

在编写异步代码时,我们通常依赖一个执行器(Executor)来调度和推进Futuretokio是Rust生态中最流行的异步运行时之一。

  • 高效IO处理tokio针对IO密集型任务进行了高度优化。它使用像epoll(Linux)这样的系统调用,可以在单个或少量操作系统线程上高效地管理成千上万个并发连接。
  • 重新实现标准库tokio提供了异步版本的许多标准库功能(如文件读写、网络操作),这些操作返回Future,可以被tokio高效地调度。
  • 与线程的对比:对于CPU密集型任务,使用tokio可能不是最佳选择,此时应考虑使用rayon这类专注于数据并行的库。tokio更适合处理大量等待IO的操作。

以下是一个在tokio中创建文件的异步代码示例:

use tokio::fs::File;

async fn create_file_example() -> std::io::Result<()> {
    let mut file = File::create("hello.txt").await?;
    // 对文件进行异步操作...
    Ok(())
}

Web框架:Rocket

Rocket是一个使编写Rust Web服务变得非常简单的框架。它构建在tokio之上,并充分利用了Rust的类型系统来保证安全。

  • 简洁的API:通过过程宏(如#[get])来定义路由,代码清晰直观。
  • 请求守卫:这是Rocket的一大特色。它允许你利用Rust的类型系统,在请求到达处理函数之前,就对参数进行验证和转换。

例如,我们可以定义一个PasteId类型,确保路由参数是一个有效的UUID:

use rocket::request::FromParam;
use uuid::Uuid;

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_33.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_34.png)

struct PasteId(Uuid);

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_36.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_38.png)

impl<'a> FromParam<'a> for PasteId {
    type Error = &'static str;

    fn from_param(param: &'a str) -> Result<Self, Self::Error> {
        Uuid::parse_str(param)
            .map(PasteId)
            .map_err(|_| "无效的 Paste ID")
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_40.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_41.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_43.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_45.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_47.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_48.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/ca8e9cfb69a280d42360d68e547db45c_50.png)

#[get("/<id>")]
async fn retrieve_paste(id: PasteId) -> Option<File> {
    // `id` 在这里已经被验证为有效的UUID
    // ... 打开文件等操作
}

通过这种方式,无效的ID会在进入业务逻辑之前就被拦截,并返回404错误。


实战:构建Pastebin服务

现在,我们将结合所学,构建一个简易的文本分享服务。

项目设置

首先,添加必要的依赖到Cargo.toml

[dependencies]
rocket = "0.5.0-rc.2"
tokio = { version = "1", features = ["full"] }
uuid = { version = "1", features = ["v4", "serde"] }
serde = { version = "1", features = ["derive"] }

核心功能实现

以下是实现创建和检索paste的两个核心路由:

  1. 创建Paste:接收POST请求和文本数据,生成一个唯一的ID,并将内容保存到文件。

    use rocket::data::{Data, ToByteUnit};
    use rocket::http::Status;
    use uuid::Uuid;
    
    #[post("/create", data = "<paste>")]
    async fn create_paste(paste: Data<'_>) -> Result<String, Status> {
        // 1. 生成唯一ID
        let id = Uuid::new_v4();
        let file_path = format!("./pastes/{}", id);
    
        // 2. 异步打开文件
        let mut file = File::create(&file_path)
            .await
            .map_err(|_| Status::InternalServerError)?;
    
        // 3. 将请求体数据流异步写入文件(限制大小)
        paste
            .open(512.kibibytes())
            .stream_to(&mut file)
            .await
            .map_err(|_| Status::PayloadTooLarge)?;
    
        // 4. 返回生成的ID
        Ok(id.to_string())
    }
    
  2. 检索Paste:根据URL中的ID,读取对应的文件并返回内容。

    use rocket::response::content;
    
    #[get("/<id>")]
    async fn get_paste(id: PasteId) -> Option<content::Plain<File>> {
        // 1. 将ID转换为文件路径
        let file_path = id.to_file_path();
    
        // 2. 异步打开文件
        File::open(file_path)
            .await
            .ok()
            .map(content::Plain)
    }
    

启动应用

最后,使用#[rocket::main]宏启动我们的异步应用:

#[rocket::main]
async fn main() -> Result<(), rocket::Error> {
    rocket::build()
        .mount("/", routes![create_paste, get_paste])
        .launch()
        .await?;

    Ok(())
}


总结

本节课我们一起学习了Rust异步编程的实战应用。我们了解到:

  1. tokio运行时 负责高效调度和执行异步任务,特别适合IO密集型应用。
  2. rocket框架 提供了构建Web服务的高级抽象,其“请求守卫”等功能巧妙地利用Rust类型系统提升了安全性与代码健壮性。
  3. 通过构建一个简单的Pastebin服务,我们实践了如何定义异步路由、处理数据流、进行文件IO,并最终运行一个并发性能优异的Web服务。

Rust的异步生态仍在快速发展,虽然一些高级特性(如异步特质方法)尚未完全稳定,但现有的工具链已经足够强大,能够用于构建高性能、可靠的后端服务。

019:将Brainfuck语言实现为宏

概述

在本节课中,我们将学习如何使用Rust的宏系统,通过创建一个macro_rules!宏,将Brainfuck这门极简的图灵完备编程语言实现为一个Rust宏。我们将从零开始,逐步构建一个能够将Brainfuck源代码在编译时转换为等效Rust代码的宏。

什么是Brainfuck?🤔

Brainfuck是一种由Urban Müller于1993年创建的深奥编程语言。深奥编程语言通常被设计来测试编程语言设计的边界、作为概念验证、软件艺术、作为其他语言的接口,或者纯粹作为一个玩笑。

Brainfuck之所以特别,是因为它极其简单,却又是图灵完备的。这意味着理论上,它可以计算任何可计算的问题。这门语言只有八个命令,每个命令由一个字符表示。

Brainfuck的八个命令

以下是Brainfuck的八个命令及其含义。Brainfuck程序操作一个字节数组(通常称为“纸带”)和一个指向该数组当前位置的“数据指针”。初始时,所有字节通常为零。

  1. >:将数据指针向右移动一个单元格。
  2. <:将数据指针向左移动一个单元格。
  3. +:将数据指针当前指向的字节值加一(通常使用环绕加法,例如 255 + 1 = 0)。
  4. -:将数据指针当前指向的字节值减一(通常使用环绕减法,例如 0 - 1 = 255)。
  5. .:输出数据指针当前指向的字节值对应的ASCII字符。
  6. ,:从输入读取一个字节,并将其ASCII值存储到数据指针当前指向的单元格。
  7. [:如果数据指针当前指向的字节值为零,则跳转到与之匹配的 ] 之后;否则,继续执行下一条指令。这相当于一个 while 循环的开始。
  8. ]:无条件跳转回与之匹配的 [ 处,以重新检查循环条件。

Rust宏基础 🛠️

上一节我们介绍了Brainfuck语言,本节中我们来看看Rust宏的基本概念。宏是Rust中强大的元编程工具,它本质上是一个在编译时运行的函数,接收源代码作为输入,并生成新的源代码作为输出。

宏的声明与使用

声明一个简单的macro_rules!宏的语法如下:

macro_rules! macro_name {
    ( /* 模式 */ ) => { /* 展开的代码 */ };
}

宏可以通过 ! 调用,例如 println!vec!

捕获与重复

在宏的模式中,我们可以使用特殊的捕获语法来匹配不同类型的代码片段,例如表达式 $expr:expr。我们还可以使用重复语法来处理可变数量的输入。

以下是重复语法的基本形式:

$ ( /* 要重复的模式 */ ) 分隔符 重复操作符
  • 分隔符:通常是 ,;,用于分隔重复项。
  • 重复操作符
    • *:零次或多次。
    • +:一次或多次。
    • ?:零次或一次。

在宏的展开部分,使用 $( ... )* 来对捕获的内容进行重复展开。

一个简单的例子:DIY vec!

为了理解宏的工作原理,让我们先创建一个简化版的 vec! 宏。

macro_rules! diy_vec {
    ( $( $elem:expr ),* $(,)? ) => {{
        let mut temp_vec = Vec::new();
        $(
            temp_vec.push($elem);
        )*
        temp_vec
    }};
}

这个宏匹配一个由逗号分隔的表达式列表(允许末尾有一个可选的逗号)。它展开的代码会创建一个临时的可变向量,将每个表达式推入向量,最后返回这个向量。

构建Brainfuck宏 🧠

现在我们已经掌握了宏的基础知识,可以开始构建我们的Brainfuck宏了。我们的目标是创建一个名为 bf! 的宏,它接收Brainfuck源代码,并将其转换为在运行时执行相同逻辑的Rust代码。

核心策略:递归TT吞噬

我们将使用一种称为“递归TT吞噬”的技术。TT代表“Token Tree”(词法树)。基本思想是:宏将逐个“吞噬”输入令牌,为每个Brainfuck命令生成一小段Rust代码,然后递归地调用自身来处理剩余的令牌。

宏的结构设计

我们的宏需要维护两个状态:纸带(字节数组)和数据指针(索引)。我们将把它们作为参数传递给宏的递归调用。

宏的顶层规则将匹配纯Brainfuck代码,初始化状态,然后进入递归处理。

macro_rules! bf {
    // 顶层规则:接收纯Brainfuck代码
    ( $($code:tt)* ) => {{
        let mut __tape = vec![0u8; 2048];
        let mut __ptr = 1024; // 从中间开始
        bf!(@inner __tape, __ptr, $($code)*);
    }};
    // 内部递归规则
    (@inner $tape:ident, $ptr:ident, ) => {}; // 没有更多指令,结束
    // 处理各个Brainfuck命令的规则...
}

实现各个命令

接下来,我们为每个Brainfuck命令实现一条规则。每条规则匹配特定的命令令牌,生成对应的Rust代码,然后递归调用宏自身处理剩余部分。

以下是处理 ><+- 命令的规则:

    // 向右移动指针
    (@inner $tape:ident, $ptr:ident, > $($rest:tt)*) => {
        $ptr += 1;
        bf!(@inner $tape, $ptr, $($rest)*);
    };
    // 向左移动指针
    (@inner $tape:ident, $ptr:ident, < $($rest:tt)*) => {
        $ptr -= 1;
        bf!(@inner $tape, $ptr, $($rest)*);
    };
    // 递增当前字节(使用环绕加法)
    (@inner $tape:ident, $ptr:ident, + $($rest:tt)*) => {
        $tape[$ptr] = $tape[$ptr].wrapping_add(1);
        bf!(@inner $tape, $ptr, $($rest)*);
    };
    // 递减当前字节(使用环绕减法)
    (@inner $tape:ident, $ptr:ident, - $($rest:tt)*) => {
        $tape[$ptr] = $tape[$ptr].wrapping_sub(1);
        bf!(@inner $tape, $ptr, $($rest)*);
    };

处理输入/输出

处理输入(,)和输出(.)需要与Rust的IO系统交互。

    // 输出当前字节
    (@inner $tape:ident, $ptr:ident, . $($rest:tt)*) => {
        print!("{}", $tape[$ptr] as char);
        std::io::Write::flush(&mut std::io::stdout()).unwrap();
        bf!(@inner $tape, $ptr, $($rest)*);
    };
    // 输入一个字节
    (@inner $tape:ident, $ptr:ident, , $($rest:tt)*) => {
        let mut input = [0u8; 1];
        std::io::Read::read(&mut std::io::stdin(), &mut input).unwrap();
        $tape[$ptr] = input[0];
        bf!(@inner $tape, $ptr, $($rest)*);
    };

实现循环

循环([])是Brainfuck中最有趣的部分。得益于Rust宏要求括号必须平衡的特性,我们可以轻松匹配整个循环体。

    // 循环开始
    (@inner $tape:ident, $ptr:ident, [ $($body:tt)* ] $($rest:tt)*) => {
        while $tape[$ptr] != 0 {
            bf!(@inner $tape, $ptr, $($body)*);
        }
        bf!(@inner $tape, $ptr, $($rest)*);
    };

这条规则匹配一个左括号 [,然后捕获直到匹配的右括号 ] 之间的所有令牌作为循环体 $body。它展开为一个Rust的 while 循环,只要当前字节不为零,就执行循环体内的Brainfuck代码。循环结束后,继续处理剩余的令牌。

处理令牌边界情况

由于Rust的词法分析器会将 >> 解析为单个“右移”令牌,而不是两个 >,我们需要添加额外规则来处理这种边界情况。

    // 处理 `>>` 被识别为右移的情况
    (@inner $tape:ident, $ptr:ident, >> $($rest:tt)*) => {
        bf!(@inner $tape, $ptr, > > $($rest)*);
    };
    // 类似地处理 `<<`
    (@inner $tape:ident, $ptr:ident, << $($rest:tt)*) => {
        bf!(@inner $tape, $ptr, < < $($rest)*);
    };

这些规则将复合令牌“分解”为单个命令,然后重新调用宏。

使用Brainfuck宏 🚀

现在,我们的宏已经完成!让我们用它来运行一些Brainfuck程序。

Hello World

最经典的例子:打印“Hello World”。

bf!(
    ++++++++[>++++[>++>+++>+++>+<<<<-]>+>+>->>+[<]<-]>>.>---.+++++++..+++.>>.<-.<.+++.------.--------.>>+.>++.
);

运行此Rust程序,将在终端输出“Hello World!”。

ROT13 加密/解密

ROT13是一种简单的字母替换密码。以下Brainfuck程序实现了一个ROT13密码器。

bf!(
    // ... (很长的ROT13 Brainfuck代码)
);

运行后,输入文本,程序会输出其ROT13加密/解密的结果。

计算数学常数 e

这个Brainfuck程序可以计算并输出数学常数 e 的十进制数字。

#![recursion_limit = "1024"] // 需要提高递归限制
bf!(
    // ... (非常长的计算e的Brainfuck代码)
);

由于该程序嵌套循环极深,导致宏递归展开次数超过默认限制(128),我们需要使用 #![recursion_limit = “1024”] 属性来提高编译器的递归限制。

Brainfuck解释器(元循环)

最令人惊叹的例子:一个用Brainfuck编写的Brainfuck解释器。我们可以用我们的 bf! 宏来运行这个解释器,然后让这个解释器再去执行其他的Brainfuck程序(例如Hello World)。

bf!(
    // ... (极其冗长的Brainfuck解释器源代码)
);

运行此程序,然后将Hello World的Brainfuck代码(以!结尾)输入给这个解释器,你将看到它最终输出“Hello World”。这实现了宏的“自举”——用我们的宏运行一个能理解Brainfuck的Brainfrick程序。

总结

本节课中我们一起学习了:

  1. Brainfuck语言:一门只有八个命令、极简却图灵完备的深奥编程语言。
  2. Rust宏macro_rules! 宏的基础,包括捕获、重复和递归。
  3. 递归TT吞噬:一种构建复杂宏的技术,通过递归调用逐步处理输入令牌。
  4. Brainfuck宏的实现:我们逐步构建了一个 bf! 宏,它能将Brainfuck源代码在编译时转换为等效的Rust代码,实现了从Brainfuck到Rust的转译。
  5. 宏的强大能力:通过这个练习,我们看到了宏如何能够将一种语言的语义嵌入到Rust中,并在编译时执行复杂的代码生成任务,展示了元编程的威力。

最终完成的宏是一个将Brainfuck语言嵌入Rust的完整、可用的转译器,它生动地演示了Rust宏系统的灵活性和强大功能。

020:Rustlings编程练习

概述

在本节课中,我们将通过一系列名为“Rustlings”的编程练习来学习Rust语言的核心概念。这些练习涵盖了从变量、函数到所有权、错误处理等各个方面。我们将跟随一个实际的编码过程,逐步解决每个练习,并深入理解背后的Rust原理。


变量与可变性

在Rust中,变量默认是不可变的。这意味着一旦给变量赋值,就不能再改变它的值。要创建一个可变的变量,需要使用 mut 关键字。

练习:variables1

以下是第一个练习,要求我们修复代码使其能够编译。

// 修复此代码,使其能够编译
fn main() {
    let x = 5;
    println!("x 的值为:{}", x);
    x = 6; // 这行会导致编译错误
    println!("x 的值为:{}", x);
}

解决方案: 要使 x 可变,需要在声明时加上 mut 关键字。

fn main() {
    let mut x = 5; // 添加 mut 关键字
    println!("x 的值为:{}", x);
    x = 6; // 现在可以重新赋值
    println!("x 的值为:{}", x);
}

练习:variables2

有时,我们可能希望在不初始化变量的情况下声明它,然后在后面再赋值。但Rust要求变量在使用前必须初始化。

// 修复此代码,使其能够编译
fn main() {
    let x;
    println!("x 的值为:{}", x); // 错误:使用了未初始化的变量
}

解决方案: 在使用变量之前,确保它已经被初始化。

fn main() {
    let x;
    x = 5; // 初始化变量
    println!("x 的值为:{}", x);
}

练习:variables3

Rust允许变量遮蔽(shadowing),这意味着可以重新声明一个同名变量,新的变量会覆盖旧的变量。

// 修复此代码,使其能够编译
fn main() {
    let x = 5;
    let x = x + 1; // 变量遮蔽
    println!("x 的值为:{}", x);
}

解决方案: 变量遮蔽是允许的,但需要注意作用域。

fn main() {
    let x = 5;
    let x = x + 1; // 正确:变量遮蔽
    println!("x 的值为:{}", x);
}


函数与参数

在Rust中,函数参数必须显式指定类型。函数体中的最后一个表达式会自动作为返回值,除非使用 return 关键字。

练习:functions1

以下练习要求我们修复一个函数,使其能够编译。

// 修复此函数,使其能够编译
fn main() {
    call_me();
}

fn call_me() {
    println!("调用了 call_me 函数");
}

解决方案: 函数定义正确,无需修改。

fn main() {
    call_me();
}

fn call_me() {
    println!("调用了 call_me 函数");
}

练习:functions2

函数参数必须指定类型。

// 修复此函数,使其能够编译
fn main() {
    call_me(3);
}

fn call_me(num: i32) { // 添加参数类型
    println!("数字为:{}", num);
}

解决方案: 在函数参数中指定类型。

fn main() {
    call_me(3);
}

fn call_me(num: i32) {
    println!("数字为:{}", num);
}

练习:functions3

函数体中的最后一个表达式会自动作为返回值。

// 修复此函数,使其能够编译
fn main() {
    let answer = square(3);
    println!("平方值为:{}", answer);
}

fn square(num: i32) -> i32 {
    num * num // 最后一个表达式作为返回值
}

解决方案: 确保函数体中的最后一个表达式是返回值。

fn main() {
    let answer = square(3);
    println!("平方值为:{}", answer);
}

fn square(num: i32) -> i32 {
    num * num // 正确:最后一个表达式作为返回值
}

所有权与移动语义

所有权是Rust的核心特性之一。它确保内存安全,防止数据竞争。当值被移动时,原来的变量将无法再使用。

练习:move_semantics1

以下练习演示了移动语义。

// 修复此代码,使其能够编译
fn main() {
    let vec0 = Vec::new();
    let vec1 = fill_vec(vec0);
    println!("vec1 的长度为:{}", vec1.len());
}

fn fill_vec(vec: Vec<i32>) -> Vec<i32> {
    let mut vec = vec;
    vec.push(88);
    vec
}

解决方案: 移动语义导致 vec0 无法再使用,但 fill_vec 函数返回了新的向量。

fn main() {
    let vec0 = Vec::new();
    let vec1 = fill_vec(vec0);
    println!("vec1 的长度为:{}", vec1.len());
}

fn fill_vec(vec: Vec<i32>) -> Vec<i32> {
    let mut vec = vec;
    vec.push(88);
    vec
}

练习:move_semantics2

有时,我们可能希望在不移动所有权的情况下修改数据。这时可以使用引用。

// 修复此代码,使其能够编译
fn main() {
    let mut vec0 = Vec::new();
    fill_vec(&mut vec0); // 传递可变引用
    println!("vec0 的长度为:{}", vec0.len());
}

fn fill_vec(vec: &mut Vec<i32>) {
    vec.push(88);
}

解决方案: 通过传递可变引用,可以在不移动所有权的情况下修改数据。

fn main() {
    let mut vec0 = Vec::new();
    fill_vec(&mut vec0);
    println!("vec0 的长度为:{}", vec0.len());
}

fn fill_vec(vec: &mut Vec<i32>) {
    vec.push(88);
}

错误处理

Rust使用 ResultOption 类型来处理错误和可选值。Result 表示操作可能成功或失败,而 Option 表示值可能存在或不存在。

练习:errors1

以下练习要求我们修复错误处理代码。

// 修复此代码,使其能够编译
fn main() {
    let result = parse_number("42");
    println!("解析结果为:{:?}", result);
}

fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
    s.parse()
}

解决方案: 使用 ? 操作符可以简化错误处理。

fn main() {
    let result = parse_number("42");
    println!("解析结果为:{:?}", result);
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/0e9e476969b75e1bbc8784dc39e2e2cc_27.png)

fn parse_number(s: &str) -> Result<i32, std::num::ParseIntError> {
    Ok(s.parse()?)
}

练习:errors2

有时,我们需要在函数中处理多个错误类型。

// 修复此代码,使其能够编译
use std::num::ParseIntError;

fn main() -> Result<(), ParseIntError> {
    let number_str = "42";
    let number = parse_number(number_str)?;
    println!("解析结果为:{}", number);
    Ok(())
}

fn parse_number(s: &str) -> Result<i32, ParseIntError> {
    s.parse()
}

解决方案: 使用 ? 操作符可以自动传播错误。

use std::num::ParseIntError;

fn main() -> Result<(), ParseIntError> {
    let number_str = "42";
    let number = parse_number(number_str)?;
    println!("解析结果为:{}", number);
    Ok(())
}

fn parse_number(s: &str) -> Result<i32, ParseIntError> {
    s.parse()
}

泛型与特质

泛型允许我们编写可以处理多种类型的代码。特质(Trait)定义了类型的行为。

练习:generics1

以下练习要求我们修复泛型代码。

// 修复此代码,使其能够编译
struct Wrapper<T> {
    value: T,
}

impl<T> Wrapper<T> {
    pub fn new(value: T) -> Self {
        Wrapper { value }
    }
}

fn main() {
    let w = Wrapper::new(42);
    println!("包装器的值为:{}", w.value);
}

解决方案: 泛型结构体和实现已经正确。

struct Wrapper<T> {
    value: T,
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/0e9e476969b75e1bbc8784dc39e2e2cc_39.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/0e9e476969b75e1bbc8784dc39e2e2cc_41.png)

impl<T> Wrapper<T> {
    pub fn new(value: T) -> Self {
        Wrapper { value }
    }
}

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/0e9e476969b75e1bbc8784dc39e2e2cc_43.png)

![](https://github.com/OpenDocCN/cs-notes-zh/raw/master/docs/unsw-comp6991-rs-mdn-prog/img/0e9e476969b75e1bbc8784dc39e2e2cc_45.png)

fn main() {
    let w = Wrapper::new(42);
    println!("包装器的值为:{}", w.value);
}

练习:generics2

特质可以用于约束泛型类型的行为。

// 修复此代码,使其能够编译
use std::fmt::Display;

struct ReportCard<T: Display> {
    grade: T,
}

impl<T: Display> ReportCard<T> {
    fn print(&self) {
        println!("成绩为:{}", self.grade);
    }
}

fn main() {
    let card = ReportCard { grade: 95 };
    card.print();
}

解决方案: 使用特质约束确保类型可以被打印。

use std::fmt::Display;

struct ReportCard<T: Display> {
    grade: T,
}

impl<T: Display> ReportCard<T> {
    fn print(&self) {
        println!("成绩为:{}", self.grade);
    }
}

fn main() {
    let card = ReportCard { grade: 95 };
    card.print();
}

总结

在本节课中,我们一起学习了Rustlings编程练习,涵盖了变量、函数、所有权、错误处理、泛型和特质等核心概念。通过实际编码练习,我们深入理解了Rust语言的设计哲学和内存安全特性。希望这些练习能够帮助你更好地掌握Rust编程。

posted @ 2026-03-29 09:33  布客飞龙II  阅读(1)  评论(0)    收藏  举报