Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

English Original

面向 C/C++ 程序员的 Rust 入门强化课程 🟢

欢迎参加 Rust 入门强化课程。本课程专为具备 C/C++ 背景、希望利用其系统编程经验的高级开发者设计,旨在帮助你拥抱 Rust 的安全特性与现代功能。

课程概览

  • 为什么选择 Rust:C/C++ 开发者为何需要 Rust 以及它能消除哪些常见问题。
  • 基础知识:类型、函数、控制流以及模式匹配。
  • 工具链:模块、Cargo 以及工作空间(Workspaces)。
  • 抽象能力:Trait、泛型以及闭包。
  • 内存与并发:生命周期、智能指针以及线程安全。
  • 系统与 FFI:Unsafe Rust、外部函数接口(FFI)以及 no_std。
  • 案例研究:将 C++ 代码迁移到 Rust 的真实架构设计模式。

Note

本课程 不 涉及 async Rust。如需深入学习 Future、执行器(Executors)以及 tokio,请参阅配套的 Async Rust Training。


自学指南

阶段主题建议时长检查点
1环境搭建、类型、控制流1 天构建一个命令行计算器。
2数据结构、所有权1-2 天理解为何 let s2 = s1 会移动 s1。
3模块、错误处理1 天使用 ? 操作符传播错误。
4Trait、泛型、闭包1-2 天编写带有 Trait 约束的泛型函数。
5并发、Unsafe/FFI1 天创建一个线程安全的计数器。

如何使用练习

每章都包含带难度标记的动手练习:

  • 🟢 入门 (Starter)
  • 🟡 中级 (Intermediate)
  • 🔶 挑战 (Challenge)

提示:务必先独立尝试练习至少 15 分钟。与借用检查器(Borrow Checker)的“博弈”就是实际学习发生的地方。如果卡住了,请研究参考答案,然后尝试从头开始重写。


目录

第一部分 — 基础篇

1. 引言与动机

2. 快速开始

3. 基础类型与变量

4. 控制流

5. 数据结构与集合

6. 模式匹配与枚举

7. 所有权与内存管理

8. 模块与 Crate

9. 错误处理

10. Trait 与泛型

11. 类型系统高级特性

12. 函数式编程

13. 并发编程

14. Unsafe Rust 与 FFI

第二部分 — 专题深入

15. no_std — 裸机环境下的 Rust

16. 案例研究:真实的 C++ 到 Rust 迁移

第三部分 — 最佳实践与参考

17. 最佳实践

18. C++ → Rust 语义深挖

19. Rust 宏

English Original

讲师介绍与通用方法

你将学到: 课程结构、互动形式,以及熟悉的 C/C++ 概念如何映射到 Rust 的对等概念。本章将设定预期,并为你提供本书其余部分的路线图。

  • 讲师介绍
    • 微软 SCHIE(芯片与云硬件基础设施工程)团队的首席固件架构师
    • 行业资深专家,专长于安全、系统编程(固件、操作系统、超管理器)、CPU 与平台架构以及 C++ 系统
    • 自 2017 年(在 AWS EC2 时)开始使用 Rust 编程,并从此深深爱上了这门语言
  • 本课程旨在尽可能地保持互动性
    • 前提假设:你已经了解 C、C++ 或两者兼有
    • 案例设计:有意识地将熟悉的概念映射到 Rust 的对等实现
    • 欢迎随时提出澄清性的问题
  • 讲师期待与各团队保持持续参与

为什么要用 Rust

想直接看代码? 请跳至 少说废话:直接看代码

无论你来自 C 还是 C++,核心痛点都是一样的:那些能正常编译通过,却在运行时导致崩溃、数据损坏或内存泄漏的内存安全 Bug。

  • 超过 70% 的 CVE 是由内存安全问题引起的 —— 缓冲区溢出、悬空指针、使用后释放(use-after-free)
  • C++ 的 shared_ptr、unique_ptr、RAII 和移动语义虽然在正确的方向上迈出了步伐,但它们只是权宜之计,而非根治良方 —— 它们依然留下了移动后使用(use-after-move)、引用循环、迭代器失效以及异常安全漏洞
  • Rust 提供了你所依赖的 C/C++ 性能,同时提供了编译期的安全保障

📖 深度解析:参见 为什么 C/C++ 开发者需要 Rust,查看具体的漏洞案例、Rust 消除的问题清单,以及为什么 C++ 智能指针还不够。


Rust 如何解决这些问题?

缓冲区溢出与越界访问

  • 所有的 Rust 数组、切片和字符串都带有显式的边界。编译器会插入检查,确保任何越界访问都会导致运行时崩溃 (Rust 中称为 Panic) —— 绝不会出现未定义行为 (Undefined Behavior)。

悬空指针与引用

  • Rust 引入了生命周期 (Lifetimes) 和借用检查 (Borrow Checking),在编译期消除悬空引用。
  • 没有悬空指针,没有使用后释放 (Use-after-free) —— 编译器根本不会让它们发生。

移动后使用 (Use-after-move)

  • Rust 的所有权系统使移动成为破坏性的 —— 一旦你移动了一个值,编译器就会拒绝让你使用原值。没有僵尸对象,也没有“有效但未指定状态”。

资源管理

  • Rust 的 Drop Trait 是正确实现的 RAII —— 编译器在资源离开作用域时自动释放,并防止移动后使用,而 C++ 的 RAII 无法强制执行这一点。
  • 不需要“五法则” (Rule of Five) —— 无需手动定义拷贝构造、移动构造、拷贝赋值、移动赋值及析构函数。

错误处理

  • Rust 没有异常。所有的错误都是值 (Result<T, E>),使得错误处理在类型签名中显式且可见。

迭代器失效

  • Rust 的借用检查器禁止在遍历集合的同时修改它。你根本写不出那些困扰 C++ 代码库的 Bug:
#![allow(unused)]
fn main() {
// Rust 中等效的迭代期间删除:retain()
pending_faults.retain(|f| f.id != fault_to_remove.id);

// 或者:收集到新的 Vec (函数式风格)
let remaining: Vec<_> = pending_faults
    .into_iter()
    .filter(|f| f.id != fault_to_remove.id)
    .collect();
}

数据竞态 (Data Races)

  • 类型系统通过 Send 和 Sync Trait 在编译期防止数据竞态。

内存安全可视化

Rust 所有权 — 设计初衷即安全

#![allow(unused)]
fn main() {
fn safe_rust_ownership() {
    // 移动是破坏性的:原变量失效
    let data = vec![1, 2, 3];
    let data2 = data;           // 移动发生
    // data.len();              // 编译错误:值在移动后被使用
    
    // 借用:安全的共享访问
    let owned = String::from("你好,世界!");
    let slice: &str = &owned;  // 借用 — 无需内存分配
    println!("{}", slice);     // 总是安全的
    
    // 不可能出现悬空引用
    /*
    let dangling_ref;
    {
        let temp = String::from("临时变量");
        dangling_ref = &temp;  // 编译错误:temp 存活时间不够长
    }
    */
}
}
graph TD
    A[Rust 所有权安全] --> B[破坏性移动]
    A --> C[自动内存管理]
    A --> D[编译期生命周期检查]
    A --> E[禁止异常 — 使用 Result 类型]
    
    B --> B1["移动后使用会导致编译错误"]
    B --> B2["无僵尸对象"]
    
    C --> C1["Drop trait = 正确实现的 RAII"]
    C --> C2["无需“五法则” (Rule of Five)"]
    
    D --> D1["借用检查器防止悬空"]
    D --> D2["引用始终有效"]
    
    E --> E1["Result<T,E> — 错误体现在类型中"]
    E --> E2["? 操作符用于传播"]
    
    style A fill:#51cf66,color:#000
    style B fill:#91e5a3,color:#000
    style C fill:#91e5a3,color:#000
    style D fill:#91e5a3,color:#000
    style E fill:#91e5a3,color:#000

内存布局:Rust 引用

graph TD
    RM1[栈] --> RP1["&i32 引用"]
    RM2[栈/堆] --> RV1["i32 值 = 42"]
    RP1 -.->|"安全引用 — 已通过生命周期检查"| RV1
    RM3[借用检查器] --> RC1["在编译期防止产生悬空引用"]
    
    style RC1 fill:#51cf66,color:#000
    style RP1 fill:#91e5a3,color:#000

Box<T> 堆内存分配可视化

#![allow(unused)]
fn main() {
fn box_allocation_example() {
    // 栈分配
    let stack_value = 42;
    
    // 使用 Box 进行堆分配
    let heap_value = Box::new(42);
    
    // 移动所有权
    let moved_box = heap_value;
    // heap_value 不再可访问
}
}
graph TD
    subgraph "栈帧 (Stack Frame)"
        SV["stack_value: 42"]
        BP["heap_value: Box<i32>"]
        BP2["moved_box: Box<i32>"]
    end
    
    subgraph "堆 (Heap)"
        HV["42"]
    end
    
    BP -->|"拥有"| HV
    BP -.->|"所有权移动"| BP2
    BP2 -->|"现在拥有"| HV
    
    subgraph "移动后状态"
        BP_X["heap_value: [警告] 已移动 (MOVED)"]
        BP2_A["moved_box: Box<i32>"]
    end
    
    BP2_A -->|"拥有"| HV
    
    style BP_X fill:#ff6b6b,color:#000
    style HV fill:#91e5a3,color:#000
    style BP2_A fill:#51cf66,color:#000

切片 (Slice) 操作可视化

#![allow(unused)]
fn main() {
fn slice_operations() {
    let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
    
    let full_slice = &data[..];        // [1,2,3,4,5,6,7,8]
    let partial_slice = &data[2..6];   // [3,4,5,6]
    let from_start = &data[..4];       // [1,2,3,4]
    let to_end = &data[3..];           // [4,5,6,7,8]
}
}
graph TD
    V["Vec: [1, 2, 3, 4, 5, 6, 7, 8]"]
    V --> FS["&data[..] → 所有元素"]
    V --> PS["&data[2..6] → [3, 4, 5, 6]"]
    V --> SS["&data[..4] → [1, 2, 3, 4]"]
    V --> ES["&data[3..] → [4, 5, 6, 7, 8]"]
    
    style V fill:#e3f2fd,color:#000
    style FS fill:#91e5a3,color:#000
    style PS fill:#91e5a3,color:#000
    style SS fill:#91e5a3,color:#000
    style ES fill:#91e5a3,color:#000

Rust 的其他核心卖点与特性

  • 线程间无数据竞态 (通过编译期的 Send/Sync 检查实现)
  • 无移动后使用 (Use-after-move) (不同于 C++ 的 std::move 会留下僵尸对象)
  • 无未初始化变量
    • 所有变量在使用前必须被初始化
  • 无显而易见的内存泄漏
    • Drop Trait = 正确实现的 RAII,不再需要“五法则”
    • 编译器在变量离开作用域时自动释放内存
  • Mutex 上不会忘记加锁/解锁
    • Lock Guard 是访问数据的唯一途径 (Mutex<T> 包裹的是数据,而非对数据的访问操作)
  • 无异常处理带来的复杂性
    • 错误即是值 (Result<T, E>),在函数签名中可见,且通过 ? 进行传播
  • 卓越的类型推导、枚举、模式匹配以及零成本抽象支持
  • 内建的依赖管理、构建、测试、格式化及 Lint 支持
    • cargo 可以取代 make/CMake + 单元测试框架 + Lint 工具

快速参考:Rust vs C/C++

概念CC++Rust关键差异
内存管理malloc()/free()unique_ptr, shared_ptrBox<T>, Rc<T>, Arc<T>自动化,无循环引用
数组int arr[10]std::vector<T>, std::array<T>Vec<T>, [T; N]默认进行边界检查
字符串以 \0 结尾的 char*std::string, string_viewString, &str保证 UTF-8,生命周期检查
引用int* ptrT&, T&& (移动)&T, &mut T借用检查,生命周期
多态函数指针虚函数,继承Traits,特征对象 (Trait Objects)组合优于继承
泛型编程宏 (void*)模板 (Templates)泛型 + Trait 约束更友好的错误提示
错误处理返回值,errno异常,std::optionalResult<T, E>, Option<T>无隐藏的控制流
NULL 安全性ptr == NULLnullptr, std::optional<T>Option<T>强制进行空值检查
线程安全性手动 (pthreads)手动同步编译期保障不可能出现数据竞态
构建系统Make, CMakeCMake, Make 等Cargo集成化的工具链
未定义行为 (UB)运行时崩溃隐蔽的 UB (有符号溢出等)编译期错误安全有保障

English Original

为什么 C/C++ 开发者需要 Rust

你将学到:

  • Rust 消除的问题完整清单 —— 内存安全、未定义行为、数据竞态等
  • 为什么 shared_ptr、unique_ptr 及其它 C++ 缓解措施只是权宜之计,而非根本落地方案
  • 具体的 C 和 C++ 漏洞案例,这些在安全的 Rust 中从结构上就是不可能发生的

想直接看代码? 请跳至 少说废话:直接看代码

Rust 消除的问题 —— 完整清单

在深入研究案例之前,先看一份执行摘要。安全的 Rust 从结构上防止了下表中的每一个问题 —— 文明、工具链或代码审查,而是通过类型系统和编译器实现的:

已消除的问题CC++Rust 是如何防止的
缓冲区上溢 / 下溢✅✅所有数组、切片和字符串都带有边界;索引访问在运行时进行检查
内存泄漏 (无需 GC)✅✅Drop trait = 实现正确的 RAII;自动清理,无需“五法则”
悬空指针 (Dangling pointers)✅✅生命周期系统在编译期证明引用比其引用的对象存活更久
使用后释放 (Use-after-free)✅✅所有权系统使这成为编译错误
移动后使用 (Use-after-move)—✅移动是破坏性的 —— 原绑定关系将不复存在
未初始化变量✅✅所有变量在使用前必须被初始化;编译器强制执行这一点
整数上溢 / 下溢 UB✅✅调试构建 (Debug) 在溢出时触发 Panic;发布构建 (Release) 执行环绕 (无论哪种都是已定义行为)
空指针解引用 / 段错误 (SEGV)✅✅不存在空指针;Option<T> 强制执行显式处理
数据竞态 (Data races)✅✅Send/Sync trait + 借用检查器使数据竞态成为编译错误
不受控的副作用✅✅默认不可变;修改操作需要显式的 mut 关键字
不使用继承 (更好的可维护性)—✅Trait + 组合取代类继承层级;促进重用而不产生耦合
无异常;可预测的控制流—✅错误即是值 (Result<T, E>);无法被忽略,没有隐藏的 throw 路径
迭代器失效—✅借用检查器禁止在迭代的同时修改集合
引用循环 / 泄漏的析构—✅所有权呈树状结构;Rc 循环属于可选功能,且可用 Weak 捕获
忘记 Mutex 解锁✅✅Mutex<T> 包裹数据;Lock Guard 是访问数据的唯一途径
未定义行为 (通用)✅✅安全 Rust 零未定义行为;unsafe 块是显式的且可审计的

核心结论:这些不是通过编码标准强制执行的理想目标,而是编译期的保证。如果你的代码能编译通过,这些 Bug 就不可能存在。


C 和 C++ 共同的问题

想跳过案例? 直接跳至 Rust 如何解决这一切 或 少说废话:直接看代码

两种语言都共有一组核心内存安全问题,这正是超过 70% 的 CVE (常见漏洞与披露) 的根源:

缓冲区溢出 (Buffer overflows)

C 数组、指针和字符串没有固有的边界。越过这些边界非常容易:

#include <stdlib.h>
#include <string.h>

void buffer_dangers() {
    char buffer[10];
    strcpy(buffer, "字符串太长了,无法放入缓冲区!");  // 缓冲区溢出

    int arr[5] = {1, 2, 3, 4, 5};
    int *ptr = arr;           // 丢失了长度信息
    ptr[10] = 42;             // 无边界检查 —— 未定义行为
}

在 C++ 中,std::vector::operator[] 仍然不执行边界检查。只有 .at() 会执行 —— 但谁会去捕获那个异常呢?

悬空指针与使用后释放

int *bar() {
    int i = 42;
    return &i;    // 返回栈变量的地址 —— 悬空指针!
}

void use_after_free() {
    char *p = (char *)malloc(20);
    free(p);
    *p = '\0';   // 使用后释放 —— 未定义行为
}

未初始化变量与未定义行为

C 和 C++ 都允许使用未初始化的变量。结果值是不确定的,读取它们属于未定义行为:

int x;               // 未初始化
if (x > 0) { ... }  // UB —— x 可能是任何值

整数溢出在 C 中对无符号类型是已定义的,但对有符号类型是未定义的。在 C++ 中,有符号溢出也是未定义行为。两种编译器都可以且确实在利用这一点进行“优化”,从而以令人惊讶的方式破坏程序。

空指针解引用 (NULL pointer dereferences)

int *ptr = NULL;
*ptr = 42;           // 段错误 (SEGV) —— 但编译器不会阻止你

在 C++ 中,std::optional<T> 有所帮助,但它不仅繁琐,而且经常被 .value() 绕过,从而抛出异常。

可视化:共同的问题

graph TD
    ROOT["C/C++ 内存安全问题"] --> BUF["缓冲区溢出"]
    ROOT --> DANGLE["悬空指针"]
    ROOT --> UAF["使用后释放"]
    ROOT --> UNINIT["未初始化变量"]
    ROOT --> NULL["空指针解引用"]
    ROOT --> UB["未定义行为 (UB)"]
    ROOT --> RACE["数据竞态"]

    BUF --> BUF1["数组/指针无边界检查"]
    DANGLE --> DANGLE1["返回栈地址"]
    UAF --> UAF1["重用已释放的内存"]
    UNINIT --> UNINIT1["不确定的数值"]
    NULL --> NULL1["无强制空值检查"]
    UB --> UB1["有符号溢出、别名"]
    RACE --> RACE1["无编译期安全保证"]

    style ROOT fill:#ff6b6b,color:#000
    style BUF fill:#ffa07a,color:#000
    style DANGLE fill:#ffa07a,color:#000
    style UAF fill:#ffa07a,color:#000
    style UNINIT fill:#ffa07a,color:#000
    style NULL fill:#ffa07a,color:#000
    style UB fill:#ffa07a,color:#000
    style RACE fill:#ffa07a,color:#000

C++ 额外引入的问题

只使用 C 的读者:如果你不使用 C++,可以直接跳到 Rust 如何解决这些问题。

想直接看代码? 请跳至 少说废话:直接看代码

C++ 引入了智能指针、RAII、移动语义和异常来解决 C 的问题。但这些只是权宜之计,而非根本性的方案 —— 它们只是将失败模式从“运行时崩溃”转变成了“运行时更隐蔽的 Bug”:

unique_ptr 与 shared_ptr —— 只是缓解,而非解决

C++ 的智能指针相比原始的 malloc/free 是一个巨大的进步,但它们并未解决底层问题:

C++ 缓解措施修复了什么未修复什么
std::unique_ptr通过 RAII 防止内存泄漏移动后使用 (Use-after-move) 依然能编译通过;会留下一个僵尸 nullptr
std::shared_ptr共享所有权引用循环会导致静默的内存泄漏;weak_ptr 的规范化使用仍靠自觉
std::optional替代了部分空值使用如果为空,.value() 会抛出异常 —— 产生隐藏的控制流
std::string_view避免多余的拷贝如果源字符串被释放,则变为悬空引用 —— 没有任何生命周期检查
移动语义 (Move semantics)高效的所有权转移移动后的对象处于**“有效但未指定状态”** —— 随时可能引发 UB
RAII自动资源清理需要精准遵守**“五法则”**才能做到正确;任何一个错误都会引发全局性破坏
// unique_ptr:移动后使用依然能正常编译
std::unique_ptr<int> ptr = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr);
std::cout << *ptr;  // 编译通过!运行时触发未定义行为。
                     // 在 Rust 中,这会导致编译错误:"在值被移动后进行了使用"
// shared_ptr:引用循环会导致静默内存泄漏
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> parent;  // 产生循环!析构函数永远不会被调用。
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->parent = a;  // 内存泄漏 —— 引用计数永远不会降为 0
                 // 在 Rust 中,Rc<T> + Weak<T> 让循环关系变得显式且可被打破

移动后使用 —— 沉默的杀手

C++ 的 std::move 并不是真正的移动 —— 它其实是一次类型转换。原对象仍保留在“有效但未指定状态”中,编译器允许你继续使用它:

auto vec = std::make_unique<std::vector<int>>({1, 2, 3});
auto vec2 = std::move(vec);
vec->size();  // 能够编译!但因为它解引用了 nullptr,所以运行时会崩溃

而在 Rust 中,移动是破坏性的。原始绑定将失效:

#![allow(unused)]
fn main() {
let vec = vec![1, 2, 3];
let vec2 = vec;           // 移动 —— vec 被消耗掉了
// vec.len();             // 编译错误:在值被移动后进行了使用
}

迭代器失效 —— 生产环境 C++ 代码中的真实 Bug

这些不是凭空捏造的例子 —— 它们代表着在大型 C++ 代码库中经常发现的真实 Bug 模式:

// BUG 1:删除后未重新赋值迭代器 (导致未定义行为)
while (it != pending_faults.end()) {
    if (*it != nullptr && (*it)->GetId() == fault->GetId()) {
        pending_faults.erase(it);   // ← 迭代器失效了!
        removed_count++;            //   下次循环使用的是“悬空迭代器”
    } else {
        ++it;
    }
}
// 修复方案:it = pending_faults.erase(it);
// BUG 2:基于索引的删除导致跳过元素
for (auto i = 0; i < entries.size(); i++) {
    if (config_status == ConfigDisable::Status::Disabled) {
        entries.erase(entries.begin() + i);  // ← 后面的元素前移
    }                                         //   i++ 会跳过由于该前移而顶替过来的元素
}
// BUG 3:一个路径正确,另一个路径错误
while (it != incomplete_ids.end()) {
    if (current_action == nullptr) {
        incomplete_ids.erase(it);  // ← BUG:迭代器未被重新赋值
        continue;
    }
    it = incomplete_ids.erase(it); // ← 正确的路径
}

以上所有代码在编译时都不会触发警告。 而在 Rust 中,借用检查器会使这三种情况全都产生编译错误 —— 因为你绝对不能在迭代一个集合的同时修改它。

异常安全与 dynamic_cast/new 模式

现代 C++ 代码库仍然重度依赖那些没有任何编译期安全保障的模式:

// 典型的 C++ 工厂模式 —— 每一个分支都是潜在的 Bug 源
DriverBase* driver = nullptr;
if (dynamic_cast<ModelA*>(device)) {
    driver = new DriverForModelA(framework);
} else if (dynamic_cast<ModelB*>(device)) {
    driver = new DriverForModelB(framework);
}
// 如果 driver 仍然是 nullptr 呢?如果 new 抛出了异常呢?谁该拥有 driver 呢?

在一个典型的包含 10 万行 C++ 的代码库中,你可能会发现数百个 dynamic_cast 调用(每一个都是潜在的运行时失效风险)、数百个原始的 new 调用(每一个都是潜在的内存泄漏风险),以及数百个 virtual/override 方法(导致到处都是虚函数表带来的开销)。

悬空引用与 Lambda 捕获

int& get_reference() {
    int x = 42;
    return x;  // 悬空引用 —— 能编译通过,在运行时触发 UB
}

auto make_closure() {
    int local = 42;
    return [&local]() { return local; };  // 悬空捕获!
}

可视化:C++ 额外引入的问题

graph TD
    ROOT["C++ 额外引入的问题<br/>(叠加在 C 的问题之上)"] --> UAM["移动后使用 (Use-After-Move)"]
    ROOT --> CYCLE["引用循环"]
    ROOT --> ITER["迭代器失效"]
    ROOT --> EXC["异常安全性"]
    ROOT --> TMPL["模板报错信息"]

    UAM --> UAM1["std::move 会留下僵尸对象<br/>能正常编译通过,无警告"]
    CYCLE --> CYCLE1["shared_ptr 循环会导致泄漏<br/>析构函数永远不会被调用"]
    ITER --> ITER1["erase() 使迭代器失效<br/>生产环境中的真实 Bug"]
    EXC --> EXC1["局部完成构造<br/>缺少 try/catch 的 new 操作"]
    TMPL --> TMPL1["30 多层深度的<br/>模板实例化错误信息"]

    style ROOT fill:#ff6b6b,color:#000
    style UAM fill:#ffa07a,color:#000
    style CYCLE fill:#ffa07a,color:#000
    style ITER fill:#ffa07a,color:#000
    style EXC fill:#ffa07a,color:#000
    style TMPL fill:#ffa07a,color:#000

Rust 如何应对这一切

上面列出的所有问题 —— 无论是 C 还是 C++ 的 —— 都能通过 Rust 的编译期保障得到根除:

问题Rust 的解决方案
缓冲区溢出切片 (Slices) 携带长度;访问时进行边界检查
悬空指针 / 使用后释放生命周期系统在编译期证明引用是有效的
移动后使用移动是破坏性的 —— 编译器拒绝让你触碰原对象
内存泄漏Drop trait = 无需“五法则”的 RAII;自动且正确的清理
引用循环所有权呈树状结构;Rc + Weak 让循环关系变得显式
迭代器失效借用检查器禁止在借用集合的同时对其进行修改
空 (NULL) 指针不存在空值。Option<T> 强制通过模式匹配进行显式处理
数据竞态Send/Sync trait 使数据竞态成为编译错误
未初始化变量所有变量必须被初始化;由编译器强制执行
整数 UB调试模式下溢出触发 Panic;发布模式下执行环绕 (均为已定义行为)
异常无异常;Result<T, E> 在类型签名中可见,通过 ? 传播
继承的复杂性Trait + 组合;没有“菱形继承”问题,也没有虚函数表的脆弱性
忘记 Mutex 解锁Mutex<T> 包裹数据;Lock Guard 是唯一的访问路径
#![allow(unused)]
fn main() {
fn rust_prevents_everything() {
    // ✅ 无缓冲区溢出 — 自动边界检查
    let arr = [1, 2, 3, 4, 5];
    // arr[10];  // 运行时触发 Panic,绝非 UB

    // ✅ 无移动后使用 — 编译错误
    let data = vec![1, 2, 3];
    let moved = data;
    // data.len();  // 错误:值在移动后被使用

    // ✅ 无悬空指针 — 生命周期错误
    // let r;
    // { let x = 5; r = &x; }  // 错误:x 的存活时间不够长

    // ✅ 无空值 — Option 强制处理
    let maybe: Option<i32> = None;
    // maybe.unwrap();  // 触发 Panic,但你应该使用 match 或 if let

    // ✅ 无数据竞态 — 编译错误
    // let mut shared = vec![1, 2, 3];
    // std::thread::spawn(|| shared.push(4));  // 错误:闭包可能比借用的值存活更久
    // shared.push(5);
}
}

Rust 的安全模型 —— 全景图

graph TD
    RUST["Rust 安全保障"] --> OWN["所有权系统 (Ownership)"]
    RUST --> BORROW["借用检查器 (Borrow Checker)"]
    RUST --> TYPES["类型系统"]
    RUST --> TRAITS["Send/Sync Traits"]

    OWN --> OWN1["无使用后释放<br/>无移动后使用<br/>无二次释放 (Double-free)"]
    BORROW --> BORROW1["无悬空引用<br/>无迭代器失效<br/>无通过引用的数据竞态"]
    TYPES --> TYPES1["无 NULL (Option&lt;T&gt;)<br/>无异常 (Result&lt;T,E&gt;)<br/>无未初始化值"]
    TRAITS --> TRAITS1["无数据竞态<br/>Send = 安全转移<br/>Sync = 安全共享"]

    style RUST fill:#51cf66,color:#000
    style OWN fill:#91e5a3,color:#000
    style BORROW fill:#91e5a3,color:#000
    style TYPES fill:#91e5a3,color:#000
    style TRAITS fill:#91e5a3,color:#000

快速参考:C vs C++ vs Rust

概念CC++Rust关键差异
内存管理malloc()/free()unique_ptr, shared_ptrBox<T>, Rc<T>, Arc<T>自动,无循环,无僵尸对象
数组int arr[10]std::vector<T>, std::array<T>Vec<T>, [T; N]默认进行边界检查
字符串以 \0 结尾的 char*std::string, string_viewString, &str保证 UTF-8,生命周期检查
引用int* (原始)T&, T&& (移动)&T, &mut T生命周期 + 借用检查
多态函数指针虚函数,继承Traits,特征对象组合优于继承
泛型宏 / void*模板 (Templates)泛型 + Trait 约束清晰的错误提示
错误处理返回值,errno异常,std::optionalResult<T, E>, Option<T>无隐藏的控制流
NULL 安全性ptr == NULLnullptr, std::optional<T>Option<T>强制执行空值检查
线程安全性手动 (pthreads)手动 (std::mutex 等)编译期 Send/Sync不可能出现数据竞态
构建系统Make, CMakeCMake, Make 等Cargo集成化的工具链
未定义行为泛滥隐晦 (有符号溢出、别名)安全代码中为零安全有保障

English Original

少说废话:直接看代码

你将学到: 你的第一个 Rust 程序 —— fn main()、println!(),以及 Rust 宏与 C/C++ 预处理器宏的本质区别。到本章结束时,你将能够编写、编译并运行简单的 Rust 程序。

fn main() {
    println!("你好,Rust 世界");
}
  • 上述语法对于任何熟悉 C 风格语言的人来说都应该非常亲切:
    • Rust 中的所有函数都以 fn 关键字开头
    • 可执行文件的默认入口点是 main()
    • println! 看起来像是一个函数,但它实际上是一个宏。Rust 中的宏与 C/C++ 的预处理器宏有着本质不同 —— 它们是卫生的(hygienic)、类型安全的,并且作用于语法树而非简单的文本替换。
  • 快速尝试 Rust 代码片段的两种绝佳方式:
    • 在线端:Rust Playground —— 粘贴代码,点击运行,分享结果。无需安装任何软件。
    • 本地交互式终端 (REPL):安装 evcxr_repl 以获得交互式的 Rust REPL 环境(类似于 Python 的 REPL,但针对 Rust):
cargo install --locked evcxr_repl
evcxr   # 启动 REPL,交互式地输入 Rust 表达式

Rust 本地安装指南

  • 可以通过以下方法在本地安装 Rust:
    • Windows:https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe
    • Linux / WSL:curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • Rust 生态由以下核心组件构成:
    • rustc 是独立的编译器,但很少被直接使用。
    • 首选工具为 cargo,它是 Rust 的“瑞士军刀”,用于依赖管理、构建、测试、格式化、Lint 检查等。
    • Rust 工具链分为 stable(稳定版)、beta(测试版)和 nightly(开发版/实验版)三个渠道,本课程将统一使用 stable。使用 rustup update 命令可以升级每六周发布一次的 stable 版本。
  • 我们还将为 VSCode 安装 rust-analyzer 插件。

Rust 软件包 (Crates)

  • Rust 二进制文件是使用软件包(以下称为 Crates)创建的。
    • 一个 Crate 可以是独立的,也可以依赖于其他 Crates。依赖可以是本地的,也可以是远程的。第三方 Crates 通常从名为 crates.io 的集中式注册中心下载。
    • cargo 工具会自动处理 Crates 及其依赖的下载。这在概念上等同于链接 C 语言库。
    • Crate 依赖在名为 Cargo.toml 的文件中定义。该文件还定义了 Crate 的目标类型:可独立执行文件、静态库、动态库(不常用)。
  • 参考资料:https://doc.rust-lang.org/cargo/reference/cargo-targets.html

Cargo 与传统 C 构建系统对比

依赖管理对比

graph TD
    subgraph "传统 C 构建过程"
        CC["C 源文件<br/>(.c, .h)"]
        CM["手动编写 Makefile<br/>或 CMake"]
        CL["链接器 (Linker)"]
        CB["最终二进制文件"]
        
        CC --> CM
        CM --> CL
        CL --> CB
        
        CDep["手动维护依赖"]
        CLib1["libcurl-dev<br/>(apt install)"]
        CLib2["libjson-dev<br/>(apt install)"]
        CInc["手动配置头文件路径<br/>-I/usr/include/curl"]
        CLink["手动指定链接库<br/>-lcurl -ljson"]
        
        CDep --> CLib1
        CDep --> CLib2
        CLib1 --> CInc
        CLib2 --> CInc
        CInc --> CM
        CLink --> CL
        
        C_ISSUES["[错误] 版本冲突<br/>[错误] 平台差异<br/>[错误] 依赖项缺失<br/>[错误] 链接顺序关键<br/>[错误] 无自动更新"]
    end
    
    subgraph "Rust Cargo 构建过程"
        RS["Rust 源文件<br/>(.rs)"]
        CT["Cargo.toml<br/>[dependencies]<br/>reqwest = '0.11'<br/>serde_json = '1.0'"]
        CRG["Cargo 构建系统"]
        RB["最终二进制文件"]
        
        RS --> CRG
        CT --> CRG
        CRG --> RB
        
        CRATES["crates.io<br/>(软件包注册中心)"]
        DEPS["自动完成依赖解析"]
        LOCK["Cargo.lock<br/>(版本锁定)"]
        
        CRATES --> DEPS
        DEPS --> CRG
        CRG --> LOCK
        
        R_BENEFITS["[OK] 语义化版本控制 (SemVer)<br/>[OK] 自动下载<br/>[OK] 跨平台一致<br/>[OK] 传递性依赖处理<br/>[OK] 可重演构建"]
    end
    
    style C_ISSUES fill:#ff6b6b,color:#000
    style R_BENEFITS fill:#91e5a3,color:#000
    style CM fill:#ffa07a,color:#000
    style CDep fill:#ffa07a,color:#000
    style CT fill:#91e5a3,color:#000
    style CRG fill:#91e5a3,color:#000
    style DEPS fill:#91e5a3,color:#000
    style CRATES fill:#91e5a3,color:#000

Cargo 项目结构

my_project/
|-- Cargo.toml          # 项目配置 (类似于 package.json 或 pyproject.toml)
|-- Cargo.lock          # 具体的依赖版本 (自动生成,由 Cargo 维护)
|-- src/
|   |-- main.rs         # 二进制程序入口
|   |-- lib.rs          # 库的根文件 (如果正在创建一个库)
|   `-- bin/            # 额外的二进制目标
|-- tests/              # 集成测试
|-- examples/           # 示例代码
|-- benches/            # 基准测试
`-- target/             # 构建产物 (类似于 C 中的 build/ 或 obj/ 文件夹)
    |-- debug/          # 调试版本 (编译快,执行慢)
    `-- release/        # 发布版本 (编译慢,高度优化)

常用 Cargo 命令

graph LR
    subgraph "项目生命周期"
        NEW["cargo new my_project<br/>[新建] 创建新项目"]
        CHECK["cargo check<br/>[语法检查] 快速类型及语法校验"]
        BUILD["cargo build<br/>[构建] 编译整个项目"]
        RUN["cargo run<br/>[运行] 编译并执行程序"]
        TEST["cargo test<br/>[测试] 执行所有测试用例"]
        
        NEW --> CHECK
        CHECK --> BUILD
        BUILD --> RUN
        BUILD --> TEST
    end
    
    subgraph "进阶工具"
        UPDATE["cargo update<br/>[更新] 更新依赖包版本"]
        FORMAT["cargo fmt<br/>[格式化] 统一代码风格"]
        LINT["cargo clippy<br/>[Lint] 获取优化建议"]
        DOC["cargo doc<br/>[文档] 生成项目文档"]
        PUBLISH["cargo publish<br/>[发布] 发布到 crates.io"]
    end
    
    subgraph "构建配置 (Profiles)"
        DEBUG["cargo build<br/>(debug 配置)<br/>编译速度快<br/>运行速度慢<br/>带调试符号"]
        RELEASE["cargo build --release<br/>(release 配置)<br/>编译速度慢<br/>高度优化性能<br/>产物最小"]
    end
    
    style NEW fill:#a3d5ff,color:#000
    style CHECK fill:#91e5a3,color:#000
    style BUILD fill:#ffa07a,color:#000
    style RUN fill:#ffcc5c,color:#000
    style TEST fill:#c084fc,color:#000
    style DEBUG fill:#94a3b8,color:#000
    style RELEASE fill:#ef4444,color:#000

案例:Cargo 与 Crates

  • 在这个例子中,我们将创建一个没有任何外部依赖的独立可执行 Crate。
  • 使用以下命令创建一个名为 helloworld 的新 Crate:
cargo new helloworld
cd helloworld
cat Cargo.toml
  • 默认情况下,cargo run 将会编译并运行该 Crate 的 debug(非优化)版本。若要执行 release 版本,请使用 cargo run --release。
  • 请注意,实际生成的二进制文件位于 target 文件夹下的 debug 或 release 子目录中。
  • 你可能已经注意到了 source 所在的文件夹中有一个名为 Cargo.lock 的文件。它是自动生成的,不应该手动修改。
    • 我们稍后会详细讨论 Cargo.lock 的具体作用。

English Original

Rust 内建类型

你将学到: Rust 的基础类型(i32、u64、f64、bool、char)、类型推导、显式类型标注,以及它们如何与 C/C++ 的原始类型进行对比。Rust 不允许隐式转换 —— 必须进行显式类型转换。

  • Rust 具有类型推导功能,但也允许显式指定类型。
描述类型示例
有符号整数i8, i16, i32, i64, i128, isize-1, 42, 1_00_000, 1_00_000i64
无符号整数u8, u16, u32, u64, u128, usize0, 42, 42u32, 42u64
浮点数f32, f640.0, 0.42
Unicode 字符char‘a’, ‘$’
布尔值booltrue, false
  • Rust 允许在数字之间任意使用 _ 以提高可读性。

Rust 类型指定与赋值

  • Rust 使用 let 关键字为变量赋值。变量的类型可以可选地跟在 : 之后。
fn main() {
    let x : i32 = 42;
    // 这两个赋值在逻辑上是等价的
    let y : u32 = 42;
    let z = 42u32;
}
  • 函数参数和返回值(如果有)必须显式指定类型。以下函数接收一个 u8 参数并返回 u32:
#![allow(unused)]
fn main() {
fn foo(x : u8) -> u32
{
    return x as u32 * x as u32;
}
}
  • 未使用的变量应以 _ 为前缀,以避免编译器警告。

Rust 类型推导

fn secret_of_life_u32(x : u32) {
    println!("u32 类型的 secret_of_life 是 {}", x);
}

fn secret_of_life_u8(x : u8) {
    println!("u8 类型的 secret_of_life 是 {}", x);
}

fn main() {
    let a = 42; // let 关键字赋值;a 的类型被推导为 u32
    let b = 42; // let 关键字赋值;b 的推导类型为 u8
    secret_of_life_u32(a);
    secret_of_life_u8(b);
}

Rust 变量与可变性

  • Rust 的变量默认是不可变 (Immutable) 的,除非显式使用 mut 关键字标识。例如,除非将 let a = 42 改为 let mut a = 42,否则以下代码将无法通过编译:
fn main() {
    let a = 42; // 必须改为 let mut a = 42 才能允许下方的赋值语句
    a = 43;  // 除非进行上述修改,否则此行无法编译
}
  • Rust 允许变量名重用(变量遮蔽 / Shadowing):
fn main() {
    let a = 42;
    {
        let a = 43; // OK: 另一个同名变量,遮蔽了外层的 a
    }
    // a = 43; // 不允许直接修改不可变变量
    let a = 43; // OK: 创建了全新的变量 a 进行了新赋值
}

English Original

Rust if 关键字

你将学到: Rust 的控制流结构 —— 作为表达式的 if/else、loop/while/for、match,以及它们如何区别于 C/C++。关键点:大多数 Rust 控制流结构都会返回一个值。

  • 在 Rust 中,if 实际上是一个表达式(Expression),也就是说它可以被用于赋值操作,但同时也像语句(Statement)一样工作。▶ 点击尝试
fn main() {
    let x = 42;
    if x < 42 {
        println!("比生命之秘要小");
    } else if x == 42 {
        println!("等于生命之秘");
    } else {
        println!("比生命之秘要大");
    }
    let is_secret_of_life = if x == 42 {true} else {false};
    println!("{}", is_secret_of_life);
}

使用 while 和 for 实现循环

  • while 关键字可用于在表达式为真时进行循环:
fn main() {
    let mut x = 40;
    while x != 42 {
        x += 1;
    }
}
  • for 关键字可用于在范围内进行迭代:
fn main() {
    // 不会打印 43;欲包含最后一个元素请使用 40..=43
    for x in 40..43 {
        println!("{}", x);
    } 
}

使用 loop 实现循环

  • loop 关键字创建一个无限循环,直到遇到 break:
fn main() {
    let mut x = 40;
    // 将其改为 'here: loop 以便为该循环指定可选标签
    loop {
        if x == 42 {
            break; // 使用 break x; 可直接返回 x 的值
        }
        x += 1;
    }
}
  • break 语句可以包含一个可选的表达式,用于从 loop 表达式返回一个值。
  • continue 关键字可用于直接返回到循环体顶部。
  • 循环标签(Loop Labels)可与 break 或 continue 搭配使用,在处理嵌套循环时非常有用。

Rust 表达式块

  • Rust 表达式块(Expression Blocks)仅仅是一系列包裹在 {} 中的表达式。块的评估值就是块中最后一个表达式的值。
fn main() {
    let x = {
        let y = 40;
        y + 2 // 注意:分号 ; 必须省略
    };
    println!("{x}");
}
  • Rust 的习惯用法是利用这种特性在函数中省略 return 关键字:
fn is_secret_of_life(x: u32) -> bool {
    // 等效于 if x == 42 {true} else {false}
    x == 42 // 注意:分号 ; 必须省略 
}
fn main() {
    println!("{}", is_secret_of_life(42));
}

5. 数据结构

English Original

Rust 数组类型

你将学到: Rust 的核心数据结构 —— 数组 (Arrays)、元组 (Tuples)、切片 (Slices)、字符串 (Strings)、结构体 (Structs)、Vec 以及 HashMap。这是一个内容密集的章节;请重点理解 String 与 &str 的区别,以及结构体的工作原理。你将在第 7 章深入复习引用与借用。

  • 数组包含固定数量的相同类型的元素。
    • 与所有其他 Rust 类型一样,数组默认是不可变的(除非显式使用 mut)。
    • 数组使用 [] 进行索引,并且会进行边界检查。可以使用 len() 方法获取数组的长度。
    fn get_index(y : usize) -> usize {
        y+1        
    }
    
    fn main() {
        // 初始化一个包含 3 个元素的数组,并将它们全设为 42
        let a : [u8; 3] = [42; 3];
        // 替代语法
        // let a = [42u8, 42u8, 42u8];
        for x in a {
            println!("{x}");
        }
        let y = get_index(a.len());
        // 取消下方注释将导致运行时的 Panic (崩溃)
        //println!("{}", a[y]);
    }

数组类型 (续)

  • 数组可以嵌套。
    • Rust 有几种内建的打印格式化程序。在下方代码中,:? 是 debug 打印格式化程序。使用 :#? 则可以进行“美化打印” (pretty print)。这些格式化程序可以针对每个类型进行自定义(后续会详细介绍)。
    fn main() {
        let a = [
            [40, 0], // 定义嵌套数组
            [41, 0],
            [42, 1],
        ];
        for x in a {
            println!("{x:?}");
        }
    }

Rust 元组 (Tuples)

  • 元组具有固定大小,可以将任意类型组合成单个复合类型。
    • 组成的各个类型可以通过它们的相对位置(.0, .1, .2, …)进行索引。空元组 () 被称为单元值 (Unit Value),等同于 C 语言中的 void 返回值。
    • Rust 支持元组解构(Destructuring),方便将变量绑定到各个元素上。
fn get_tuple() -> (u32, bool) {
    (42, true)        
}

fn main() {
   let t : (u8, bool) = (42, true);
   let u : (u32, bool) = (43, false);
   println!("{}, {}", t.0, t.1);
   println!("{}, {}", u.0, u.1);
   let (num, flag) = get_tuple(); // 元组解构
   println!("{num}, {flag}");
}

Rust 引用 (References)

  • Rust 中的引用大致等同于 C 中的指针,但存在一些关键区别:
    • 在任何时间点,可以有任意数量的只读 (不可变) 变量引用。引用不能超出变量的作用域(这是一个名为生命周期 (Lifetime) 的核心概念;稍后详细讨论)。
    • 对一个可变变量,只允许有一个可写 (可变) 引用,且该引用不能与其他任何引用重叠。
fn main() {
    let mut a = 42;
    {
        let b = &a;
        let c = b;
        println!("{} {}", *b, *c); // 编译器会自动解引用 *c
        
        let d = &mut a;
        
        /* 
         * 取消下方注释将导致程序无法编译,
         * 因为在可变引用 `d` 处于当前作用域活跃状态时使用了 `b`。
         * 
         * 你不能在同一作用域内同时使用可变引用和不可变引用!
         */
        // println!("{}", *b);
    }
    let d = &mut a; // OK: b 和 c 已不在作用域内
    *d = 43;
}

Rust 切片 (Slices)

  • Rust 引用可用于创建数组的子集:
    • 与长度在编译时即固定的数组不同,切片的大小可以是任意的。在内部,切片是通过“胖指针 (Fat-pointer)”实现的,其中包含切片的长度以及指向原始数组起始元素的指针。
fn main() {
    let a = [40, 41, 42, 43];
    let b = &a[1..a.len()]; // 包含从第二个元素开始的切片
    let c = &a[1..]; // 与上方等效
    let d = &a[..]; // 与 &a[0..] 或 &a[0..a.len()] 等效
    println!("{b:?} {c:?} {d:?}");
}

Rust 常量 (Constants) 与 静态变量 (Statics)

  • const 关键字可用于定义常量。常量值在编译时进行求值,并会内联到程序中。
  • static 关键字用于定义类似于 C/C++ 中的全局变量。静态变量具有可寻址的内存位置,且在程序整个生命周期内只被创建一次。
const SECRET_OF_LIFE: u32 = 42;
static GLOBAL_VARIABLE : u32 = 2;
fn main() {
    println!("生命之秘是 {}", SECRET_OF_LIFE);
    println!("全局变量的值是 {GLOBAL_VARIABLE}")
}

Rust 字符串:String vs &str

  • Rust 有 两类 字符串类型,分别用于不同目的:
    • String —— 有所有权的、堆分配的、可增长的(类似于 C 语言中使用 malloc 分配的缓冲区,或 C++ 中的 std::string)。
    • &str —— 借用的、轻量级的引用(类似于 C 语言中带有长度信息的 const char*,或 C++ 中的 std::string_view —— 但 &str 是经过生命周期检查的,因此永远不会产生悬空引用)。
    • 与 C 语言中以 null 结尾的字符串不同,Rust 字符串会追踪其长度,并保证是有效的 UTF-8 编码。

针对 C++ 开发者:String ≈ std::string,&str ≈ std::string_view。与 std::string_view 不同的是,&str 通过借用检查器保证在其整个生命周期内都是有效的。

String vs &str:所有权与借用

生产环境模式:参见 JSON 处理:nlohmann::json → serde 了解在生产代码中字符串处理如何与 serde 配合工作。

维度C char*C++ std::stringRust StringRust &str
内存手动管理 (malloc/free)堆分配,拥有缓冲区堆分配,自动释放借用引用 (生命周期检查)
可变性始终可通过指针修改可变使用 mut 时可变始终不可变
长度信息无 (依赖 '\0')追踪长度与容量追踪长度与容量追踪长度 (胖指针)
编码未指定 (通常为 ASCII)未指定 (通常为 ASCII)保证为有效的 UTF-8保证为有效的 UTF-8
Null 终止符必须有必须有 (c_str())不使用不使用
fn main() {
    // &str - 字符串切片 (借用的、不可变的,通常是字符串字面量)
    let greeting: &str = "你好";  // 指向只读内存

    // String - 有所有权的、堆分配的、可增长的
    let mut owned = String::from(greeting);  // 将数据复制到堆中
    owned.push_str(",Rust 世界!");        // 增长字符串
    owned.push('!');                       // 追加单个字符

    // 在 String 和 &str 之间转换
    let slice: &str = &owned;          // String -> &str (开销极低,仅为借用)
    let owned2: String = slice.to_string();  // &str -> String (涉及内存分配)
    let owned3: String = String::from(slice); // 与上方等效

    // 字符串拼接 (注意:+ 会消耗左侧的操作数)
    let hello = String::from("Hello");
    let world = String::from(", World!");
    let combined = hello + &world;  // hello 被移动 (消耗),world 被借用
    // println!("{hello}");  // 无法编译:hello 已经被移动了

    // 使用 format! 宏避免移动问题
    let a = String::from("Hello");
    let b = String::from("World");
    let combined = format!("{a}, {b}!");  // a 和 b 都不会被消耗

    println!("{combined}");
}

为什么不能直接使用 [] 索引字符串

fn main() {
    let s = String::from("hello");
    // let c = s[0];  // 无法编译!Rust 字符串是 UTF-8 编码,而非简单的字节数组

    // 安全的替代方案:
    let first_char = s.chars().next();           // Option<char>: Some('h')
    let as_bytes = s.as_bytes();                 // &[u8]: 原始 UTF-8 字节
    let substring = &s[0..1];                    // &str: "h" (字节范围,必须在有效的 UTF-8 边界上)

    println!("首字符: {:?}", first_char);
    println!("字节序列: {:?}", &as_bytes[..5]);
}

练习:字符串操作

🟢 入门级

  • 编写一个函数 fn count_words(text: &str) -> usize,用于计算字符串中由空格分隔的单词数量。
  • 编写一个函数 fn longest_word(text: &str) -> &str,返回字符串中最长的单词(提示:你需要思考生命周期 —— 为什么返回类型必须是 &str 而不是 String?)。
参考答案 (点击展开)
fn count_words(text: &str) -> usize {
    text.split_whitespace().count()
}

fn longest_word(text: &str) -> &str {
    text.split_whitespace()
        .max_by_key(|word| word.len())
        .unwrap_or("")
}

fn main() {
    let text = "the quick brown fox jumps over the lazy dog";
    println!("单词数: {}", count_words(text));       // 9
    println!("最长单词: {}", longest_word(text));     // "jumps"
}

Rust 结构体 (Structs)

  • struct 关键字用于声明用户自定义的结构体类型。
    • struct 成员既可以是命名的,也可以是匿名的(元组结构体)。
  • 与 C++ 等语言不同,Rust 中没有“数据继承”的概念。
fn main() {
    struct MyStruct {
        num: u32,
        is_secret_of_life: bool,
    }
    let x = MyStruct {
        num: 42,
        is_secret_of_life: true,
    };
    let y = MyStruct {
        num: x.num,
        is_secret_of_life: x.is_secret_of_life,
    };
    let z = MyStruct { num: x.num, ..x }; // ..x 表示复制剩余的未显式指定的字段
    println!("{} {} {}", x.num, y.is_secret_of_life, z.num);
}

Rust 元组结构体 (Tuple Structs)

  • Rust 元组结构体与元组类似,其具体的各个字段没有名称。
    • 与元组一样,各个元素通过 .0, .1, .2, … 进行访问。元组结构的一个常见用例是包装原始类型以创建自定义类型。这对于避免混淆同一类型的不同含义非常有用。
struct WeightInGrams(u32);
struct WeightInMilligrams(u32);
fn to_weight_in_grams(kilograms: u32) -> WeightInGrams {
    WeightInGrams(kilograms * 1000)
}

fn to_weight_in_milligrams(w : WeightInGrams) -> WeightInMilligrams  {
    WeightInMilligrams(w.0 * 1000)
}

fn main() {
    let x = to_weight_in_grams(42);
    let y = to_weight_in_milligrams(x);
    // let z : WeightInGrams = x;  // 无法编译:x 已经在调用 to_weight_in_milligrams() 时移动了 (Move)
    // let a : WeightInGrams = y;   // 无法编译:类型不匹配 (WeightInMilligrams 与 WeightInGrams 不同)
}

注意:#[derive(...)] 属性可以为结构体和枚举自动生成常见的 Trait 实现。你会在本课程中经常看到它:

#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{:?}", p);           // Debug: 因为 #[derive(Debug)] 而生效
    let p2 = p.clone();           // Clone: 因为 #[derive(Clone)] 而生效
    assert_eq!(p, p2);            // PartialEq: 因为 #[derive(PartialEq)] 而生效
}

我们稍后会深入探讨 Trait 系统,但 #[derive(Debug)] 非常实用,你应该为几乎每个创建的 struct 和 enum 都加上它。


Rust Vec 类型

  • Vec<T> 类型实现了动态的堆分配缓冲区(类似于 C 语言中手动管理的 malloc/realloc 数组,或 C++ 中的 std::vector)。
    • 与大小固定的数组不同,Vec 可以在运行时增长或缩小。
    • Vec 拥有其数据的所有权,并自动管理内存的分配与释放。
  • 常用操作:push()、pop()、insert()、remove()、len()、capacity()。
fn main() {
    let mut v = Vec::new();    // 创建空向量,类型根据后续使用推导
    v.push(42);                // 在末尾添加元素 - Vec<i32>
    v.push(43);                
    
    // 安全迭代 (推荐方式)
    for x in &v {              // 借用元素,不消耗向量的所有权
        println!("{x}");
    }
    
    // 初始化快捷方式
    let mut v2 = vec![1, 2, 3, 4, 5];           // 使用宏进行初始化
    let v3 = vec![0; 10];                       // 初始化为 10 个 0
    
    // 安全的访问方法 (优于通过索引访问)
    match v2.get(0) {
        Some(first) => println!("首个元素: {first}"),
        None => println!("空向量"),
    }
    
    // 实用方法
    println!("长度: {}, 容量: {}", v2.len(), v2.capacity());
    if let Some(last) = v2.pop() {             // 移除并返回最后一个元素
        println!("弹出的元素: {last}");
    }
    
    // 危险操作:直接索引访问 (可能导致 Panic!)
    // println!("{}", v2[100]);  // 将在运行时导致崩溃
}

生产环境模式:参见 避免未检查的索引访问 了解生产环境 Rust 代码中关于 .get() 的安全模式。

Rust HashMap 类型

  • HashMap 实现了通用的 键 (Key) -> 值 (Value) 查找(也称为“字典”或“映射”)。
fn main() {
    use std::collections::HashMap;      // 与 Vec 不同,HashMap 需要显式导入
    let mut map = HashMap::new();       // 分配一个空的 HashMap
    map.insert(40, false);  // 类型被推导为 int -> bool
    map.insert(41, false);
    map.insert(42, true);
    for (key, value) in map {
        println!("{key} {value}");
    }
    let map = HashMap::from([(40, false), (41, false), (42, true)]);
    if let Some(x) = map.get(&43) {
        println!("43 映射到了 {:?}", x);
    } else {
        println!("未找到 43 的映射");
    }
    let x = map.get(&43).or(Some(&false));  // 如果未找到键,则提供默认值
    println!("{x:?}"); 
}

练习:Vec 与 HashMap

🟢 入门级

  • 创建一个带有若干条目的 HashMap<u32, bool>(确保其中有些值为 true,有些为 false)。遍历该 HashMap 的所有元素,将键 (Keys) 放入一个 Vec 中,将值 (Values) 放入另一个 Vec 中。
参考答案 (点击展开)
use std::collections::HashMap;

fn main() {
    let map = HashMap::from([(1, true), (2, false), (3, true), (4, false)]);
    let mut keys = Vec::new();
    let mut values = Vec::new();
    for (k, v) in &map {
        keys.push(*k);
        values.push(*v);
    }
    println!("键 (Keys):   {:?}", keys);
    println!("值 (Values): {:?}", values);

    // 替代方案:使用带有 unzip() 的迭代器
    let (keys2, values2): (Vec<u32>, Vec<bool>) = map.into_iter().unzip();
    println!("键 (unzip):   {:?}", keys2);
    println!("值 (unzip):   {:?}", values2);
}

深度解析:C++ 引用 vs Rust 引用

针对 C++ 开发者:C++ 程序员通常假设 Rust 的 &T 与 C++ 的 T& 工作方式相同。虽然表面上相似,但存在一些容易引起混淆的根本区别。C 开发者可以跳过此部分 —— 关于 Rust 引用的内容在 所有权与借用 中有详细介绍。

1. 没有右值引用 (Rvalue References) 或万能引用 (Universal References)

在 C++ 中,&& 根据上下文有两种含义:

// C++: && 代表不同的含义:
int&& rref = 42;           // 右值引用 — 绑定到临时变量
void process(Widget&& w);   // 右值引用 — 调用者必须显式调用 std::move

// 万能(转发)引用 — 模板推导上下文:
template<typename T>
void forward(T&& arg) {     // 注意:这不一定是右值引用!取决于推导为 T& 还是 T&&
    inner(std::forward<T>(arg));  // 完美转发
}

在 Rust 中:这些都不存在。 && 仅仅是逻辑“与 (AND)”运算符。

#![allow(unused)]
fn main() {
// Rust: && 仅仅是逻辑与运算符
let a = true && false; // false

// Rust 没有右值引用,没有万能引用,也没有完美转发。
// 取而代之的是:
//   - 对于非 Copy 类型,移动 (Move) 是默认行为(无需显式调用 std::move)
//   - 泛型 + Trait 约束取代了万能引用
//   - 没有“绑定到临时变量”的特殊区分 —— 值就是值

fn process(w: Widget) { }      // 获取所有权(类似于 C++ 的值传递参数 + 隐式移动)
fn process_ref(w: &Widget) { } // 不可变借用(类似于 C++ 的 const T&)
fn process_mut(w: &mut Widget) { } // 可变借用(类似于 C++ 的 T&,但是具有排他性)
}
C++ 概念Rust 等价概念备注
T& (左值引用)&T 或 &mut TRust 将其拆分为共享引用与独占引用
T&& (右值引用)直接使用 T按值接收 = 获取所有权
模板中的 T&& (万能引用)impl Trait 或 <T: Trait>泛型取代了转发机制
std::move(x)x (直接使用)移动是默认行为
std::forward<T>(x)无需等价物没有万能引用需要转发

2. 移动是按字节进行的 —— 没有移动构造函数

在 C++ 中,移动是一个用户定义的操作(通过移动构造函数 / 移动赋值运算符实现)。而在 Rust 中,移动始终是对值的 按字节进行的内存拷贝 (bitwise memcpy),并且原变量会失效:

#![allow(unused)]
fn main() {
// Rust 的移动 = 拷贝字节,并将原变量标记为无效
let s1 = String::from("hello");
let s2 = s1; // s1 的字节被拷贝到 s2 的栈槽中
              // s1 现在失效了 —— 编译器会强制执行这一点
// println!("{s1}"); // ❌ 编译错误:值在移动后被使用
}
// C++ 的移动 = 调用移动构造函数 (用户定义的!)
std::string s1 = "hello";
std::string s2 = std::move(s1); // 调用字符串的移动构造函数
// s1 现在处于“有效但未指定状态”的“僵尸”状态
std::cout << s1; // 能够编译!打印结果... 不确定 (通常是空字符串)

结论:

  • Rust 没有“五法则”(Rule of Five)—— 不需要定义拷贝构造函数、移动构造函数、拷贝赋值、移动赋值或析构函数。
  • 没有移动后的“僵尸”状态 —— 编译器直接禁止访问。
  • 移动时无需考虑 noexcept —— 按字节拷贝不会抛出异常。

3. 自动解引用 (Auto-Deref):编译器透视间接引用

Rust 通过 Deref trait 自动对多层指针/包装器进行解引用。这在 C++ 中没有等价物:

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};

// 嵌套包装:Arc<Mutex<Vec<String>>>
let data = Arc::new(Mutex::new(vec!["hello".to_string()]));

// 在 C++ 中,你需要在每一层进行显式解锁和手动解引用。
// 在 Rust 中,编译器会自动通过 Arc → Mutex → MutexGuard → Vec 进行解引用:
let guard = data.lock().unwrap(); // Arc 自动解引用为 Mutex
let first: &str = &guard[0];      // MutexGuard→Vec (Deref), Vec[0] (Index),
                                   // &String→&str (Deref 强制转换)
println!("首个元素: {first}");

// 方法调用也支持自动解引用:
let boxed_string = Box::new(String::from("hello"));
println!("长度: {}", boxed_string.len());  // Box→String,然后调用 String::len()
// 无需写成 (*boxed_string).len() 或 boxed_string->len()
}

Deref 强制转换 (Deref coercion) 也适用于函数参数 —— 编译器会自动插入解引用动作以使类型匹配:

fn greet(name: &str) {
    println!("你好,{name}");
}

fn main() {
    let owned = String::from("Alice");
    let boxed = Box::new(String::from("Bob"));
    let arced = std::sync::Arc::new(String::from("Carol"));

    greet(&owned);  // &String → &str  (1 次解引用转换)
    greet(&boxed);  // &Box<String> → &String → &str  (2 次解引用转换)
    greet(&arced);  // &Arc<String> → &String → &str  (2 次解引用转换)
    greet("Dave");  // 本身就是 &str — 无需转换
}
// 在 C++ 中,你可能需要为每种情况调用 .c_str() 或进行显式转换。

Deref 链:当你调用 x.method() 时,Rust 的方法解析器会尝试接收类型 T,然后是 &T,接着是 &mut T。如果都不匹配,它会通过 Deref trait 进行解引用,并对目标类型重复上述过程。这一过程可以穿透多层 —— 这就是为什么 Box<Vec<T>> 可以像 Vec<T> 一样“无缝工作”的原因。Deref 强制转换(针对函数参数)是一个相关但独立的机制,它通过链接 Deref 实现将 &Box<String> 自动转换为 &str。


4. 没有空引用,也没有可选引用

// C++: 引用理论上不能为 null,但指针可以,且两者界限模糊
Widget& ref = *ptr;  // 如果 ptr 为 null → 触发未定义行为 (UB)
Widget* opt = nullptr;  // 通过指针实现“可选”引用
#![allow(unused)]
fn main() {
// Rust: 引用始终有效 —— 由借用检查器保证
// 在安全代码中无法创建空引用或悬空引用
let r: &i32 = &42; // 始终有效

// “可选引用”必须显式表达:
let opt: Option<&Widget> = None; // 意图清晰,非空指针
if let Some(w) = opt {
    w.do_something(); // 仅当引用存在时才可访问
}
}

5. 引用不可被“重定向 (Reseated)”

// C++: 引用是别名 — 一旦绑定即不可更改
int a = 1, b = 2;
int& r = a;
r = b;  // 这是将 b 的值赋给 a — 而不是让 r 重新绑定到 b!
// a 的值现在是 2,r 依然指向 a
#![allow(unused)]
fn main() {
// Rust: let 绑定可以被遮蔽 (Shadowing),但引用遵循不同的规则
let a = 1;
let b = 2;
let r = &a;
// r = &b;   // ❌ 无法为不可变变量重新赋值
let r = &b;  // ✅ 但你可以使用新的绑定“遮蔽”原有的 r
             // 旧的绑定已不复存在,但这并非重定向原有引用

// 使用 mut 关键字:
let mut r = &a;
r = &b;      // ✅ r 现在指向了 b —— 这就是重定向 (而不是通过引用赋值)
}

心智模型:在 C++ 中,引用是一个对象的永久别名。在 Rust 中,引用是一个值(一个带有生命周期保证的指针),它遵循普通的变量绑定规则 —— 默认不可变,只有在声明为 mut 时才可以重新绑定。


English Original

Rust 枚举类型 (Enums)

你将学到: 作为可辨识联合 (Discriminated Unions) 的 Rust 枚举(真正好用的标签联合)、用于穷尽性模式匹配的 match,以及枚举如何通过编译器强制的安全机制取代 C++ 类层次结构和 C 的标签联合。

  • 枚举类型是可辨识联合,即它们是多种可能类型的“求和类型 (Sum Type)”,带有一个用于标识具体变体 (Variant) 的标签。
    • 对于 C 开发者:Rust 中的枚举可以携带数据(真正好用的标签联合 —— 编译器会追踪哪个变体是活跃的)。
    • 对于 C++ 开发者:Rust 的枚举类似于 std::variant,但支持穷尽性模式匹配,没有 std::get 异常,也没有 std::visit 那样繁琐的样板代码。
    • enum 的大小由其可能的最大变体决定。各个变体之间没有关联,并且可以拥有完全不同的类型。
    • enum 类型是 Rust 最强大的特性之一 —— 它们可以取代 C++ 中复杂的类继承体系(详见后续案例研究章节)。
fn main() {
    enum Numbers {
        Zero,
        SmallNumber(u8),
        BiggerNumber(u32),
        EvenBiggerNumber(u64),
    }
    let a = Numbers::Zero;
    let b = Numbers::SmallNumber(42);
    let c : Numbers = a; // OK -- a 的类型是 Numbers
    let d : Numbers = b; // OK -- b 的类型是 Numbers
}

Rust match 语句

  • Rust 的 match 相当于“加强版”的 C 语言 switch 语句:
    • match 可用于对简单数据类型、struct、enum 进行模式匹配。
    • match 语句必须是穷尽的 (Exhaustive),即它们必须复盖给定类型的所有可能情况。_ 可以用作捕获“所有其他情况”的通配符。
    • match 可以产生一个值,但所有分支 (=>) 必须返回相同类型的值。
fn main() {
    let x = 42;
    // 在这里,_ 复盖了除明确列出以外的所有数字
    let is_secret_of_life = match x {
        42 => true, // 返回布尔值
        _ => false, // 返回布尔值
        // 下行无法编译,因为返回类型不是布尔值
        // _ => 0  
    };
    println!("{is_secret_of_life}");
}

Rust match 语句的应用

  • match 支持范围匹配、布尔过滤器以及 if 守卫 (Guard) 语句。
fn main() {
    let x = 42;
    match x {
        // 注意:..=41 表示包含 41 的闭区间范围
        0..=41 => println!("小于生命之秘"),
        42 => println!("生命之秘"),
        _ => println!("大于生命之秘"),
    }
    let y = 100;
    match y {
        100 if x == 43 => println!("y 百分之百不是生命之秘"),
        100 if x == 42 => println!("y 百分之百是生命之秘"),
        _ => (),    // 什么都不做
    }
}

Rust match 语句与枚举

  • match 和 enum 经常配合使用:
    • match 语句可以将其包含的值“绑定”到一个变量上。如果不对值感兴趣,请使用 _。
    • matches! 宏可用于测试是否匹配特定的变体。
fn main() {
    enum Numbers {
        Zero,
        SmallNumber(u8),
        BiggerNumber(u32),
        EvenBiggerNumber(u64),
    }
    let b = Numbers::SmallNumber(42);
    match b {
        Numbers::Zero => println!("零"),
        Numbers::SmallNumber(value) => println!("小数字 {value}"),
        Numbers::BiggerNumber(_) | Numbers::EvenBiggerNumber(_) => println!("较大的数字或更大的数字"),
    }
    
    // 针对特定变体进行布尔判断
    if matches!(b, Numbers::Zero | Numbers::SmallNumber(_)) {
        println!("匹配到了 零 或者 小数字");
    }
}

Rust match 语句与解构

  • match 还可以针对解构 (Destructuring) 和切片 (Slices) 执行匹配:
fn main() {
    struct Foo {
        x: (u32, bool),
        y: u32
    }
    let f = Foo {x: (42, true), y: 100};
    match f {
        // 将 x 的值捕获到一个叫做 tuple 的变量中
        Foo{y: 100, x : tuple} => println!("匹配到了 x: {tuple:?}"),
        _ => ()
    }
    let a = [40, 41, 42];
    match a {
        // 切片的最后一个元素必须是 42。使用 @ 进行匹配绑定
        [rest @ .., 42] => println!("剩余元素: {rest:?}"),
        // 切片的第一个元素必须是 42。使用 @ 进行匹配绑定
        [42, rest @ ..] => println!("剩余元素: {rest:?}"),
        _ => (),
    }
}

练习:利用 match 和 enum 实现加减法计算

🟢 入门级

  • 编写一个函数,对 64 位无符号整数执行算术运算。
  • 第一步:定义一个表示操作的枚举:
#![allow(unused)]
fn main() {
enum Operation {
    Add(u64, u64),
    Subtract(u64, u64),
}
}
  • 第二步:定义一个表示结果的枚举:
#![allow(unused)]
fn main() {
enum CalcResult {
    Ok(u64),                    // 成功结果
    Invalid(String),            // 无效操作的错误信息
}
}
  • 第三步:实现 calculate(op: Operation) -> CalcResult 函数
    • 对于 Add:返回 Ok(sum)。
    • 对于 Subtract:如果第一个数 >= 第二个数,返回 Ok(difference),否则返回 Invalid("Underflow")。
  • 提示:在函数中使用模式匹配:
#![allow(unused)]
fn main() {
match op {
    Operation::Add(a, b) => { /* 你的代码 */ },
    Operation::Subtract(a, b) => { /* 你的代码 */ },
}
}
参考答案 (点击展开)
enum Operation {
    Add(u64, u64),
    Subtract(u64, u64),
}

enum CalcResult {
    Ok(u64),
    Invalid(String),
}

fn calculate(op: Operation) -> CalcResult {
    match op {
        Operation::Add(a, b) => CalcResult::Ok(a + b),
        Operation::Subtract(a, b) => {
            if a >= b {
                CalcResult::Ok(a - b)
            } else {
                CalcResult::Invalid("Underflow".to_string())
            }
        }
    }
}

fn main() {
    match calculate(Operation::Add(10, 20)) {
        CalcResult::Ok(result) => println!("10 + 20 = {result}"),
        CalcResult::Invalid(msg) => println!("错误: {msg}"),
    }
    match calculate(Operation::Subtract(5, 10)) {
        CalcResult::Ok(result) => println!("5 - 10 = {result}"),
        CalcResult::Invalid(msg) => println!("错误: {msg}"),
    }
}
// 输出示例:
// 10 + 20 = 30
// 错误: Underflow

Rust 关联方法 (Associated Methods)

  • impl 块可以为 struct、enum 等类型定义关联方法。
    • 方法可以可选地接收 self 作为参数。self 在概念上类似于 C 语言中作为第一个参数传递的结构体指针,或者是 C++ 中的 this。
    • 对 self 的引用可以是不可变的(默认:&self)、可变的(&mut self),或者获取所有权的(self)。
    • Self 关键字可以作为类型名的缩写。
struct Point {x: u32, y: u32}
impl Point {
    fn new(x: u32, y: u32) -> Self {
        Point {x, y}
    }
    fn increment_x(&mut self) {
        self.x += 1;
    }
}
fn main() {
    let mut p = Point::new(10, 20);
    p.increment_x();
}

练习:Point 的相加与转换

🟡 中级 —— 此练习旨在加深对方法签名中“移动 (Move)”与“借用 (Borrow)”区别的理解。

  • 为 Point 结构体实现以下关联方法:
    • add():接收另一个 Point,并原地增加当前点的 x 和 y 值(提示:使用 &mut self)。
    • transform():消耗现有的 Point(提示:使用 self),并返回一个新的 Point,其 x 和 y 值为原值的平方。
参考答案 (点击展开)
struct Point { x: u32, y: u32 }

impl Point {
    fn new(x: u32, y: u32) -> Self {
        Point { x, y }
    }
    fn add(&mut self, other: &Point) {
        self.x += other.x;
        self.y += other.y;
    }
    fn transform(self) -> Point {
        Point { x: self.x * self.x, y: self.y * self.y }
    }
}

fn main() {
    let mut p1 = Point::new(2, 3);
    let p2 = Point::new(10, 20);
    p1.add(&p2);
    println!("相加之后: x={}, y={}", p1.x, p1.y);           // x=12, y=23
    let p3 = p1.transform();
    println!("转换之后: x={}, y={}", p3.x, p3.y);           // x=144, y=529
    // p1 现在无法再被访问了 —— transform() 已经消耗了它的所有权
}

English Original

Rust 内存管理

你将学到: Rust 的所有权系统 (Ownership System) —— 它是该语言中唯一最重要的概念。在本章之后,你将理解移动语义 (Move Semantics)、借用规则以及 Drop trait。如果你掌握了本章内容,Rust 的其余部分将顺理成章。如果你感到吃力,请多读几遍 —— 对于大多数 C/C++ 开发者来说,所有权通常在读第二遍时才会真正领悟。

  • C/C++ 中的内存管理一直是 Bug 的滋生地:
    • 在 C 中:内存通过 malloc() 分配并使用 free() 释放。没有针对悬空指针、使用后释放 (Use-after-free) 或二次释放 (Double-free) 的检查机制。
    • 在 C++ 中:RAII (资源获取即初始化) 和智能指针有所帮助,但在 std::move(ptr) 之后代码依然能通过编译 —— 移动后使用 (Use-after-move) 属于未定义行为 (UB)。
  • Rust 使 RAII 变得万无一失:
    • 移动是破坏性的 —— 编译器拒绝让你触碰已经移动的原变量。
    • 无需“五法则 (Rule of Five)”(不需要手动定义拷贝构造、移动构造、拷贝赋值、移动赋值、析构函数)。
    • Rust 让你可以完全控制内存分配,但在编译时强制执行安全性。
    • 这一切是通过所有权、借用、可变性及生命周期等机制的结合来实现的。
    • Rust 的运行时分配可以同时发生在栈 (Stack) 和堆 (Heap) 上。

针对 C++ 开发者 —— 智能指针对应关系:

C++Rust安全性提升
std::unique_ptr<T>Box<T>不可能出现移动后使用
std::shared_ptr<T>Rc<T> (单线程)默认不存在引用循环
std::shared_ptr<T> (线程安全)Arc<T>显式的线程安全保证
std::weak_ptr<T>Weak<T>必须显式检查有效性
原始指针*const T / *mut T仅限于 unsafe 块中使用

对于 C 开发者:Box<T> 取代了 malloc/free 对。Rc<T> 取代了手动引用计数。原始指针依然存在,但被限制在 unsafe 块中。


Rust 所有权、借用与生命周期

  • 回想一下,Rust 仅允许对变量存在单一的可变引用或多个只读引用。
    • 变量的初始声明确立了所有权 (Ownership)。
    • 之后的引用是从原始所有者那里进行的借用 (Borrow)。其规则是:借用的作用域绝不能超过拥有者的作用域。换句话说,借用的生命周期 (Lifetime) 不能超过拥有者的生命周期。
fn main() {
    let a = 42; // 所有者 (Owner)
    let b = &a; // 第一个借用
    {
        let aa = 42;
        let c = &a; // 第二个借用;a 依然在作用域内
        // OK: c 在这里超出作用域
        // aa 在这里超出作用域
    }
    // let d = &aa; // 无法通过编译,除非将 aa 的所有权移到外部作用域
    // b 隐式地在 a 之前超出作用域
    // a 最后超出作用域
}
  • Rust 可以通过几种不同的机制将参数传递给方法:
    • 传值 (By value / Copy):通常是那些可以容易地被拷贝的类型(例如:u8, u32, i8, i32)。
    • 传引用 (By reference):等效于传递一个指向实际值的指针。这也被通俗地称为借用 (Borrowing),引用可以是不可变的 (&),或可变的 (&mut)。
    • 通过移动 (By moving):这会将值的所有权转移给函数。调用者将不再引用原始值。
fn foo(x: &u32) {
    println!("{x}");
}
fn bar(x: u32) {
    println!("{x}");
}
fn main() {
    let a = 42;
    foo(&a);    // 传引用
    bar(a);     // 传值 (拷贝)
}
  • Rust 禁止从方法返回悬空引用 (Dangling References):
    • 方法返回的引用必须依然在有效作用域内。
    • 当变量超出作用域时,Rust 会自动将其 释放 (Drop)。
fn no_dangling() -> &u32 {
    // a 的生命周期在这里开始
    let a = 42;
    // 无法通过编译。a 的生命周期在这里结束
    &a
}

fn ok_reference(a: &u32) -> &u32 {
    // OK:因为 a 的生命周期总是超过 ok_reference()
    a
}
fn main() {
    let a = 42;     // a 的生命周期在这里开始
    let b = ok_reference(&a);
    // b 的生命周期在这里结束
    // a 的生命周期在这里结束
}

Rust 移动语义 (Move Semantics)

  • 默认情况下,Rust 的赋值操作会转移所有权:
fn main() {
    let s = String::from("Rust");    // 从堆中分配一个字符串
    let s1 = s; // 将所有权转移给 s1。此时 s 已失效
    println!("{s1}");
    // 下行将无法通过编译
    //println!("{s}");
    // s1 在这里超出作用域,其占用的内存被释放
    // s 在这里超出作用域,但因为它不再拥有任何资产,所以没有任何影响
}
graph LR
    subgraph "之前的状态: let s1 = s"
        S["s (栈/Stack)<br/>ptr"] -->|"拥有 (owns)"| H1["堆/Heap: R u s t"]
    end

    subgraph "执行 let s1 = s 之后"
        S_MOVED["s (栈/Stack)<br/>⚠️ 已移动 (MOVED)"] -.->|"无效"| H2["堆/Heap: R u s t"]
        S1["s1 (栈/Stack)<br/>ptr"] -->|"新拥有者"| H2
    end

    style S_MOVED fill:#ff6b6b,color:#000,stroke:#333
    style S1 fill:#51cf66,color:#000,stroke:#333
    style H2 fill:#91e5a3,color:#000,stroke:#333

在 let s1 = s 之后,所有权转移到了 s1。堆上的数据保持不变 —— 只有栈指针发生了移动。s 现在已无资产,处于失效状态。


移动语义与借用 (Borrowing)

fn foo(s : String) {
    println!("{s}");
    // s 指向的堆内存将在此处释放
}
fn bar(s : &String) {
    println!("{s}");
    // 这里没有任何操作 —— s 是被借用的
}
fn main() {
    let s = String::from("Rust string 移动案例");    // 从堆中分配字符串
    foo(s); // 转移所有权;s 此时已失效
    // println!("{s}");  // 无法通过编译
    let t = String::from("Rust string 借用案例");
    bar(&t);    // t 继续保留所有权
    println!("{t}"); 
}

移动语义与所有权 (Ownership)

  • 可以通过移动操作来转移所有权:
    • 在移动完成后,引用原变量的行为是非法的。
    • 如果不希望进行移动,请考虑使用借用。
struct Point {
    x: u32,
    y: u32,
}
fn consume_point(p: Point) {
    println!("{} {}", p.x, p.y);
}
fn borrow_point(p: &Point) {
    println!("{} {}", p.x, p.y);
}
fn main() {
    let p = Point {x: 10, y: 20};
    // 试着调换下面两行的顺序
    borrow_point(&p);
    consume_point(p);
}

Rust 克隆 (Clone)

  • clone() 方法可以用于拷贝原始内存。原引用依然有效(代价是我们分配了 2 倍的内存)。
fn main() {
    let s = String::from("Rust");    // 在堆上分配一个字符串
    let s1 = s.clone(); // 拷贝字符串;这会在堆上创建一个新的分配
    println!("{s1}");  
    println!("{s}");
    // s1 这里超出作用域并被释放
    // s 这里超出作用域并被释放
}
graph LR
    subgraph "之后的状态: let s1 = s.clone()"
        S["s (栈/Stack)<br/>ptr"] -->|"拥有"| H1["堆/Heap: R u s t"]
        S1["s1 (栈/Stack)<br/>ptr"] -->|"也拥有 (副本)"| H2["堆/Heap: R u s t"]
    end

    style S fill:#51cf66,color:#000,stroke:#333
    style S1 fill:#51cf66,color:#000,stroke:#333
    style H1 fill:#91e5a3,color:#000,stroke:#333
    style H2 fill:#91e5a3,color:#000,stroke:#333

clone() 会创建一个独立的堆分配。s 和 s1 都是有效的 —— 每个变量都拥有的一个自己的副本。


Rust Copy Trait

  • Rust 通过 Copy trait 为内建类型实现了拷贝语义 (Copy Semantics):
    • 示例包括 u8, u32, i8, i32 等。拷贝语义使用“按值传递”。
    • 用户定义的数据类型可以通过 derive 宏自动实现 Copy trait,从而选择加入拷贝语义。
    • 编译器会在每次赋值时为副本分配空间。
// 试着注释掉下面这行,观察 let p1 = p; 带来的变化
#[derive(Copy, Clone, Debug)]
struct Point{x: u32, y:u32}
fn main() {
    let p = Point {x: 42, y: 40};
    let p1 = p;     // 由于实现了 Copy,这里会执行拷贝而非移动
    println!("p: {p:?}");
    println!("p1: {p:?}");
    let p2 = p1.clone();    // 语义上等同于拷贝
}

Rust Drop Trait

  • Rust 会在作用域结束时自动调用 drop() 方法:
    • drop 是 Drop 这一通用 Trait 的一部分。编译器为所有类型提供了一个默认的空操作 (NOP) 实现,但具体类型可以复写它。例如,String 类型复写了 drop 以释放堆分配的内存。
    • 对于 C 开发者:这取代了手动调用 free() 的需要 —— 资源在超出作用域时会自动释放 (RAII)。
  • 关键安全性:你不能直接手动调用 .drop()(编译器禁止这样做)。相反,应该使用 drop(obj) 函数,它会将值移动到函数内部,运行其析构函数,并阻止后续的任何访问 —— 从而根除了二次释放 (Double-free) Bug。

针对 C++ 开发者:Drop 可以直接对应到 C++ 的析构函数 (~ClassName()):

C++ 析构函数Rust Drop
语法~MyClass() { ... }impl Drop for MyType { fn drop(&mut self) { ... } }
何时调用作用域结束时 (RAII)作用域结束时 (相同)
在移动时调用原对象处于“有效但未指定”的僵尸状态 —— 析构函数依然会在原对象上运行原对象消失了 —— 不会在已移动的值上运行析构函数
手动调用obj.~MyClass() (危险,极少使用)drop(obj) (安全 —— 获取所有权,调用 drop,阻止后续使用)
执行顺序与声明顺序相反与声明顺序相反 (相同)
五法则必须管理拷贝/移动构造、拷贝/移动赋值、析构函数仅需 Drop —— 编译器处理移动语义,且 Clone 是显式选用的
是否需要虚析构是,如果通过基类指针删除不需要 —— 没继承,所以不存在切片 (Slicing) 问题
struct Point {x: u32, y:u32}

// 等效于:~Point() { printf("Goodbye point x:%u, y:%u\n", x, y); }
impl Drop for Point {
    fn drop(&mut self) {
        println!("再见 Point x:{}, y:{}", self.x, self.y);
    }
}
fn main() {
    let p = Point{x: 42, y: 42};
    {
        let p1 = Point{x:43, y: 43};
        println!("正在退出内部代码块");
        // p1.drop() 在此处被调用 — 类似于 C++ 作用域结束时的析构函数
    }
    println!("正在退出 main");
    // p.drop() 在此处被调用
}

练习:移动 (Move)、拷贝 (Copy) 与 释放 (Drop)

🟡 中级 —— 尽管放手实验;编译器会指引你。

  • 使用 Point 来创建你自己的实验,对比在 #[derive(Debug)] 中带有和不带有 Copy 时的区别,确保你理解了其中的差异。这个练习的目的是为了让你对移动还是拷贝有深入的理解,如果有疑问请务必提问。
  • 为 Point 实现一个自定义的 Drop,在其中将 x 和 y 设置为 0。这是一种很有用的模式,例如可用于释放锁或其他资源。
struct Point{x: u32, y: u32}
fn main() {
    // 创建 Point,将其赋值给不同的变量,创建新的作用域,
    // 将 point 传递给函数,等等。
}
参考答案 (点击展开)
#[derive(Debug)]
struct Point { x: u32, y: u32 }

impl Drop for Point {
    fn drop(&mut self) {
        println!("正在释放 Point({}, {})", self.x, self.y);
        self.x = 0;
        self.y = 0;
        // 注意:在 drop 中将其设为 0 是为了演示这种模式,
        // 但在 drop 完成后你就无法再观察到这些值了
    }
}

fn consume(p: Point) {
    println!("正在消耗: {:?}", p);
    // p 在此处被释放
}

fn main() {
    let p1 = Point { x: 10, y: 20 };
    let p2 = p1;  // 移动 (Move) — p1 不再有效
    // println!("{:?}", p1);  // 无法编译:p1 已被移动

    {
        let p3 = Point { x: 30, y: 40 };
        println!("内部作用域中的 p3: {:?}", p3);
        // p3 在此处释放 (作用域结束)
    }

    consume(p2);  // p2 移动到 consume 函数中并在此释放
    // println!("{:?}", p2);  // 无法编译:p2 已被移动

    // 接下来尝试:为 Point 添加 #[derive(Copy, Clone)] (并移除 Drop 实现)
    // 观察在 let p2 = p1; 之后 p1 是否依然有效
}

输出示例:

内部作用域中的 p3: Point { x: 30, y: 40 }
正在释放 Point(30, 40)
正在消耗: Point { x: 10, y: 20 }
正在释放 Point(10, 20)

English Original

Rust 生命周期与借用深度解析

你将学到: Rust 的生命周期系统如何确保引用永远不会悬空 —— 从隐式生命周期到显式注解,再到让大多数代码无需注解的三大省略规则。在进入下一节智能指针的学习之前,深入理解生命周期是至关重要的。

  • Rust 强制执行单一可变引用或任意数量的不可变引用规则。
    • 任何引用的生命周期必须至少与原始拥有者的生命周期一样长。这些是隐式生命周期,由编译器自动推导(参见 生命周期省略)。
fn borrow_mut(x: &mut u32) {
    *x = 43;
}
fn main() {
    let mut x = 42;
    let y = &mut x;
    borrow_mut(y);
    let _z = &x; // 允许,因为编译器知道 y 在之后不再被使用
    //println!("{y}"); // 如果取消这行注释,将无法通过编译
    borrow_mut(&mut x); // 允许,因为 _z 不再被使用 
    let z = &x; // OK -- 对 x 的可变借用在 borrow_mut() 返回后结束
    println!("{z}");
}

Rust 生命周期注解 (Lifetime Annotations)

  • 在处理多个生命周期时,需要显示地使用注解。
    • 生命周期由 ' 符号后跟标示符表示(如 'a, 'b, 'static 等)。
    • 当编译器无法确定引用应该存活多久时,它需要开发者提供帮助。
  • 常见场景:函数返回一个引用,但这个引用来自哪个输入参数?
#[derive(Debug)]
struct Point {x: u32, y: u32}

// 没有生命周期注解时,下行无法通过编译:
// fn left_or_right(pick_left: bool, left: &Point, right: &Point) -> &Point

// 带有生命周期注解 —— 所有引用共用同一个生命周期 'a
fn left_or_right<'a>(pick_left: bool, left: &'a Point, right: &'a Point) -> &'a Point {
    if pick_left { left } else { right }
}

// 更复杂的情况:输入参数有不同的生命周期
fn get_x_coordinate<'a, 'b>(p1: &'a Point, _p2: &'b Point) -> &'a u32 {
    &p1.x  // 返回值的生命周期与 p1 绑定,而不是 p2
}

fn main() {
    let p1 = Point {x: 20, y: 30};
    let result;
    {
        let p2 = Point {x: 42, y: 50};
        result = left_or_right(true, &p1, &p2);
        // 这行有效,因为我们在 p2 超出作用域之前使用了 result
        println!("选择了: {result:?}");
    }
    // 这行无效 —— result 引用的 p2 已经失效了:
    // println!("在作用域之外: {result:?}");
}

Rust 生命周期注解的应用

  • 数据结构中的引用也需要生命周期注解。
use std::collections::HashMap;
#[derive(Debug)]
struct Point {x: u32, y: u32}
struct Lookup<'a> {
    map: HashMap<u32, &'a Point>,
}
fn main() {
    let p = Point{x: 42, y: 42};
    let p1 = Point{x: 50, y: 60};
    let mut m = Lookup {map : HashMap::new()};
    m.map.insert(0, &p);
    m.map.insert(1, &p1);
    {
        let p3 = Point{x: 60, y:70};
        //m.map.insert(3, &p3); // 无法通过编译
        // p3 在此处被销毁,但 m 的生存期比它长
    }
    for (k, v) in m.map {
        println!("{v:?}");
    }
    // m 在此处被销毁
    // p1 和 p 按顺序在此处被销毁
} 

练习:利用生命周期获取首个单词

🟢 入门级 —— 实践生命周期省略 (Elision)

编写一个函数 fn first_word(s: &str) -> &str,返回字符串中第一个由空格分隔的单词。思考一下为什么这段代码在没有显式生命周期注解的情况下也能通过编译(提示:参考省略规则 #1 和 #2)。

参考答案 (点击展开)
fn first_word(s: &str) -> &str {
    // 编译器会自动应用生命周期省略规则:
    // 规则 1: 输入的 &str 获得生命周期 'a → fn first_word(s: &'a str) -> &str
    // 规则 2: 只有一个输入生命周期参数 → 输出获得相同的生命周期 → fn first_word(s: &'a str) -> &'a str
    match s.find(' ') {
        Some(pos) => &s[..pos],
        None => s,
    }
}

fn main() {
    let text = "hello world foo";
    let word = first_word(text);
    println!("第一个单词: {word}");  // "hello"
    
    let single = "onlyone";
    println!("第一个单词: {}", first_word(single));  // "onlyone"
}

练习:利用生命周期存储切片

🟡 中级 —— 首次尝试编写生命周期注解

  • 创建一个存储 &str 切片引用的结构体:
    • 创建一个长字符串 &str,并将从中提取的切片引用存入该结构体中。
    • 编写一个接收该结构体并返回其中所存切片的函数。
// TODO: 创建一个存储切片引用的结构体
struct SliceStore {

}
fn main() {
    let s = "这是一段很长的字符串";
    let s1 = &s[0..];
    let s2 = &s[1..2];
    // let slice = struct SliceStore {...};
    // let slice2 = struct SliceStore {...};
}
参考答案 (点击展开)
struct SliceStore<'a> {
    slice: &'a str,
}

impl<'a> SliceStore<'a> {
    fn new(slice: &'a str) -> Self {
        SliceStore { slice }
    }

    fn get_slice(&self) -> &'a str {
        self.slice
    }
}

fn main() {
    let s = "这是一段很长的字符串";
    let store1 = SliceStore::new(&s[0..6]);   // "这是一段"
    let store2 = SliceStore::new(&s[6..12]);  // "很长的"
    println!("store1: {}", store1.get_slice());
    println!("store2: {}", store2.get_slice());
}

生命周期省略规则深度解析

C 程序员经常问:“如果生命周期如此重要,为什么大多数 Rust 函数不需要写 'a 注解呢?”答案就是 生命周期省略 (Lifetime Elision) —— 编译器会自动应用三条确定性的规则来推断生命周期。

三条省略规则

Rust 编译器会按顺序将这些规则应用到函数签名中。如果在应用规则后,所有的输出生命周期都能被确定,那么就不需要手动注解。

flowchart TD
    A["带有引用的<br/>函数签名"] --> R1
    R1["规则 1: 每个输入<br/>引用都有自己的限制<br/>生命周期<br/><br/>fn f(&amp;str, &amp;str)<br/>→ fn f&lt;'a,'b&gt;(&amp;'a str,<br/>&amp;'b str)"]
    R1 --> R2
    R2["规则 2: 如果只有一个<br/>输入生命周期,将其指派给<br/>所有输出参数<br/><br/>fn f(&amp;str) → &amp;str<br/>→ fn f&lt;'a&gt;(&amp;'a str)<br/>→ &amp;'a str"]
    R2 --> R3
    R3["规则 3: 如果其中一个输入是<br/>&amp;self 或 &amp;mut self,<br/>将其生命周期指派给所有输出参数<br/><br/>fn f(&amp;self, &amp;str) → &amp;str<br/>→ fn f&lt;'a&gt;(&amp;'a self, &amp;str)<br/>→ &amp;'a str"]
    R3 --> CHECK{{"所有输出生命周期<br/>都已确定?"}}
    CHECK -->|是| OK["✅ 无需手动注解"]
    CHECK -->|否| ERR["❌ 编译错误:<br/>必须手动注解"]
    
    style OK fill:#91e5a3,color:#000
    style ERR fill:#ff6b6b,color:#000

逐条规则示例

规则 1 —— 每个输入引用都会获得自己的生命周期参数:

#![allow(unused)]
fn main() {
// 你所编写的代码:
fn first_word(s: &str) -> &str { ... }

// 编译器在应用规则 1 后看到的:
fn first_word<'a>(s: &'a str) -> &str { ... }
// 只有一个输入生命周期参数 → 适用规则 2
}

规则 2 —— 唯一的输入生命周期参数会传播到所有输出:

#![allow(unused)]
fn main() {
// 应用规则 2 后:
fn first_word<'a>(s: &'a str) -> &'a str { ... }
// ✅ 所有的输出生命周期都已确定 —— 无需手动注解!
}

规则 3 —— &self 的生命周期会传播到输出:

#![allow(unused)]
fn main() {
// 你所编写的代码:
impl SliceStore<'_> {
    fn get_slice(&self) -> &str { self.slice }
}

// 编译器在应用规则 1 和 3 后看到的:
impl SliceStore<'_> {
    fn get_slice<'a>(&'a self) -> &'a str { self.slice }
}
// ✅ 无需手动注解 —— 输出使用了 &self 的生命周期
}

当省略失效时 —— 你必须手动注解:

#![allow(unused)]
fn main() {
// 有两个输入引用,且没有 &self → 规则 2 和规则 3 都不适用
// fn longest(a: &str, b: &str) -> &str  ← 下行无法通过编译

// 解决方案:告诉编译器输出的借用来自于哪个输入参数
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}
}

C 程序员的心智模型

在 C 语言中,每个指针都是独立的 —— 程序员需要在脑海中跟踪每个指针所指向的内存分配,而编译器则完全信任你。而在 Rust 中,生命周期让这种跟踪变成了显式的且经过编译器校验的行为:

CRust背后发生了什么
char* get_name(struct User* u)fn get_name(&self) -> &str规则 3 判定:输出从 self 借用
char* concat(char* a, char* b)fn concat<'a>(a: &'a str, b: &'a str) -> &'a str必须手动注解 —— 有两个输入参数
void process(char* in, char* out)fn process(input: &str, output: &mut String)无返回引用 —— 无需生命周期注解
char* buf; /* 谁拥有这里? */如果生命周期错误会报错编译器会捕捉到悬空指针

'static 生命周期

'static 意味着引用的有效期贯穿整个程序运行期间。它是 Rust 中等效于 C 语言全局变量或字符串字面量的概念:

#![allow(unused)]
fn main() {
// 字符串字面量始终是 'static 的 —— 它们存储在二进制文件的只读区域
let s: &'static str = "hello";  // 等同于 C 中的 static const char* s = "hello";

// 常量也是 'static 的
static GREETING: &str = "hello";

// 在线程派生的 Trait 约束中很常见:
fn spawn<F: FnOnce() + Send + 'static>(f: F) { /* ... */ }
// 这里的 'static 意味着:“闭包不得借用任何局部变量”
// (要么将变量移动到闭包内,要么仅使用 'static 的数据)
}

练习:预测省略结果

🟡 中级

请预测下述每个 function 签名是否可以通过编译器的生命周期省略。如果不能,请添加必要的注解:

#![allow(unused)]
fn main() {
// 1. 编译器能否省略?
fn trim_prefix(s: &str) -> &str { &s[1..] }

// 2. 编译器能否省略?
fn pick(flag: bool, a: &str, b: &str) -> &str {
    if flag { a } else { b }
}

// 3. 编译器能否省略?
struct Parser { data: String }
impl Parser {
    fn next_token(&self) -> &str { &self.data[..5] }
}

// 4. 编译器能否省略?
fn split_at(s: &str, pos: usize) -> (&str, &str) {
    (&s[..pos], &s[pos..])
}
}
参考答案 (点击展开)
// 1. 可以 —— 规则 1 为 s 指派 'a,规则 2 传播到输出
fn trim_prefix(s: &str) -> &str { &s[1..] }

// 2. 不可以 —— 有两个输入引用,且没有 &self。必须手动注解:
fn pick<'a>(flag: bool, a: &'a str, b: &'a str) -> &'a str {
    if flag { a } else { b }
}

// 3. 可以 —— 规则 1 为 &self 指派 'a,规则 3 传播到输出
impl Parser {
    fn next_token(&self) -> &str { &self.data[..5] }
}

// 4. 可以 —— 规则 1 为 s 指派 'a(只有一个输入引用),
//    规则 2 将其传播到两个输出。两个切片都从 s 借用。
fn split_at(s: &str, pos: usize) -> (&str, &str) {
    (&s[..pos], &s[pos..])
}

English Original

Rust Box<T>

你将学到: Rust 的智能指针类型 —— 用于堆分配的 Box<T>、用于共享所有权的 Rc<T>,以及用于内部可变性 (Interior Mutability) 的 Cell<T>/RefCell<T>。这些概念均建立在上一节的所有权与生命周期基础之上。你还将简要了解用于打破引用循环的 Weak<T>。

为什么要用 Box<T>? 在 C 语言中,你使用 malloc/free 进行堆分配。在 C++ 中,std::unique_ptr<T> 封装了 new/delete。Rust 的 Box<T> 就是其等效物 —— 一个堆分配的、单一所有者的指针,在超出作用域时会自动释放。与 malloc 不同的是,这里没有配套的 free 需要你去费心记忆。与 unique_ptr 不同的是,这里不存在“移动后使用”的情况 —— 编译器会完全杜绝此类行为。

何时使用 Box 而非栈分配:

  • 包含的类型体量巨大,你不希望在栈上进行拷贝。

  • 你需要定义递归类型(例如:一个包含自身类型的链表节点)。

  • 你需要使用 Trait 对象 (Box<dyn Trait>)。

  • Box<T> 可用于创建一个指向堆分配类型的指针。无论 <T> 的具体类型为何,该指针的大小始终是固定的。

fn main() {
    // 在堆上创建一个指向整数(值为 42)的指针
    let f = Box::new(42);
    println!("{} {}", *f, f);
    // 克隆一个 Box 会在堆上创建一个新的分配
    let mut g = f.clone();
    *g = 43;
    println!("{f} {g}");
    // g 和 f 在此处超出作用域并被自动释放
}
graph LR
    subgraph "栈 (Stack)"
        F["f: Box&lt;i32&gt;"]
        G["g: Box&lt;i32&gt;"]
    end

    subgraph "堆 (Heap)"
        HF["42"]
        HG["43"]
    end

    F -->|"拥有"| HF
    G -->|"拥有 (克隆出的)"| HG

    style F fill:#51cf66,color:#000,stroke:#333
    style G fill:#51cf66,color:#000,stroke:#333
    style HF fill:#91e5a3,color:#000,stroke:#333
    style HG fill:#91e5a3,color:#000,stroke:#333

所有权与借用的可视化对比

C/C++ vs Rust:指针与所有权管理

// C - 手动管理内存,存在潜在问题
void c_pointer_problems() {
    int* ptr1 = malloc(sizeof(int));
    *ptr1 = 42;
    
    int* ptr2 = ptr1;  // 两者都指向相同的内存
    int* ptr3 = ptr1;  // 三者都指向相同的内存
    
    free(ptr1);        // 释放该内存
    
    *ptr2 = 43;        // [重大错误] 释放后使用 (Use after free) - 未定义行为!
    *ptr3 = 44;        // [重大错误] 释放后使用 (Use after free) - 未定义行为!
}

针对 C++ 开发者:虽然智能指针有所帮助,但并不能防止所有问题:

// C++ - 智能指针有所帮助,但不能解决所有问题
void cpp_pointer_issues() {
    auto ptr1 = std::make_unique<int>(42);
    
    // auto ptr2 = ptr1;  // 编译错误:unique_ptr 不可拷贝
    auto ptr2 = std::move(ptr1);  // OK: 所有权已转移
    
    // 但是 C++ 依然允许移动后使用 (Use-after-move):
    // std::cout << *ptr1;  // 能够编译!但是导致未定义行为!
    
    // shared_ptr 别名问题:
    auto shared1 = std::make_shared<int>(42);
    auto shared2 = shared1;  // 两者共同拥有数据
    // 到底谁才算是“真正”的所有者?谁也不是。引用计数开销无处不在。
}

#![allow(unused)]
fn main() {
// Rust - 所有权系统杜绝了上述问题
fn rust_ownership_safety() {
    let data = Box::new(42);  // data 拥有了堆上的内存分配
    
    let moved_data = data;    // 所有权转移给了 moved_data
    // data 此时无法再被通过 —— 如果使用,将导致编译错误
    
    let borrowed = &moved_data;  // 不可变借用
    println!("{}", borrowed);    // 能够安全使用
    
    // moved_data 在超出作用域时会自动触发释放
}
}
graph TD
    subgraph "C/C++ 的内存管理问题"
        CP1["int* ptr1"] --> CM["堆内存 (Heap)<br/>值: 42"]
        CP2["int* ptr2"] --> CM
        CP3["int* ptr3"] --> CM
        CF["free(ptr1)"] --> CM_F["[ERROR] 已释放内存"]
        CP2 -.->|"Use after free<br/>未定义行为"| CM_F
        CP3 -.->|"Use after free<br/>未定义行为"| CM_F
    end
    
    subgraph "Rust 所有权系统"
        RO1["data: Box<i32>"] --> RM["堆内存 (Heap)<br/>值: 42"]
        RO1 -.->|"转移所有权 (Move)"| RO2["moved_data: Box<i32>"]
        RO2 --> RM
        RO1_X["data: [WARNING] 已移动<br/>无法通过"]
        RB["&moved_data<br/>不可变借用"] -.->|"安全引用"| RM
        RD["自动释放 (Drop)<br/>在超出作用域时"] --> RM
    end
    
    style CM_F fill:#ff6b6b,color:#000
    style CP2 fill:#ff6b6b,color:#000
    style CP3 fill:#ff6b6b,color:#000
    style RO1_X fill:#ffa07a,color:#000
    style RO2 fill:#51cf66,color:#000
    style RB fill:#91e5a3,color:#000
    style RD fill:#91e5a3,color:#000

借用规则的可视化展示

#![allow(unused)]
fn main() {
fn borrowing_rules_example() {
    let mut data = vec![1, 2, 3, 4, 5];
    
    // 多个不可变借用 —— OK
    let ref1 = &data;
    let ref2 = &data;
    println!("{:?} {:?}", ref1, ref2);  // 二者均可被使用
    
    // 可变借用 —— 独占式访问
    let ref_mut = &mut data;
    ref_mut.push(6);
    // 当 ref_mut 处于活跃状态时,ref1 和 ref2 无法被使用
    
    // 在 ref_mut 结束之后,不可变借用再次生效
    let ref3 = &data;
    println!("{:?}", ref3);
}
}
graph TD
    subgraph "Rust 借用规则"
        D["mut data: Vec<i32>"]
        
        subgraph "第一阶段:多个不可变借用 [OK]"
            IR1["&data (ref1)"]
            IR2["&data (ref2)"]
            D --> IR1
            D --> IR2
            IR1 -.->|"只读访问"| MEM1["内存内容: [1,2,3,4,5]"]
            IR2 -.->|"只读访问"| MEM1
        end
        
        subgraph "第二阶段:排他性可变借用 [OK]"
            MR["&mut data (ref_mut)"]
            D --> MR
            MR -.->|"排他性读/写"| MEM2["内存内容: [1,2,3,4,5,6]"]
            BLOCK["[ERROR] 其他借用已被阻塞"]
        end
        
        subgraph "第三阶段:再次允许不可变借用 [OK]"
            IR3["&data (ref3)"]
            D --> IR3
            IR3 -.->|"只读访问"| MEM3["内存内容: [1,2,3,4,5,6]"]
        end
    end
    
    subgraph "C/C++ 的处理方式 (危险)"
        CP["int* ptr"]
        CP2["int* ptr2"]
        CP3["int* ptr3"]
        CP --> CMEM["同一块内存"]
        CP2 --> CMEM
        CP3 --> CMEM
        RACE["[ERROR] 可能导致数据竞态<br/>[ERROR] 可能导致释放后使用"]
    end
    
    style MEM1 fill:#91e5a3,color:#000
    style MEM2 fill:#91e5a3,color:#000
    style MEM3 fill:#91e5a3,color:#000
    style BLOCK fill:#ffa07a,color:#000
    style RACE fill:#ff6b6b,color:#000
    style CMEM fill:#ff6b6b,color:#000

内部可变性 (Interior Mutability):Cell<T> 与 RefCell<T>

回想一下,开发者在 Rust 中定义的变量默认是不可变的。但有时我们希望类型的大部分字段是只读的,同时允许对其中某个特定字段进行写操作。

#![allow(unused)]
fn main() {
struct Employee {
    employee_id : u64,   // 该字段必须是不可变的
    on_vacation: bool,   // 如果我们想允许对该字段进行写操作,同时保持 employee_id 不可变,该怎么办?
}
}
  • 回想一下,Rust 仅允许对变量存在单一可变引用或任意数量的不可变引用 —— 这在编译时强制执行。
  • 如果我们想传递一个包含员工信息的不可变向量(Vector),但允许更新 on_vacation 字段,同时确保 employee_id 无法被修改,该怎么办?

Cell<T> —— 针对 Copy 类型的内部可变性

  • Cell<T> 提供了内部可变性,即:即便引用本身是只读的,也可以对其特定元素获得写权限。
  • 它通过值的拷贝(In/Out)来工作(调用 .get() 要求 T: Copy)。

RefCell<T> —— 带有运行时借用检查的内部可变性

  • RefCell<T> 提供了一种基于引用的变体。
    • 它在 运行时 而非编译时强制执行 Rust 的借用检查。
    • 它允许单一的可变借用,但如果同时存在其他活跃引用,则会导致 程序崩溃 (Panic)。
    • 使用 .borrow() 进行不可变访问,使用 .borrow_mut() 进行可变访问。

如何选择 Cell 还是 RefCell

判定标准Cell<T>RefCell<T>
适用类型Copy 类型(整数, 布尔, 浮点数等)任意类型(String, Vec, 结构体等)
访问模式值的拷贝 (.get(), .set())原地借用 (.borrow(), .borrow_mut())
失败模式绝不会失败 —— 无运行时检查如果在已有借用时再次进行可变借用,会触发 Panic
运行时开销零开销 —— 仅执行字节拷贝极小开销 —— 在运行时追踪借用状态
使用场景在不可变结构体中需要可变标志、计数器或小数值时在不可变结构体中需要修改 String, Vec 或复杂类型时

共享所有权:Rc<T>

Rc<T> 允许通过引用计数对不可变数据进行所有权的共享。如果我们想在多个地方存储同一个 Employee 且不进行拷贝,该怎么办?

#[derive(Debug)]
struct Employee {
    employee_id: u64,
}
fn main() {
    let mut us_employees = vec![];
    let mut all_global_employees = Vec::<Employee>::new();
    let employee = Employee { employee_id: 42 };
    us_employees.push(employee);
    // [编译失败] —— employee 已经被移动 (Moved) 了
    //all_global_employees.push(employee);
}

Rc<T> 通过允许共享的不可变访问来解决这个问题:

  • 指向的类型会自动解引用。
  • 当引用计数减为 0 时,该类型会被释放。
use std::rc::Rc;
#[derive(Debug)]
struct Employee {employee_id: u64}
fn main() {
    let mut us_employees = vec![];
    let mut all_global_employees = vec![];
    let employee = Employee { employee_id: 42 };
    let employee_rc = Rc::new(employee);
    us_employees.push(employee_rc.clone());  // 增加引用计数,而非拷贝数据
    all_global_employees.push(employee_rc.clone());
    let employee_one = all_global_employees.get(0); // 共享的不可变引用
    for e in us_employees {
        println!("{}", e.employee_id);  // 共享的不可变引用
    }
    println!("{employee_one:?}");
}

针对 C++ 开发者:智能指针映射关系

C++ 智能指针Rust 等价物关键区别
std::unique_ptr<T>Box<T>Rust 版是默认行为 —— 移动是语言层面的,而非可选的
std::shared_ptr<T>Rc<T> (单线程) / Arc<T> (多线程)Rc 没有原子性开销;仅在跨线程共享时才需要使用 Arc
std::weak_ptr<T>Weak<T> (通过 Rc::downgrade() 或 Arc::downgrade())用途一致:用于打破引用循环

关键区别:在 C++ 中,你选择使用智能指针。而在 Rust 中,拥有值 (T) and 借用 (&T) 涵盖了绝大多数场景 —— 只有在确实需要堆分配或共享所有权时,才会去诉诸 Box/Rc/Arc。


利用 Weak<T> 打破引用循环

Rc<T> 采用引用计数机制 —— 如果两个 Rc 值相互指向对方,那么它们永远不会被释放(即产生循环引用)。Weak<T> 可以解决这个问题:

use std::rc::{Rc, Weak};

struct Node {
    value: i32,
    parent: Option<Weak<Node>>,  // 弱引用 —— 不会阻止对象被释放
}

fn main() {
    let parent = Rc::new(Node { value: 1, parent: None });
    let child = Rc::new(Node {
        value: 2,
        parent: Some(Rc::downgrade(&parent)),  // 指向父节点的弱引用
    });

    // 若要使用 Weak,需先尝试将其升级 —— 返回 Option<Rc<T>>
    if let Some(parent_rc) = child.parent.as_ref().unwrap().upgrade() {
        println!("父节点的值: {}", parent_rc.value);
    }
    println!("父节点的强引用计数: {}", Rc::strong_count(&parent)); // 结果为 1,而非 2
}

更多关于 Weak<T> 的深度内容请参阅 避免过度的 clone()。目前只需记住一个核心点:在树形或图形结构中,使用 Weak 来实现“父节点引用 (Back-references)”,以避免内存泄漏。


将 Rc 与内部可变性结合使用

当我们将 Rc<T>(共享所有权)与 Cell<T> 或 RefCell<T>(内部可变性)结合时,真正的威力就显现出来了。这使得多个所有者可以同时读取并修改共享数据:

模式组合使用场景
Rc<RefCell<T>>共享的可变数据 (单线程)
Arc<Mutex<T>>共享的可变数据 (多线程 —— 详见 第 13 章)
Rc<Cell<T>>共享的可变 Copy 类型 (简单的标志、计数器)

练习:共享所有权与内部可变性

🟡 中级

  • 第一阶段 (Rc):创建一个拥有 employee_id: u64 和 name: String 字段的 Employee 结构体。将其封装在 Rc<Employee> 中,并克隆到两个独立的向量中(us_employees 和 global_employees)。从这两个向量中打印数据,以此证明它们共享同一份数据。
  • 第二阶段 (Cell):为 Employee 添加一个 on_vacation: Cell<bool> 字段。编写一个函数,接收一个不可变的 &Employee 引用,并从函数内部切换 on_vacation 的状态 —— 注意,无需将引用设为可变。
  • 第三阶段 (RefCell):将 name: String 替换为 name: RefCell<String>。编写一个函数,通过 &Employee(不可变引用)在员工姓名后追加一个后缀。

初始代码:

use std::cell::{Cell, RefCell};
use std::rc::Rc;

#[derive(Debug)]
struct Employee {
    employee_id: u64,
    name: RefCell<String>,
    on_vacation: Cell<bool>,
}

fn toggle_vacation(emp: &Employee) {
    // TODO: 利用 Cell::set() 切换 on_vacation 的状态
}

fn append_title(emp: &Employee, title: &str) {
    // TODO: 通过 RefCell 进行可变借用,并使用 push_str 追加标题
}

fn main() {
    // TODO: 创建一名员工,封装在 Rc 中,并克隆到两个 Vec 中,
    // 调用 toggle_vacation 和 append_title,最后打印结果
}
参考答案 (点击展开)
use std::cell::{Cell, RefCell};
use std::rc::Rc;

#[derive(Debug)]
struct Employee {
    employee_id: u64,
    name: RefCell<String>,
    on_vacation: Cell<bool>,
}

fn toggle_vacation(emp: &Employee) {
    emp.on_vacation.set(!emp.on_vacation.get());
}

fn append_title(emp: &Employee, title: &str) {
    emp.name.borrow_mut().push_str(title);
}

fn main() {
    let emp = Rc::new(Employee {
        employee_id: 42,
        name: RefCell::new("Alice".to_string()),
        on_vacation: Cell::new(false),
    });

    let mut us_employees = vec![];
    let mut global_employees = vec![];
    us_employees.push(Rc::clone(&emp));
    global_employees.push(Rc::clone(&emp));

    // 通过不可变引用切换假期状态
    toggle_vacation(&emp);
    println!("休假中: {}", emp.on_vacation.get()); // true

    // 通过不可变引用追加标题
    append_title(&emp, ", 资深工程师");
    println!("姓名: {}", emp.name.borrow()); // "Alice, 资深工程师"

    // 两个向量看到的是同一份数据(Rc 共享了所有权)
    println!("美国分部: {:?}", us_employees[0].name.borrow());
    println!("全球分部: {:?}", global_employees[0].name.borrow());
    println!("Rc 强引用计数: {}", Rc::strong_count(&emp));
}

输出示例:

休假中: true
姓名: Alice, 资深工程师
美国分部: "Alice, 资深工程师"
全球分部: "Alice, 资深工程师"
Rc 强引用计数: 3

English Original

Rust Crates 与 Modules (单元包与模块)

你将学到: Rust 如何将代码组织为模块 (Modules) 和单元包 (Crates) —— 默认私有的可见性规则、pub 修饰符、工作区 (Workspaces) 以及 crates.io 生态系统。这些将取代 C/C++ 中的头文件、#include 以及 CMake 的依赖管理。

  • 在 Crates 内部,模块 (Modules) 是组织代码的基本单位。
    • 每个源文件 (.rs) 都是一个独立的模块,并且可以使用 mod 关键字创建嵌套子模块。
    • 模块(及其子模块)内部的所有类型默认为私有 (Private)。除非显式标记为 pub (Public),否则它们在当前 Crate 外部是不可见的。pub 的作用域可以进一步限定,例如 pub(crate) 等。
    • 即使一个类型是公开的,它也不会在另一个模块的作用域内自动可见,除非使用 use 关键字将其导入。子模块可以使用 use super:: 来引用父级作用域中的类型。
    • 源文件 (.rs) 不会自动被包含到 Crate 中,除非在 main.rs(可执行文件入口)或 lib.rs(库出口)中显式列出。

练习:模块与函数

  • 接下来我们将修改之前的 hello world 程序,尝试调用另一个函数。
    • 正如之前提到的,函数使用 fn 关键字定义。-> 关键字用于声明函数的返回值类型(默认为空,即 void),例如 u32(无符号 32 位整数)。
    • 函数的作用域受模块限制,这意味着在两个不同模块中定义同名函数不会导致命名冲突。
      • 模块作用域同样适用于所有类型(例如:mod a { struct foo; } 中的 foo 类型是 a::foo,这与 mod b { struct foo; } 中的 b::foo 是截然不同的两个类型)。

初始代码 —— 请补全函数实现:

mod math {
    // TODO: 实现 pub fn add(a: u32, b: u32) -> u32
}

fn greet(name: &str) -> String {
    // TODO: 返回 "Hello, <name>! The secret number is <math::add(21,21)>"
    todo!()
}

fn main() {
    println!("{}", greet("Rustacean"));
}
参考答案 (点击展开)
mod math {
    pub fn add(a: u32, b: u32) -> u32 {
        a + b
    }
}

fn greet(name: &str) -> String {
    format!("Hello, {}! The secret number is {}", name, math::add(21, 21))
}

fn main() {
    println!("{}", greet("Rustacean"));
}
// 输出: Hello, Rustacean! The secret number is 42

工作区 (Workspaces) 与单元包 (Crates/Packages)

  • 任何稍具规模的 Rust 项目都应当使用工作区来组织构成该项目的各单元包 (Crates)。
    • 工作区可以包含一系列被编译进目标二进制文件的本地 Crates。在工作区根目录下的 Cargo.toml 应当包含指向这些构成包 (Packages/Crates) 的指针。
[workspace]
resolver = "2"
members = ["package1", "package2"]
workspace_root/
|-- Cargo.toml      # 工作区配置
|-- package1/
|   |-- Cargo.toml  # 包 (Package) 1 配置
|   `-- src/
|       `-- lib.rs  # 包 (Package) 1 源代码
|-- package2/
|   |-- Cargo.toml  # 包 (Package) 2 配置
|   `-- src/
|       `-- main.rs # 包 (Package) 2 源代码

练习:使用工作区与包依赖

  • 接下来我们将创建一个简单的工作区,并在我们的 hello world 程序中使用包。
  • 首先创建工作区目录:
mkdir workspace
cd workspace
  • 创建 Cargo.toml 文件并添加如下内容。这将创建一个空工作区。
[workspace]
resolver = "2"
members = []
  • 添加包(cargo new --lib 用于创建一个库而非可执行程序):
cargo new hello
cargo new --lib hellolib
  • 请查看 hello 和 hellolib 目录下生成的 Cargo.toml。注意,它们都已被添加到上一层的 Cargo.toml 中。
  • hellolib 目录下的 lib.rs 表明它是一个库 (Library) 包(更多自定义选项详见 Cargo 目标配置)。
  • 在 hello 的 Cargo.toml 中为 hellolib 添加依赖项:
[dependencies]
hellolib = {path = "../hellolib"}
  • 调用 hellolib 中的 add() 函数:
fn main() {
    println!("Hello, world! {}", hellolib::add(21, 21));
}
参考答案 (点击展开)

完整的工作区配置如下:

# 终端命令
mkdir workspace && cd workspace

# 创建名为 Cargo.toml 的工作区配置文件
cat > Cargo.toml << 'EOF'
[workspace]
resolver = "2"
members = ["hello", "hellolib"]
EOF

cargo new hello
cargo new --lib hellolib
# hello/Cargo.toml —— 添加依赖项
[dependencies]
hellolib = {path = "../hellolib"}
#![allow(unused)]
fn main() {
// hellolib/src/lib.rs —— 执行 cargo new --lib 时通常会自动生成 add()
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}
}
// hello/src/main.rs
fn main() {
    println!("Hello, world! {}", hellolib::add(21, 21));
}
// 输出: Hello, world! 42

使用来自 crates.io 的社区单元包 (Crates)

  • Rust 拥有一个充满活力的社区单元包生态系统(详见 crates.io)。
    • Rust 的哲学是保持标准库的精简,并将具体功能外包给社区单元包。
    • 关于使用社区单元包并没有死板的规定,但一般的原则是确保该单元包具有一定的成熟度(由版本号体现)且正被积极维护。如果对某个单元包有疑问,请咨询内部资源。
  • 在 crates.io 上发布的每个单元包都有一个主版本号 (Major) 和次版本号 (Minor)。
    • 单元包应当遵守此处定义的 SemVer(语义化版本控制)指南:Cargo 语义化版本控制。
    • 简而言之:在同一个次版本号内不应有破坏性变更 (Breaking changes)。例如,v0.11 必须与 v0.15 兼容(但 v0.20 可能会有破坏性变更)。

Crates 依赖与语义化版本 (SemVer)

  • Crates 可以定义对某个单元包特定版本、特定次/主版本或者任意版本的依赖。以下示例展示了在 Cargo.toml 中声明对 rand 单元包依赖的不同方式。
  • 至少是 0.10.0,且任何 < 0.11.0 的版本均可:
[dependencies]
rand = { version = "0.10.0"}
  • 仅限 0.10.0,不接受其他版本:
[dependencies]
rand = { version = "=0.10.0"}
  • 任意版本;cargo 将选择最新版本:
[dependencies]
rand = { version = "*"}

练习:使用 rand 单元包

  • 修改之前的 helloworld 程序,尝试打印一个随机数。
  • 使用 cargo add rand 命令添加依赖项。
  • 参考 rand 官方文档 获取 API 信息。

初始代码 —— 在运行 cargo add rand 后,将以下内容添加至 main.rs:

use rand::RngExt;

fn main() {
    let mut rng = rand::rng();
    // TODO: 生成并打印一个在 1..=100 范围内的随机 u32
    // TODO: 生成并打印一个随机布尔值 (bool)
    // TODO: 生成并打印一个随机浮点数 (f64)
}
参考答案 (点击展开)
use rand::RngExt;

fn main() {
    let mut rng = rand::rng();
    let n: u32 = rng.random_range(1..=100);
    println!("随机数 (1-100): {n}");

    // 生成一个随机布尔值
    let b: bool = rng.random();
    println!("随机布尔值: {b}");

    // 生成一个介于 0.0 和 1.0 之间的随机浮点数
    let f: f64 = rng.random();
    println!("随机浮点数: {f:.4}");
}

Cargo.toml 与 Cargo.lock

  • 正如前文所述,Cargo.lock 是根据 Cargo.toml 自动生成的。
    • Cargo.lock 的核心作用是确保构建的可复现性 (Reproducible builds)。例如,如果 Cargo.toml 指定的版本是 0.10.0,cargo 可以在符合语义化版本规则的情况下自由选择任何 < 0.11.0 的版本。
    • Cargo.lock 记录了在某次具体构建中所使用的单元包的确切版本。
    • 建议将 Cargo.lock 文件提交至 Git 仓库,以确保所有开发者在构建时使用完全一致的依赖版本。

Cargo 的测试 (Test) 功能

  • 按惯例,Rust 的单元测试与源代码位于同一文件中,并通常被组织在一个独立的模块内。
    • 测试代码绝不会被包含在最终生成的二进制文件中。这得益于 #[cfg(test)] (配置项测试) 装饰器。配置项装饰器在编写针对特定平台(如 Linux vs Windows)的代码时非常有用。
    • 测试可以通过 cargo test 命令执行。
    • 参考:条件编译。
#![allow(unused)]
fn main() {
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}
// 该模块仅在执行测试时被包含
#[cfg(test)]
mod tests {
    use super::*; // 让父级作用域的所有类型在此可见
    #[test]
    fn it_works() {
        let result = add(2, 2); // 或者使用 super::add(2, 2);
        assert_eq!(result, 4);
    }
}
}

其他 Cargo 功能

  • cargo 还提供了若干实用的功能:
    • cargo clippy: 用于代码静态分析 (Lint)。通常应修复这些警告(除非极个别情况下确需抑制)。
    • cargo format: 运行 rustfmt 工具来格式化源代码。使用此工具可确保代码风格的统一,杜绝关于风格冲突的争论。
    • cargo doc: 用于根据 /// 风格的注释生成文档。crates.io 上所有单元包的文档均是通过此方法生成的。

构建配置 (Build Profiles):控制优化级别

在 C 语言中,你会向 gcc/clang 传递 -O0, -O2, -Os, -flto 等参数。而在 Rust 中,你可以在 Cargo.toml 中配置构建配置:

# Cargo.toml —— 构建配置项

[profile.dev]
opt-level = 0          # 无优化 (编译速度快,类似于 -O0)
debug = true           # 完整的调试符号 (类似于 -g)

[profile.release]
opt-level = 3          # 最大程度优化 (类似于 -O3)
lto = "fat"            # 链接时优化 (类似于 -flto)
strip = true           # 移除符号表 (类似于 strip 命令)
codegen_units = 1      # 单个代码生成单元 —— 编译慢,但优化效果更好
panic = "abort"        # 无展开表 (Unwind tables),减小二进制体积
C/GCC 标志Cargo.toml 配置项可选值
-O0 / -O2 / -O3opt-level0, 1, 2, 3, "s", "z"
-fltoltofalse, "thin", "fat"
-g / no -gdebugtrue, false, "line-tables-only"
strip 命令strip"none", "debuginfo", "symbols", true/false
—codegen_units1 = 最佳优化,最慢编译
cargo build              # 使用 [profile.dev]
cargo build --release    # 使用 [profile.release]

构建脚本 (build.rs):链接 C 语言库

在 C 语言中,你通过 Makefiles 或 CMake 来链接库并执行代码生成。而 Rust 在单元包的根目录下使用 build.rs 文件:

// build.rs —— 在编译单元包之前运行

fn main() {
    // 链接系统 C 库 (类似于 gcc 中的 -lbmc_ipmi)
    println!("cargo::rustc-link-lib=bmc_ipmi");

    // 指定库的查找路径 (类似于 -L/usr/lib/bmc)
    println!("cargo::rustc-link-search=/usr/lib/bmc");

    // 如果 C 语言头文件发生变化,则重新运行
    println!("cargo::rerun-if-changed=wrapper.h");
}

你甚至可以直接从 Rust 单元包中编译 C 语言源码:

# Cargo.toml
[build-dependencies]
cc = "1"  # C 编译器集成
// build.rs
fn main() {
    cc::Build::new()
        .file("src/c_helpers/ipmi_raw.c")
        .include("/usr/include/bmc")
        .compile("ipmi_raw");   // 生成 libipmi_raw.a 并自动完成链接
    println!("cargo::rerun-if-changed=src/c_helpers/ipmi_raw.c");
}
C / Make / CMakeRust 的 build.rs
-lfooprintln!("cargo::rustc-link-lib=foo")
-L/pathprintln!("cargo::rustc-link-search=/path")
编译 C 源码cc::Build::new().file("foo.c").compile("foo")
生成代码将文件写入 $OUT_DIR,然后通过 include!() 引入

交叉编译 (Cross-Compilation)

在 C 语言中,交叉编译需要安装独立的工具链(如 arm-linux-gnueabihf-gcc)并配置 Make/CMake。而 Rust 的操作如下:

# 安装一个交叉编译目标
rustup target add aarch64-unknown-linux-gnu

# 执行交叉编译
cargo build --target aarch64-unknown-linux-gnu --release

在 .cargo/config.toml 中指定链接器:

[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
C 语言交叉编译Rust 对应方法
apt install gcc-aarch64-linux-gnurustup target add aarch64-unknown-linux-gnu + 安装相应链接器
CC=aarch64-linux-gnu-gcc make在 .cargo/config.toml 中配置 [target.X] linker = "..."
#ifdef __aarch64__#[cfg(target_arch = "aarch64")]
独立的 Makefile target使用 cargo build --target ...

特性标记 (Feature Flags):条件编译

C 语言使用 #ifdef 和 -DFOO 进行条件编译。Rust 则使用在 Cargo.toml 中定义的特性标记:

# Cargo.toml
[features]
default = ["json"]         # 默认启用
json = ["dep:serde_json"]  # 可选依赖
verbose = []               # 仅作为标记,不带依赖
gpu = ["dep:cuda-sys"]     # 可选的 GPU 支持
#![allow(unused)]
fn main() {
// 由特性标记管控的代码:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
    serde_json::from_str(data).map_err(Error::from)
}

#[cfg(feature = "verbose")]
macro_rules! verbose {
    ($($arg:tt)*) => { eprintln!("[VERBOSE] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose"))]
macro_rules! verbose {
    ($($arg:tt)*) => {}; // 编译为空操作
}
}
C 预处理器Rust 特性标记
gcc -DDEBUGcargo build --features verbose
#ifdef DEBUG#[cfg(feature = "verbose")]
#define MAX 100const MAX: u32 = 100;
#ifdef __linux__#[cfg(target_os = "linux")]

集成测试 (Integration Tests) vs 单元测试 (Unit Tests)

单元测试与代码位于同一文件中并标有 #[cfg(test)]。而集成测试则位于项目根目录下的 tests/ 目录中,且只能测试该单元包的公开 API:

#![allow(unused)]
fn main() {
// tests/smoke_test.rs —— 此处不需要 #[cfg(test)]
use my_crate::parse_config;

#[test]
fn parse_valid_config() {
    let config = parse_config("test_data/valid.json").unwrap();
    assert_eq!(config.max_retries, 5);
}
}
测试维度单元测试 (#[cfg(test)])集成测试 (tests/)
位置与源码同文件独立的 tests/ 目录
访问权限能够访问私有及公开项仅限公开 API
运行命令cargo testcargo test --test smoke_test

测试模式与策略

对于 C 语言固件开发团队来说,通常需要使用 CUnit、CMocka 或自定义框架来编写测试,且伴随着大量的样板代码。而 Rust 内置的测试框架功能远比此强大。本节将涵盖生产环境代码中所需的测试模式。

#[should_panic] —— 测试预期的失败

#![allow(unused)]
fn main() {
// 测试特定条件是否会触发 Panic(类似于 C 语言中的断言失败)
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_bounds_check() {
    let v = vec![1, 2, 3];
    let _ = v[10];  // 应当触发 Panic
}

#[test]
#[should_panic(expected = "temperature exceeds safe limit")]
fn test_thermal_shutdown() {
    fn check_temperature(celsius: f64) {
        if celsius > 105.0 {
            panic!("temperature exceeds safe limit: {celsius}°C");
        }
    }
    check_temperature(110.0);
}
}

#[ignore] —— 耗时较长或依赖硬件的测试

#![allow(unused)]
fn main() {
// 标记需要特殊外部条件的测试(类似于 C 语言中的 #ifdef HARDWARE_TEST)
#[test]
#[ignore = "requires GPU hardware"]
fn test_gpu_ecc_scrub() {
    // 该测试仅在带有 GPU 的机器上运行
    // 运行方式:cargo test -- --ignored
    // 运行方式:cargo test -- --include-ignored  (运行包括被忽略测试在内的所有测试)
}
}

返回 Result 的测试 (取代大量的 unwrap 链)

#![allow(unused)]
fn main() {
// 相比于写一堆会掩盖实际错误原因的 unwrap(),这样做更好:
#[test]
fn test_config_parsing() -> Result<(), Box<dyn std::error::Error>> {
    let json = r#"{"hostname": "node-01", "port": 8080}"#;
    let config: ServerConfig = serde_json::from_str(json)?;  // 使用 ? 而非 unwrap()
    assert_eq!(config.hostname, "node-01");
    assert_eq!(config.port, 8080);
    Ok(())  // 如果代码运行到此处且未出错,则测试通过
}
}

使用 Builder 函数构建测试脚手架 (Test Fixtures)

C 语言通常使用 setUp()/tearDown() 函数。而 Rust 则利用 Helper 函数和 Drop Trait 来实现:

#![allow(unused)]
fn main() {
struct TestFixture {
    temp_dir: std::path::PathBuf,
    config: Config,
}

impl TestFixture {
    fn new() -> Self {
        let temp_dir = std::env::temp_dir().join(format!("test_{}", std::process::id()));
        std::fs::create_dir_all(&temp_dir).unwrap();
        let config = Config {
            log_dir: temp_dir.clone(),
            max_retries: 3,
            ..Default::default()
        };
        Self { temp_dir, config }
    }
}

impl Drop for TestFixture {
    fn drop(&mut self) {
        // 自动清理 —— 类似于 C 语言中的 tearDown(),但绝不会被遗忘
        let _ = std::fs::remove_dir_all(&self.temp_dir);
    }
}

#[test]
fn test_with_fixture() {
    let fixture = TestFixture::new();
    // 使用 fixture.config, fixture.temp_dir...
    assert!(fixture.temp_dir.exists());
    // fixture 在此处被自动释放 (Drop) ——> 清理逻辑自动运行
}
}

针对硬件接口构建 Trait Mock

在 C 语言中,模拟 (Mocking) 硬件往往需要预处理器技巧或函数指针替换。而在 Rust 中,使用 Trait 是一种非常自然的选择:

#![allow(unused)]
fn main() {
// 用于 IPMI 通信的生产环境 Trait
trait IpmiTransport {
    fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String>;
}

// 真实实现 (用于生产环境)
struct RealIpmi { /* BMC 链接细节 */ }
impl IpmiTransport for RealIpmi {
    fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String> {
        // 与真实的 BMC 硬件通信
        todo!("Real IPMI call")
    }
}

// Mock 实现 (用于测试)
struct MockIpmi {
    responses: std::collections::HashMap<u8, Vec<u8>>,
}
impl IpmiTransport for MockIpmi {
    fn send_command(&self, cmd: u8, _data: &[u8]) -> Result<Vec<u8>, String> {
        self.responses.get(&cmd)
            .cloned()
            .ok_or_else(|| format!("未配置 cmd 0x{cmd:02x} 的 Mock 响应"))
    }
}

// 既能配合真实实现,也能配合 Mock 实现工作的通用函数
fn read_sensor_temperature(transport: &dyn IpmiTransport) -> Result<f64, String> {
    let response = transport.send_command(0x2D, &[])?;
    if response.len() < 2 {
        return Err("响应长度过短".into());
    }
    Ok(response[0] as f64 + (response[1] as f64 / 256.0))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_temperature_reading() {
        let mut mock = MockIpmi { responses: std::collections::HashMap::new() };
        mock.responses.insert(0x2D, vec![72, 128]); // 72.5°C

        let temp = read_sensor_temperature(&mock).unwrap();
        assert!((temp - 72.5).abs() < 0.01);
    }

    #[test]
    fn test_short_response() {
        let mock = MockIpmi { responses: std::collections::HashMap::new() };
        // 未配置 Mock 响应 ——> 报错
        assert!(read_sensor_temperature(&mock).is_err());
    }
}
}

利用 proptest 进行基于属性的测试 (Property-Based Testing)

与其编写特定的测试用例,不如测试那些在任何输入下都应当成立的属性 (Properties)。proptest 会生成随机输入并寻找能使测试失败的最小用例:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies] proptest = "1"
use proptest::prelude::*;

fn parse_sensor_id(s: &str) -> Option<u32> {
    s.strip_prefix("sensor_")?.parse().ok()
}

fn format_sensor_id(id: u32) -> String {
    format!("sensor_{id}")
}

proptest! {
    #[test]
    fn roundtrip_sensor_id(id in 0u32..10000) {
        // 属性:格式化后再解析应当得到原始值
        let formatted = format_sensor_id(id);
        let parsed = parse_sensor_id(&formatted);
        prop_assert_eq!(parsed, Some(id));
    }

    #[test]
    fn parse_rejects_garbage(s in "[^s].*") {
        // 属性:不以 's' 开头的字符串应当解析失败
        let result = parse_sensor_id(&s);
        prop_assert!(result.is_none());
    }
}
}

C 语言 vs Rust 测试对比

C 语言测试Rust 对应方法
CUnit, CMocka, 自定义框架内置的 #[test] + cargo test
setUp() / tearDown()Builder 函数 + Drop Trait
#ifdef TEST 模拟函数基于 Trait 的依赖注入
assert(x == y)assert_eq!(x, y) 且带自动的差异 (Diff) 输出
独立的测试可执行文件与源码位于同一二进制文件,通过 #[cfg(test)] 进行条件编译
valgrind --leak-check=full ./testcargo test (默认内存安全) + cargo miri test
代码覆盖率:gcov / lcovcargo tarpaulin 或 cargo llvm-cov
测试发现:手动注册自动发现 —— 任何带有 #[test] 的函数都会被识别

English Original

针对 C++ 程序员的测试模式

你将学到: Rust 内置的测试框架 —— #[test]、#[should_panic]、返回 Result 的测试、测试数据的 Builder 模式、基于 Trait 的 Mock、利用 proptest 进行属性测试、利用 insta 进行快照测试以及集成测试的组织。这些“零配置”测试将取代 Google Test 和 CMake。

C++ 测试通常依赖于外部框架(如 Google Test、Catch2、Boost.Test),并涉及复杂的构建集成。而 Rust 的测试框架是内置于语言和工具链中的 —— 无需额外依赖,无需配置 CMake,也无需单独配置测试运行器 (Test runner)。

除了 #[test] 之外的测试属性

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_pass() {
        assert_eq!(2 + 2, 4);
    }

    // 预期触发 Panic —— 等同于 GTest 中的 EXPECT_DEATH
    #[test]
    #[should_panic]
    fn out_of_bounds_panics() {
        let v = vec![1, 2, 3];
        let _ = v[10]; // 触发 Panic ——> 测试通过
    }

    // 预期触发包含特定信息的 Panic
    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn specific_panic_message() {
        let v = vec![1, 2, 3];
        let _ = v[10];
    }

    // 返回 Result<(), E> 的测试 —— 使用 ? 而不是 unwrap()
    #[test]
    fn test_with_result() -> Result<(), String> {
        let value: u32 = "42".parse().map_err(|e| format!("{e}"))?;
        assert_eq!(value, 42);
        Ok(())
    }

    // 默认忽略耗时较长的测试 —— 使用 `cargo test -- --ignored` 来运行
    #[test]
    #[ignore]
    fn slow_integration_test() {
        std::thread::sleep(std::time::Duration::from_secs(10));
    }
}
}
cargo test                          # 运行所有非忽略的测试
cargo test -- --ignored             # 仅运行被忽略的测试
cargo test -- --include-ignored     # 运行所有测试(包括被忽略的)
cargo test test_name                # 运行名称匹配特定模式的测试
cargo test -- --nocapture           # 在测试期间打印 println! 的输出
cargo test -- --test-threads=1      # 串行运行测试(适用于共享状态的情况)

测试辅助函数:测试数据的 Builder 模式

在 C++ 中,你会使用 Google Test 的 Fixture (class MyTest : public ::testing::Test)。而在 Rust 中,使用 Builder 函数或 Default Trait 即可。

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // Builder 函数 —— 创建具有合理默认值的测试数据
    fn make_gpu_event(severity: Severity, fault_code: u32) -> DiagEvent {
        DiagEvent {
            source: "accel_diag".to_string(),
            severity,
            message: format!("测试事件 FC:{fault_code}"),
            fault_code,
        }
    }

    // 可复用的测试脚手架 —— 一组预构建的事件
    fn sample_events() -> Vec<DiagEvent> {
        vec![
            make_gpu_event(Severity::Critical, 67956),
            make_gpu_event(Severity::Warning, 32709),
            make_gpu_event(Severity::Info, 10001),
        ]
    }

    #[test]
    fn filter_critical_events() {
        let events = sample_events();
        let critical: Vec<_> = events.iter()
            .filter(|e| e.severity == Severity::Critical)
            .collect();
        assert_eq!(critical.len(), 1);
        assert_eq!(critical[0].fault_code, 67956);
    }
}
}

利用 Trait 进行 Mock

在 C++ 中,模拟 (Mocking) 需要 Google Mock 之类的框架或者手动重载虚函数。而在 Rust 中,只需为依赖项定义 Trait,然后在测试中替换对应的实现即可:

#![allow(unused)]
fn main() {
// 生产环境 Trait
trait SensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String>;
}

// 生产实现
struct HwSensorReader;
impl SensorReader for HwSensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
        // 真实的硬件调用代码...
        Ok(72.5)
    }
}

// 测试环境 Mock —— 返回可预测的值
#[cfg(test)]
struct MockSensorReader {
    temperatures: std::collections::HashMap<u32, f64>,
}

#[cfg(test)]
impl SensorReader for MockSensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
        self.temperatures.get(&sensor_id)
            .copied()
            .ok_or_else(|| format!("未知传感器 ID {sensor_id}"))
    }
}

// 待测试函数 —— 对读取器应用泛型
fn check_overtemp(reader: &impl SensorReader, ids: &[u32], threshold: f64) -> Vec<u32> {
    ids.iter()
        .filter(|&&id| reader.read_temperature(id).unwrap_or(0.0) > threshold)
        .copied()
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_overtemp_sensors() {
        let mut mock = MockSensorReader { temperatures: Default::default() };
        mock.temperatures.insert(0, 72.5);
        mock.temperatures.insert(1, 91.0);  // 超过阈值
        mock.temperatures.insert(2, 65.0);

        let hot = check_overtemp(&mock, &[0, 1, 2], 80.0);
        assert_eq!(hot, vec![1]);
    }
}
}

测试中的临时文件与目录

C++ 测试通常使用平台特定的临时目录。而 Rust 拥有 tempfile 库:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// tempfile = "3"

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;
    use std::io::Write;

    #[test]
    fn parse_config_from_file() -> Result<(), Box<dyn std::error::Error>> {
        // 创建一个在释放 (Drop) 时会自动删除的临时文件
        let mut file = NamedTempFile::new()?;
        writeln!(file, r#"{{"sku": "ServerNode", "level": "Quick"}}"#)?;

        let config = load_config(file.path().to_str().unwrap())?;
        assert_eq!(config.sku, "ServerNode");
        Ok(())
        // 文件在此处被删除 —— 无需额外的清理代码
    }
}
}

利用 proptest 进行基于属性的测试

无需编写特定的测试用例,只需描述对所有输入都成立的属性 (Properties)。proptest 将生成随机输入并自动探测能使程序出错的最小失败用例:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// proptest = "1"

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    fn parse_and_format(n: u32) -> String {
        format!("{n}")
    }

    proptest! {
        #[test]
        fn roundtrip_u32(n: u32) {
            let formatted = parse_and_format(n);
            let parsed: u32 = formatted.parse().unwrap();
            prop_assert_eq!(n, parsed);
        }

        #[test]
        fn string_contains_no_null(s in "[a-zA-Z0-9 ]{0,100}") {
            prop_assert!(!s.contains('\0'));
        }
    }
}
}

利用 insta 进行快照测试 (Snapshot Testing)

对于产生复杂输出(如 JSON、格式化字符串)的测试,insta 可以自动生成并管理参考快照 (Reference snapshots):

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// insta = { version = "1", features = ["json"] }

#[cfg(test)]
mod tests {
    use insta::assert_json_snapshot;

    #[test]
    fn der_entry_format() {
        let entry = DerEntry {
            fault_code: 67956,
            component: "GPU".to_string(),
            message: "检测到 ECC 错误".to_string(),
        };
        // 首次运行:在 tests/snapshots/ 目录下创建一个快照文件
        // 后续运行:与已保存的快照进行比对
        assert_json_snapshot!(entry);
    }
}
}
cargo insta test              # 运行测试并检查新增或变化的快照
cargo insta review            # 交互式检查快照的变化情况

C++ vs Rust 测试对比

C++ (Google Test)Rust备注
TEST(Suite, Name) { }#[test] fn name() { }无需套件 (Suite)/类层次结构
ASSERT_EQ(a, b)assert_eq!(a, b)内置宏,无需框架
ASSERT_NEAR(a, b, eps)assert!((a - b).abs() < eps)或者使用 approx 库
EXPECT_THROW(expr, type)#[should_panic(expected = "...")]或者使用 catch_unwind 进行精细控制
EXPECT_DEATH(expr, "msg")#[should_panic(expected = "msg")]
class Fixture : public ::testing::TestBuilder 函数 + Default无需继承
Google Mock MOCK_METHODTrait + 测试环境实现更显式,无宏魔法
INSTANTIATE_TEST_SUITE_P (参数化测试)proptest! 或由宏生成的测试
SetUp() / TearDown()通过 Drop 实现 RAII —— 清理是自动的变量在测试结束时自动释放
独立的测试二进制 + CMakecargo test —— 零配置
ctest --output-on-failurecargo test -- --nocapture

集成测试:tests/ 目录

单元测试与你的代码并排处于 #[cfg(test)] 模块中。而集成测试 (Integration tests) 则位于单元包根目录下的独立 tests/ 目录中,它们会像外部消费者使用你的库那样,仅对库的公开 API 进行测试:

my_crate/
├── src/
│   └── lib.rs          # 库代码
├── tests/
│   ├── smoke.rs        # 每个 .rs 文件都是一个独立的测试二进制
│   ├── regression.rs
│   └── common/
│       └── mod.rs      # 共享的测试辅助函数 (其本身并非测试)
└── Cargo.toml
#![allow(unused)]
fn main() {
// tests/smoke.rs —— 像外部用户那样测试你的库
use my_crate::DiagEngine;  // 只能访问公开 (pub) API

#[test]
fn engine_starts_successfully() {
    let engine = DiagEngine::new("test_config.json");
    assert!(engine.is_ok());
}

#[test]
fn engine_rejects_invalid_config() {
    let engine = DiagEngine::new("nonexistent.json");
    assert!(engine.is_err());
}
}
#![allow(unused)]
fn main() {
// tests/common/mod.rs —— 共享 Helper 函数,不会被编译为测试二进制
pub fn setup_test_environment() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("config.json"), r#"{"log_level": "debug"}"#).unwrap();
    dir
}
}
#![allow(unused)]
fn main() {
// tests/regression.rs —— 可以使用共享 Helper 
mod common;

#[test]
fn regression_issue_42() {
    let env = common::setup_test_environment();
    let engine = my_crate::DiagEngine::new(
        env.path().join("config.json").to_str().unwrap()
    );
    assert!(engine.is_ok());
}
}

运行集成测试:

cargo test                          # 运行单元测试 AND 集成测试
cargo test --test smoke             # 仅运行 tests/smoke.rs
cargo test --test regression        # 仅运行 tests/regression.rs
cargo test --lib                    # 仅运行单元测试 (跳过集成测试)

与单元测试的关键区别:集成测试无法访问私有函数或 pub(crate) 项。这会迫使你验证公开 API 设计是否完备 —— 这是一个极其有价值的设计信号。用 C++ 的话来说,这就像只根据公开头文件进行测试,且没有任何 friend 访问权限。


9. 错误处理

English Original

将枚举与 Option 和 Result 联系起来

你将学到: Rust 如何用 Option<T> 取代空指针,用 Result<T, E> 取代异常,以及 ? 操作符如何使错误传播变得简洁。这是 Rust 最具特色的模式 —— 错误是可处理的值,而非隐藏的控制流。

  • 还记得我们之前学过的 enum 类型吗?Rust 的 Option 和 Result 实际上就是标准库中定义的简单枚举:
#![allow(unused)]
fn main() {
// 这几乎就是 Option 在 std 中的定义方式:
enum Option<T> {
    Some(T),  // 包含一个值
    None,     // 无值
}

// 以及 Result:
enum Result<T, E> {
    Ok(T),    // 成功,包含结果值
    Err(E),   // 失败,包含错误细节
}
}
  • 这意味着你学过的关于 match 模式匹配的一切知识都可以直接应用于 Option 和 Result。
  • Rust 中没有空指针 (null pointer) —— Option<T> 是替代方案,且编译器会强制你处理 None 的情况。

C++ 对比:异常 vs Result

C++ 模式Rust 等价物优势
throw std::runtime_error(msg)Err(MyError::Runtime(msg))错误体现在返回类型中 —— 不会忘记处理
try { } catch (...) { }match result { Ok(v) => ..., Err(e) => ... }没有隐藏的控制流
std::optional<T>Option<T>必须进行穷尽匹配 —— 不会忘记 None 的处理
noexcept 注解默认 —— 所有 Rust 函数都是 “noexcept”异常根本不存在
errno / 返回码Result<T, E>类型安全,无法被忽略

Rust Option 类型

  • Rust 的 Option 类型是一个只有两个变体的枚举:Some<T> 和 None。
    • 它的核心理念是表示一个可为空 (nullable) 的类型。换言之,它要么包含一个该类型的有效值 (Some<T>),要么不含任何有效值 (None)。
    • 在那些操作结果可能成功(返回有效值)或可能失败(由于特定错误无关紧要而无需详情)的 API 中,Option 类型非常有用。例如,在字符串中查找某个值的索引:
fn main() {
    // 返回 Option<usize>
    let a = "1234".find("1");
    match a {
        Some(a) => println!("在索引 {a} 处找到了 '1'"),
        None => println!("未找到 '1'")
    }
}
  • 可以通过多种方式处理 Rust 的 Option:
    • unwrap():如果 Option<T> 是 None,则会触发程序崩溃 (Panic);否则返回 T。这是最不被推荐的方法。
    • or():用于返回一个备用值。
    • if let:一种测试 Some<T> 变体的便捷语法。

生产环境模式:关于生产环境中 Rust 代码的真实示例,请参阅 使用 unwrap_or 安全提取值 以及 函数式变换:map, map_err, find_map。

fn main() {
  // 返回 Option<usize>
  let a = "1234".find("1");
  println!("{a:?} {}", a.unwrap());
  let a = "1234".find("5").or(Some(42));
  println!("{a:?}");
  if let Some(a) = "1234".find("1") {
      println!("{a}");
  } else {
    println!("字符串中未找到目标");
  }
  // 这会触发程序崩溃 (Panic)
  // "1234".find("5").unwrap();
}

Rust Result 类型

  • Result 是一个类似于 Option 的枚举类型,带有两个变体:Ok<T> 或 Err<E>。
    • 在 Rust API 中,Result 被广泛用于可能失败的操作。其核心理念是:函数在成功时返回 Ok<T>,而在失败时返回包含具体错误信息的 Err<E>。
  use std::num::ParseIntError;
  fn main() {
  let a : Result<i32, ParseIntError>  = "1234z".parse();
  match a {
      Ok(n) => println!("解析成功 {n}"),
      Err(e) => println!("解析失败 {e:?}"),
  }
  let a : Result<i32, ParseIntError>  = "1234z".parse().or(Ok(-1));
  println!("{a:?}");
  if let Ok(a) = "1234".parse::<i32>() {
    println!("成功解析为 {a}");  
  }
  // 这会触发程序崩溃 (Panic)
  //"1234z".parse().unwrap();
}

Option 与 Result:同根同源

Option 和 Result 之间有着极深的渊源 —— Option<T> 本质上相当于 Result<T, ()>(即一个错误信息为空的 Result):

Option<T>Result<T, E>含义
Some(value)Ok(value)成功 —— 存在有效值
NoneErr(error)失败 —— 无值 (Option) 或包含错误详情 (Result)

相互转换:

fn main() {
    let opt: Option<i32> = Some(42);
    let res: Result<i32, &str> = opt.ok_or("值为 None");  // Option → Result
    
    let res: Result<i32, &str> = Ok(42);
    let opt: Option<i32> = res.ok();  // Result → Option (丢弃错误详情)
    
    // 它们共享许多相同的方法:
    // .map(), .and_then(), .unwrap_or(), .unwrap_or_else(), .is_some()/is_ok()
}

经验法则:当“缺失”是一种正常现象时(例如查找某个 Key),使用 Option。当“失败”需要解释时(例如文件 I/O、解析),使用 Result。


练习:利用 Option 实现 log() 函数

🟢 初学

  • 实现一个接受 Option<&str> 参数的 log() 函数。如果参数为 None,它应当打印一条默认字符串。
  • 该函数应当返回一个在成功和出错时均为 () 的 Result 类型(在本练习中我们暂不处理错误情况)。
参考答案 (点击展开)
fn log(message: Option<&str>) -> Result<(), ()> {
    match message {
        Some(msg) => println!("LOG: {msg}"),
        None => println!("LOG: (未提供任何信息)"),
    }
    Ok(())
}

fn main() {
    let _ = log(Some("系统已初始化"));
    let _ = log(None);
    
    // 或者使用 unwrap_or 的替代方案:
    let msg: Option<&str> = None;
    println!("LOG: {}", msg.unwrap_or("(默认信息)"));
}

输出示例:

LOG: 系统已初始化
LOG: (未提供任何信息)
LOG: (默认信息)

Rust 错误处理机制

  • Rust 的错误分为不可恢复 (Irrecoverable)(致命)和可恢复 (Recoverable) 两种。致命错误会导致程序崩溃 (Panic)。
    • 通常应当尽量避免会导致 panic 的情况。panic 常由程序逻辑 Bug 引起,例如数组越界、对 Option<None> 调用 unwrap() 等。
    • 对于那些“理应不可能发生”的条件,使用显式的 panic 是可以接受的。panic! 或 assert! 宏常用于此类完整性检查。
fn main() {
   let x : Option<u32> = None;
   // println!("{}", x.unwrap()); // 会导致程序崩溃 (Panic)
   println!("{}", x.unwrap_or(0));  // 正常 —— 打印 0
   let x = 41;
   //assert!(x == 42); // 会导致程序崩溃 (Panic)
   //panic!("出错了"); // 无条件触发崩溃 (Panic)
   let _a = vec![0, 1];
   // println!("{}", _a[2]); // 越界崩溃;应使用 a.get(2),它会返回 Option<T>
}

错误处理:C++ vs Rust

C++ 基于异常的错误处理存在的问题

// C++ 错误处理 —— 异常产生了“隐藏”的控制流
#include <fstream>
#include <stdexcept>

std::string read_config(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open()) {
        throw std::runtime_error("无法打开文件: " + path);
    }
    std::string content;
    // 如果 getline 抛出异常怎么办?文件是否正确关闭?
    // 虽然 RAII 保证了文件关闭,但其他资源呢?
    std::getline(file, content);
    return content;  // 如果调用者忘记 try/catch 怎么办?
}

int main() {
    // 错误:忘记包裹在 try/catch 中了!
    auto config = read_config("nonexistent.txt");
    // 异常会悄无声息地向上传播,导致程序崩溃
    // 函数签名中没有任何关于异常的警示
    return 0;
}
graph TD
    subgraph "C++ 错误处理的问题"
        CF["函数调用"]
        CR["抛出异常<br/>或返回码"]
        CIGNORE["[错误] 异常未被捕获<br/>或返回码被忽略"]
        CCHECK["try/catch 或检查"]
        CERROR["隐藏的控制流<br/>签名中未标注 throw"]
        CERRNO["无编译期强制机制"]
        
        CF --> CR
        CR --> CIGNORE
        CR --> CCHECK
        CCHECK --> CERROR
        CERROR --> CERRNO
        
        CPROBLEMS["[错误] 异常在类型中不可见<br/>[错误] 隐藏的控制流<br/>[错误] 容易遗忘 try/catch<br/>[错误] 异常安全性难以保障<br/>[错误] noexcept 是可选的"]
    end
    
    subgraph "Rust Result<T, E> 体系"
        RF["函数调用"]
        RR["Result<T, E><br/>Ok(value) | Err(error)"]
        RMUST["[正确] 必须处理<br/>忽略则报错"]
        RMATCH["模式匹配<br/>match, if let, ?"]
        RDETAIL["详细的错误详情<br/>自定义错误类型"]
        RSAFE["类型安全<br/>无全局状态"]
        
        RF --> RR
        RR --> RMUST
        RMUST --> RMATCH
        RMATCH --> RDETAIL
        RDETAIL --> RSAFE
        
        RBENEFITS["[正确] 强制处理错误<br/>[正确] 类型安全的错误<br/>[正确] 详细的错误详情<br/>[正确] 可通过 ? 轻松组合<br/>[正确] 零运行时开销"]
    end
    
    style CPROBLEMS fill:#ff6b6b,color:#000
    style RBENEFITS fill:#91e5a3,color:#000
    style CIGNORE fill:#ff6b6b,color:#000
    style RMUST fill:#91e5a3,color:#000

Result<T, E> 的可视化

// Rust 错误处理 —— 全面且强制
use std::fs::File;
use std::io::Read;

fn read_file_content(filename: &str) -> Result<String, std::io::Error> {
    let mut file = File::open(filename)?;  // ? 会自动传播错误
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)  // 成功的情况
}

fn main() {
    match read_file_content("example.txt") {
        Ok(content) => println!("文件内容: {}", content),
        Err(error) => println!("读取文件失败: {}", error),
        // 编译器强制我们处理这两种情况!
    }
}
graph TD
    subgraph "Result<T, E> 流程"
        START["函数开始"]
        OP1["File::open()"]
        CHECK1{{"Result 检查"}}
        OP2["file.read_to_string()"]
        CHECK2{{"Result 检查"}}
        SUCCESS["Ok(contents)"]
        ERROR1["Err(io::Error)"]
        ERROR2["Err(io::Error)"]
        
        START --> OP1
        OP1 --> CHECK1
        CHECK1 -->|"Ok(file)"| OP2
        CHECK1 -->|"Err(e)"| ERROR1
        OP2 --> CHECK2
        CHECK2 -->|"Ok(())"| SUCCESS
        CHECK2 -->|"Err(e)"| ERROR2
        
        ERROR1 --> PROPAGATE["? 操作符<br/>传播错误"]
        ERROR2 --> PROPAGATE
        PROPAGATE --> CALLER["调用者必须<br/>处理错误"]
    end
    
    subgraph "模式匹配选项"
        MATCH["match result"]
        IFLET["if let Ok(val) = result"]
        UNWRAP["result.unwrap()<br/>[警告] 出错时崩溃"]
        EXPECT["result.expect(msg)<br/>[警告] 出错时带消息崩溃"]
        UNWRAP_OR["result.unwrap_or(default)<br/>[正确] 安全备选值"]
        QUESTION["result?<br/>[正确] 提前退出"]
        
        MATCH --> SAFE1["[正确] 处理两种情况"]
        IFLET --> SAFE2["[正确] 处理成功情况"]
        UNWRAP_OR --> SAFE3["[正确] 总能返回一个值"]
        QUESTION --> SAFE4["[正确] 向上传播给调用者"]
        UNWRAP --> UNSAFE1["[错误] 可能导致崩溃"]
        EXPECT --> UNSAFE2["[错误] 可能导致崩溃"]
    end
    
    style SUCCESS fill:#91e5a3,color:#000
    style ERROR1 fill:#ffa07a,color:#000
    style ERROR2 fill:#ffa07a,color:#000
    style SAFE1 fill:#91e5a3,color:#000
    style SAFE2 fill:#91e5a3,color:#000
    style SAFE3 fill:#91e5a3,color:#000
    style SAFE4 fill:#91e5a3,color:#000
    style UNSAFE1 fill:#ff6b6b,color:#000
    style UNSAFE2 fill:#ff6b6b,color:#000

Rust 错误处理

  • Rust 使用 enum Result<T, E> 枚举来进行可恢复的错误处理。
    • Ok<T> 变元包含成功时的结果,而 Err<E> 变元包含错误信息。
fn main() {
    let x = "1234x".parse::<u32>();
    match x {
        Ok(x) => println!("成功解析数字 {x}"),
        Err(e) => println!("解析错误 {e:?}"),
    }
    let x  = "1234".parse::<u32>();
    // 与上方相同,但针对有效数字
    if let Ok(x) = &x {
        println!("成功解析数字 {x}")
    } else if let Err(e) = &x {
        println!("错误: {e:?}");
    }
}
  • 尝试操作符 ? 是 match Ok / Err 模式的一种便捷简写方式。
    • 注意:使用 ? 的方法必须返回 Result<T, E>。
    • Result<T, E> 的类型可以更改。在下方示例中,我们返回了与 str::parse() 相同的错误类型 (std::num::ParseIntError)。
fn double_string_number(s : &str) -> Result<u32, std::num::ParseIntError> {
   let x = s.parse::<u32>()?; // 出错时立即返回
   Ok(x*2)
}
fn main() {
    let result = double_string_number("1234");
    println!("{result:?}");
    let result = double_string_number("1234x");
    println!("{result:?}");
}

Rust 错误处理

  • 错误可以映射到其他类型,或映射到默认值 (参考:unwrap_or_default)。
#![allow(unused)]
fn main() {
// 如果出错,则将错误类型转换为 ()
fn double_string_number(s : &str) -> Result<u32, ()> {
   let x = s.parse::<u32>().map_err(|_|())?; // 出错时立即返回
   Ok(x*2)
}
}
#![allow(unused)]
fn main() {
fn double_string_number(s : &str) -> Result<u32, ()> {
   let x = s.parse::<u32>().unwrap_or_default(); // 解析出错时默认为 0
   Ok(x*2)
}
}
#![allow(unused)]
fn main() {
fn double_optional_number(x : Option<u32>) -> Result<u32, ()> {
    // 下方示例中,ok_or 会将 Option<None> 转换为 Result<u32, ()>
    x.ok_or(()).map(|x|x*2) // .map() 仅作用于 Ok(u32)
}
}

练习:错误处理

🟡 中级

  • 实现一个仅接受单个 u32 参数的 log() 函数。如果参数不等于 42,则返回错误。成功和错误的 Result<> 类型均为 ()。
  • 调用 log() 函数,如果 log() 返回错误,则以相同的 Result<> 类型立即退出。否则打印一条消息,说明 log 已成功调用。
fn log(x: u32) -> ?? {

}

fn call_log(x: u32) -> ?? {
    // 调用 log(x),如果解析出错则立即退出
    println!("log 已成功调用");
}

fn main() {
    call_log(42);
    call_log(43);
}
参考答案 (点击展开)
fn log(x: u32) -> Result<(), ()> {
    if x == 42 {
        Ok(())
    } else {
        Err(())
    }
}

fn call_log(x: u32) -> Result<(), ()> {
    log(x)?;  // 如果 log() 返回错误则立即退出
    println!("log 已成功调用,参数为 {x}");
    Ok(())
}

fn main() {
    let _ = call_log(42);  // 打印:log 已成功调用,参数为 42
    let _ = call_log(43);  // 返回 Err(()),不打印任何内容
}

输出示例:

log 已成功调用,参数为 42

English Original

Rust Option 和 Result 核心要点

你将学到: 惯用 (Idiomatic) 的错误处理模式 —— unwrap() 的安全替代方案、用于传播错误的 ? 操作符、自定义错误类型,以及在生产环境代码中何时使用 anyhow vs thiserror。

  • Option 和 Result 是编写惯用 Rust 代码不可或缺的一部分。
  • unwrap() 的安全替代方案:
#![allow(unused)]
fn main() {
// Option<T> 的安全替代
let value = opt.unwrap_or(default);              // 提供备选值 (Fallback)
let value = opt.unwrap_or_else(|| compute());    // 惰性计算备选值
let value = opt.unwrap_or_default();             // 使用 Default Trait 的实现
let value = opt.expect("描述性错误信息");          // 仅在可以接受崩溃的情况下使用

// Result<T, E> 的安全替代  
let value = result.unwrap_or(fallback);          // 忽略错误,使用备选值
let value = result.unwrap_or_else(|e| handle(e)); // 处理错误并返回备选值
let value = result.unwrap_or_default();          // 使用 Default Trait
}
  • 用于显式控制的模式匹配:
#![allow(unused)]
fn main() {
match some_option {
    Some(value) => println!("获取到: {}", value),
    None => println!("未找到任何值"),
}

match some_result {
    Ok(value) => process(value),
    Err(error) => log_error(error),
}
}
  • 使用 ? 操作符进行错误传播:实现错误触发时的“短路”行为并将其向上层抛出:
#![allow(unused)]
fn main() {
fn process_file(path: &str) -> Result<String, std::io::Error> {
    let content = std::fs::read_to_string(path)?; // 发生错误时自动返回 Err
    Ok(content.to_uppercase())
}
}
  • 变换方法:
    • map():变换成功的变元,即 Ok(T) -> Ok(U) 或 Some(T) -> Some(U)。
    • map_err():变换错误类型,即 Err(E) -> Err(F)。
    • and_then():链式调用可能失败的操作。
  • 在自定义 API 中使用:优先选择 Result<T, E>,而非异常或错误码。
  • 参考资料:Option 文档 | Result 文档

Rust 常见坑点与调试技巧

  • 借用 (Borrowing) 问题:最常见的初学者错误。
    • “cannot borrow as mutable”:一次仅允许存在一个可变引用。
    • “borrowed value does not live long enough”:引用超出了它所指向数据的生命周期。
    • 解决方法:使用作用域 {} 来限制引用的生命周期,或在需要时克隆 (Clone) 数据。
  • 缺失 Trait 实现:“method not found” 错误。
    • 解决方法:为常用类型添加 #[derive(Debug, Clone, PartialEq)]。
    • 使用 cargo check 而非 cargo run 来获取更详细的错误消息。
  • 调试模式下的整数溢出:Rust 在溢出时会崩溃 (Panic)。
    • 解决方法:使用 wrapping_add()、saturating_add() 或 checked_add() 以获得明确的行为。
  • String 与 &str 的混淆:它们是针对不同用例的不同类型。
    • &str 用于字符串切片(借用),String 用于拥有所有权的字符串。
    • 解决方法:使用 .to_string() 或 String::from() 将 &str 转换为 String。
  • 对抗借用检查器 (Borrow Checker):不要试图超越它。
    • 解决方法:调整代码结构以适应所有权规则,而不是试图绕过它。
    • 在需要复杂共享的场景下(谨慎)使用 Rc<RefCell<T>>。

错误处理示例:好与坏

#![allow(unused)]
fn main() {
// [错误] BAD: 可能会在没有任何预警的情况下触发崩溃
fn bad_config_reader() -> String {
    let config = std::env::var("CONFIG_FILE").unwrap(); // 如果未设置该环境变量,程序会崩溃!
    std::fs::read_to_string(config).unwrap()           // 如果文件不存在,程序会崩溃!
}

// [好] GOOD: 优雅地处理错误
fn good_config_reader() -> Result<String, ConfigError> {
    let config_path = std::env::var("CONFIG_FILE")
        .unwrap_or_else(|_| "default.conf".to_string()); // 默认退回 default.conf
    
    let content = std::fs::read_to_string(config_path)
        .map_err(ConfigError::FileRead)?;                // 转换并传播错误
    
    Ok(content)
}

// [更好] EVEN BETTER: 使用专门的错误类型
use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("无法读取配置文件: {0}")]
    FileRead(#[from] std::io::Error),
    
    #[error("配置信息无效: {message}")]
    Invalid { message: String },
}
}

让我们分析一下这里的逻辑。ConfigError 仅包含两个变元 (Variants) —— 一个用于 I/O 错误,另一个用于验证错误。对于大多数模块来说,这都是一个非常好的起点:

ConfigError 变元持有的内容创建方式
FileRead(io::Error)原始 I/O 错误#[from] 会实现通过 ? 自动转换
Invalid { message }人类可读的解释说明你的验证代码

现在,你可以编写返回 Result<T, ConfigError> 的函数了:

#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ConfigError> {
    let content = std::fs::read_to_string(path)?;  // io::Error → ConfigError::FileRead
    if content.is_empty() {
        return Err(ConfigError::Invalid {
            message: "配置文件为空".to_string(),
        });
    }
    Ok(content)
}
}

🟢 自学检查点: 在继续之前,请确保你能回答以下两个问题:

  1. 为什么在 read_to_string 调用处使用 ? 是有效的?(因为 #[from] 生成了 impl From<io::Error> for ConfigError)
  2. 如果你增加第三个变元 MissingKey(String) —— 哪些代码需要修改?(只需增加变元即可;现有代码依然可以正常编译)

单元包级别的错误类型与 Result 别名

随着项目规模超出单个文件,你会将多个模块级别的错误组合成一个单元包级别的错误类型。这是 Rust 生产环境代码中的标准模式。让我们在上面的 ConfigError 基础上继续构建。

在真实的 Rust 项目中,每个单元包 (Crate)(或规模较大的模块)都会定义自己的 Error 枚举和 Result 类型别名。这是一种惯用模式 —— 类似于在 C++ 中为每个库定义异常层次结构以及 using Result = std::expected<T, Error>。

模式实现

#![allow(unused)]
fn main() {
// src/error.rs (或 lib.rs 的顶部)
use thiserror::Error;

/// 本单元包可能产生的所有错误。
#[derive(Error, Debug)]
pub enum Error {
    #[error("I/O 错误: {0}")]
    Io(#[from] std::io::Error),          // 通过 From 自动转换

    #[error("JSON 解析错误: {0}")]
    Json(#[from] serde_json::Error),     // 通过 From 自动转换

    #[error("传感器 ID 无效: {0}")]
    InvalidSensor(u32),                  // 业务领域相关的变元

    #[error("在 {ms} 毫秒后超时")]
    Timeout { ms: u64 },
}

/// 单元包通用的 Result 别名 —— 减少全包范围内的重复输入。
pub type Result<T> = core::result::Result<T, Error>;
}

该模式如何简化每一个函数

如果不使用别名,你需要这样写:

#![allow(unused)]
fn main() {
// 冗长 —— 错误类型在各处不断重复
fn read_sensor(id: u32) -> Result<f64, crate::Error> { ... }
fn parse_config(path: &str) -> Result<Config, crate::Error> { ... }
}

使用别名后:

#![allow(unused)]
fn main() {
// 简洁 —— 仅需 `Result<T>`
use crate::{Error, Result};

fn read_sensor(id: u32) -> Result<f64> {
    if id > 128 {
        return Err(Error::InvalidSensor(id));
    }
    // io::Error → Error::Io (通过 ? 自动转换)
    let raw = std::fs::read_to_string(format!("/dev/sensor/{id}"))?; 
    let value: f64 = raw.trim().parse()
        .map_err(|_| Error::InvalidSensor(id))?;
    Ok(value)
}
}

thiserror 的 #[from] 属性会为你免费生成如下 impl:

#![allow(unused)]
fn main() {
// 由 thiserror 的 #[from] 自动生成
impl From<std::io::Error> for Error {
    fn from(source: std::io::Error) -> Self {
        Error::Io(source)
    }
}
}

这就是 ? 能够工作的原因:当某个函数正在抛出 std::io::Error,而你的函数返回的是你的别名 Result<T> 时,编译器会自动调用 From::from() 进行转换。


组合模块级别的错误

较大规模的单元包会在每个模块中分别定义错误,然后在单元包根节点进行组合:

#![allow(unused)]
fn main() {
// src/config/error.rs
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
    #[error("缺失键: {0}")]
    MissingKey(String),
    #[error("'{key}' 的值无效: {reason}")]
    InvalidValue { key: String, reason: String },
}

// src/error.rs (单元包级别)
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error(transparent)]               // 将 Display 委托给内部错误
    Config(#[from] crate::config::ConfigError),

    #[error("I/O 错误: {0}")]
    Io(#[from] std::io::Error),
}
pub type Result<T> = core::result::Result<T, Error>;
}

调用者仍然可以匹配特定的配置错误:

#![allow(unused)]
fn main() {
match result {
    Err(Error::Config(ConfigError::MissingKey(k))) => eprintln!("请在配置中添加 '{k}'"),
    Err(e) => eprintln!("其他错误: {e}"),
    Ok(v) => use_value(v),
}
}

C++ 对比

概念C++Rust
错误层次结构class AppError : public std::runtime_error#[derive(thiserror::Error)] enum Error { ... }
返回错误std::expected<T, Error> 或 throwfn foo() -> Result<T>
转换错误手动的 try/catch + 重新抛出 (Rethrow)#[from] + ? —— 零样板代码
Result 别名template<class T> using Result = std::expected<T, Error>;pub type Result<T> = core::result::Result<T, Error>;
错误消息重写 (Override) what()#[error("...")] —— 编译为 Display 实现

English Original

Rust 特性 (Traits)

你将学到: 特性 (Traits) —— Rust 对接口、抽象基类及运算符重载的解决方案。你将学习如何定义特性、为你的类型实现它们,以及如何使用动态分发 (dyn Trait) 与静态分发(泛型)。对于 C++ 开发者:特性取代了虚函数、CRTP 以及 Concepts。对于 C 开发者:特性是 Rust 实现多态的结构化方式。

  • Rust 的特性类似于其他语言中的接口 (Interfaces)。
    • 特性定义了实现该特性的类型必须具备的方法。
fn main() {
    trait Pet {
        fn speak(&self);
    }
    struct Cat;
    struct Dog;
    impl Pet for Cat {
        fn speak(&self) {
            println!("喵");
        }
    }
    impl Pet for Dog {
        fn speak(&self) {
            println!("汪!")
        }
    }
    let c = Cat{};
    let d = Dog{};
    c.speak();  // Cat 和 Dog 之间不存在“是一个 (is-a)”的关系
    d.speak();  // Cat 和 Dog 之间不存在“是一个 (is-a)”的关系
}

特性 vs C++ Concepts 和接口

传统的 C++ 继承 vs Rust 特性

// C++ - 基于继承的多态
class Animal {
public:
    virtual void speak() = 0;  // 纯虚函数
    virtual ~Animal() = default;
};

class Cat : public Animal {  // "Cat 是一个 (IS-A) Animal"
public:
    void speak() override {
        std::cout << "喵" << std::endl;
    }
};

void make_sound(Animal* animal) {  // 运行时多态
    animal->speak();  // 虚函数调用
}
#![allow(unused)]
fn main() {
// Rust - 组合优于继承,使用特性
trait Animal {
    fn speak(&self);
}

struct Cat;  // Cat 不是 Animal,但实现了 (IMPLEMENTS) Animal 的行为

impl Animal for Cat {  // "Cat 可以执行 (CAN-DO) Animal 的行为"
    fn speak(&self) {
        println!("喵");
    }
}

fn make_sound<T: Animal>(animal: &T) {  // 静态多态
    animal.speak();  // 直接调用函数(零开销)
}
}

graph TD
    subgraph "C++ 面向对象层次结构"
        CPP_ANIMAL["Animal<br/>(抽象基类)"]
        CPP_CAT["Cat : public Animal<br/>(“是一个”关系)"]
        CPP_DOG["Dog : public Animal<br/>(“是一个”关系)"]
        
        CPP_ANIMAL --> CPP_CAT
        CPP_ANIMAL --> CPP_DOG
        
        CPP_VTABLE["虚函数表 (vtable)<br/>(运行时分发)"]
        CPP_HEAP["通常需要堆分配"]
        CPP_ISSUES["[错误] 庞大的继承树<br/>[错误] 菱形继承问题<br/>[错误] 运行时开销<br/>[错误] 紧耦合"]
    end
    
    subgraph "Rust 基于特性的组合"
        RUST_TRAIT["trait Animal<br/>(行为定义)"]
        RUST_CAT["struct Cat<br/>(仅包含数据)"]
        RUST_DOG["struct Dog<br/>(仅包含数据)"]
        
        RUST_CAT -.->|"impl Animal for Cat<br/>(“可以执行”行为)"| RUST_TRAIT
        RUST_DOG -.->|"impl Animal for Dog<br/>(“可以执行”行为)"| RUST_TRAIT
        
        RUST_STATIC["静态分发<br/>(编译时确定)"]
        RUST_STACK["支持栈分配"]
        RUST_BENEFITS["[正确] 无继承层次结构<br/>[正确] 支持多个特性实现<br/>[正确] 零运行时开销<br/>[正确] 松耦合"]
    end
    
    style CPP_ISSUES fill:#ff6b6b,color:#000
    style RUST_BENEFITS fill:#91e5a3,color:#000
    style CPP_VTABLE fill:#ffa07a,color:#000
    style RUST_STATIC fill:#91e5a3,color:#000

特性结合 (Trait Bounds) 与泛型约束

#![allow(unused)]
fn main() {
use std::fmt::Display;
use std::ops::Add;

// C++ 模板等价物(约束较少)
// template<typename T>
// T add_and_print(T a, T b) {
//     // 无法保证 T 支持 + 运算或打印
//     return a + b;  // 可能会在编译时失败
// }

// Rust - 显式的特性结合 (Trait Bounds)
fn add_and_print<T>(a: T, b: T) -> T 
where 
    T: Display + Add<Output = T> + Copy,
{
    println!("正在计算 {} + {}", a, b);  // Display 特性
    a + b  // Add 特性
}
}

graph TD
    subgraph "泛型约束的演进"
        UNCONSTRAINED["fn process<T>(data: T)<br/>[错误] T 可以是任何类型"]
        SINGLE_BOUND["fn process<T: Display>(data: T)<br/>[正确] T 必须实现 Display"]
        MULTI_BOUND["fn process<T>(data: T)<br/>where T: Display + Clone + Debug<br/>[正确] 支持多个约束要求"]
        
        UNCONSTRAINED --> SINGLE_BOUND
        SINGLE_BOUND --> MULTI_BOUND
    end
    
    subgraph "特性结合语法"
        INLINE["fn func<T: Trait>(param: T)"]
        WHERE_CLAUSE["fn func<T>(param: T)<br/>where T: Trait"]
        IMPL_PARAM["fn func(param: impl Trait)"]
        
        COMPARISON["内联: 适用于简单场景<br/>Where 子句: 适用于复杂约束<br/>impl: 语法更简洁"]
    end
    
    subgraph "编译时“魔法”"
        GENERIC_FUNC["带有特性结合的泛型函数"]
        TYPE_CHECK["编译器验证特性实现情况"]
        MONOMORPH["单态化 (Monomorphization)<br/>(创建特定版本的函数)"]
        OPTIMIZED["全优化的机器码"]
        
        GENERIC_FUNC --> TYPE_CHECK
        TYPE_CHECK --> MONOMORPH
        MONOMORPH --> OPTIMIZED
        
        EXAMPLE["add_and_print::<i32><br/>add_and_print::<f64><br/>(生成两个独立的函数)"]
        MONOMORPH --> EXAMPLE
    end
    
    style UNCONSTRAINED fill:#ff6b6b,color:#000
    style SINGLE_BOUND fill:#ffa07a,color:#000
    style MULTI_BOUND fill:#91e5a3,color:#000
    style OPTIMIZED fill:#91e5a3,color:#000

C++ 运算符重载 → Rust std::ops 特性

在 C++ 中,你通过编写具有特殊名称(如 operator+、operator<<、operator[] 等)的普通函数或成员函数来实现运算符重载。在 Rust 中,每个运算符都对应 std::ops(或用于输出的 std::fmt)中的一个特性。你通过实现该特性来代替编写魔术名称的函数。

侧面对比:+ 运算符

// C++: 运算符重载作为成员函数或普通函数
struct Vec2 {
    double x, y;
    Vec2 operator+(const Vec2& rhs) const {
        return {x + rhs.x, y + rhs.y};
    }
};

Vec2 a{1.0, 2.0}, b{3.0, 4.0};
Vec2 c = a + b;  // 调用 a.operator+(b)
#![allow(unused)]
fn main() {
use std::ops::Add;

#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }

impl Add for Vec2 {
    type Output = Vec2;                     // 关联类型 —— + 运算的结果
    fn add(self, rhs: Vec2) -> Vec2 {
        Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b;  // 调用 <Vec2 as Add>::add(a, b)
println!("{c:?}"); // Vec2 { x: 4.0, y: 6.0 }
}

与 C++ 的关键区别

维度C++Rust
机制魔术名称的函数 (operator+)实现一个特性 (impl Add for T)
可发现性全局搜索 operator+ 或阅读头文件查看特性的实现情况 —— IDE 支持极佳
返回类型自由选择由 Output 关联类型固定
接收端通常通过 const T&(借用)默认通过 self 以值 (Value) 方式获取(移动!)
对称性可以编写 impl operator+(int, Vec2)必须添加 impl Add<Vec2> for i32 (受孤儿规则约束)
用于打印的 <<operator<<(ostream&, T) —— 针对任意流进行重载impl fmt::Display for T —— 唯一规范的 to_string 表示

关于 self 传值的陷阱

在 Rust 中,Add::add(self, rhs) 默认以值 (Value) 方式接收 self。对于 Copy 类型(如上文派生了 Copy 的 Vec2)这没有问题 —— 编译器会自动进行复制。但对于非 Copy 类型,+ 运算符会消耗 (Consume) 操作数:

#![allow(unused)]
fn main() {
let s1 = String::from("hello ");
let s2 = String::from("world");
let s3 = s1 + &s2;  // s1 被 移动 (MOVE) 到了 s3 中!
// println!("{s1}");  // ❌ 编译错误:值在发生移动后被使用
println!("{s2}");     // ✅ s2 仅被借用 (&s2)
}

这就是为什么 String + &str 可以工作,而 &str + &str 却不行的原因 —— Add 仅为 String + &str 实现了重载,它会消耗左侧的 String 以复用其缓冲区。而在 C++ 中没有类似的机制:std::string::operator+ 总是会创建一个新的字符串。


完整对照表:C++ 运算符 → Rust 特性

C++ 运算符Rust 特性备注
operator+std::ops::AddOutput 关联类型
operator-std::ops::Sub
operator*std::ops::Mul非指针解引用 —— 对应的是 Deref
operator/std::ops::Div
operator%std::ops::Rem
operator- (一元)std::ops::Neg
operator! / operator~std::ops::NotRust 对逻辑和按位取反均使用 !(无 ~ 运算符)
operator&, `, ^`BitAnd, BitOr, BitXor
operator<<, >> (位移)Shl, Shr非流式 I/O!
operator+=std::ops::AddAssign接收 &mut self(而非 self)
operator[]std::ops::Index / IndexMut返回 &Output / &mut Output
operator()Fn / FnMut / FnOnce闭包实现了这些特性;你不能直接调用 impl Fn
operator==PartialEq (+ Eq)位于 std::cmp 中,而非 std::ops
operator<PartialOrd (+ Ord)位于 std::cmp 中
operator<< (流)fmt::Display用于 println!("{}", x)
operator<< (调试)fmt::Debug用于 println!("{:?}", x)
operator bool无直接等价物使用 impl From<T> for bool 或命名方法(如 .is_empty())
operator T() (隐式转换)无隐式转换使用 From/Into 特性(显式调用)

防护栏:Rust 禁止的行为

  1. 禁止隐式转换:C++ 的 operator int() 可能会导致意外的静默类型转换。Rust 不支持隐式转换运算符 —— 必须显式使用 From/Into 并调用 .into()。
  2. 禁止重载 && / ||:C++ 允许这样做(但这会破坏短路逻辑!)。Rust 则禁止。
  3. 禁止重载 =:赋值操作要么是移动,要么是复制,永远不能由用户自定义。复合赋值(如 +=)可以通过 AddAssign 等进行重载。
  4. 禁止重载 ,:C++ 允许重载 operator,() —— 这是 C++ 中最臭名昭著的坑点之一。Rust 不支持。
  5. 禁止重载 & (取地址):这是另一个 C++ 的坑点(为此 C++ 专门提供了 std::addressof 避坑)。Rust 的 & 永远代表“借用”。
  6. 相干性 (Coherence) 规则:你只能为你自己的类型实现外部特性,或者为外部类型实现你自己的特性 —— 绝不能为外部类型实现外部特性。这防止了不同单元包之间发生运算符重载冲突。

核心结论:在 C++ 中,运算符重载虽然强大但缺乏监管 —— 只要你想,几乎可以重载任何东西(包括逗号和取地址符),且隐式转换可能会静默触发。Rust 通过特性在算术和比较运算方面提供了同等的表现力,但封锁了历史上被证明危险的重载行为,并强制所有转换必须显式进行。


Rust 特性

  • Rust 允许在内置类型(如本例中的 u32)上实现用户定义的特性。但是,特性或类型本身必须至少有一个属于当前的单元包(Crate)。
trait IsSecret {
    fn is_secret(&self);
}
// IsSecret 特性属于当前单元包,所以这样做是合法的
impl IsSecret for u32 {
    fn is_secret(&self) {
        if *self == 42 {
            println!("这是生命的秘密");
        }
    }
}

fn main() {
    42u32.is_secret();
    43u32.is_secret();
}

Rust 特性

  • 特性支持接口继承与默认实现。
trait Animal {
  // 默认实现
  fn is_mammal(&self) -> bool {
    true
  }
}
trait Feline : Animal {
  // 默认实现
  fn is_feline(&self) -> bool {
    true
  }
}

struct Cat;
// 使用默认实现。请注意,所有的父特性 (Supertrait) 都必须被单独实现。
impl Feline for Cat {}
impl Animal for Cat {}

fn main() {
  let c = Cat{};
  println!("是哺乳动物:{} 是猫科动物:{}", c.is_mammal(), c.is_feline());
}

练习:实现 Logger 特性

🟡 中级

  • 实现一个名为 Log 的特性,包含一个名为 log() 且接收一个 u64 参数的方法。
    • 实现两个不同的记录器:SimpleLogger 和 ComplexLogger。它们都需实现 Log 特性。
    • 前者应输出 “Simple logger” 以及传入的 u64 值;后者应输出 “Complex logger” 连同该 u64 值及其十六进制、二进制格式。
参考答案 (点击展开)
trait Log {
    fn log(&self, value: u64);
}

struct SimpleLogger;
struct ComplexLogger;

impl Log for SimpleLogger {
    fn log(&self, value: u64) {
        println!("Simple logger: {value}");
    }
}

impl Log for ComplexLogger {
    fn log(&self, value: u64) {
        println!("Complex logger: {value} (十六进制: 0x{value:x}, 二进制: {value:b})");
    }
}

fn main() {
    let simple = SimpleLogger;
    let complex = ComplexLogger;
    simple.log(42);
    complex.log(42);
}

输出示例:

Simple logger: 42
Complex logger: 42 (十六进制: 0x2a, 二进制: 101010)

Rust 特性关联类型 (Associated Types)

#[derive(Debug)]
struct Small(u32);
#[derive(Debug)]
struct Big(u32);
trait Double {
    type T;
    fn double(&self) -> Self::T;
}

impl Double for Small {
    type T = Big;
    fn double(&self) -> Self::T {
        Big(self.0 * 2)
    }
}
fn main() {
    let a = Small(42);
    println!("{:?}", a.double());
}

Rust 特性实现 (Trait impl)

  • impl 关键字可以与特性结合使用,通过 &impl Trait 来接收任何实现了指定特性的类型。
trait Pet {
    fn speak(&self);
}
struct Dog {}
struct Cat {}
impl Pet for Dog {
    fn speak(&self) {println!("汪!")}
}
impl Pet for Cat {
    fn speak(&self) {println!("喵")}
}
fn pet_speak(p: &impl Pet) {
    p.speak();
}
fn main() {
    let c = Cat {};
    let d = Dog {};
    pet_speak(&c);
    pet_speak(&d);
}

Rust 特性实现 (Trait impl)

  • impl 关键字亦可用于返回值。
trait Pet {}
struct Dog;
struct Cat;
impl Pet for Cat {}
impl Pet for Dog {}
fn cat_as_pet() -> impl Pet {
    let c = Cat {};
    c
}
fn dog_as_pet() -> impl Pet {
    let d = Dog {};
    d
}
fn main() {
    let _p = cat_as_pet();
    let _d = dog_as_pet();
}

Rust 动态特性 (Dynamic Traits)

  • 动态特性可用于在不了解具体底层类型的情况下调用特性的功能。这种机制被称为“类型擦除 (Type Erasure)”。
trait Pet {
    fn speak(&self);
}
struct Dog {}
struct Cat {x: u32}
impl Pet for Dog {
    fn speak(&self) {println!("汪!")}
}
impl Pet for Cat {
    fn speak(&self) {println!("喵")}
}
fn pet_speak(p: &dyn Pet) {
    p.speak();
}
fn main() {
    let c = Cat {x: 42};
    let d = Dog {};
    pet_speak(&c);
    pet_speak(&d);
}

在 impl Trait、dyn Trait 和枚举之间进行选择

这三种方法都能实现多态,但各有权衡:

方法分发方式性能异构集合?适用场景
impl Trait / 泛型静态 (单态化)零开销 —— 在编译时内联不支持 —— 每个位置只能是单一具体类型默认方案。函数参数、返回值
dyn Trait动态 (虚函数表)每次调用有微小开销(约 1 次指针寻址)支持 —— 如 Vec<Box<dyn Trait>>需要在集合中存放混合类型,或支持插件式扩展时
enum类型匹配 (Match)零开销 —— 编译时变元已知支持 —— 但变元必须已知当变元集合是封闭且在编译时已知时
#![allow(unused)]
fn main() {
trait Shape {
    fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } }
impl Shape for Rect   { fn area(&self) -> f64 { self.w * self.h } }

// 静态分发 —— 编译器为每种类型生成独立的代码
fn print_area(s: &impl Shape) { println!("{}", s.area()); }

// 动态分发 —— 只有一个函数,通过指针处理任何形状 (Shape)
fn print_area_dyn(s: &dyn Shape) { println!("{}", s.area()); }

// 枚举 —— 变元集合封闭,无需特性
enum ShapeEnum { Circle(f64), Rect(f64, f64) }
impl ShapeEnum {
    fn area(&self) -> f64 {
        match self {
            ShapeEnum::Circle(r) => std::f64::consts::PI * r * r,
            ShapeEnum::Rect(w, h) => w * h,
        }
    }
}
}

对于 C++ 开发者:impl Trait 类似于 C++ 模板(单态化、零开销)。dyn Trait 类似于 C++ 虚函数(虚表分发)。Rust 带有 match 的枚举类似于 std::variant 结合 std::visit —— 但 Rust 编译器会强制要求进行穷尽性匹配。

经验法则:优先使用 impl Trait(静态分发)。只有当你需要异构集合,或者在编译时无法确定具体类型时,才考虑使用 dyn Trait。而在你拥有(并控制)所有可能的变元时,请使用 enum。


English Original

Rust 泛型 (Generics)

你将学到: 泛型类型参数、单态化(零开销泛型)、特性结合,以及 Rust 泛型与 C++ 模板的对比 —— 具有更友好的错误消息且无需 SFINAE。

  • 泛型允许相同的算法或数据结构跨多种数据类型进行复用。
    • 泛型参数以标识符的形式出现在 <> 中,例如:<T>。该参数可以使用任何合法的标识符名称,但为了简洁,通常保持较短。
    • 编译器在编译时执行“单态化 (Monomorphization)”,即为遇到的每一个 T 的变体生成一个新的类型。
// 返回一个由 T 类型的 left 和 right 组成的 T 类型元组
fn pick<T>(x: u32, left: T, right: T) -> (T, T) {
   if x == 42 {
    (left, right) 
   } else {
    (right, left)
   }
}
fn main() {
    let a = pick(42, true, false);
    let b = pick(42, "hello", "world");
    println!("{a:?}, {b:?}");
}

Rust 泛型

  • 泛型也可以应用于数据类型及其关联方法。还可以为特定的 <T>(例如:f32 vs. u32)实现特化。
#[derive(Debug)] // 我们稍后会讨论这个
struct Point<T> {
    x : T,
    y : T,
}

impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point {x, y}
    }
    fn set_x(&mut self, x: T) {
         self.x = x;       
    }
    fn set_y(&mut self, y: T) {
         self.y = y;       
    }
}

impl Point<f32> {
    fn is_secret(&self) -> bool {
        self.x == 42.0
    }    
}

fn main() {
    let mut p = Point::new(2, 4); // 推导为 i32
    let q = Point::new(2.0, 4.0); // 推导为 f32
    p.set_x(42);
    p.set_y(43);
    println!("{p:?} {q:?} {}", q.is_secret());
}

练习:泛型

平衡性 入门

  • 修改 Point 类型,使其对 x 和 y 使用两个不同的泛型类型(T 和 U)。
参考答案 (点击展开)
#[derive(Debug)]
struct Point<T, U> {
    x: T,
    y: U,
}

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

fn main() {
    let p1 = Point::new(42, 3.14);        // Point<i32, f64>
    let p2 = Point::new("你好", true);     // Point<&str, bool>
    let p3 = Point::new(1u8, 1000u64);    // Point<u8, u64>
    println!("{p1:?}");
    println!("{p2:?}");
    println!("{p3:?}");
}

输出示例:

Point { x: 42, y: 3.14 }
Point { x: "你好", y: true }
Point { x: 1, y: 1000 }

特性与泛型的结合

  • 特性可用于对泛型类型施加限制(约束)。
  • 可以通过在泛型类型参数后面使用 : 或使用 where 子句来指定约束。下文定义了一个泛型函数 get_area,它接受任何实现了 ComputeArea 特性的类型 T。
#![allow(unused)]
fn main() {
trait ComputeArea {
    fn area(&self) -> u64;
}
fn get_area<T: ComputeArea>(t: &T) -> u64 {
    t.area()
}
}

特性与泛型的结合

  • 也可以设置多个特性约束。
trait Fish {}
trait Mammal {}
struct Shark;
struct Whale;
impl Fish for Shark {}
impl Fish for Whale {}
impl Mammal for Whale {}
fn only_fish_and_mammals<T: Fish + Mammal>(_t: &T) {}
fn main() {
    let w = Whale {};
    only_fish_and_mammals(&w);
    let _s = Shark {};
    // 下行将无法编译
    // only_fish_and_mammals(&_s);
}

数据类型中的 Rust 特性约束

  • 特性约束可以与数据类型中的泛型相结合。
  • 在下例中,我们定义了 PrintDescription 特性以及一个名为 Shape 的泛型结构体,其成员受到该特性的约束。
#![allow(unused)]
fn main() {
trait PrintDescription {
    fn print_description(&self);
}
struct Shape<S: PrintDescription> {
    shape: S,
}
// 为任何实现了 PrintDescription 的类型 S 实现泛型结构体 Shape
impl<S: PrintDescription> Shape<S> {
    fn print(&self) {
        self.shape.print_description();
    }
}
}

练习:特性约束与泛型

🟡 中级

  • 实现一个包含泛型成员 cipher 的 struct,且该成员需实现 CipherText 特性。
#![allow(unused)]
fn main() {
trait CipherText {
    fn encrypt(&self);
}
// 待完成
//struct Cipher<>
}
  • 接着,在结构体的 impl 块中实现一个名为 encrypt 的方法,该方法调用 cipher 的 encrypt 方法。
#![allow(unused)]
fn main() {
// 待完成
impl for Cipher<> {}
}
  • 最后,为名为 CipherOne 和 CipherTwo 的两个结构体实现 CipherText 特性(仅打印 println() 即可)。创建 CipherOne 和 CipherTwo 的实例,并使用 Cipher 调用它们。
参考答案 (点击展开)
trait CipherText {
    fn encrypt(&self);
}

struct Cipher<T: CipherText> {
    cipher: T,
}

impl<T: CipherText> Cipher<T> {
    fn encrypt(&self) {
        self.cipher.encrypt();
    }
}

struct CipherOne;
struct CipherTwo;

impl CipherText for CipherOne {
    fn encrypt(&self) {
        println!("已应用 CipherOne 加密");
    }
}

impl CipherText for CipherTwo {
    fn encrypt(&self) {
        println!("已应用 CipherTwo 加密");
    }
}

fn main() {
    let c1 = Cipher { cipher: CipherOne };
    let c2 = Cipher { cipher: CipherTwo };
    c1.encrypt();
    c2.encrypt();
}

输出示例:

已应用 CipherOne 加密
已应用 CipherTwo 加密

Rust 类型状态模式 (Type State Pattern) 与泛型

  • Rust 类型可用于在编译时强制执行状态机转换。

    • 想象一架具有两种状态的“无人机”:Idle(空闲)和 Flying(飞行)。在 Idle 状态下,唯一允许的方法是 takeoff()。在 Flying 状态下,我们允许 land()。
  • 一种方法是使用类似以下代码的方式对状态机建模:

#![allow(unused)]
fn main() {
enum DroneState {
    Idle,
    Flying
}
struct Drone {x: u64, y: u64, z: u64, state: DroneState}  // x, y, z 为坐标
}
  • 这需要大量的运行时检查来强制执行状态机语义 —— ▶ 尝试一下以了解其原因。

使用泛型的类型状态模式

  • 泛型允许我们在编译时强制执行状态机。这需要使用一种特殊的泛型,即 PhantomData<T>。
  • PhantomData<T> 是一种零大小 (Zero-sized) 的标记数据类型。在本例中,我们用它来表示 Idle 和 Flying 状态,但它在运行时的占用空间为计为零。
  • 请注意,takeoff 和 land 方法接收 self 作为参数。这被称为“消耗 (Consuming)”(与之相对的是使用借用的 &self)。基本上,一旦我们调用了 Drone<Idle> 的 takeoff() 方法,我们就只能取回一个 Drone<Flying>,反之亦然。
#![allow(unused)]
fn main() {
struct Drone<T> {x: u64, y: u64, z: u64, state: PhantomData<T> }
impl Drone<Idle> {
    fn takeoff(self) -> Drone<Flying> {...}
}
impl Drone<Flying> {
    fn land(self) -> Drone<Idle> { ...}
}
}
- [▶ 在 Rust Playground 中尝试](https://play.rust-lang.org/)

使用泛型的类型状态模式

  • 核心要点:
    • 状态可以使用结构体表示(零大小)。
    • 我们可以将状态 T 与 PhantomData<T> 相结合(零大小)。
    • 为状态机的特定阶段实现方法,现在只需编写 impl State<T>。
    • 使用消耗 self 的方法从一个状态转换到另一个状态。
    • 这为我们提供了零成本 (Zero cost) 抽象。编译器可以在编译时强制执行状态机规则,除非状态匹配,否则根本无法通过编译调用方法。

Rust 构建器 (Builder) 模式

  • 消耗 self 的模式对于构建器模式也非常有用。
  • 考虑一个具有几十个引脚的 GPIO 配置。引脚可以配置为高电平或低电平(默认为低电平)。
#![allow(unused)]
fn main() {
#[derive(Default)]
enum PinState {
    #[default]
    Low,
    High,
} 
#[derive(Default)]
struct GPIOConfig {
    pin0: PinState,
    pin1: PinState,
    // ... 
}
}
  • 构建器模式可以通过链式调用来构造 GPIO 配置 —— ▶ 尝试一下。

English Original

Rust From 和 Into 特性

你将学到: Rust 的类型转换特性 —— 用于无损转换的 From<T> 和 Into<T>,以及用于可能失败的转换的 TryFrom 和 TryInto。实现 From 即可免费获得 Into 的实现。它们取代了 C++ 的转换运算符和构造函数。

  • From 和 Into 是互补的特性,旨在简化类型转换。
  • 类型通常实现 From 特性。例如 String::from() 可以将 &str 转换为 String,同时编译器可以自动推导出 &str.into()。
struct Point {x: u32, y: u32}
// 从元组构造一个 Point
impl From<(u32, u32)> for Point {
    fn from(xy : (u32, u32)) -> Self {
        Point {x : xy.0, y: xy.1}       // 使用元组元素构造 Point
    }
}
fn main() {
    let s = String::from("Rust");
    let x = u32::from(true);
    let p = Point::from((40, 42));
    // let p : Point = (40, 42).into(); // 上述代码的另一种形式
    println!("s: {s} x:{x} p.x:{} p.y {}", p.x, p.y);   
}

练习:From 和 Into

  • 为 Point 实现 From 特性,将其转换为名为 TransposePoint 的类型。TransposePoint 会交换 Point 的 x 和 y 元素。
参考答案 (点击展开)
struct Point { x: u32, y: u32 }
struct TransposePoint { x: u32, y: u32 }

impl From<Point> for TransposePoint {
    fn from(p: Point) -> Self {
        TransposePoint { x: p.y, y: p.x }
    }
}

fn main() {
    let p = Point { x: 10, y: 20 };
    let tp = TransposePoint::from(p);
    println!("转置后: x={}, y={}", tp.x, tp.y);  // x=20, y=10

    // 使用 .into() —— 在实现 From 后会自动生效
    let p2 = Point { x: 3, y: 7 };
    let tp2: TransposePoint = p2.into();
    println!("转置后: x={}, y={}", tp2.x, tp2.y);  // x=7, y=3
}

输出示例:

转置后: x=20, y=10
转置后: x=7, y=3

Rust Default 特性

  • Default 可用于为类型实现默认值。
    • 类型可以使用带有 Default 的 Derive 宏进行派生,或提供一个自定义实现。
#[derive(Default, Debug)]
struct Point {x: u32, y: u32}
#[derive(Debug)]
struct CustomPoint {x: u32, y: u32}

impl Default for CustomPoint {
    fn default() -> Self {
        CustomPoint {x: 42, y: 42}
    }
}

fn main() {
    let x = Point::default();   // 创建 Point{0, 0}
    println!("{x:?}");
    let y = CustomPoint::default();
    println!("{y:?}");
}

Rust Default 特性

  • Default 特性有几个典型的用例,包括:
    • 进行部分复制,并对剩余部分使用默认初始化。
    • 在 unwrap_or_default() 等方法中,为 Option 类型提供默认备选方案。
#[derive(Debug)]
struct CustomPoint {x: u32, y: u32}
impl Default for CustomPoint {
    fn default() -> Self {
        CustomPoint {x: 42, y: 42}
    }
}
fn main() {
    let x = CustomPoint::default();
    // 覆盖 y,但其余元素保留默认值
    let y = CustomPoint {y: 43, ..CustomPoint::default()};
    println!("{x:?} {y:?}");
    let z : Option<CustomPoint> = None;
    // 尝试将 unwrap_or_default() 更改为 unwrap() 看看效果
    println!("{:?}", z.unwrap_or_default());
}

其他 Rust 类型转换

  • Rust 不支持隐式类型转换,可以使用 as 进行“显式”转换。
  • 应谨慎使用 as,因为它在进行窄化转换等操作时可能会造成数据丢失。通常情况下,尽可能优先使用 into() 或 from()。
fn main() {
    let f = 42u8;
    // let g : u32 = f;    // 将无法编译
    let g = f as u32;      // 可以,但不推荐。受窄化转换规则约束
    let g : u32 = f.into(); // 最推荐的形式;无损且受编译器检查
    // let k : u8 = g.into();  // 无法编译;窄化转换可能导致数据丢失
    
    // 尝试进行窄化操作需要使用 try_into
    if let Ok(k) = TryInto::<u8>::try_into(g) {
        println!("{k}");
    }
}

English Original

Rust 闭包 (Closures)

你将学到: 闭包作为匿名函数、三种捕获特性 (Fn、FnMut、FnOnce)、move 闭包,以及 Rust 闭包与 C++ lambda 的对比 —— 具有自动捕获分析功能,无需像 C++ 那样手动指定 [&] 或 [=]。

  • 闭包是能够捕获其环境的匿名函数。
    • C++ 等价物:lambdas ([&](int x) { return x + 1; })。
    • 关键区别:Rust 闭包具有三种捕获特性 (Fn、FnMut、FnOnce),编译器会自动选择。
    • C++ 的捕获模式 ([=]、[&]、[this]) 需要手动指定且容易出错(例如悬垂的 [&]!)。
    • Rust 的借用检查器在编译时即可防止悬垂捕获。
  • 闭包可以通过 || 符号来识别。参数类型包含在 || 中,并且支持类型推导。
  • 闭包经常与迭代器(下一节的主题)配合使用。
fn add_one(x: u32) -> u32 {
    x + 1
}
fn main() {
    let add_one_v1 = |x : u32| {x + 1}; // 显式指定类型
    let add_one_v2 = |x| {x + 1};       // 类型由调用处推导
    let add_one_v3 = |x| x + 1;         // 对于单行函数允许省略大括号
    println!("{} {} {} {}", add_one(42), add_one_v1(42), add_one_v2(42), add_one_v3(42) );
}

练习:闭包与捕获

🟡 中级

  • 创建一个捕获外层作用域 String 并向其追加内容的闭包(提示:使用 move)。
  • 创建一个闭包向量:Vec<Box<dyn Fn(i32) -> i32>>,其中包含分别执行加 1、乘以 2 和对输入取平方的闭包。遍历该向量并将每个闭包应用于数字 5。
参考答案 (点击展开)
fn main() {
    // 第一部分:捕获并向 String 追加内容的闭包
    let mut greeting = String::from("Hello");
    let mut append = |suffix: &str| {
        greeting.push_str(suffix);
    };
    append(", world");
    append("!");
    println!("{greeting}");  // "Hello, world!"

    // 第二部分:闭包向量
    let operations: Vec<Box<dyn Fn(i32) -> i32>> = vec![
        Box::new(|x| x + 1),      // 加 1
        Box::new(|x| x * 2),      // 乘以 2
        Box::new(|x| x * x),      // 取平方
    ];

    let input = 5;
    for (i, op) in operations.iter().enumerate() {
        println!("对 {input} 执行操作 {i}: {}", op(input));
    }
}

输出示例:

Hello, world!
对 5 执行操作 0: 6
对 5 执行操作 1: 10
对 5 执行操作 2: 25

Rust 迭代器 (Iterators)

  • 迭代器是 Rust 最强大的特性之一。它们能以非常优雅的方式对集合执行各种操作,包括过滤 (filter())、变换 (map())、查找 (find()) 等等。
  • 在下例中,|&x| *x >= 42 是一个执行比较操作的闭包。|x| println!("{x}") 是另一个闭包。
fn main() {
    let a = [0, 1, 2, 3, 42, 43];
    for x in &a {
        if *x >= 42 {
            println!("{x}");
        }
    }
    // 与上述逻辑等效
    a.iter().filter(|&x| *x >= 42).for_each(|x| println!("{x}"))
}

Rust 迭代器

  • 迭代器的一个关键特性是它们大多数都是惰性的 (Lazy)。也就是说,在它们被实际评估之前,什么也不会做。例如,如果没有 for_each,a.iter().filter(|&x| *x >= 42); 将不会执行任何操作。Rust 编译器在检测到这种情形时会发出明确的警告。
fn main() {
    let a = [0, 1, 2, 3, 42, 43];
    // 为每个元素加 1 并打印
    let _ = a.iter().map(|x| x + 1).for_each(|x| println!("{x}"));
    let found = a.iter().find(|&x| *x == 42);
    println!("{found:?}");
    // 统计元素数量
    let count = a.iter().count();
    println!("{count}");
}

Rust 迭代器

  • collect() 方法可用于将结果收集到一个单独的集合中。
    • 下例中 Vec<_> 中的 _ 相当于接收 map 返回类型的通配符。例如,我们甚至可以从 map 中返回 String。
fn main() {
    let a = [0, 1, 2, 3, 42, 43];
    let squared_a : Vec<_> = a.iter().map(|x| x * x).collect();
    for x in &squared_a {
        println!("{x}");
    }
    let squared_a_strings : Vec<_> = a.iter().map(|x| (x * x).to_string()).collect();
    // 这些实际上是字符串表示
    for x in &squared_a_strings {
        println!("{x}");
    }
}

练习:Rust 迭代器

平衡性 入门

  • 创建一个包含奇数和偶数元素的整数数组。遍历该数组并将其拆分为两个不同的向量,分别包含偶数和奇数。
  • 这能否在单次遍历中完成?(提示:使用 partition())
参考答案 (点击展开)
fn main() {
    let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // 方法 1:手动分发
    let mut evens = Vec::new();
    let mut odds = Vec::new();
    for n in numbers {
        if n % 2 == 0 {
            evens.push(n);
        } else {
            odds.push(n);
        }
    }
    println!("偶数: {evens:?}");
    println!("奇数: {odds:?}");

    // 方法 2:使用 partition() 进行单次处理
    let (evens, odds): (Vec<i32>, Vec<i32>) = numbers
        .into_iter()
        .partition(|n| n % 2 == 0);
    println!("偶数 (partition): {evens:?}");
    println!("奇数 (partition): {odds:?}");
}

输出示例:

偶数: [2, 4, 6, 8, 10]
奇数: [1, 3, 5, 7, 9]
偶数 (partition): [2, 4, 6, 8, 10]
奇数 (partition): [1, 3, 5, 7, 9]

生产环境模式:关于生产环境 Rust 代码中的真实迭代器链(如 .map().collect()、.filter().collect()、.find_map()),请参考通过闭包消除分支陷阱。


迭代器高阶工具:取代 C++ 循环的方法

以下迭代器适配器在生产环境的 Rust 代码中被广泛使用。C++ 虽有 <algorithm> 和 C++20 的 ranges,但 Rust 的迭代器链更具可组合性,且使用频率更高。

enumerate —— 索引 + 值(取代 for (int i = 0; ...))

#![allow(unused)]
fn main() {
let sensors = vec!["temp0", "temp1", "temp2"];
for (idx, name) in sensors.iter().enumerate() {
    println!("传感器 {idx}: {name}");
}
// 传感器 0: temp0
// 传感器 1: temp1
// 传感器 2: temp2
}

C++ 等价物:for (size_t i = 0; i < sensors.size(); ++i) { auto& name = sensors[i]; ... }


zip —— 配对两个迭代器的元素(取代并行索引循环)

#![allow(unused)]
fn main() {
let names = ["gpu0", "gpu1", "gpu2"];
let temps = [72.5, 68.0, 75.3];

let report: Vec<String> = names.iter()
    .zip(temps.iter())
    .map(|(name, temp)| format!("{name}: {temp}°C"))
    .collect();
println!("{report:?}");
// ["gpu0: 72.5°C", "gpu1: 68.0°C", "gpu2: 75.3°C"]

// 在较短的迭代器处停止 —— 无越界风险
}

C++ 等价物:for (size_t i = 0; i < std::min(names.size(), temps.size()); ++i) { ... }


flat_map —— 对嵌套集合进行映射并扁平化

#![allow(unused)]
fn main() {
// 每块 GPU 都有多个 PCIe BDF;收集所有 GPU 上的所有 BDF
let gpu_bdfs = vec![
    vec!["0000:01:00.0", "0000:02:00.0"],
    vec!["0000:41:00.0"],
    vec!["0000:81:00.0", "0000:82:00.0"],
];

let all_bdfs: Vec<&str> = gpu_bdfs.iter()
    .flat_map(|bdfs| bdfs.iter().copied())
    .collect();
println!("{all_bdfs:?}");
// ["0000:01:00.0", "0000:02:00.0", "0000:41:00.0", "0000:81:00.0", "0000:82:00.0"]
}

C++ 等价物:使用嵌套的 for 循环并将结果推入(push)到单个 vector 中。


chain —— 连接两个迭代器

#![allow(unused)]
fn main() {
let critical_gpus = vec!["gpu0", "gpu3"];
let warning_gpus = vec!["gpu1", "gpu5"];

// 处理所有被标记的 GPU,优先处理关键(critical)GPU
for gpu in critical_gpus.iter().chain(warning_gpus.iter()) {
    println!("已标记: {gpu}");
}
}

windows 和 chunks —— 切片上的滑动窗口/固定大小视图

#![allow(unused)]
fn main() {
let temps = [70, 72, 75, 73, 71, 68, 65];

// windows(3): 大小为 3 的滑动窗口 —— 用于检测趋势
let rising = temps.windows(3)
    .any(|w| w[0] < w[1] && w[1] < w[2]);
println!("检测到上涨趋势: {rising}"); // true (70 < 72 < 75)

// chunks(2): 固定大小的分组 —— 进行成对处理
for pair in temps.chunks(2) {
    println!("配对: {pair:?}");
}
// 配对: [70, 72]
// 配对: [75, 73]
// 配对: [71, 68]
// 配对: [65]       ← 最后一组可以更小
}

C++ 等价物:使用 i 和 i+1/i+2 进行的手动索引算术运算。


fold —— 累加为单个值(取代 std::accumulate)

#![allow(unused)]
fn main() {
let errors = vec![
    ("gpu0", 3u32),
    ("gpu1", 0),
    ("gpu2", 7),
    ("gpu3", 1),
];

// 在单次遍历中统计错误总数并构建摘要
let (total, summary) = errors.iter().fold(
    (0u32, String::new()),
    |(count, mut s), (name, errs)| {
        if *errs > 0 {
            s.push_str(&format!("{name}:{errs} "));
        }
        (count + errs, s)
    },
);
println!("错误总数: {total}, 详情: {summary}");
// 错误总数: 11, 详情: gpu0:3 gpu2:7 gpu3:1
}

scan —— 有状态转换(累加总量、增量检测)

#![allow(unused)]
fn main() {
let readings = [100, 105, 103, 110, 108];

// 计算连续读数之间的增量
let deltas: Vec<i32> = readings.iter()
    .scan(None::<i32>, |prev, &val| {
        let delta = prev.map(|p| val - p);
        *prev = Some(val);
        Some(delta)
    })
    .flatten()  // 移除初始的 None
    .collect();
println!("增量: {deltas:?}"); // [5, -2, 7, -2]
}

快速参考:C++ 循环 → Rust 迭代器

C++ 模式Rust 迭代器示例
for (int i = 0; i < v.size(); i++).enumerate()v.iter().enumerate()
带有索引的并行迭代.zip()a.iter().zip(b.iter())
嵌套循环 → 扁平结果.flat_map()`vecs.iter().flat_map(
连接两个容器.chain()a.iter().chain(b.iter())
滑动窗口 v[i..i+n].windows(n)v.windows(3)
按固定大小分组处理.chunks(n)v.chunks(4)
std::accumulate / 手动累加器.fold()`.fold(init,
运行总量 / 增量追踪.scan()`.scan(state,
while (it != end && count < n) { ++it; ++count; }.take(n).iter().take(5)
while (it != end && !pred(*it)) { ++it; }.skip_while()`.skip_while(
std::any_of.any()`.iter().any(
std::all_of.all()`.iter().all(
std::none_of!.any()`!iter.any(
std::count_if.filter().count()`.filter(
std::min_element / std::max_element.min() / .max().iter().max() → Option<&T>
std::unique.dedup() (针对有序序列)v.dedup() (在 Vec 上原地执行)

练习:迭代器链

给定传感器数据 Vec<(String, f64)> (名称, 温度),编写一个单一的迭代器链来完成以下任务:

  1. 过滤掉温度 > 80.0 的传感器。
  2. 按温度(降序)对它们进行排序。
  3. 将每一项格式化为 "{name}: {temp}°C [ALARM]"。
  4. 收集到 Vec<String> 中。

提示:由于排序需要 Vec,所以你需要在调用 .sort_by() 之前先执行一次 .collect()。

参考答案 (点击展开)
fn alarm_report(sensors: &[(String, f64)]) -> Vec<String> {
    let mut hot: Vec<_> = sensors.iter()
        .filter(|(_, temp)| *temp > 80.0)
        .collect();
    hot.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
    hot.iter()
        .map(|(name, temp)| format!("{name}: {temp}°C [ALARM]"))
        .collect()
}

fn main() {
    let sensors = vec![
        ("gpu0".to_string(), 72.5),
        ("gpu1".to_string(), 85.3),
        ("gpu2".to_string(), 91.0),
        ("gpu3".to_string(), 78.0),
        ("gpu4".to_string(), 88.7),
    ];
    for line in alarm_report(&sensors) {
        println!("{line}");
    }
}

输出示例:

gpu2: 91°C [ALARM]
gpu4: 88.7°C [ALARM]
gpu1: 85.3°C [ALARM]

Rust 迭代器

  • Iterator 特性用于为用户定义类型实现迭代功能(参考:https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)。
    • 在下例中,我们将为一个斐波那契(Fibonacci)序列实现迭代器,该序列从 1, 1, 2, … 开始,后继项是前两项之和。
    • Iterator 中的关联类型 (type Item = u32;) 定义了迭代器输出的类型 (u32)。
    • next() 方法包含实现迭代器的逻辑。在本例中,所有状态信息都保存在 Fibonacci 结构体中。
    • 我们本可以实现另一个名为 IntoIterator 的特性,从而为更特殊的迭代器实现 into_iter() 方法。
struct Fibonacci {
    curr: u32,
    next: u32,
}

impl Default for Fibonacci {
    fn default() -> Self {
        Fibonacci { curr: 0, next: 1 }
    }
}

impl Iterator for Fibonacci {
    type Item = u32;

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

        Some(self.curr)
    }
}

fn main() {
    let mut fib = Fibonacci::default();
    for n in fib.take(10) {
        println!("{n}");
    }
}

English Original

迭代器高阶工具参考手册

你将学到: 除了 filter/map/collect 之外的高级迭代器组合器 —— enumerate、zip、chain、flat_map、scan、windows 和 chunks。这些工具对于将带有手动索引、结果累加或固定大小块处理的 C 风格 for 循环替换为安全、富有表现力的 Rust 迭代器至关重要。

基础的 filter/map/collect 链条可以覆盖许多场景,但 Rust 的迭代器库远不止于此。本节涵盖了你日常会用到的工具 —— 尤其是在翻译那些手动追踪索引、累积结果或以固定大小块分批处理数据的 C 循环时。

快速参考表

方法C 语言等价物功能说明返回值类型
enumerate()for (int i=0; ...)将每个元素与其索引配对(usize, T)
zip(other)具有相同索引的并行数组配对来自两个迭代器的元素(A, B)
chain(other)先处理数组 1,再处理数组 2连接两个迭代器T
flat_map(f)嵌套循环先映射再扁平化一层U
windows(n)for (int i=0; i<len-n+1; i++) &arr[i..i+n]大小为 n 的重叠切片(滑动窗口)&[T]
chunks(n)每次处理 n 个元素大小为 n 的非重叠切片&[T]
fold(init, f)int acc = init; for (...) acc = f(acc, x);归约为单个值Acc
scan(init, f)带有输出的运行累加器类似 fold 但产出中间结果Option<B>
take(n) / skip(n)从偏移处开始循环 / 限制循环次数获取前 n 个 / 跳过前 n 个元素T
take_while(f) / skip_while(f)while (pred) {...}当谓词成立时获取/跳过T
peekable()使用 arr[i+1] 进行前瞻允许在不消耗的情况下执行 .peek()T
step_by(n)for (i=0; i<len; i+=n)每隔 n 个元素取一个T
unzip()拆分并行数组将对(Pairs)收集到两个集合中(A, B)
sum() / product()累加总和/乘积使用 + 或 * 进行归约T
min() / max()寻找极值返回 Option<T>Option<T>
any(f) / all(f)bool found = false; for (...) ...短路布尔搜索bool
position(f)for (i=0; ...) if (pred) return i;第一个匹配项的索引Option<usize>

enumerate —— 索引 + 值(取代 C 语言索引循环)

fn main() {
    let sensors = ["GPU_TEMP", "CPU_TEMP", "FAN_RPM", "PSU_WATT"];

    // C 风格:for (int i = 0; i < 4; i++) printf("[%d] %s\n", i, sensors[i]);
    for (i, name) in sensors.iter().enumerate() {
        println!("[{i}] {name}");
    }

    // 查找特定传感器的索引
    let gpu_idx = sensors.iter().position(|&s| s == "GPU_TEMP");
    println!("GPU 传感器索引: {gpu_idx:?}");  // Some(0)
}

zip —— 并行迭代(取代并行数组循环)

fn main() {
    let names = ["accel_diag", "nic_diag", "cpu_diag"];
    let statuses = [true, false, true];
    let durations_ms = [1200, 850, 3400];

    // C 语言方式:for (int i=0; i<3; i++) printf("%s: %s (%d ms)\n", names[i], ...);
    for ((name, passed), ms) in names.iter().zip(&statuses).zip(&durations_ms) {
        let status = if *passed { "通过" } else { "失败" };
        println!("{name}: {status} ({ms} ms)");
    }
}

chain —— 连接迭代器

fn main() {
    let critical = vec!["ECC error", "Thermal shutdown"];
    let warnings = vec!["Link degraded", "Fan slow"];

    // 按优先级顺序处理所有事件
    let all_events: Vec<_> = critical.iter().chain(warnings.iter()).collect();
    println!("{all_events:?}");
    // ["ECC error", "Thermal shutdown", "Link degraded", "Fan slow"]
}

flat_map —— 扁平化嵌套结果

fn main() {
    let lines = vec!["gpu:42:ok", "nic:99:fail", "cpu:7:ok"];

    // 从冒号分隔的行中提取所有数值
    let numbers: Vec<u32> = lines.iter()
        .flat_map(|line| line.split(':'))
        .filter_map(|token| token.parse::<u32>().ok())
        .collect();
    println!("{numbers:?}");  // [42, 99, 7]
}

windows 和 chunks —— 滑动窗口与固定大小分组

fn main() {
    let temps = [65, 68, 72, 71, 75, 80, 78, 76];

    // windows(3): 重叠的 3 个元素分组(类似滑动平均值)
    // C 风格:for (int i = 0; i <= len-3; i++) avg(arr[i], arr[i+1], arr[i+2]);
    let moving_avg: Vec<f64> = temps.windows(3)
        .map(|w| w.iter().sum::<i32>() as f64 / 3.0)
        .collect();
    println!("滑动平均值: {moving_avg:.1?}");

    // chunks(2): 非重叠的 2 个元素分组
    // C 风格:for (int i = 0; i < len; i += 2) process(arr[i], arr[i+1]);
    for pair in temps.chunks(2) {
        println!("块 (Chunk): {pair:?}");
    }

    // chunks_exact(2): 与上述类似,但如果存在剩余元素则会触发 panic
    // 此外:.remainder() 可以获取剩余处理不了的元素
}

fold 和 scan —— 累加处理

fn main() {
    let values = [10, 20, 30, 40, 50];

    // fold: 返回单个最终结果(类似 C 语言的累加循环)
    let sum = values.iter().fold(0, |acc, &x| acc + x);
    println!("总和: {sum}");  // 150

    // 使用 fold 构建字符串
    let csv = values.iter()
        .fold(String::new(), |acc, x| {
            if acc.is_empty() { format!("{x}") }
            else { format!("{acc},{x}") }
        });
    println!("CSV 字符串: {csv}");  // "10,20,30,40,50"

    // scan: 类似于 fold,但会产出中间结果
    let running_sum: Vec<i32> = values.iter()
        .scan(0, |state, &x| {
            *state += x;
            Some(*state)
        })
        .collect();
    println!("累计和: {running_sum:?}");  // [10, 30, 60, 100, 150]
}

练习:传感器数据流水线

给定原始传感器读数(每行一个,格式为 "传感器名称:数值:单位"),编写一个迭代器流水线来执行以下操作:

  1. 将每一行解析为 (name, f64, unit)。
  2. 过滤掉低于特定阈值的读数。
  3. 使用 fold 归约到 HashMap 中,按传感器名称进行分组。
  4. 打印每个传感器的平均读数。
// 初始代码
fn main() {
    let raw_data = vec![
        "gpu_temp:72.5:C",
        "cpu_temp:65.0:C",
        "gpu_temp:74.2:C",
        "fan_rpm:1200.0:RPM",
        "cpu_temp:63.8:C",
        "gpu_temp:80.1:C",
        "fan_rpm:1150.0:RPM",
    ];
    let threshold = 70.0;
    // 待完成:解析、过滤 >= 阈值的数值、按名称分组、计算平均值
}

参考答案 (点击展开)
use std::collections::HashMap;

fn main() {
    let raw_data = vec![
        "gpu_temp:72.5:C",
        "cpu_temp:65.0:C",
        "gpu_temp:74.2:C",
        "fan_rpm:1200.0:RPM",
        "cpu_temp:63.8:C",
        "gpu_temp:80.1:C",
        "fan_rpm:1150.0:RPM",
    ];
    let threshold = 70.0;

    // 解析 → 过滤 → 分组 → 平均
    let grouped = raw_data.iter()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(3, ':').collect();
            if parts.len() == 3 {
                let value: f64 = parts[1].parse().ok()?;
                Some((parts[0], value, parts[2]))
            } else {
                None
            }
        })
        .filter(|(_, value, _)| *value >= threshold)
        .fold(HashMap::<&str, Vec<f64>>::new(), |mut acc, (name, value, _)| {
            acc.entry(name).or_default().push(value);
            acc
        });

    for (name, values) in &grouped {
        let avg = values.iter().sum::<f64>() / values.len() as f64;
        println!("{name}: 平均值={avg:.1} ({} 次读取)", values.len());
    }
}

输出示例 (顺序可能有所不同):

gpu_temp: 平均值=75.6 (3 次读取)
fan_rpm: 平均值=1175.0 (2 次读取)

Rust 迭代器

  • Iterator 特性用于为用户定义类型实现迭代功能(参考:https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)。
    • 在下例中,我们将为一个斐波那契(Fibonacci)序列实现迭代器,该序列从 1, 1, 2, … 开始,后继项是前两项之和。
    • Iterator 中的关联类型 (type Item = u32;) 定义了迭代器输出的类型 (u32)。
    • next() 方法包含实现迭代器的逻辑。在本例中,所有状态信息都保存在 Fibonacci 结构体中。
    • 我们本可以实现另一个名为 IntoIterator 的特性,从而为更特殊的迭代器实现 into_iter() 方法。
    • ▶ 在 Rust Playground 中尝试

English Original

Rust 并发 (Concurrency)

你将学到: Rust 的并发模型 —— 线程、Send/Sync 标记特性、Mutex<T>、Arc<T>、通道,以及编译器如何在编译时防止数据竞争。对于不使用的线程安全特性,Rust 不会引入任何运行时开销。

  • Rust 内置了对并发的支持,类似于 C++ 中的 std::thread。
    • 关键区别:Rust 通过 Send 和 Sync 标记特性在编译时防止数据竞争。
    • 在 C++ 中,在没有互斥锁的情况下跨线程共享 std::vector 是未定义行为 (UB),但可以顺利编译。而在 Rust 中,这根本无法通过编译。
    • Rust 中的 Mutex<T> 封装了数据本身,而不仅仅是访问权限 —— 若不加锁,你完全无法读取数据。
  • 可以使用 thread::spawn() 创建一个单独的线程,并行执行闭包 ||。
use std::thread;
use std::time::Duration;
fn main() {
    let handle = thread::spawn(|| {
        for i in 0..10 {
            println!("线程内计数: {i}!");
            thread::sleep(Duration::from_millis(5));
        }
    });

    for i in 0..5 {
        println!("主线程计数: {i}");
        thread::sleep(Duration::from_millis(5));
    }

    handle.join().unwrap(); // handle.join() 确保衍生的线程执行完毕后主线程才退出
}

Rust 并发

  • thread::scope() 常用于需要从环境中借用的场景。这之所以可行,是因为 thread::scope 会等待其内部线程返回。
  • 尝试在不使用 thread::scope 的情况下执行此练习,看看会出现什么问题。
use std::thread;
fn main() {
  let a = [0, 1, 2];
  thread::scope(|scope| {
      scope.spawn(|| {
          for x in &a {
            println!("{x}");
          }
      });
  });
}

Rust 并发

  • 我们还可以使用 move 将所有权转移到线程。对于像 [i32; 3] 这样的 Copy 类型,move 关键字会将数据复制到闭包中,而原始数据仍然可用。
use std::thread;
fn main() {
  let mut a = [0, 1, 2];
  let handle = thread::spawn(move || {
      for x in a {
        println!("{x}");
      }
  });
  a[0] = 42;    // 不会影响发送到线程中的副本
  handle.join().unwrap();
}

Rust 并发

  • Arc<T> 可用于在多个线程之间共享只读引用。
    • Arc 代表原子引用计数(Atomic Reference Counted)。只有当引用计数降至 0 时,引用才会被释放。
    • Arc::clone() 仅仅增加引用计数,而不会克隆底层数据。
use std::sync::Arc;
use std::thread;
fn main() {
    let a = Arc::new([0, 1, 2]);
    let mut handles = Vec::new();
    for i in 0..2 {
        let arc = Arc::clone(&a);
        handles.push(thread::spawn(move || {
            println!("线程: {i} {arc:?}");
        }));
    }
    handles.into_iter().for_each(|h| h.join().unwrap());
}

Rust 并发

  • Arc<T> 可以与 Mutex<T> 结合使用,以提供可变的共享引用。
    • Mutex 负责保护受保护的数据,并确保只有持有锁的线程才能访问。
    • MutexGuard 在离开作用域时会自动释放(RAII)。注:虽然 std::mem::forget 仍可能导致守护者泄漏,但“不可能忘记解锁”比“不可能泄漏”更贴切。
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..5 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
            // MutexGuard 在此处被销毁 —— 锁自动释放
        }));
    }

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

    println!("最终计数: {}", *counter.lock().unwrap());
    // 输出: 最终计数: 5
}

Rust 并发:RwLock

  • RwLock<T> 允许多个并发读取者或一个独占写入者 —— 即 C++ 中的读写锁模式(std::shared_mutex)。
    • 当读取远多于写入时(例如配置、缓存),请使用 RwLock。
    • 当读写频率相近或临界区极短时,请使用 Mutex。
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let config = Arc::new(RwLock::new(String::from("v1.0")));
    let mut handles = Vec::new();

    // 衍生 5 个读取者 —— 全都可以并发运行
    for i in 0..5 {
        let config = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            let val = config.read().unwrap();  // 允许多个读取者
            println!("读取者 {i}: {val}");
        }));
    }

    // 1 个写入者 —— 阻塞直至所有读取者完成任务
    {
        let config = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            let mut val = config.write().unwrap();  // 独占访问
            *val = String::from("v2.0");
            println!("写入者: 已更新至 {val}");
        }));
    }

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

Rust 并发:Mutex 中毒 (Mutex poisoning)

  • 如果一个线程在持有 Mutex 或 RwLock 的情况下发生 panic,该锁就会中毒 (poisoned)。
    • 随后对 .lock() 的调用将返回 Err(PoisonError) —— 这意味着数据可能处于不一致的状态。
    • 如果你确信数据仍然有效,可以使用 .into_inner() 进行恢复。
    • C++ 中没有等效的概念 —— std::mutex 没有中毒的概念,发生 panic 的线程只会导致锁仍处于持有的状态。
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));

    let data2 = Arc::clone(&data);
    let handle = thread::spawn(move || {
        let mut guard = data2.lock().unwrap();
        guard.push(4);
        panic!("糟了!");  // 此时锁已中毒
    });

    let _ = handle.join();  // 线程发生过 panic

    // 随后的加锁尝试将返回 Err(PoisonError)
    match data.lock() {
        Ok(guard) => println!("数据: {guard:?}"),
        Err(poisoned) => {
            println!("锁已经中毒!正在恢复...");
            let guard = poisoned.into_inner();  // 无论如何都要访问数据
            println!("恢复的数据: {guard:?}");  // [1, 2, 3, 4] —— 在 panic 发生前 push 已成功
        }
    }
}

Rust 并发:Atomics

  • 对于简单的计数器和标志,std::sync::atomic 类型可以避免 Mutex 的开销。
    • AtomicBool、AtomicI32、AtomicU64、AtomicUsize 等。
    • 等同于 C++ 中的 std::atomic<T> —— 二者具有相同的内存排序模型(Relaxed、Acquire、Release、SeqCst)。
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    let counter = Arc::new(AtomicU64::new(0));
    let mut handles = Vec::new();

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                counter.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }

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

    println!("计数器: {}", counter.load(Ordering::SeqCst));
    // 输出: 计数器: 10000
}
原语使用场景C++ 等价物
Mutex<T>通用的可变共享状态std::mutex + 手动数据关联
RwLock<T>读取密集的负载std::shared_mutex
Atomic*简单的计数器、标志、无锁模式std::atomic<T>
Condvar等待条件成立std::condition_variable

Rust 并发:Condvar

  • Condvar(条件变量)可以让一个线程进入睡眠状态,直到另一个线程发出信号通知条件已改变。
    • 始终与 Mutex 配对使用 —— 其模式为:锁定、检查条件、若未就绪则等待、就绪后执行操作。
    • 等同于 C++ 中的 std::condition_variable / std::condition_variable::wait。
    • 处理虚假唤醒 (spurious wakeups) —— 始终在循环中重新检查条件(或者使用 wait_while/wait_until)。
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));

    // 衍生一个等待信号的工作线程
    let pair2 = Arc::clone(&pair);
    let worker = thread::spawn(move || {
        let (lock, cvar) = &*pair2;
        let mut ready = lock.lock().unwrap();
        // wait: 进入睡眠直至收到信号(始终在循环中重新检查以防虚假唤醒)
        while !*ready {
            ready = cvar.wait(ready).unwrap();
        }
        println!("工作线程:条件已满足,继续执行!");
    });

    // 主线程执行一些工作,然后向工作线程发送信号
    thread::sleep(std::time::Duration::from_millis(100));
    {
        let (lock, cvar) = &*pair;
        let mut ready = lock.lock().unwrap();
        *ready = true;
        cvar.notify_one();  // 唤醒一个等待中的线程(notify_all() 会唤醒所有线程)
    }

    worker.join().unwrap();
}

何时使用 Condvar 与通道 (channels): 当线程共享可变状态且需要等待该状态下的某个条件(例如“缓冲区非空”)时,请使用 Condvar。而当线程需要传递消息时,请使用通道(mpsc)。通道通常更容易理解和推理。


Rust 并发

  • Rust 通道可用于在发送者 (Sender) 和接收者 (Receiver) 之间交换消息。
    • 这里使用的是名为 mpsc 或“多生产者,单消费者 (Multi-producer, Single-Consumer)”的范式。
    • send() 和 recv() 都会对线程产生阻塞。
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    
    tx.send(10).unwrap();
    tx.send(20).unwrap();
    
    println!("接收到: {:?}", rx.recv());
    println!("接收到: {:?}", rx.recv());

    let tx2 = tx.clone();
    tx2.send(30).unwrap();
    println!("接收到: {:?}", rx.recv());
}

Rust 并发

  • 通道可以与线程结合使用
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();
    for _ in 0..2 {
        let tx2 = tx.clone();
        thread::spawn(move || {
            let thread_id = thread::current().id();
            for i in 0..10 {
                tx2.send(format!("消息 {i}")).unwrap();
                println!("{thread_id:?}: 发送了消息 {i}");
            }
            println!("{thread_id:?}: 完成");
        });
    }

    // 丢弃原始发送者,以便当所有克隆的发送者也被丢弃时 rx.iter() 能够正常终止
    drop(tx);

    thread::sleep(Duration::from_millis(100));

    for msg in rx.iter() {
        println!("主线程:获取到 {msg}");
    }
}

为什么 Rust 能防止数据竞争:Send 与 Sync

  • Rust 使用两个标记特性在编译时强制实施线程安全:
    • Send:如果一个类型可以安全地转移到另一个线程,它就是 Send。
    • Sync:如果一个类型可以安全地在线程之间(通过 &T)共享,它就是 Sync。
  • 大多数类型都是自动实现 Send + Sync 的。一些显著的例外包括:
    • Rc<T> 既不是 Send 也不是 Sync(在线程中请使用 Arc<T>)。
    • Cell<T> 和 RefCell<T> 不是 Sync(请使用 Mutex<T> 或 RwLock<T>)。
    • 裸指针(*const T、*mut T)既不是 Send 也不是 Sync。
  • 这就是为什么编译器会阻止你跨线程使用 Rc<T> —— 它根本没有实现 Send。
  • Arc<Mutex<T>> 是 Rc<RefCell<T>> 的线程安全版本。

直观理解 (Jon Gjengset):把值想象成玩具。 Send = 你可以把你的玩具送给另一个孩子(线程)—— 转移所有权是安全的。 Sync = 你可以让其他孩子同时玩你的玩具 —— 共享引用是安全的。 Rc<T> 有一个脆弱的(非原子)引用计数器;送出或共享它都会破坏计数,因此它既不是 Send 也不是 Sync。


练习:多线程词频统计

🔴 极度挑战 —— 融合线程、Arc、Mutex 以及 HashMap

  • 给定一组由 Vec<String> 组成的文本行,为每一行衍生一个线程来统计该行中的单词。
  • 使用 Arc<Mutex<HashMap<String, usize>>> 收集统计结果。
  • 打印所有行的单词总数。
  • 加分项:尝试使用通道 (mpsc) 而非共享状态来实现。
参考答案 (点击展开)
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let lines = vec![
        "the quick brown fox".to_string(),
        "jumps over the lazy dog".to_string(),
        "the fox is quick".to_string(),
    ];

    let word_counts: Arc<Mutex<HashMap<String, usize>>> =
        Arc::new(Mutex::new(HashMap::new()));

    let mut handles = vec![];
    for line in &lines {
        let line = line.clone();
        let counts = Arc::clone(&word_counts);
        handles.push(thread::spawn(move || {
            for word in line.split_whitespace() {
                let mut map = counts.lock().unwrap();
                *map.entry(word.to_lowercase()).or_insert(0) += 1;
            }
        }));
    }

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

    let counts = word_counts.lock().unwrap();
    let total: usize = counts.values().sum();
    println!("词频统计: {counts:#?}");
    println!("单词总数: {total}");
}

输出示例 (顺序可能有所不同):

词频统计: {
    "the": 3,
    "quick": 2,
    "brown": 1,
    "fox": 2,
    "jumps": 1,
    "over": 1,
    "lazy": 1,
    "dog": 1,
    "is": 1,
}
单词总数: 13

14. Unsafe Rust 与 FFI

English Original

不安全 Rust (Unsafe Rust)

你将学到: 何时以及如何使用 unsafe —— 解引用裸指针、用于 Rust 与 C 互调的 FFI(外部函数接口)、用于字符串交互的 CString/CStr,以及如何为不安全代码编写安全包装器。

  • unsafe 关键字解锁了 Rust 编译器通常禁止访问的功能:
    • 解引用裸指针。
    • 访问可变静态变量。
    • 更多内容请参考:https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
  • 能力越大,责任越大:
    • unsafe 告诉编译器:“我,程序员,负责维护编译器通常保证的各项不变性。”
    • 必须保证不存在重叠的可变和不可变引用、无悬垂指针、无无效引用等。
    • unsafe 的使用应限制在尽可能小的范围内。
    • 所有使用 unsafe 的代码都应附带“Safety”注释,描述其背后的假设。

不安全 Rust 示例

unsafe fn harmless() {}
fn main() {
    // 安全性:我们正在调用一个无害的不安全函数
    unsafe {
        harmless();
    }
    let a = 42u32;
    let p = &a as *const u32;
    // 安全性:p 是指向一个仍处于作用域内的变量的有效指针
    unsafe {
        println!("{}", *p);
    }
    // 安全性:不安全;此处仅用于演示
    let dangerous_buffer = 0xb8000 as *mut u32;
    unsafe {
        println!("即将发生崩溃!!!");
        *dangerous_buffer = 0; // 在大多数现代机器上这会导致段错误 (SEGV)
    }
}

FFI 字符串:CString 与 CStr

FFI 代表外部函数接口(Foreign Function Interface)—— Rust 用于调用其他语言(如 C)编写的函数以及被其他语言调用的机制。

在与 C 代码交互时,Rust 的 String 和 &str 类型(采用不含空终止符的 UTF-8 编码)无法直接与 C 字符串(以空字符终止的字节数组)兼容。为此,Rust 在 std::ffi 中提供了 CString(所有权型)和 CStr(借用型):

类型类似于使用场景
CStringString (所有权)从 Rust 数据创建 C 字符串
&CStr&str (借用)从外部代码接收 C 字符串
#![allow(unused)]
fn main() {
use std::ffi::{CString, CStr};
use std::os::raw::c_char;

fn demo_ffi_strings() {
    // 创建 C 兼容字符串(添加空终止符 \0)
    let c_string = CString::new("Hello from Rust").expect("CString::new 失败");
    let ptr: *const c_char = c_string.as_ptr();

    // 将 C 字符串转换回 Rust(因信任指针而具有潜在不安全性)
    // 安全性:ptr 有效且以空字符终止(我们在上文刚创建了它)
    let back_to_rust: &CStr = unsafe { CStr::from_ptr(ptr) };
    let rust_str: &str = back_to_rust.to_str().expect("无效的 UTF-8");
    println!("{}", rust_str);
}
}

注意:如果输入中包含内部空字节(\0),CString::new() 将返回错误。请务必处理 Result。在下文的 FFI 示例中,你将看到 CStr 的广泛应用。


简单 FFI 示例(由 C 取用的 Rust 库函数)

  • FFI 方法必须标记为 #[no_mangle],以确保编译器不会混淆(mangle)其名称。
  • 我们将该 crate 编译为一个静态库。
    #![allow(unused)]
    fn main() {
    #[no_mangle] 
    pub extern "C" fn add(left: u64, right: u64) -> u64 {
        left + right
    }
    }
  • 我们将编译如下 C 代码,并将其与我们的静态库链接。
    #include <stdio.h>
    #include <stdint.h>
    extern uint64_t add(uint64_t, uint64_t);
    int main() {
        printf("Add 返回了 %llu\n", add(21, 21));
    }
    

复杂 FFI 示例

  • 在接下来的示例中,我们将创建一个 Rust 日志接口并将其暴露给 [PYTHON] 和 C。
    • 我们将看到同一个接口如何被 Rust 和 C 原生地使用。
    • 我们将探索使用 cbindgen 等工具为 C 生成头文件。
    • 我们将看到 unsafe 包装器如何充当通往安全 Rust 代码的桥梁。

日志助手函数

#![allow(unused)]
fn main() {
fn create_or_open_log_file(log_file: &str, overwrite: bool) -> Result<File, String> {
    if overwrite {
        File::create(log_file).map_err(|e| e.to_string())
    } else {
        OpenOptions::new()
            .write(true)
            .append(true)
            .open(log_file)
            .map_err(|e| e.to_string())
    }
}

fn log_to_file(file_handle: &mut File, message: &str) -> Result<(), String> {
    file_handle
        .write_all(message.as_bytes())
        .map_err(|e| e.to_string())
}
}

日志结构体 (Logger struct)

#![allow(unused)]
fn main() {
struct SimpleLogger {
    log_level: LogLevel,
    file_handle: File,
}

impl SimpleLogger {
    fn new(log_file: &str, overwrite: bool, log_level: LogLevel) -> Result<Self, String> {
        let file_handle = create_or_open_log_file(log_file, overwrite)?;
        Ok(Self {
            file_handle,
            log_level,
        })
    }

    fn log_message(&mut self, log_level: LogLevel, message: &str) -> Result<(), String> {
        if log_level as u32 <= self.log_level as u32 {
            let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
            let message = format!("Simple: {timestamp} {log_level} {message}\n");
            log_to_file(&mut self.file_handle, &message)
        } else {
            Ok(())
        }
    }
}
}

测试

  • 使用 Rust 完成功能测试非常简单。
    • 测试方法采用 #[test] 进行装饰,且不会成为编译产物的一部分。
    • 出于测试目的创建模拟 (mock) 方法非常方便。
#![allow(unused)]
fn main() {
#[test]
fn testfunc() -> Result<(), String> {
    let mut logger = SimpleLogger::new("test.log", false, LogLevel::INFO)?;
    logger.log_message(LogLevel::TRACELEVEL1, "Hello world")?;
    logger.log_message(LogLevel::CRITICAL, "Critical message")?;
    Ok(()) // 编译器在此处自动 drop 掉 logger
}
}
cargo test

(C)-Rust FFI

  • cbindgen 是一个用于为导出的 Rust 函数生成头文件的极佳工具。
    • 可以使用 cargo 进行安装。
cargo install cbindgen
cbindgen 
  • 函数和结构体可以使用 #[no_mangle] 和 #[repr(C)] 进行导出。
    • 既然要遵循通用的接口模式,我们需要将 ** 传递给实际实现,成功返回 0,出错返回非 0 值。
    • 不透明(Opaque)与透明(Transparent)结构体:我们的 SimpleLogger 是作为不透明指针 (*mut SimpleLogger) 传递的 —— C 端永远不会访问其字段,因此不需要 #[repr(C)]。只有当 C 代码需要直接读写结构体字段时,才使用 #[repr(C)]:
#![allow(unused)]
fn main() {
// 不透明 —— C 只持有指针,从不检查字段。不需要 #[repr(C)]。
struct SimpleLogger { /* 仅限 Rust 的字段 */ }

// 透明 —— C 直接读写字段。必须使用 #[repr(C)]。
#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}
}
typedef struct SimpleLogger SimpleLogger;
uint32_t create_simple_logger(const char *file_name, struct SimpleLogger **out_logger);
uint32_t log_entry(struct SimpleLogger *logger, const char *message);
uint32_t drop_logger(struct SimpleLogger *logger);

  • 注意我们需要进行大量的健全性检查。
  • 我们必须显式地进行内存泄漏处理,以防止 Rust 自动释放内存。
#![allow(unused)]
fn main() {
#[no_mangle] 
pub extern "C" fn create_simple_logger(file_name: *const std::os::raw::c_char, out_logger: *mut *mut SimpleLogger) -> u32 {
    use std::ffi::CStr;
    // 确保指针不为 NULL
    if file_name.is_null() || out_logger.is_null() {
        return 1;
    }
    // 安全性:根据约定,传入的指针要么为 NULL,要么是以空字符结尾的
    let file_name = unsafe {
        CStr::from_ptr(file_name)
    };
    let file_name = file_name.to_str();
    // 确保 file_name 中不包含乱码
    if file_name.is_err() {
        return 1;
    }
    let file_name = file_name.unwrap();
    // 假定一些默认值;实际应用中我们会传入这些值
    let new_logger = SimpleLogger::new(file_name, false, LogLevel::CRITICAL);
    // 检查是否能够成功构造 logger
    if new_logger.is_err() {
        return 1;
    }
    let new_logger = Box::new(new_logger.unwrap());
    // 这可以防止 Box 在离开作用域时被释放
    let logger_ptr: *mut SimpleLogger = Box::leak(new_logger);
    // 安全性:logger 非空且 logger_ptr 有效
    unsafe {
        *out_logger = logger_ptr;
    }
    return 0;
}
}

  • 我们在 log_entry() 中也有类似的错误检查。
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn log_entry(logger: *mut SimpleLogger, message: *const std::os::raw::c_char) -> u32 {
    use std::ffi::CStr;
    if message.is_null() || logger.is_null() {
        return 1;
    }
    // 安全性:message 非空
    let message = unsafe {
        CStr::from_ptr(message)
    };
    let message = message.to_str();
    // 确保 message 中不包含乱码
    if message.is_err() {
        return 1;
    }
    // 安全性:logger 是先前由 create_simple_logger() 构造的正确定向指针
    unsafe {
        (*logger).log_message(LogLevel::CRITICAL, message.unwrap()).is_err() as u32
    }
}

#[no_mangle]
pub extern "C" fn drop_logger(logger: *mut SimpleLogger) -> u32 {
    if logger.is_null() {
        return 1;
    }
    // 安全性:logger 是先前由 create_simple_logger() 构造的正确定向指针
    unsafe {
        // 这将构造一个 Box<SimpleLogger>,它在离开作用域时会被释放
        let _ = Box::from_raw(logger);
    }
    0
}
}

  • 我们可以使用 Rust 或编写 C 程序来测试我们的 (C)-FFI。
#![allow(unused)]
fn main() {
#[test]
fn test_c_logger() {
    // c".." 创建一个以空字符结尾的字符串
    let file_name = c"test.log".as_ptr() as *const std::os::raw::c_char;
    let mut c_logger: *mut SimpleLogger = std::ptr::null_mut();
    assert_eq!(create_simple_logger(file_name, &mut c_logger), 0);
    // 这是手动创建 c"..." 字符串的方法
    let message = b"message from C\0".as_ptr() as *const std::os::raw::c_char;
    assert_eq!(log_entry(c_logger, message), 0);
    drop_logger(c_logger);
}
}
#include "logger.h"
// ...
int main() {
    SimpleLogger *logger = NULL;
    if (create_simple_logger("test.log", &logger) == 0) {
        log_entry(logger, "Hello from C");
        drop_logger(logger); /* 需要关闭句柄等操作 */
    } 
    // ...
}

确保不安全代码的正确性

  • 简而言之,使用 unsafe 需要经过深思熟虑。
    • 始终记录代码的安全假设,并组织专家进行评审。
    • 使用 cbindgen、Miri、Valgrind 等工具来辅助验证正确性。
    • 绝不让 panic 跨越 FFI 边界传播 —— 这是未定义行为。请在 FFI 入口点使用 std::panic::catch_unwind,或者在你的 profile 中配置 panic = "abort"。
    • 如果结构体在 FFI 之间共享,请标记为 #[repr(C)] 以保证其内存布局与 C 兼容。
    • 请查阅 https://doc.rust-lang.org/nomicon/intro.html(《Rustonomicon》—— 介绍不安全 Rust 的“黑魔法”)。
    • 寻求团队内部专家的帮助。

验证工具:Miri 对比 Valgrind

C++ 开发者熟悉 Valgrind 和各类 sanitizer。Rust 除了提供这些工具,还拥有针对 Rust 特有未定义行为(UB)更为精准的 Miri。

MiriValgrindC++ sanitizers (ASan/MSan/UBSan)
捕捉内容Rust 特有的 UB:Stacked Borrows、无效枚举判别值、未初始化读取、别名违反内存泄漏、释放后使用、无效读/写、未初始化内存缓冲区溢出、释放后使用、数据竞争、UB
工作原理解释 MIR(Rust 的中级 IR)—— 非原生执行在运行时对编译后的二进制进行插桩编译时插桩
FFI 支持❌ 无法跨越 FFI 边界(跳过 C 调用)✅ 适用于任何编译后的二进制,包括 FFI✅ 只要 C 代码也使用了 sanitizer 编译即可
运行速度比原生慢约 100 倍慢约 10-50 倍慢约 2-5 倍
使用时机纯 Rust unsafe 代码、数据结构不变性验证FFI 代码、完整二进制的集成测试FFI 的 C/C++ 端、性能敏感型测试
捕捉别名 Bug✅ Stacked Borrows 模型❌部分(TSan 可捕捉数据竞争)

建议:两者结合使用 —— 针对纯 Rust 的不安全代码使用 Miri,针对 FFI 集成使用 Valgrind:

  • Miri —— 捕捉 Valgrind 看不到的 Rust 特有 UB(如别名违反、无效枚举值、stacked borrows 等):

    rustup +nightly component add miri
    cargo +nightly miri test                    # 在 Miri 下运行所有测试
    cargo +nightly miri test -- test_name       # 运行特定测试
    

    ⚠️ Miri 需要使用 nightly 版本且无法执行 FFI 调用。请将不安全的 Rust 逻辑隔离为可独立测试的单元。

  • Valgrind —— 你已经熟悉的工具,适用于包括 FFI 在内的编译后的二进制:

    sudo apt install valgrind
    cargo install cargo-valgrind
    cargo valgrind test                         # 在 Valgrind 下运行所有测试
    

    它可以捕捉 FFI 代码中常见的 Box::leak / Box::from_raw 模式导致的内存泄漏。

  • cargo-careful —— 开启额外的运行时检查来运行测试(介于常规测试与 Miri 之间):

    cargo install cargo-careful
    cargo +nightly careful test
    

不安全 Rust 小结

  • cbindgen 是用于 Rust (C) FFI 的绝佳工具。
    • 另一个方向的 FFI 接口请使用 bindgen(请查阅其详尽的文档)。
  • 不要理所当然地认为你的不安全代码是正确的,或者认为它可以安全地在安全 Rust 中使用。由于一些微妙的原因,即使看起来运行正确的代码也可能是错误的。
    • 使用相关工具验证正确性。
    • 若仍有疑问,请咨询专家。
  • 确保你的 unsafe 代码包含详尽的注释,记录其背后的假设以及正确性的原因。
    • unsafe 代码的调用者也应在安全性方面附带相应的注释,并遵守相关限制。

练习:编写安全 FFI 包装器

🔴 挑战 —— 需要理解不安全块、裸指针和安全 API 设计

  • 实现一个围绕 unsafe FFI 风格函数的安全 Rust 包装器。本练习模拟调用一个 C 函数,该函数向调用方提供的缓冲区中写入一个格式化的字符串。
  • 步骤 1:实现不安全函数 unsafe_greet,它将向裸 *mut u8 缓冲区中写入问候语。
  • 步骤 2:编写安全包装器 safe_greet,它分配一个 Vec<u8>,调用该不安全函数,并返回一个 String。
  • 步骤 3:为每个不安全块添加恰当的 // Safety: 注释。

初始代码:

use std::fmt::Write as _;

/// 模拟 C 函数:向缓冲区写入 "Hello, <name>!"。
/// 返回写入的字节数(不包括空终止符 \0)。
/// # Safety
/// - `buf` 必须指向至少 `buf_len` 个可写字节。
/// - `name` 必须是向有效且以此空字符结尾的 C 字符串的指针。
unsafe fn unsafe_greet(buf: *mut u8, buf_len: usize, name: *const u8) -> isize {
    // 待办:构建问候语,将字节复制到 buf 中,返回长度
    // 提示:使用 std::ffi::CStr::from_ptr 或手动遍历字节
    todo!()
}

/// 安全包装器 —— 公共 API 中没有不安全部分
fn safe_greet(name: &str) -> Result<String, String> {
    // 待办:分配一个 Vec<u8> 缓冲区,创建一个带空终止符的名称,
    // 在带有安全注释的 unsafe 块中调用 unsafe_greet,
    // 将结果转换回 String
    todo!()
}

fn main() {
    match safe_greet("Rustacean") {
        Ok(msg) => println!("{msg}"),
        Err(e) => eprintln!("错误:{e}"),
    }
    // 预期输出:Hello, Rustacean!
}

参考答案 (点击展开)
use std::ffi::CStr;

/// 模拟 C 函数:向缓冲区写入 "Hello, <name>!"。
/// 返回写入的字节数,如果缓冲区太小则返回 -1。
/// # Safety
/// - `buf` 必须指向至少 `buf_len` 个可写字节.
/// - `name` 必须是向有效且以此空字符结尾的 C 字符串的指针.
unsafe fn unsafe_greet(buf: *mut u8, buf_len: usize, name: *const u8) -> isize {
    // 安全性:调用方保证 name 是一个有效的以空字符结尾的字符串
    let name_cstr = unsafe { CStr::from_ptr(name as *const std::os::raw::c_char) };
    let name_str = match name_cstr.to_str() {
        Ok(s) => s,
        Err(_) => return -1,
    };
    let greeting = format!("Hello, {}!", name_str);
    if greeting.len() > buf_len {
        return -1;
    }
    // 安全性:调用方保证 buf 指向至少 buf_len 个可写字节
    unsafe {
        std::ptr::copy_nonoverlapping(greeting.as_ptr(), buf, greeting.len());
    }
    greeting.len() as isize
}

/// 安全包装器 —— 公共 API 中没有不安全部分
fn safe_greet(name: &str) -> Result<String, String> {
    let mut buffer = vec![0u8; 256];
    // 为 C API 创建一个带空终止符的 name 版本
    let name_with_null: Vec<u8> = name.bytes().chain(std::iter::once(0)).collect();

    // 安全性:buffer 拥有 256 个可写字节,name_with_null 已添加空终止符
    let bytes_written = unsafe {
        unsafe_greet(buffer.as_mut_ptr(), buffer.len(), name_with_null.as_ptr())
    };

    if bytes_written < 0 {
        return Err("缓冲区太小或名称无效".to_string());
    }

    String::from_utf8(buffer[..bytes_written as usize].to_vec())
        .map_err(|e| format!("无效的 UTF-8 编码:{e}"))
}

fn main() {
    match safe_greet("Rustacean") {
        Ok(msg) => println!("{msg}"),
        Err(e) => eprintln!("错误:{e}"),
    }
}
// 输出:
// Hello, Rustacean!

English Original

no_std —— 不依赖标准库的 Rust

你将学到: 如何使用 #![no_std] 为裸机和嵌入式目标编写 Rust 代码 —— core 与 alloc crate 的拆分、panic 句柄,以及这与不带 libc 的嵌入式 C 的对比。

如果你拥有嵌入式 C 开发背景,想必已经习惯了在没有 libc 或只有极简运行时的环境下工作。Rust 也有一个同等级别的原生功能:#![no_std] 属性。

什么是 no_std?

当你在 crate 根部添加 #![no_std] 时,编译器会移除隐式的 extern crate std;,并仅链接至 core(以及可选的 alloc)。

层次提供的内容是否需要 OS / 堆?
core基础类型、Option、Result、Iterator、数学运算、slice、str、原子操作、fmt不需要 —— 运行于裸机之上
allocVec、String、Box、Rc、Arc、BTreeMap需要全局分配器,但不需要 OS
stdHashMap、fs、net、thread、io、env、process需要 —— 必须有 OS 支持

面向嵌入式开发者的经验准则: 如果你的 C 项目链接了 -lc 并使用了 malloc,那么你大概可以使用 core + alloc。如果它是在没有 malloc 的裸机上运行,请仅坚持使用 core。


声明 no_std

#![allow(unused)]
fn main() {
// src/lib.rs (或带有 #![no_main] 的二进制程序 src/main.rs)
#![no_std]

// 你仍然可以从 `core` 获得所有内容:
use core::fmt;
use core::result::Result;
use core::option::Option;

// 如果有分配器,选择使用堆类型:
extern crate alloc;
use alloc::vec::Vec;
use alloc::string::String;
}

对于裸机二进制程序,你还需要 #![no_main] 和一个 panic 句柄:

#![allow(unused)]
#![no_std]
#![no_main]

fn main() {
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {} // 在发生 panic 时挂起 —— 替换为您开发板的复位/LED 闪烁逻辑
}

// 入口点取决于您的 HAL / 链接器脚本
}

权衡与替代方案

std 功能no_std 替代方案
println!core::write! 输出到 UART / defmt
HashMapheapless::FnvIndexMap (固定容量) 或 BTreeMap (通过 alloc)
Vecheapless::Vec (栈分配,固定容量)
Stringheapless::String 或 &str
std::io::Read/Writeembedded_io::Read/Write
thread::spawn中断处理程序、RTIC 任务
std::time硬件定时器外设
std::fsFlash / EEPROM 驱动

值得关注的嵌入式 no_std Crates

Crate用途备注
heapless固定容量的 Vec、String、Queue、Map无需分配器 —— 全部位于栈上
defmt通过 probe/ITM 进行高效日志记录类似于 printf,但格式化工作推迟到主机端完成
embedded-hal硬件抽象层 Trait(SPI、I²C、GPIO、UART)一次实现,随处运行(在任何 MCU 上)
cortex-mARM Cortex-M 内部指令与寄存器访问低级接口,类似于 CMSIS
cortex-m-rtCortex-M 的运行时/启动代码替代您的 startup.s
rtic实时中断驱动并发(Real-Time Interrupt-driven Concurrency)编译时任务调度,零成本开销
embassy嵌入式异步执行器在裸机上实现 async/await
postcardno_std 环境下的 serde 序列化 (二进制)当您无法负担字符串开销时,用于替代 serde_json
thiserror为 Error Trait 提供派生宏自 v2 版本起支持 no_std;推荐优于 anyhow
smoltcpno_std TCP/IP 协议栈用于在没有 OS 的情况下进行网络通信

C 语言与 Rust:裸机对比

一个典型的嵌入式 C 语言 Blinky(闪灯程序):

// C — 裸机运行,厂商 HAL 库
#include "stm32f4xx_hal.h"

void SysTick_Handler(void) {
    HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}

int main(void) {
    HAL_Init();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    GPIO_InitTypeDef gpio = { .Pin = GPIO_PIN_5, .Mode = GPIO_MODE_OUTPUT_PP };
    HAL_GPIO_Init(GPIOA, &gpio);
    HAL_SYSTICK_Config(HAL_RCC_GetHCLKFreq() / 1000);
    while (1) {}
}

Rust 的等效程序(使用 embedded-hal + 开发板 crate):

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _; // panic 句柄:死循环
use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let gpioa = dp.GPIOA.split();
    let mut led = gpioa.pa5.into_push_pull_output();

    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();
    let mut delay = dp.TIM2.delay_ms(&clocks);

    loop {
        led.toggle();
        delay.delay_ms(500u32);
    }
}

面向 C 语言开发者的关键差异:

  • Peripherals::take() 返回 Option —— 这在编译时确保了单例模式(排除了双重初始化导致的 Bug)。
  • .split() 转移了各引脚的所有权 —— 不会发生两个模块同时驱动同一个引脚的风险。
  • 所有寄存器访问均经过类型检查 —— 您不会意外地向只读寄存器执行写操作。
  • 借用检查协议防止了 main 与中断处理程序之间发生数据竞争(配合 RTIC 使用)。

何时使用 no_std 与 std

flowchart TD
    A[您的目标平台是否有 OS?] -->|是| B[使用 std]
    A -->|否| C[您是否有堆分配器?]
    C -->|是| D["使用 #![no_std] + extern crate alloc"]
    C -->|否| E["仅使用带有 core 的 #![no_std]"]
    B --> F[完整的 Vec、HashMap、线程、fs、网络支持]
    D --> G[Vec、String、Box、BTreeMap —— 无 fs/网络/线程]
    E --> H[固定尺寸数组、heapless 集合、无分配操作]

练习:no_std 环形缓冲区 (Ring Buffer)

🔴 挑战 —— 在 no_std 上下文中融合泛型、MaybeUninit 和 #[cfg(test)]。

在嵌入式系统中,您通常需要一个永远不请求分配且尺寸固定的环形缓冲区(循环缓冲区)。请仅使用 core(不使用 alloc 或 std)来实现一个环形缓冲区。

要求:

  • 对元素类型 T: Copy 泛型化。
  • 固定容量 N(常量泛型,const generic)。
  • push(&mut self, item: T) —— 若已满,则覆盖最旧的元素。
  • pop(&mut self) -> Option<T> —— 返回最旧的元素。
  • len(&self) -> usize
  • is_empty(&self) -> bool
  • 必须支持以 #![no_std] 方式编译。
#![allow(unused)]
fn main() {
// 初始代码
#![no_std]

use core::mem::MaybeUninit;

pub struct RingBuffer<T: Copy, const N: usize> {
    buf: [MaybeUninit<T>; N],
    head: usize,  // 下一个写入位置
    tail: usize,  // 下一个读取位置
    count: usize,
}

impl<T: Copy, const N: usize> RingBuffer<T, N> {
    pub const fn new() -> Self {
        todo!()
    }
    pub fn push(&mut self, item: T) {
        todo!()
    }
    pub fn pop(&mut self) -> Option<T> {
        todo!()
    }
    pub fn len(&self) -> usize {
        todo!()
    }
    pub fn is_empty(&self) -> bool {
        todo!()
    }
}
}

参考答案
#![allow(unused)]
#![no_std]

fn main() {
use core::mem::MaybeUninit;

pub struct RingBuffer<T: Copy, const N: usize> {
    buf: [MaybeUninit<T>; N],
    head: usize,
    tail: usize,
    count: usize,
}

impl<T: Copy, const N: usize> RingBuffer<T, N> {
    pub const fn new() -> Self {
        Self {
            // 安全性:MaybeUninit 不需要进行初始化
            buf: unsafe { MaybeUninit::uninit().assume_init() },
            head: 0,
            tail: 0,
            count: 0,
        }
    }

    pub fn push(&mut self, item: T) {
        self.buf[self.head] = MaybeUninit::new(item);
        self.head = (self.head + 1) % N;
        if self.count == N {
            // 缓冲区已满 —— 覆盖最旧的元素,并推进尾部
            self.tail = (self.tail + 1) % N;
        } else {
            self.count += 1;
        }
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.count == 0 {
            return None;
        }
        // 安全性:我们只读取先前通过 push() 写入的位置
        let item = unsafe { self.buf[self.tail].assume_init() };
        self.tail = (self.tail + 1) % N;
        self.count -= 1;
        Some(item)
    }

    pub fn len(&self) -> usize {
        self.count
    }

    pub fn is_empty(&self) -> bool {
        self.count == 0
    }
}
}

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_push_pop() {
        let mut rb = RingBuffer::<u32, 4>::new();
        assert!(rb.is_empty());

        rb.push(10);
        rb.push(20);
        rb.push(30);
        assert_eq!(rb.len(), 3);

        assert_eq!(rb.pop(), Some(10));
        assert_eq!(rb.pop(), Some(20));
        assert_eq!(rb.pop(), Some(30));
        assert_eq!(rb.pop(), None);
    }

    #[test]
    fn overwrite_on_full() {
        let mut rb = RingBuffer::<u8, 3>::new();
        rb.push(1);
        rb.push(2);
        rb.push(3);
        // 缓冲区已满: [1, 2, 3]

        rb.push(4); // 覆盖 1 → [4, 2, 3], tail 推进
        assert_eq!(rb.len(), 3);
        assert_eq!(rb.pop(), Some(2)); // 最旧的幸存元素
        assert_eq!(rb.pop(), Some(3));
        assert_eq!(rb.pop(), Some(4));
        assert_eq!(rb.pop(), None);
    }
}
}

为什么这对于嵌入式 C 语言开发者很重要:

  • MaybeUninit 是 Rust 对未初始化内存的等效表达 —— 编译器不会插入零填充(zero-fills),就像 C 语言中的 char buf[N]; 一样。
  • unsafe 块非常精简(仅 2 行),且每一行都附带了 // 安全性: 注释。
  • const fn new() 意味着你可以在 static 变量中创建环形缓冲区,而无需运行时构造函数。
  • 尽管代码是 no_std 的,各项测试仍可以在您的主机上通过 cargo test 运行。

嵌入式深入探讨

English Original

MMIO 与 Volatile 寄存器访问

你将学到: 嵌入式 Rust 中类型安全的硬件寄存器访问 —— volatile MMIO 模式、寄存器抽象 crate,以及 Rust 的类型系统如何编码 C 语言 volatile 关键字无法实现的寄存器权限。

在 C 语言固件中,你通过指向特定内存地址的 volatile 指针来访问硬件寄存器。Rust 也有类似的机制 —— 但具备类型安全性。

C 语言 volatile 对比 Rust volatile

// C — 典型的 MMIO 寄存器访问
#define GPIO_BASE     0x40020000
#define GPIO_MODER    (*(volatile uint32_t*)(GPIO_BASE + 0x00))
#define GPIO_ODR      (*(volatile uint32_t*)(GPIO_BASE + 0x14))

void toggle_led(void) {
    GPIO_ODR ^= (1 << 5);  // 翻转第 5 引脚
}
#![allow(unused)]
fn main() {
// Rust — 原始 volatile(底层机制,极少直接使用)
use core::ptr;

const GPIO_BASE: usize = 0x4002_0000;
const GPIO_ODR: *mut u32 = (GPIO_BASE + 0x14) as *mut u32;

/// # 安全性 (Safety)
/// 调用者必须确保 GPIO_BASE 是一个有效的已映射外设地址。
unsafe fn toggle_led() {
    // 安全性:GPIO_ODR 是一个有效的内存映射寄存器地址。
    let current = unsafe { ptr::read_volatile(GPIO_ODR) };
    unsafe { ptr::write_volatile(GPIO_ODR, current ^ (1 << 5)) };
}
}

svd2rust —— 类型安全的寄存器访问(Rust 方式)

在实践中,你绝不会直接编写原始 volatile 指针。相反,svd2rust 会根据芯片的 SVD 文件(即 IDE 调试视图所使用的同一个 XML 文件)生成一个外设访问 Crate (PAC):

#![allow(unused)]
fn main() {
// 生成的 PAC 代码(您无需编写 —— 由 svd2rust 完成)
// PAC 将无效的寄存器访问变为编译错误

// 使用 PAC:
use stm32f4::stm32f401;  // 针对您芯片的 PAC crate

fn configure_gpio(dp: stm32f401::Peripherals) {
    // 启用 GPIOA 时钟 —— 类型安全,无魔法数字
    dp.RCC.ahb1enr.modify(|_, w| w.gpioaen().enabled());

    // 将第 5 引脚设为输出 —— 不会意外写入只读字段
    dp.GPIOA.moder.modify(|_, w| w.moder5().output());

    // 翻转第 5 引脚 —— 经过类型检查的字段访问
    dp.GPIOA.odr.modify(|r, w| {
        // 安全性:翻转有效寄存器字段中的单个位。
        unsafe { w.bits(r.bits() ^ (1 << 5)) }
    });
}
}
C 语言寄存器访问Rust PAC 等效操作
#define REG (*(volatile uint32_t*)ADDR)由 svd2rust 生成的 PAC crate
`REG= BITMASK;`
value = REG;let val = periph.reg.read().field().bits()
错误的寄存器字段 → 静默 UB编译错误 —— 字段不存在
错误的寄存器宽度 → 静默 UB类型检查 —— u8 vs u16 vs u32

中断处理与临界区 (Critical Sections)

C 语言固件使用 __disable_irq() / __enable_irq() 函数以及返回值为 void 的 ISR(中断服务例程)函数。Rust 提供了对应的类型安全等效功能。

C 对比 Rust 的中断模式

// C — 传统中断处理程序
volatile uint32_t tick_count = 0;

void SysTick_Handler(void) {   // 命名约定至关重要 —— 弄错会导致 HardFault
    tick_count++;
}

uint32_t get_ticks(void) {
    __disable_irq();
    uint32_t t = tick_count;   // 在临界区内读取
    __enable_irq();
    return t;
}
#![allow(unused)]
fn main() {
// Rust — 使用 cortex-m 和临界区
use core::cell::Cell;
use cortex_m::interrupt::{self, Mutex};

// 受临界区 Mutex 保护的共享状态
static TICK_COUNT: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));

#[cortex_m_rt::exception]     // 属性确保在中断向量表中正确放置
fn SysTick() {                // 如果名称与有效的异常不匹配,则会报错
    interrupt::free(|cs| {    // cs = 临界区 Token(证明已禁用中断)
        let count = TICK_COUNT.borrow(cs).get();
        TICK_COUNT.borrow(cs).set(count + 1);
    });
}

fn get_ticks() -> u32 {
    interrupt::free(|cs| TICK_COUNT.borrow(cs).get())
}
}

RTIC —— 实时中断驱动并发 (Real-Time Interrupt-driven Concurrency)

对于具有多个中断优先级的复杂固件,RTIC(原名为 RTFM)提供零成本开销的编译时任务调度:

#![allow(unused)]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [USART1])]
mod app {
    use stm32f4xx_hal::prelude::*;

    #[shared]
    struct Shared {
        temperature: f32,   // 在任务间共享 —— RTIC 负责管理锁定
    }

    #[local]
    struct Local {
        led: stm32f4xx_hal::gpio::Pin<'A', 5, stm32f4xx_hal::gpio::Output>,
    }

    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        let dp = cx.device;
        let gpioa = dp.GPIOA.split();
        let led = gpioa.pa5.into_push_pull_output();
        (Shared { temperature: 25.0 }, Local { led })
    }

    // 硬件任务:在 SysTick 中断时运行
    #[task(binds = SysTick, shared = [temperature], local = [led])]
    fn tick(mut cx: tick::Context) {
        cx.local.led.toggle();
        cx.shared.temperature.lock(|temp| {
            // RTIC 保证此处是独占访问 —— 不需要手动加锁
            *temp += 0.1;
        });
    }
}
}

为什么 RTIC 对于 C 语言固件开发者很重要:

  • #[shared] 注解取代了手动的互斥锁管理。
  • 基于优先级的抢占式调度在编译时完成配置 —— 无运行时开销。
  • 借由框架设计,在编译时即可证明不存在死锁。
  • ISR 的命名错误在编译阶段报错,而不是在运行时引发 HardFault。

Panic 句柄策略

在 C 语言中,当固件出错时,你通常会选择复位或让 LED 闪烁。Rust 的 panic 句柄则提供了更为结构化的控制:

#![allow(unused)]
fn main() {
// 策略 1:挂起(用于调试 —— 连接调试器,检查状态)
use panic_halt as _;  // 发生 panic 时进入无限循环

// 策略 2:复位 MCU
use panic_reset as _;  // 触发系统复位

// 策略 3:通过调试器探针记录(开发阶段)
use panic_probe as _;  // 通过调试探针发送 panic 信息(需配合 defmt)

// 策略 4:通过 defmt 记录并挂起
use defmt_panic as _;  // 通过 ITM/RTT 发送丰富的 panic 信息

// 策略 5:自定义句柄(生产环节固件)
use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    // 1. 禁用中断以防止进一步破坏
    cortex_m::interrupt::disable();

    // 2. 将 panic 信息写入预留的 RAM 区域(复位后仍然保留)
    // 安全性:PANIC_LOG 是链接脚本中定义的预留内存区域。
    unsafe {
        let log = 0x2000_0000 as *mut [u8; 256];
        // 写入截断后的 panic 信息
        use core::fmt::Write;
        let mut writer = FixedWriter::new(&mut *log);
        let _ = write!(writer, "{}", info);
    }

    // 3. 触发看门狗复位(或者让错误 LED 闪烁)
    loop {
        cortex_m::asm::wfi();  // 等待中断(挂起期间保持低功耗)
    }
}
}

链接脚本与内存布局

C 语言固件开发者通过编写链接脚本(Linker Scripts)来定义 FLASH/RAM 区域。嵌入式 Rust 借由 memory.x 实现了同样的概念:

/* memory.x —— 置于 crate 根部,由 cortex-m-rt 使用 */
MEMORY
{
  /* 针对您的 MCU 进行调整 —— 这里是 STM32F401 的取值 */
  FLASH : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   : ORIGIN = 0x20000000, LENGTH = 96K
}

/* 可选:为 panic 日志预留空间(参见上方的 panic 句柄) */
_panic_log_start = ORIGIN(RAM);
_panic_log_size  = 256;
# .cargo/config.toml —— 设置目标平台及链接器标志
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F401RE"  # 通过调试探针进行烧录并运行
rustflags = [
    "-C", "link-arg=-Tlink.x",              # cortex-m-rt 链接脚本
]

[build]
target = "thumbv7em-none-eabihf"            # 带有硬件 FPU 的 Cortex-M4F
C 链接脚本Rust 等效项
MEMORY { FLASH ..., RAM ... }crate 根部的 memory.x
__attribute__((section(".data")))#[link_section = ".data"]
Makefile 中的 -T linker.ld.cargo/config.toml 中的 -C link-arg=-Tlink.x
__bss_start__, __bss_end__由 cortex-m-rt 自动处理
启动汇编 (startup.s)cortex-m-rt 的 #[entry] 宏

编写 embedded-hal 驱动程序

embedded-hal crate 为 SPI、I2C、GPIO、UART 等定义了 Trait。基于这些 Trait 编写的驱动程序可以运行在任意 MCU 上 —— 这正是 Rust 在嵌入式领域实现代码复用的“杀手锏”。

C 语言对比 Rust:温度传感器驱动程序

// C — 驱动代码与 STM32 HAL 库紧密耦合
#include "stm32f4xx_hal.h"

float read_temperature(I2C_HandleTypeDef* hi2c, uint8_t addr) {
    uint8_t buf[2];
    HAL_I2C_Mem_Read(hi2c, addr << 1, 0x00, I2C_MEMADD_SIZE_8BIT,
                     buf, 2, HAL_MAX_DELAY);
    int16_t raw = ((int16_t)buf[0] << 4) | (buf[1] >> 4);
    return raw * 0.0625;
}
// 问题:该驱动仅能配合 STM32 HAL 运行。若要移植到 Nordic 平台,必须重写。
#![allow(unused)]
fn main() {
// Rust — 只要是实现了 embedded-hal 的 MCU,该驱动均可运行
use embedded_hal::i2c::I2c;

pub struct Tmp102<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C: I2c> Tmp102<I2C> {
    pub fn new(i2c: I2C, address: u8) -> Self {
        Self { i2c, address }
    }

    pub fn read_temperature(&mut self) -> Result<f32, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.address, &[0x00], &mut buf)?;
        let raw = ((buf[0] as i16) << 4) | ((buf[1] as i16) >> 4);
        Ok(raw as f32 * 0.0625)
    }
}

// 可运行于 STM32、Nordic nRF、ESP32、RP2040 —— 任何带有 embedded-hal I2C 实现的芯片
}

graph TD
    subgraph "C 语言驱动架构"
        CD["温度传感器驱动"]
        CD --> STM["STM32 HAL"]
        CD -.->|"移植 = 重写"| NRF["Nordic HAL"]
        CD -.->|"移植 = 重写"| ESP["ESP-IDF"]
    end
    
    subgraph "Rust embedded-hal 架构"
        RD["温度传感器驱动<br/>impl&lt;I2C: I2c&gt;"]
        RD --> EHAL["embedded-hal::I2c trait"]
        EHAL --> STM2["stm32f4xx-hal"]
        EHAL --> NRF2["nrf52-hal"]
        EHAL --> ESP2["esp-hal"]
        EHAL --> RP2["rp2040-hal"]
        NOTE["只需编写一次驱动,<br/>即可在所有芯片上运行"]
    end
    
    style CD fill:#ffa07a,color:#000
    style RD fill:#91e5a3,color:#000
    style EHAL fill:#91e5a3,color:#000
    style NOTE fill:#91e5a3,color:#000

全局分配器设置

alloc crate 为您提供了 Vec、String、Box 等功能 —— 但您需要告知 Rust 堆内存(heap memory)的来源。这等同于为您的平台实现 malloc():

#![no_std]
extern crate alloc;

use alloc::vec::Vec;
use alloc::string::String;
use embedded_alloc::LlffHeap as Heap;

#[global_allocator]
static HEAP: Heap = Heap::empty();

#[cortex_m_rt::entry]
fn main() -> ! {
    // 使用一块内存区域初始化分配器
    // (通常是 RAM 中未被栈或静态数据占用的部分)
    {
        const HEAP_SIZE: usize = 4096;
        static mut HEAP_MEM: [u8; HEAP_SIZE] = [0; HEAP_SIZE];
        // 安全性:HEAP_MEM 仅在此初始化期间被访问,且位于任何分配操作发生之前。
        unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
    }

    // 现在可以使用堆类型了!
    let mut log_buffer: Vec<u8> = Vec::with_capacity(256);
    let name: String = String::from("sensor_01");
    // ...

    loop {}
}
C 堆设置Rust 等效项
_sbrk() / 自定义 malloc()#[global_allocator] + Heap::init()
configTOTAL_HEAP_SIZE (FreeRTOS)HEAP_SIZE 常量
pvPortMalloc()alloc::vec::Vec::new() —— 自动完成
堆耗尽 → 未定义行为alloc_error_handler → 可控的 panic

no_std 与 std 混合的工作空间 (Workspaces)

真实项目(如大型 Rust 工作空间)通常包含以下结构:

  • 用于硬件无关逻辑的 no_std 库 Crate
  • 用于 Linux 应用层的 std 二进制 Crate
workspace_root/
├── Cargo.toml              # [workspace] members = [...]
├── protocol/               # no_std — 有线协议、解析
│   ├── Cargo.toml          # 禁用默认特性 (no default-features),无 std
│   └── src/lib.rs          # #![no_std]
├── driver/                 # no_std — 硬件抽象
│   ├── Cargo.toml
│   └── src/lib.rs          # #![no_std],使用 embedded-hal trait
├── firmware/               # no_std — MCU 二进制文件
│   ├── Cargo.toml          # 依赖 protocol 与 driver
│   └── src/main.rs         # #![no_std] #![no_main]
└── host_tool/              # std — Linux 命令行工具
    ├── Cargo.toml          # 依赖 protocol(同一个 crate!)
    └── src/main.rs         # 使用 std::fs、std::net 等

关键模式:protocol crate 使用 #![no_std],因此它既可以为 MCU 固件编译,也可以为 Linux 主机工具编译。代码共享,零重复。

# protocol/Cargo.toml
[package]
name = "protocol"

[features]
default = []
std = []  # 可选:为主机构建时启用特定于 std 的功能

[dependencies]
serde = { version = "1", default-features = false, features = ["derive"] }
# 注意:default-features = false 会丢弃 serde 对 std 的依赖

#![allow(unused)]
fn main() {
// protocol/src/lib.rs
#![cfg_attr(not(feature = "std"), no_std)]

#[cfg(feature = "std")]
extern crate std;

extern crate alloc;
use alloc::vec::Vec;
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct DiagPacket {
    pub sensor_id: u16,
    pub value: i32,
    pub fault_code: u16,
}

// 该函数在 no_std 和 std 上下文中均可正常工作
pub fn parse_packet(data: &[u8]) -> Result<DiagPacket, &'static str> {
    if data.len() < 8 {
        return Err("数据包太短");
    }
    Ok(DiagPacket {
        sensor_id: u16::from_le_bytes([data[0], data[1]]),
        value: i32::from_le_bytes([data[2], data[3], data[4], data[5]]),
        fault_code: u16::from_le_bytes([data[6], data[7]]),
    })
}
}

练习:硬件抽象层 (HAL) 驱动程序

为假想的、通过 SPI 进行通信的 LED 控制器实现一个 no_std 驱动程序。该驱动程序应对使用 embedded-hal 的任何 SPI 实现进行泛型化。

要求:

  1. 定义一个 LedController<SPI> 结构体。
  2. 实现 new()、set_brightness(led: u8, brightness: u8) 以及 all_off() 函数。
  3. SPI 协议:将 [led_index, brightness_value] 作为 2 字节事务发送。
  4. 使用模拟 (mock) SPI 实现编写测试。
#![allow(unused)]
fn main() {
// 初始代码
#![no_std]
use embedded_hal::spi::SpiDevice;

pub struct LedController<SPI> {
    spi: SPI,
    num_leds: u8,
}

// 待办:实现 new(), set_brightness(), all_off()
// 待办:创建用于测试的 MockSpi
}

参考答案 (点击展开)
#![allow(unused)]
#![no_std]
fn main() {
use embedded_hal::spi::SpiDevice;

pub struct LedController<SPI> {
    spi: SPI,
    num_leds: u8,
}

impl<SPI: SpiDevice> LedController<SPI> {
    pub fn new(spi: SPI, num_leds: u8) -> Self {
        Self { spi, num_leds }
    }

    pub fn set_brightness(&mut self, led: u8, brightness: u8) -> Result<(), SPI::Error> {
        if led >= self.num_leds {
            return Ok(()); // 静默忽略超出范围的 LED
        }
        self.spi.write(&[led, brightness])
    }

    pub fn all_off(&mut self) -> Result<(), SPI::Error> {
        for led in 0..self.num_leds {
            self.spi.write(&[led, 0])?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // 记录所有事务的 Mock SPI
    struct MockSpi {
        transactions: Vec<Vec<u8>>,
    }

    // 针对 mock 的最小化错误类型
    #[derive(Debug)]
    struct MockError;
    impl embedded_hal::spi::Error for MockError {
        fn kind(&self) -> embedded_hal::spi::ErrorKind {
            embedded_hal::spi::ErrorKind::Other
        }
    }

    impl embedded_hal::spi::ErrorType for MockSpi {
        type Error = MockError;
    }

    impl SpiDevice for MockSpi {
        fn write(&mut self, buf: &[u8]) -> Result<(), Self::Error> {
            self.transactions.push(buf.to_vec());
            Ok(())
        }
        fn read(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
        fn transfer(&mut self, _r: &mut [u8], _w: &[u8]) -> Result<(), Self::Error> { Ok(()) }
        fn transfer_in_place(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
        fn transaction(&mut self, _ops: &mut [embedded_hal::spi::Operation<'_, u8>]) -> Result<(), Self::Error> { Ok(()) }
    }

    #[test]
    fn test_set_brightness() {
        let mock = MockSpi { transactions: vec![] };
        let mut ctrl = LedController::new(mock, 4);
        ctrl.set_brightness(2, 128).unwrap();
        assert_eq!(ctrl.spi.transactions, vec![vec![2, 128]]);
    }

    #[test]
    fn test_all_off() {
        let mock = MockSpi { transactions: vec![] };
        let mut ctrl = LedController::new(mock, 3);
        ctrl.all_off().unwrap();
        assert_eq!(ctrl.spi.transactions, vec![
            vec![0, 0], vec![1, 0], vec![2, 0],
        ]);
    }

    #[test]
    fn test_out_of_range_led() {
        let mock = MockSpi { transactions: vec![] };
        let mut ctrl = LedController::new(mock, 2);
        ctrl.set_brightness(5, 255).unwrap(); // 超出范围 —— 被忽略
        assert!(ctrl.spi.transactions.is_empty());
    }
}
}

调试嵌入式 Rust —— probe-rs、defmt 以及 VS Code

C 语言固件开发者通常使用 OpenOCD + GDB 或厂商特定的 IDE(如 Keil、IAR、Segger Ozone)进行调试。Rust 嵌入式生态系统已经收敛于使用 probe-rs 作为统一的调试探针接口,用单一的 Rust 原生工具取代了 OpenOCD + GDB 组合。

probe-rs —— 全能型调试探针工具

probe-rs 取代了 OpenOCD + GDB。它开箱即用地支持 CMSIS-DAP、ST-Link、J-Link 以及其他多种调试探针:

# 安装 probe-rs (包含 cargo-flash 和 cargo-embed)
cargo install probe-rs-tools

# 烧录并运行您的固件
cargo flash --chip STM32F401RE --release

# 烧录、运行并开启 RTT (Real-Time Transfer) 控制台
cargo embed --chip STM32F401RE

probe-rs 对比 OpenOCD + GDB:

特性OpenOCD + GDBprobe-rs
安装2 个独立的包 + 脚本文件cargo install probe-rs-tools
配置每个板卡/探针对应 .cfg 文件--chip 标志或 Embed.toml
控制台输出Semihosting (速度非常慢)RTT (快约 10 倍)
日志框架printfdefmt (结构化,零成本开销)
烧录算法XML 包文件内建支持 1000 多种芯片
GDB 支持原生支持probe-rs gdb 适配器

Embed.toml —— 项目配置

probe-rs 不再需要繁琐的 .cfg 和 .gdbinit 文件,而是使用单一配置:

# Embed.toml —— 放置在项目根目录下
[default.general]
chip = "STM32F401RETx"

[default.rtt]
enabled = true           # 开启 Real-Time Transfer 控制台
channels = [
    { up = 0, mode = "BlockIfFull", name = "Terminal" },
]

[default.flashing]
enabled = true           # 运行前进行烧录
restore_unwritten_bytes = false

[default.reset]
halt_afterwards = false  # 烧录后立即复位并运行

[default.gdb]
enabled = false          # 设为 true 可将 GDB 服务器开启在 :1337 端口
gdb_connection_string = "127.0.0.1:1337"
# 配置好 Embed.toml 后,只需运行:
cargo embed              # 烧录 + RTT 控制台 —— 无需额外参数
cargo embed --release    # Release 版本构建

defmt —— 面向嵌入式日志的延迟格式化

defmt (deferred formatting) 取代了原来的 printf 调试方式。格式化字符串存储在 ELF 文件中,而不是 FLASH 里面。这样,目标芯片在执行日志调用时,仅需发送一个索引 + 对应的参数字节。这使得日志记录速度比 printf 快 10–100 倍,且仅消耗极小比例的 FLASH 空间:

#![no_std]
#![no_main]

use defmt::{info, warn, error, debug, trace};
use defmt_rtt as _; // RTT 传输层 —— 将 defmt 的输出链接至 probe-rs

#[cortex_m_rt::entry]
fn main() -> ! {
    info!("启动完成,固件版本 v{}", env!("CARGO_PKG_VERSION"));

    let sensor_id: u16 = 0x4A;
    let temperature: f32 = 23.5;

    // 格式化字符串存储在 ELF 文件中,而非 FLASH —— 实现近乎于零的运行负担
    debug!("传感器 {:#06X}: {:.1}°C", sensor_id, temperature);

    if temperature > 80.0 {
        warn!("传感器 {:#06X} 过热: {:.1}°C", sensor_id, temperature);
    }

    loop {
        cortex_m::asm::wfi(); // 等待中断
    }
}

// 自定义类型 —— 派生 defmt::Format 而非 Debug
#[derive(defmt::Format)]
struct SensorReading {
    id: u16,
    value: i32,
    status: SensorStatus,
}

#[derive(defmt::Format)]
enum SensorStatus {
    Ok,
    Warning,
    Fault(u8),
}

// 用法示例:
// info!("读取结果:{:?}", reading);  // <-- 使用的是 defmt::Format,而非 std 中的 Debug

defmt 对比 printf 对比 log:

特性C 语言 printf (semihosting)Rust log cratedefmt
速度每次调用约 100msN/A (需要 std)每次调用约 1μs
FLASH 占用包含完整的格式化字符串包含完整的格式化字符串仅索引(字节)
传输方式Semihosting (挂起 CPU)串口/UARTRTT (非阻塞)
结构化输出否仅文本强类型、二进制编码
no_std 支持通过 semihosting 支持仅提供 Facade (后端需要 std)✅ 原生支持
过滤级别手动 #ifdefRUST_LOG=debugdefmt::println + features

VS Code 调试配置

配合 probe-rs 的 VS Code 插件,您可以获得完整的图形化调试体验 —— 包括断点设置、变量检查、调用栈查看以及寄存器视图:

// .vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "probe-rs-debug",
            "request": "launch",
            "name": "烧录并调试 (probe-rs)",
            "chip": "STM32F401RETx",
            "coreConfigs": [
                {
                    "programBinary": "target/thumbv7em-none-eabihf/debug/${workspaceFolderBasename}",
                    "rttEnabled": true,
                    "rttChannelFormats": [
                        {
                            "channelNumber": 0,
                            "dataFormat": "Defmt",
                            "showTimestamps": true
                        }
                    ]
                }
            ],
            "connectUnderReset": true,
            "speed": 4000
        }
    ]
}

安装该插件:

#![allow(unused)]
fn main() {
ext install probe-rs.probe-rs-debugger
}

C 语言调试工作流对比 Rust 嵌入式调试

graph LR
    subgraph "C 语言工作流 (传统方式)"
        C1["编写代码"] --> C2["make flash"]
        C2 --> C3["openocd -f board.cfg"]
        C3 --> C4["arm-none-eabi-gdb<br/>target remote :3333"]
        C5["通过 semihosting 打印 printf<br/>(每次调用约 100ms,会挂起 CPU)"]
        C4 -.-> C5
    end
    
    subgraph "Rust 工作流 (probe-rs)"
        R1["编写代码"] --> R2["cargo embed"]
        R2 --> R3["单条命令完成<br/>烧录并开启 RTT 控制台"]
        R3 --> R4["defmt 实时流式传输日志<br/>(约 1μs)"]
        R2 -.->|"或者"| R5["VS Code F5<br/>全图形化调试器"]
    end
    
    style C5 fill:#ffa07a,color:#000
    style R3 fill:#91e5a3,color:#000
    style R4 fill:#91e5a3,color:#000
    style R5 fill:#91e5a3,color:#000
C 语言调试操作Rust 等效操作
openocd -f board/st_nucleo_f4.cfgprobe-rs info (自动检测探针与芯片)
arm-none-eabi-gdb -x .gdbinitprobe-rs gdb --chip STM32F401RE
target remote :3333GDB 连接至 localhost:1337
monitor reset haltprobe-rs reset --chip ...
load firmware.elfcargo flash --chip ...
printf("debug: %d\n", val) (semihosting)defmt::info!("debug: {}", val) (RTT)
Keil/IAR 图形化调试器VS Code + probe-rs-debugger 插件
Segger SystemViewdefmt + probe-rs RTT 查看器

交叉引用:关于在嵌入式驱动中使用的进阶不安全模式(如引脚投影、自定义 arena/slab 分配器),请参阅配套的《Rust 设计模式 (Rust Patterns)》指南中的“Pin 投影 —— 结构化 Pinning”以及“自定义分配器 —— Arena 与 Slab 模式”章节。


English Original

案例研究概览:C++ 到 Rust 的迁移实战

你将学到: 将约 10 万行 C++ 代码迁移至分布在约 20 个 Crate 中的 9 万行 Rust 代码的实战经验。我们将探讨五种关键的转换模式以及背后的架构决策。

  • 我们将一个大型 C++ 诊断系统(约 10 万行代码)迁移到了 Rust 实现(约 20 个 Crate,9 万行代码)。
  • 本节将展示真实的模式 —— 不是玩具示例,而是生产环境中的代码。
  • 五大关键转型:
#C++ 模式Rust 模式影响
1类继承层次 + dynamic_cast枚举分发 (Enum dispatch) + matchdynamic_cast 从约 400 次降至 0 次
2shared_ptr / enable_shared_from_this 树Arena (池化) + 索引关联彻底消除引用循环
3每个模块中都有 Framework* 原始指针带有生命周期借用的 DiagContext<'a>编译时保证有效性
4上帝对象 (God object)可组合的状态结构体可测试、模块化
5到处都是 vector<unique_ptr<Base>>仅在必要处使用 Trait 对象(约 25 处)默认采用静态分发

迁移前后的指标对比

指标C++ (原始)Rust (重写)
dynamic_cast / 类型向下转型约 400 次0 次
virtual / override 方法约 900 次约 25 次 (Box<dyn Trait>)
原始 new 分配约 200 次0 次 (全部使用所有权类型)
shared_ptr / 引用计数约 10 次 (拓扑库)0 次 (仅在 FFI 边界使用 Arc)
enum class 定义约 60 处约 190 处 pub enum
模式匹配表达式N/A约 750 处 match
上帝对象 (超过 5000 行)2 个0 个

案例研究 1:继承层次 → 枚举分发 (Enum Dispatch)

C++ 模式:事件类继承层次

// C++ 原始代码:每种 GPU 事件类型都是一个继承自 GpuEventBase 的类
class GpuEventBase {
public:
    virtual ~GpuEventBase() = default;
    virtual void Process(DiagFramework* fw) = 0;
    uint16_t m_recordId;
    uint8_t  m_sensorType;
    // ... 通用字段
};

class GpuPcieDegradeEvent : public GpuEventBase {
public:
    void Process(DiagFramework* fw) override;
    uint8_t m_linkSpeed;
    uint8_t m_linkWidth;
};

class GpuPcieFatalEvent : public GpuEventBase { /* ... */ };
class GpuBootEvent : public GpuEventBase { /* ... */ };
// ... 继承自 GpuEventBase 的 10 多个事件类

// 处理事件时需要使用 dynamic_cast:
void ProcessEvents(std::vector<std::unique_ptr<GpuEventBase>>& events,
                   DiagFramework* fw) {
    for (auto& event : events) {
        if (auto* degrade = dynamic_cast<GpuPcieDegradeEvent*>(event.get())) {
            // 处理降速事件...
        } else if (auto* fatal = dynamic_cast<GpuPcieFatalEvent*>(event.get())) {
            // 处理致命事件...
        }
        // ... 10 个以上的分支
    }
}

Rust 解决方案:枚举分发

#![allow(unused)]
fn main() {
// 示例:types.rs —— 无继承、无虚函数表、无 dynamic_cast
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GpuEventKind {
    PcieDegrade,
    PcieFatal,
    PcieUncorr,
    Boot,
    BaseboardState,
    EccError,
    OverTemp,
    PowerRail,
    ErotStatus,
    Unknown,
}
}
#![allow(unused)]
fn main() {
// 示例:manager.rs —— 分离的具名类型 Vec,无需向下转型
pub struct GpuEventManager {
    sku: SkuVariant,
    degrade_events: Vec<GpuPcieDegradeEvent>,   // 具体类型,而非 Box<dyn>
    fatal_events: Vec<GpuPcieFatalEvent>,
    uncorr_events: Vec<GpuPcieUncorrEvent>,
    boot_events: Vec<GpuBootEvent>,
    baseboard_events: Vec<GpuBaseboardEvent>,
    ecc_events: Vec<GpuEccEvent>,
    // ... 每一类事件都有其独立的 Vec 容器
}

// 访问函数返回具体的切片 —— 零歧义
impl GpuEventManager {
    pub fn degrade_events(&self) -> &[GpuPcieDegradeEvent] {
        &self.degrade_events
    }
    pub fn fatal_events(&self) -> &[GpuPcieFatalEvent] {
        &self.fatal_events
    }
}
}

为什么不直接使用 Vec<Box<dyn GpuEvent>>?

  • 错误的方式(生搬硬套):将所有事件放在一个异构集合中,然后再进行向下转型 —— 这正是 C++ 使用 vector<unique_ptr<Base>> 所做的事。
  • 正确的方式:分离的具名 Vec 彻底消除了所有的向下转型。每个使用者仅请求其正是需要的对应事件类型。
  • 性能优势:分离的 Vec 提供更好的缓存局部性(所有的降速事件在内存中都是连续的)。

案例研究 2:shared_ptr 树 → Arena/索引 模式

C++ 模式:引用计数树

// C++ 拓扑库:PcieDevice 使用 enable_shared_from_this 
// 因为父节点和子节点都需要相互引用
class PcieDevice : public std::enable_shared_from_this<PcieDevice> {
public:
    std::shared_ptr<PcieDevice> m_upstream;
    std::vector<std::shared_ptr<PcieDevice>> m_downstream;
    // ... 设备数据
    
    void AddChild(std::shared_ptr<PcieDevice> child) {
        child->m_upstream = shared_from_this();  // 父节点 ↔ 子节点 循环引用!
        m_downstream.push_back(child);
    }
};
// 问题:父→子和子→父引用导致了循环引用。
// 需要 weak_ptr 来打破循环,但很容易被遗忘。

Rust 解决方案:具有索引关联的 Arena

#![allow(unused)]
fn main() {
// 示例:components.rs —— 扁平化的 Vec 拥有所有设备
pub struct PcieDevice {
    pub base: PcieDeviceBase,
    pub kind: PcieDeviceKind,

    // 通过索引建立树形关联 —— 无引用计数、无循环引用
    pub upstream_idx: Option<usize>,      // 指向 arena Vec 中的索引
    pub downstream_idxs: Vec<usize>,      // 指向 arena Vec 中的索引
}

// “arena” 仅仅是由树所拥有的 Vec<PcieDevice>:
pub struct DeviceTree {
    devices: Vec<PcieDevice>,  // 扁平化的所有权 —— 一个 Vec 拥有所有内容
}

impl DeviceTree {
    pub fn parent(&self, device_idx: usize) -> Option<&PcieDevice> {
        self.devices[device_idx].upstream_idx
            .map(|idx| &self.devices[idx])
    }
    
    pub fn children(&self, device_idx: usize) -> Vec<&PcieDevice> {
        self.devices[device_idx].downstream_idxs
            .iter()
            .map(|&idx| &self.devices[idx])
            .collect()
    }
}
}

关键洞察

  • 无需 shared_ptr、weak_ptr 或 enable_shared_from_this。
  • 不可能产生循环引用 —— 索引仅仅是 usize 数值。
  • 更好的缓存性能 —— 所有的设备在内存中都是连续存放的。
  • 更简洁的思维模型 —— 只有一个所有者(Vec),存在多处观察者(索引)。

graph LR
    subgraph "C++ shared_ptr 树"
        A1["shared_ptr<Device>"] -->|"shared_ptr"| B1["shared_ptr<Device>"]
        B1 -->|"shared_ptr (父节点)"| A1
        A1 -->|"shared_ptr"| C1["shared_ptr<Device>"]
        C1 -->|"shared_ptr (父节点)"| A1
        style A1 fill:#ff6b6b,color:#000
        style B1 fill:#ffa07a,color:#000
        style C1 fill:#ffa07a,color:#000
    end

    subgraph "Rust Arena + 索引"
        V["Vec<PcieDevice>"]
        V --> D0["[0] 根节点<br/>上游: None<br/>下游: [1,2]"]
        V --> D1["[1] 子节点<br/>上游: Some(0)<br/>下游: []"]
        V --> D2["[2] 子节点<br/>上游: Some(0)<br/>下游: []"]
        style V fill:#51cf66,color:#000
        style D0 fill:#91e5a3,color:#000
        style D1 fill:#91e5a3,color:#000
        style D2 fill:#91e5a3,color:#000
    end

English Original

案例研究 3:框架通信 → 生命周期借用

你将学到: 如何将 C++ 的原始指针框架通信模式转换为 Rust 基于生命周期的借用系统,在保持零成本抽象的同时彻底消除悬垂指针风险。

C++ 模式:指向框架的原始指针

// C++ 原始代码:每个诊断模块都存储一个指向框架的原始指针
class DiagBase {
protected:
    DiagFramework* m_pFramework;  // 原始指针 —— 谁拥有它?
public:
    DiagBase(DiagFramework* fw) : m_pFramework(fw) {}
    
    void LogEvent(uint32_t code, const std::string& msg) {
        m_pFramework->GetEventLog()->Record(code, msg);  // 希望它还活着!
    }
};
// 问题:m_pFramework 是一个没有生命周期保证的原始指针。
// 如果框架在模块仍引用它时被销毁,将会导致未定义行为 (UB)。

Rust 解决方案:带有生命周期借用的 DiagContext

#![allow(unused)]
fn main() {
// 示例:module.rs —— 借用,而不存储

/// 执行期间传递给诊断模块的上下文。
/// 生命周期 'a 保证了框架的存续时间长于该上下文。
pub struct DiagContext<'a> {
    pub der_log: &'a mut EventLogManager,
    pub config: &'a ModuleConfig,
    pub framework_opts: &'a HashMap<String, String>,
}

/// 模块将上下文作为参数接收 —— 绝不存储框架指针
pub trait DiagModule {
    fn id(&self) -> &str;
    fn execute(&mut self, ctx: &mut DiagContext) -> DiagResult<()>;
    fn pre_execute(&mut self, _ctx: &mut DiagContext) -> DiagResult<()> {
        Ok(())
    }
    fn post_execute(&mut self, _ctx: &mut DiagContext) -> DiagResult<()> {
        Ok(())
    }
}
}

关键洞察

  • C++ 模块存储一个指向框架的指针(危险:如果框架先被销毁了怎么办?)。
  • Rust 模块接收一个作为函数参数的上下文 —— 借用检查器保证了框架在调用期间是存活的。
  • 无原始指针、无生命周期歧义、不再需要“祈祷它还活着”。

案例研究 4:上帝对象 → 可组合的状态

C++ 模式:整体式框架类

// C++ 原始代码:框架即为上帝对象 (God Object)
class DiagFramework {
    // 监控器陷阱处理 (Health-monitor trap processing)
    std::vector<AlertTriggerInfo> m_alertTriggers;
    std::vector<WarnTriggerInfo> m_warnTriggers;
    bool m_healthMonHasBootTimeError;
    uint32_t m_healthMonActionCounter;
    
    // GPU 诊断
    std::map<uint32_t, GpuPcieInfo> m_gpuPcieMap;
    bool m_isRecoveryContext;
    bool m_healthcheckDetectedDevices;
    // ... 30 多个其他 GPU 相关的字段
    
    // PCIe 树
    std::shared_ptr<CPcieTreeLinux> m_pPcieTree;
    
    // 事件日志
    CEventLogMgr* m_pEventLogMgr;
    
    // ... 一系列其他方法
    void HandleGpuEvents();
    void HandleNicEvents();
    void RunGpuDiag();
    // 一切都依赖于一切
};

Rust 解决方案:可组合的状态结构体

#![allow(unused)]
fn main() {
// 示例:main.rs —— 状态被分解为聚焦的子结构体

#[derive(Default)]
struct HealthMonitorState {
    alert_triggers: Vec<AlertTriggerInfo>,
    warn_triggers: Vec<WarnTriggerInfo>,
    health_monitor_action_counter: u32,
    health_monitor_has_boot_time_error: bool,
    // 仅包含监控器相关的字段
}

#[derive(Default)]
struct GpuDiagState {
    gpu_pcie_map: HashMap<u32, GpuPcieInfo>,
    is_recovery_context: bool,
    healthcheck_detected_devices: bool,
    // 仅包含 GPU 相关的字段
}

/// 框架负责组合这些状态,而不是将其全盘扁平化地堆叠在一起
struct DiagFramework {
    ctx: DiagContext,             // 执行上下文
    args: Args,                   // 命令行参数 (CLI)
    pcie_tree: Option<DeviceTree>,  // 无需 shared_ptr
    event_log_mgr: EventLogManager,   // 所有权类型,而非原始指针
    fc_manager: FcManager,        // 故障代码管理
    health: HealthMonitorState,   // 监控状态 —— 分立的结构体
    gpu: GpuDiagState,           // GPU 状态 —— 分立的结构体
}
}

关键洞察

  • 可测试性:每个状态结构体都可以独立地进行单元测试。
  • 可读性:self.health.alert_triggers 对比 m_alertTriggers —— 所有权关系更加清晰。
  • 大胆重构:修改 GpuDiagState 不会意外地影响到监控状态的处理逻辑。
  • 避免臃肿的方法库:仅需要监控状态的函数仅携带 &mut HealthMonitorState 作为参数,而无需整个框架。

案例研究 5:Trait 对象 —— 它们在何时是正确的

  • 并不是所有的东西都应该是枚举!诊断模块插件系统就是一个真正需要使用 Trait 对象的案例。
  • 为什么?因为诊断模块需要对扩展开放 —— 开发者可以在不修改框架核心代码的情况下添加新的模块。
#![allow(unused)]
fn main() {
// 示例:framework.rs —— 在此处使用 Vec<Box<dyn DiagModule>> 是正确的
pub struct DiagFramework {
    modules: Vec<Box<dyn DiagModule>>,        // 运行时多态
    pre_diag_modules: Vec<Box<dyn DiagModule>>,
    event_log_mgr: EventLogManager,
    // ...
}

impl DiagFramework {
    /// 注册诊断模块 —— 任何实现了 DiagModule trait 的类型
    pub fn register_module(&mut self, module: Box<dyn DiagModule>) {
        info!("正在注册模块: {}", module.id());
        self.modules.push(module);
    }
}
}

何时使用何种模式

用例模式原因
编译器已知的固定变体集合enum + match完备性检查,无虚函数表开销
硬件事件类型(降级、致命、引导……)enum GpuEventKind所有变体均为已知,注重性能
PCIe 设备类型(GPU、网卡、交换机……)enum PcieDeviceKind集合固定,每个变体包含不同数据
插件/模块系统(针对扩展开放)Box<dyn Trait>无需修改框架即可添加新模块
测试模拟 (Mocking)Box<dyn Trait>注入测试替身

练习:翻译前的思考

给定如下 C++ 代码:

class Shape { public: virtual double area() = 0; };
class Circle : public Shape { double r; double area() override { return 3.14*r*r; } };
class Rect : public Shape { double w, h; double area() override { return w*h; } };
std::vector<std::unique_ptr<Shape>> shapes;

问题:在 Rust 翻译中应该使用 enum Shape 还是 Vec<Box<dyn Shape>>?

答案 (点击展开)

答案:应该使用 enum Shape —— 因为形状的集合是封闭的(在编译时已知)。只有当用户可以在运行时添加新的形状类型时,才需要使用 Box<dyn Shape>。

// 正确的 Rust 翻译:
enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { r } => std::f64::consts::PI * r * r,
            Shape::Rect { w, h } => w * h,
        }
    }
}

fn main() {
    let shapes: Vec<Shape> = vec![
        Shape::Circle { r: 5.0 },
        Shape::Rect { w: 3.0, h: 4.0 },
    ];
    for shape in &shapes {
        println!("面积: {:.2}", shape.area());
    }
}
// 输出:
// 面积: 78.54
// 面积: 12.00

迁移指标与经验总结

我们的收获

  1. 优先使用枚举分发 —— 在 10 万行 C++ 代码中,仅有约 25 处真正需要使用 Box<dyn Trait>(如插件系统、测试模拟)。其余约 900 处虚函数都转为了带有模式匹配的枚举。
  2. Arena 模式消除了引用循环 —— shared_ptr 和 enable_shared_from_this 是权责不明的所有权的典型症状。首先思考谁拥有数据。
  3. 传递上下文,而非存储指针 —— 带有生命周期限制的 DiagContext<'a> 比在每个模块中存储 Framework* 原始指针更加安全且清晰。
  4. 分解上帝对象 —— 如果一个结构体拥有 30 多个字段,它很可能是三四个结构体套在一起的产物。
  5. 编译器是你的结对编程伙伴 —— 约 400 次 dynamic_cast 调用意味着约 400 次潜在的运行时失败。在 Rust 中实现零 dynamic_cast 等效项意味着零运行时类型错误。

最困难的部分

  • 生命周期标注:当你习惯了原始指针后,正确处理借用需要一些时间 —— 但一旦代码通过编译,它就是正确的。
  • 与借用检查器“搏斗”:想要在两处地方同时使用 &mut self。解决方案:将状态分解为独立的结构体。
  • 抵制生搬硬套式的翻译:容易禁不住诱惑在各处都写上 Vec<Box<dyn Base>>。问问自己:“这个变体的集合是封闭的吗?” → 如果是,请使用枚举。

给 C++ 团队的建议

  1. 从一个小型的、自包含的模块开始(不要从上帝对象开始)。
  2. 先翻译数据结构,再翻译行为。
  3. 让编译器引导你 —— 它的错误信息非常出色。
  4. 在使用 dyn Trait 之前,先行尝试使用 enum。
  5. 在集成之前,使用 Rust playground 对模式进行原型设计。

English Original

Rust 最佳实践总结

你将学到: 编写地道 Rust 代码的实用指南 —— 包括代码组织、命名规范、错误处理模式以及文档编写。这是一个你会经常回顾的快速参考章节。

代码组织

  • 优先编写短小精悍的函数:易于测试和理解。
  • 使用描述性的名称:例如使用 calculate_total_price() 而非 calc()。
  • 对相关功能进行分组:利用模块 (Modules) 和独立文件进行组织。
  • 编写文档:为公共 API 使用 /// 编写文档注释。

错误处理

  • 除非确定万无一失,否则避免使用 unwrap():仅当你 100% 确定不会发生 panic 时才使用它。
#![allow(unused)]
fn main() {
// 不良实践:可能会引发 panic
let value = some_option.unwrap();

// 良好实践:处理 None 的情况
let value = some_option.unwrap_or(default_value);
let value = some_option.unwrap_or_else(|| expensive_computation());
let value = some_option.unwrap_or_default(); // 使用 Default trait

// 对于 Result<T, E>
let value = some_result.unwrap_or(fallback_value);
let value = some_result.unwrap_or_else(|err| {
    eprintln!("发生错误: {err}");
    default_value
});
---

- **使用带有描述性信息的 `expect()`**:当使用 unwrap 是合理的时候,请解释其原因。
```rust
let config = std::env::var("CONFIG_PATH")
    .expect("必须设置 CONFIG_PATH 环境变量");
}
  • 为可能失败的操作返回 Result<T, E>:由调用者决定如何处理错误。
  • 针对自定义错误类型使用 thiserror:比手动实现更加符合工效学。
#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("IO 错误: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("解析错误: {message}")]
    Parse { message: String },
    
    #[error("数值 {value} 超出范围")]
    OutOfRange { value: i32 },
}
}
  • 利用 ? 运算符链式处理错误:将错误沿调用栈向上传递。
  • 优先选择 thiserror 而非 anyhow:我们团队的惯例是定义显式的带有 #[derive(thiserror::Error)] 的错误枚举,以便调用者可以对具体的变体进行模式匹配。anyhow::Error 虽然在快速原型设计时非常方便,但它会抹除错误的类型信息,导致调用者难以处理特定的故障。在库文件和生产环境代码中,请使用 thiserror;将 anyhow 留给临时的脚本或是仅需打印错误信息的顶层二进制文件。
  • 何时使用 unwrap() 是可以接受的:
    • 单元测试:assert_eq!(result.unwrap(), expected)
    • 原型设计:后期会被替换的临时代码。
    • 确定的无误操作:当你能证明操作绝不会失败时。
#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3];
let first = numbers.get(0).unwrap(); // 安全:我们刚刚创建了带有元素的 vec

// 更好的方式:使用带有解释的 expect()
let first = numbers.get(0).expect("依构造可知 numbers vec 非空");
}
  • 尽早失败 (Fail fast):尽早检查前置条件并立即返回错误。

内存管理

  • 优先使用借用而非克隆:只要可能,尽量使用 &T 而非克隆 (clone)。
  • 谨慎使用 Rc<T>:仅在你真正需要共享所有权时才使用它。
  • 限制生命周期范围:利用代码块 {} 显式控制值的销毁时机。
  • 避免在公开 API 中使用 RefCell<T>:将内部可变性保持在内部实现。

性能

  • 优化前先进行基准测试:使用 cargo bench 和性能剖析工具。
  • 优先使用迭代器而非循环:更具可读性且通常性能更佳。
  • 优先使用 &str 而非 String:当你不需要拥有所有权时。
  • 为大型栈对象考虑使用 Box<T>:如有必要,将其移动至堆空间。

必须实现的必备 Trait

每个类型都应考虑实现的核心 Trait

在创建自定义类型时,请考虑实现以下基础 Trait,使你的类型在 Rust 中显得更加“地道”:

Debug 和 Display

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug)]  // 自动生成以便于调试
struct Person {
    name: String,
    age: u32,
}

// 手动实现 Display 以获得面向用户的输出
impl fmt::Display for Person {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} (年龄 {})", self.name, self.age)
    }
}

// 用法示例:
let person = Person { name: "Alice".to_string(), age: 30 };
println!("{:?}", person);  // 调试辅助: Person { name: "Alice", age: 30 }
println!("{}", person);    // 用户显示: Alice (年龄 30)
}

Clone 和 Copy

#![allow(unused)]
fn main() {
// Copy: 对小型简单类型进行隐式复制
#[derive(Debug, Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

// Clone: 对复杂类型进行显式复制
#[derive(Debug, Clone)]
struct Person {
    name: String,  // String 未实现 Copy
    age: u32,
}

let p1 = Point { x: 1, y: 2 };
let p2 = p1;  // Copy (隐式)

let person1 = Person { name: "Bob".to_string(), age: 25 };
let person2 = person1.clone();  // Clone (显式)
}

PartialEq 和 Eq

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
struct UserId(u64);

#[derive(Debug, PartialEq)]
struct Temperature {
    celsius: f64,  // 由于 NaN 的存在,f64 未实现 Eq
}

let id1 = UserId(123);
let id2 = UserId(123);
assert_eq!(id1, id2);  // 由于实现 PartialEq,此处成立

let temp1 = Temperature { celsius: 20.0 };
let temp2 = Temperature { celsius: 20.0 };
assert_eq!(temp1, temp2);  // 由于实现 PartialEq,此处成立
}

PartialOrd 和 Ord

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Priority(u8);

let high = Priority(1);
let low = Priority(10);
assert!(high < low);  // 数值越小 = 优先级越高

// 用于集合
let mut priorities = vec![Priority(5), Priority(1), Priority(8)];
priorities.sort();  // 由于 Priority 实现了 Ord,此处成立
}

Default

#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
struct Config {
    debug: bool,           // false (默认值)
    max_connections: u32,  // 0 (默认值)
    timeout: Option<u64>,  // None (默认值)
}

// 手动实现 Default
impl Default for Config {
    fn default() -> Self {
        Config {
            debug: false,
            max_connections: 100,  // 自定义默认值
            timeout: Some(30),     // 自定义默认值
        }
    }
}

let config = Config::default();
let config = Config { debug: true, ..Default::default() };  // 部分属性覆盖
}

From 和 Into

#![allow(unused)]
fn main() {
struct UserId(u64);
struct UserName(String);

// 实现 From 后,Into 将被自动实现
impl From<u64> for UserId {
    fn from(id: u64) -> Self {
        UserId(id)
    }
}

impl From<String> for UserName {
    fn from(name: String) -> Self {
        UserName(name)
    }
}

impl From<&str> for UserName {
    fn from(name: &str) -> Self {
        UserName(name.to_string())
    }
}

// 用法示例:
let user_id: UserId = 123u64.into();         // 使用 Into
let user_id = UserId::from(123u64);          // 使用 From
let username = UserName::from("alice");      // 从 &str 到 UserName
let username: UserName = "bob".into();       // 使用 Into
}

TryFrom 和 TryInto

#![allow(unused)]
fn main() {
use std::convert::TryFrom;

struct PositiveNumber(u32);

#[derive(Debug)]
struct NegativeNumberError;

impl TryFrom<i32> for PositiveNumber {
    type Error = NegativeNumberError;
    
    fn try_from(value: i32) -> Result<Self, Self::Error> {
        if value >= 0 {
            Ok(PositiveNumber(value as u32))
        } else {
            Err(NegativeNumberError)
        }
    }
}

// 用法示例:
let positive = PositiveNumber::try_from(42)?;     // Ok(PositiveNumber(42))
let error = PositiveNumber::try_from(-5);         // Err(NegativeNumberError)
}

Serde (用于序列化)

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
    email: String,
}

// 自动进行 JSON 序列化/反序列化
let user = User {
    id: 1,
    name: "Alice".to_string(),
    email: "[email protected]".to_string(),
};

let json = serde_json::to_string(&user)?;
let deserialized: User = serde_json::from_str(&json)?;
}

Trait 实现清单

对于任意新类型,请考虑以下清单:

#![allow(unused)]
fn main() {
#[derive(
    Debug,          // [推荐] 对调试始终实现
    Clone,          // [可选] 如果该类型应该是可复制的
    PartialEq,      // [可选] 如果该类型应该是可比较的
    Eq,             // [可选] 如果比较满足自反性/传递性
    PartialOrd,     // [可选] 如果该类型具有顺序关系
    Ord,            // [可选] 如果该类型具有全序关系
    Hash,           // [可选] 如果该类型将用作 HashMap 的键 (Key)
    Default,        // [可选] 如果该类型存在合理的默认值
)]
struct MyType {
    // 字段...
}

// 考虑进行手动实现的 Trait:
impl Display for MyType { /* 面向用户的表现形式 */ }
impl From<OtherType> for MyType { /* 便捷转换 */ }
impl TryFrom<FallibleType> for MyType { /* 可能失败的转换 */ }
}

何时不应实现某些 Trait

  • 不要为持有堆数据的类型实现 Copy:如 String、Vec、HashMap 等。
  • 如果值可能为 NaN,则不要实现 Eq:包含 f32/f64 的类型。
  • 如果没有合理的默认值,则不要实现 Default:如文件句柄、网络连接。
  • 如果克隆开销巨大,则不要实现 Clone:大型数据结构(此时考虑使用 Rc<T> 替代)。

总结:Trait 的收益

Trait收益何时使用
Debugprintln!("{:?}", value)几乎总是(极少数除外)
Displayprintln!("{}", value)面向用户的类型
Clonevalue.clone()显式复制有意义时
Copy隐式复制小型、简单类型
PartialEq== 和 != 运算符大多数类型
Eq满足自反性的相等相等具有数学健全性时
PartialOrd<, >, <=, >=具有自然顺序的类型
Ordsort(), BinaryHeap全序关系成立时
HashHashMap 的键类型需作为 Map 的键时
DefaultDefault::default()有明显默认值的类型
From/Into便捷转换常用的类型转换
TryFrom/TryInto可能失败的转换转换逻辑可能失败时

避免过度使用 clone()

English Original

避免过度使用 clone()

你将学到: 为什么在 Rust 中使用 .clone() 可能是一种“代码异味 (Code Smell)”、如何重构所有权以消除不必要的拷贝,以及哪些特定模式暗示了所有权设计存在问题。

  • 对于从 C++ 转来的开发者,.clone() 往往感觉像是一个稳妥的默认选项 —— “直接拷贝一份就行”。然而,过度克隆会掩盖所有权设计上的缺陷,并损害程序性能。
  • 经验法则:如果你克隆是为了取悦借用检查器,那么你可能需要重构所有权结构,而不是直接克隆。

何时使用 clone() 是错误的

#![allow(unused)]
fn main() {
// 错误写法:克隆一个 String,仅仅是为了将其传递给一个只需读取它的函数
fn log_message(msg: String) {  // 不必要地夺取了所有权
    println!("[LOG] {}", msg);
}
let message = String::from("GPU 测试通过");
log_message(message.clone());  // 浪费:分配了一个全新的 String
log_message(message);           // 原始值被消耗 —— 前面的克隆毫无意义
}
#![allow(unused)]
fn main() {
// 正确写法:接受一个借用 —— 零分配
fn log_message(msg: &str) {    // 借用,而不拥有
    println!("[LOG] {}", msg);
}
let message = String::from("GPU 测试通过");
log_message(&message);          // 无克隆,无分配
---

## 真实示例:返回 `&str` 而非克隆
```rust
// 示例:healthcheck.rs —— 返回借用的视图,零分配
pub fn serial_or_unknown(&self) -> &str {
    self.serial.as_deref().unwrap_or(UNKNOWN_VALUE)
}

pub fn model_or_unknown(&self) -> &str {
    self.model.as_deref().unwrap_or(UNKNOWN_VALUE)
}
}

在 C++ 中,等效的操作通常会返回 const std::string& 或 std::string_view —— 然而在 C++ 中,这两者都没有经过生命周期检查。而在 Rust 中,借用检查器保证了返回的 &str 绝不会比 self 存活得更久。

真实示例:静态字符串切片 —— 彻底避免堆分配

#![allow(unused)]
fn main() {
// 示例:healthcheck.rs —— 编译期字符串表
const HBM_SCREEN_RECIPES: &[&str] = &[
    "hbm_ds_ntd", "hbm_ds_ntd_gfx", "hbm_dt_ntd", "hbm_dt_ntd_gfx",
    "hbm_burnin_8h", "hbm_burnin_24h",
];
}

在 C++ 中,这通常会是 std::vector<std::string>(在第一次使用时在堆中分配内存)。而在 Rust 中,&'static [&'static str] 存储在只读内存中 —— 运行时开销为零。

何时 clone() 确实 适用

场景为什么克隆是可以接受的示例
多线程中的 Arc::clone()仅增加引用计数(耗时约 1 纳秒),而不拷贝数据let flag = stop_flag.clone();
将数据移动到新生成的线程线程需要持有其自身的一份独立副本let ctx = ctx.clone(); thread::spawn(move || { ... })
从 &self 字段中提取数据无法从借用中移动数据在需要返回拥有所有权的 String 时使用 self.name.clone()
包装在 Option 中的小型 Copy 类型使用 .copied() 比 .clone() 语义更清晰opt.get(0).copied() 可将 Option<&u32> 转为 Option<u32>

真实示例:用于线程共享的 Arc::clone

#![allow(unused)]
fn main() {
// 示例:workload.rs —— Arc::clone 极其轻量(仅增加引用计数)
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_flag_clone = stop_flag.clone();   // 耗时约 1 纳秒,无数据拷贝
let ctx_clone = ctx.clone();               // 为移动至线程而克隆上下文

let sensor_handle = thread::spawn(move || {
    // ... 在此处使用 stop_flag_clone 和 ctx_clone
});
}

检查清单:我是否需要克隆?

  1. 我是否能接受使用 &str / &T 来替代 String / T? → 应尽量借用而不是克隆。
  2. 能否重构代码以避免共用所有权? → 尽量通过引用传递或在代码块中限定作用域。
  3. 这是 Arc::clone() 吗? → 这是可以的,其算法复杂度为常数级 O(1)。
  4. 我需要将数据移动到线程或闭包中吗? → 这种情况下克隆是必需的。
  5. 我是不是在性能热点或循环内部进行克隆? → 请进行性能评估,并考虑改为借用或使用写时复制(Cow<T>)。

Cow<'a, T>: 写时复制 (Clone-on-Write) —— 尽可能借用,必要时克隆

Cow (Clone-on-Write) 是一个枚举类型,它可以持有一个借用的引用,或者持有一个拥有的值。它是 Rust 中的内置类型,代表了“尽可能避免分配内存,但如果需要修改,则分配内存”的策略。C++ 中没有直接的等价实现 —— 最接近的做法是根据情况有时返回 const std::string&,有时返回 std::string 的函数。

为什么需要 Cow

#![allow(unused)]
fn main() {
// 如果不使用 Cow —— 你必须二选一:总是借用,或者总是克隆
fn normalize(s: &str) -> String {          // 总是会进行内存分配!
    if s.contains(' ') {
        s.replace(' ', "_")               // 生成新的 String(需要分配内存)
    } else {
        s.to_string()                     // 做了不必要的分配!
    }
}

// 使用 Cow —— 仅在修改时才进行分配,其余情况仅借用
use std::borrow::Cow;

fn normalize(s: &str) -> Cow<'_, str> {
    if s.contains(' ') {
        Cow::Owned(s.replace(' ', "_"))    // 发生了修改,必须分配内存
    } else {
        Cow::Borrowed(s)                   // 零分配,直接透传引用
    }
}
}

Cow 是如何运作的

use std::borrow::Cow;

// Cow<'a, str> 核心逻辑等效于如下定义:
// enum Cow<'a, str> {
//     Borrowed(&'a str),     // 零拷贝的引用
//     Owned(String),          // 属于该变量的 String (位于堆上)
// }

fn greet(name: &str) -> Cow<'_, str> {
    if name.is_empty() {
        Cow::Borrowed("陌生人")             // 静态字符串 —— 零分配
    } else if name.starts_with(' ') {
        Cow::Owned(name.trim().to_string()) // 发生了修剪 —— 需要分配内存
    } else {
        Cow::Borrowed(name)                 // 直接透传 —— 零分配
    }
}

fn main() {
    let g1 = greet("Alice");     // Cow::Borrowed("Alice")
    let g2 = greet("");          // Cow::Borrowed("陌生人")
    let g3 = greet(" Bob ");     // Cow::Owned("Bob")
    
    // Cow<str> 实现了 Deref<Target = str>,因此你可以像对待 &str 那样使用它:
    println!("你好, {g1}!");    // 正常工作 —— Cow 自动解引用为 &str
    println!("你好, {g2}!");
    println!("你好, {g3}!");
}

现实应用场景:配置信息规范化

use std::borrow::Cow;

/// 规范化 SKU 名称:修整两端空格并转为小写。
/// 如果已经是规范化后的,则返回 Cow::Borrowed (零分配)。
fn normalize_sku(sku: &str) -> Cow<'_, str> {
    let trimmed = sku.trim();
    if trimmed == sku && sku.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) {
        Cow::Borrowed(sku)   // 已是规范化状态 —— 零分配
    } else {
        Cow::Owned(trimmed.to_lowercase())  // 需要修改 —— 重新分配内存
    }
}

fn main() {
    let s1 = normalize_sku("server-x1");   // Borrowed —— 零分配
    let s2 = normalize_sku("  Server-X1 "); // Owned —— 必须分配
    println!("{s1}, {s2}"); // "server-x1, server-x1"
}

何时使用 Cow

场景是否使用 Cow?
大部分情况下函数都会原样返回输入值✅ 是 —— 避免不必要的克隆
解析/规范化字符串(修剪、小写、替换等)✅ 是 —— 通常输入已经是目标状态
每一条代码路径都会进行修改并导致分配❌ 否 —— 直接返回 String 即可
简单的透传(从未发生修改)❌ 否 —— 直接返回 &str 即可
需要将数据长期存储在结构体中❌ 否 —— 直接使用拥有所有权的 String

C++ 开发者的类比:Cow<str> 就像是一个返回 std::variant<std::string_view, std::string> 的函数 —— 只不过它具备自动解解引用机制,访问其值时无需编写任何繁琐的样板代码。


Weak<T>: 打破引用循环 —— Rust 中的 weak_ptr

Weak<T> 是 Rust 中与 C++ 的 std::weak_ptr<T> 等效的类型。它持有一个指向 Rc<T> 或 Arc<T> 值的非拥有引用。即便 Weak 引用仍然存在,其指向的值也可以被销毁 —— 如果目标值已不存在,调用 upgrade() 将返回 None。

为什么需要 Weak

如果两个值相互指向对方,Rc<T> 和 Arc<T> 就会产生引用循环 —— 导致两者的引用计数永远无法归零,从而无法被释放(即产生内存泄漏)。Weak 可以有效地打破这种循环:

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: String,
    parent: RefCell<Weak<Node>>,      // Weak —— 不会阻止父节点被释放
    children: RefCell<Vec<Rc<Node>>>,  // Strong —— 父节点拥有子节点的所有权
}

impl Node {
    fn new(value: &str) -> Rc<Node> {
        Rc::new(Node {
            value: value.to_string(),
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(Vec::new()),
        })
    }

    fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
        // 子节点获得指向父节点的弱引用(无循环引用)
        *child.parent.borrow_mut() = Rc::downgrade(parent);
        // 父节点获得指向子节点的强引用
        parent.children.borrow_mut().push(Rc::clone(child));
    }
}

fn main() {
    let root = Node::new("根节点");
    let child = Node::new("子节点");
    Node::add_child(&root, &child);

    // 通过 upgrade() 从子节点访问父节点
    if let Some(parent) = child.parent.borrow().upgrade() {
        println!("子节点的父节点: {}", parent.value); // "根节点"
    }
    
    println!("根节点强引用计数: {}", Rc::strong_count(&root));  // 1
    println!("根节点弱引用计数: {}", Rc::weak_count(&root));      // 1
}

C++ 对比

// C++ — 使用 weak_ptr 打破 shared_ptr 循环引用
struct Node {
    std::string value;
    std::weak_ptr<Node> parent;                  // Weak — 无所有权
    std::vector<std::shared_ptr<Node>> children;  // Strong — 拥有子节点
    
    static auto create(const std::string& v) {
        return std::make_shared<Node>(Node{v, {}, {}});
    }
};

auto root = Node::create("root");
auto child = Node::create("child");
child->parent = root;          // weak_ptr 赋值
root->children.push_back(child);

if (auto p = child->parent.lock()) {   // lock() → 得到 shared_ptr 或空值
    std::cout << "Parent: " << p->value << std::endl;
}
C++Rust说明
shared_ptr<T>Rc<T> (单线程) / Arc<T> (多线程)语义相同
weak_ptr<T>通过 Rc::downgrade() / Arc::downgrade() 获得的 Weak<T>语义相同
weak_ptr::lock() → shared_ptr 或空Weak::upgrade() → Option<Rc<T>>如果已释放则返回 None
shared_ptr::use_count()Rc::strong_count()含义相同

何时使用 Weak

场景模式
父 ↔ 子 树形关系父节点持有 Rc<Child>,子节点持有 Weak<Parent>
观察者模式 / 事件监听器事件源持有 Weak<Observer>,观察者持有 Rc<Source>
不阻碍释放的缓存HashMap<Key, Weak<Value>> —— 条目会自然过期
打破图结构中的循环交叉链接使用 Weak,树边使用 Rc/Arc

提示:在编写新代码时,相较于 Rc/Weak,更推荐使用 Arena 模式(参见案例研究 2)来构建树形结构。Vec<T> + 索引的方式更简单、更快速,且具备零引用计数开销。仅当你确实需要具有动态生命周期的共享所有权时,才使用 Rc/Weak。


Copy 对比 Clone,PartialEq 对比 Eq —— 应在何时派生哪些 Trait

  • Copy ≈ C++ 的平凡可复制 (Trivially Copyable,无自定义拷贝构造函数/析构函数)。对于如 int、enum 以及简单的 POD 结构体,编译器会自动生成按位拷贝的 memcpy。在 Rust 中,Copy 的理念也是如此:赋值操作 let b = a; 会执行隐式的按位拷贝,且两个变量在此后依然有效。
  • Clone ≈ C++ 的拷贝构造函数 / operator= 深拷贝。当一个 C++ 类拥有自定义拷贝构造函数(例如深拷贝一个 std::vector 成员)时,Rust 中的等效做法是实现 Clone。你必须显式调用 .clone() —— Rust 绝不会将开销巨大的拷贝操作掩盖在 = 赋值符号之后。
  • 关键区别:在 C++ 中,平凡拷贝和深拷贝都通过相同的 = 语法隐式发生。而 Rust 迫使你进行选择:Copy 类型会默默拷贝(开销极低),非 Copy 类型默认会执行 移动 (Move) 语义,你必须通过 .clone() 显式选择执行开销巨大的数据副本。
  • 类似地,C++ 的 operator== 并不区分 a == a 总是成立的类型(如整数)和不成立的类型(如带有 NaN 的浮点数)。Rust 在 PartialEq 与 Eq 中对这种区别进行了编码。

Copy 对比 Clone

CopyClone
工作原理按位 memcpy (隐式发生)自定义逻辑 (显式调用 .clone())
发生时机赋值时:let b = a;仅当你显式调用 .clone() 时
拷贝/克隆后a 和 b 均保持有效a 和 b 均保持有效
不具备两者时let b = a; 会移动 a (a 变无效)let b = a; 会移动 a (a 变无效)
适用范围不持有堆数据的类型任何类型
C++ 类比平凡可复制 / POD 类型 (无自定义拷贝构造)自定义拷贝构造函数 (深拷贝)

真实示例:Copy —— 简单枚举

#![allow(unused)]
fn main() {
// 摘自 fan_diag/src/sensor.rs —— 均为单元变体,占用 1 字节
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FanStatus {
    #[default]
    Normal,
    Low,
    High,
    Missing,
    Failed,
    Unknown,
}

let status = FanStatus::Normal;
let copy = status;   // 隐式拷贝 —— status 依然有效
println!("{:?} {:?}", status, copy);  // 两者均可正常使用
}

真实示例:Copy —— 带有整数负载的枚举

#![allow(unused)]
fn main() {
// 示例:healthcheck.rs —— u32 负载支持 Copy,因此整个枚举也支持
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthcheckStatus {
    Pass,
    ProgramError(u32),
    DmesgError(u32),
    RasError(u32),
    OtherError(u32),
    Unknown,
}
}

真实示例:仅限 Clone —— 持有堆数据的结构体

#![allow(unused)]
fn main() {
// 示例:components.rs —— String 类型导致其无法支持 Copy
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FruData {
    pub technology: DeviceTechnology,
    pub physical_location: String,      // ← String 类型位于堆上,无法进行 Copy
    pub expected: bool,
    pub removable: bool,
}
// let a = fru_data;   → 会发生移动 (move),原变量 fru_data 变得无效
// let a = fru_data.clone();  → 克隆操作 (fru_data 依然有效,同时发生了新的堆内存分配)
}

判定准则:是否可以支持 Copy?

该类型是否包含 String、Vec、Box、HashMap、
Rc、Arc 或任何其他持有堆内存所有权的类型?
    是 → 仅限 Clone (无法支持 Copy)
    否 → 你可以派生 Copy Trait (如果类型占用空间较小,建议派生)

PartialEq 对比 Eq

PartialEqEq
提供的功能== 和 != 运算符标记 Trait:“相等关系满足自反性”
自反性?(a == a)不保证保证
重要性何在f32::NAN != f32::NANHashMap 的键 必须 实现 Eq
何时派生几乎所有类型当类型不包含 f32/f64 字段时
C++ 类比operator==无直接对应项 (C++ 不进行此类检查)

真实示例:Eq —— 用作 HashMap 的键

#![allow(unused)]
fn main() {
// 摘自 hms_trap/src/cpu_handler.rs —— Hash 要求实现 Eq
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CpuFaultType {
    InvalidFaultType,
    CpuCperFatalErr,
    CpuLpddr5UceErr,
    CpuC2CUceFatalErr,
    // ...
}
// 用法:HashMap<CpuFaultType, FaultHandler>
// HashMap 的键必须同时实现 Eq + Hash —— 仅实现 PartialEq 将无法通过编译
}

真实示例:无法实现 Eq —— 类型包含 f32

#![allow(unused)]
fn main() {
// 示例:types.rs —— f32 阻碍了 Eq 的实现
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TemperatureSensors {
    pub warning_threshold: Option<f32>,   // ← f32 存在 NaN ≠ NaN 的情况
    pub critical_threshold: Option<f32>,  // ← 无法派生 Eq
    pub sensor_names: Vec<String>,
}
// 该类型无法用作 HashMap 的键。无法派生 Eq。
// 原因:f32::NAN == f32::NAN 的结果为 false,违反了自反性。
}

PartialOrd 对比 Ord

PartialOrdOrd
提供的功能<, >, <=, >= 运算符.sort(), BTreeMap 的键
全序关系?否(某些值可能无法比较)是(任意两个值均可比较)
f32/f64?仅支持 PartialOrd (NaN 会破坏顺序)无法派生 Ord

真实示例:Ord —— 严重程度排序

#![allow(unused)]
fn main() {
// 摘自 hms_trap/src/fault.rs —— 变体的顺序决定了严重程度
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FaultSeverity {
    Info,      // 最低 (判别值为 0)
    Warning,   //      (判别值为 1)
    Error,     //      (判别值为 2)
    Critical,  // 最高 (判别值为 3)
}
// FaultSeverity::Info < FaultSeverity::Critical → true
// 使得如下逻辑成为可能:if severity >= FaultSeverity::Error { escalate(); }
}

真实示例:Ord —— 用于比较的诊断级别

#![allow(unused)]
fn main() {
// 示例:orchestration.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum GpuDiagLevel {
    #[default]
    Quick,     // 最低
    Standard,
    Extended,
    Full,      // 最高
}
// 使得如下逻辑成为可能:if requested_level >= GpuDiagLevel::Extended { run_extended_tests(); }
}

派生决策树

                        你的新类型
                             │
                   是否包含 String/Vec/Box?
                       /              \
                     是                否
                     │                  │
               仅限 Clone          Clone + Copy
                     │                  │
               是否包含 f32/f64?    是否包含 f32/f64?
                 /          \         /          \
               是           否       是           否
               │             │      │             │
         仅派生             派生    仅派生         派生
         PartialEq       PartialEq  PartialEq  PartialEq
         只有其一         + Eq       只有其一    + Eq
                           │                      │
                     是否需要排序?           是否需要排序?
                       /       \               /       \
                     是        否             是        否
                     │          │              │          │
               PartialOrd      完成        PartialOrd    完成
               + Ord                     + Ord
                     │                        │
               是否需要用作             是否需要用作
               Map 的键?                 Map 的键?
                   │                        │
                 + Hash                   + Hash

快速参考:生产环境 Rust 代码中的常见派生组合

类型类别典型派生组合示例
简单的状态枚举Copy, Clone, PartialEq, Eq, DefaultFanStatus
用作 HashMap 键的枚举Copy, Clone, PartialEq, Eq, HashCpuFaultType, SelComponent
可排序的严重程度枚举Copy, Clone, PartialEq, Eq, PartialOrd, OrdFaultSeverity, GpuDiagLevel
持有 String 的数据结构体Clone, Debug, Serialize, DeserializeFruData, OverallSummary
可序列化的配置信息Clone, Debug, Default, Serialize, DeserializeDiagConfig

避免未检查的索引访问

English Original

避免未检查的索引访问

你将学到: 为什么在 Rust 中使用 vec[i] 是危险的(越界会引发 panic)、以及安全的替代方案如 .get()、迭代器和 HashMap 的 entry() API。用显式的处理方式取代 C++ 中的未定义行为。

  • 在 C++ 中,vec[i] 和 map[key] (在键缺失时自动插入)往往伴随着未定义行为。而 Rust 的 [] 在发生越界时会直接引发 panic。
  • 准则:除非你能证明索引始终有效,否则请优先使用 .get() 而非 []。

C++ 与 Rust 的对比

// C++ —— 默默发生的未定义行为 (UB) 或自动插入
std::vector<int> v = {1, 2, 3};
int x = v[10];        // UB!使用 operator[] 不进行边界检查

std::map<std::string, int> m;
int y = m["missing"]; // 默默地插入键,并赋予默认值 0!
#![allow(unused)]
fn main() {
// Rust —— 安全替代方案
let v = vec![1, 2, 3];

// 错误做法:如果索引越界会引发 panic
// let x = v[10];

// 正确做法:返回 Option<&i32>
let x = v.get(10);              // 返回 None —— 不会引发 panic
---

## 真实示例:生产代码中的安全字节解析
```rust
// 示例:diagnostics.rs
// 解析二进制 SEL 记录 —— 缓冲区长度可能短于预期
let sensor_num = bytes.get(7).copied().unwrap_or(0);
let ppin = cpu_ppin.get(i).map(|s| s.as_str()).unwrap_or("");
}

真实示例:使用 .and_then() 进行链式安全查考

#![allow(unused)]
fn main() {
// 示例:profile.rs —— 双重查找:HashMap → Vec
pub fn get_processor(&self, location: &str) -> Option<&Processor> {
    self.processor_by_location
        .get(location)                              // HashMap → Option<&usize>
        .and_then(|&idx| self.processors.get(idx))   // Vec → Option<&Processor>
}
// 两次查考均返回 Option —— 无 panic,无未定义行为
}

真实示例:安全的 JSON 导航

#![allow(unused)]
fn main() {
// 示例:framework.rs —— 每一个 JSON 键均返回 Option
let manufacturer = product_fru
    .get("Manufacturer")            // Option<&Value>
    .and_then(|v| v.as_str())       // Option<&str>
    .unwrap_or(UNKNOWN_VALUE)       // &str (安全回退值)
    .to_string();
}

对比 C++ 模式:json["SystemInfo"]["ProductFru"]["Manufacturer"] —— 任何键的缺失都会抛出 nlohmann::json::out_of_range 异常。

何时使用 [] 是可以接受的

  • 在边界检查之后:if i < v.len() { v[i] }
  • 在单元测试中:此时 panic 是预期的行为
  • 使用常量时:例如在 assert!(!v.is_empty()); 之后紧接着使用 let first = v[0];

使用 unwrap_or 进行安全提取

  • unwrap() 在遇到 None / Err 时会引发 panic。在生产环境中,应优先选用安全的替代方案。

unwrap 家族

方法遇到 None/Err 时的行为适用场景
.unwrap()引发 Panic仅限于测试,或可证明绝对不会失败时
.expect("msg")引发带信息的 Panic当 panic 合理时,对其原因提供解释
.unwrap_or(default)返回 default提供零开销的常量回退值时
`.unwrap_or_else(expr)`
.unwrap_or_default()返回 Default::default()当类型实现了 Default Trait 时

真实示例:使用安全默认值进行解析

#![allow(unused)]
fn main() {
// 示例:peripherals.rs
// 正则捕获组可能未匹配 —— 提供安全的回退值
let bus_hex = caps.get(1).map(|m| m.as_str()).unwrap_or("00");
let fw_status = caps.get(5).map(|m| m.as_str()).unwrap_or("0x0");
let bus = u8::from_str_radix(bus_hex, 16).unwrap_or(0);
}

真实示例:带有回退结构体的 unwrap_or_else

#![allow(unused)]
fn main() {
// 示例:framework.rs
// 该函数在返回 Option 的闭包中封装了逻辑;
// 如果任何环节失败,则返回一个默认的结构体:
(|| -> Option<BaseboardFru> {
    let content = std::fs::read_to_string(path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
    // ... 之后是利用 .get()? 链提取字段
    Some(baseboard_fru)
})()
.unwrap_or_else(|| BaseboardFru {
    manufacturer: String::new(),
    model: String::new(),
    product_part_number: String::new(),
    serial_number: String::new(),
    asset_tag: String::new(),
})
}

真实示例:在配置反序列化时使用 unwrap_or_default

#![allow(unused)]
fn main() {
// 示例:framework.rs
// 如果 JSON 配置解析失败,则回退到 Default 状态 —— 避免程序崩溃
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
}

其 C++ 等效做法通常是在 nlohmann::json::parse() 周围使用 try/catch 块,并在 catch 块中手动构造默认对象。


函数式转换:map、map_err 与 find_map

  • Option 和 Result 的这些方法允许你在不解包 (unwrapping) 的情况下转换其中包含的值,将嵌套的 if/else 逻辑替换为线性的链式调用。

快速参考

方法作用于功能C++ 等效做法
`.map(v…)`Option / Result
`.map_err(e…)`Result
`.and_then(v…)`Option / Result
`.find_map(v…)`迭代器
`.filter(v…)`Option / 迭代器
.ok()?Result将 Result 转为 Option 并向上传递 Noneif (result.has_error()) return nullopt;

真实示例:用于提取 JSON 字段的 .and_then() 链

#![allow(unused)]
fn main() {
// 示例:framework.rs —— 带有回退机制的序列号查找
let sys_info = json.get("SystemInfo")?;

// 首先尝试从 BaseboardFru.BoardSerialNumber 中获取
if let Some(serial) = sys_info
    .get("BaseboardFru")
    .and_then(|b| b.get("BoardSerialNumber"))
    .and_then(|v| v.as_str())
    .filter(valid_serial)     // 仅接受非空且有效的序列号
{
    return Some(serial.to_string());
}

// 如果获取不到,则回退到 BoardFru.SerialNumber
sys_info
    .get("BoardFru")
    .and_then(|b| b.get("SerialNumber"))
    .and_then(|v| v.as_str())
    .filter(valid_serial)
    .map(|s| s.to_string())   // 仅在为 Some 时才进行 &str -> String 转换
}

在 C++ 中,这会导致形成一个缩进极深的结构:if (json.contains("BaseboardFru")) { if (json["BaseboardFru"].contains("BoardSerialNumber")) { ... } }。


真实示例:find_map —— 在单次遍历中完成 查找+转换

#![allow(unused)]
fn main() {
// 示例:context.rs —— 查找匹配特定传感器编号及所有者 ID 的 SDR 记录
pub fn find_for_event(&self, sensor_number: u8, owner_id: u8) -> Option<&SdrRecord> {
    self.by_sensor.get(&sensor_number).and_then(|indices| {
        indices.iter().find_map(|&i| {
            let record = &self.records[i];
            if record.sensor_owner_id() == Some(owner_id) {
                Some(record)
            } else {
                None
            }
        })
    })
}
}

find_map 是 find 与 map 的融合体:它会在发现第一个匹配项时停止遍历并执行转换。其 C++ 的等效做法通常是一个带有 if 判断以及 break 跳出的 for 循环。

真实示例:为错误添加上下文的 map_err

#![allow(unused)]
fn main() {
// 示例:main.rs —— 在传播错误之前,为错误添加详细的上下文信息
let json_str = serde_json::to_string_pretty(&config)
    .map_err(|e| format!("序列化配置信息失败: {}", e))?;
}

该操作将 serde_json::Error 转为了一段描述性的 String 错误消息,其中涵盖了导致故障的具体上下文。


JSON 处理:从 nlohmann::json 到 serde

  • C++ 团队通常使用 nlohmann::json 进行 JSON 解析。而 Rust 使用 serde + serde_json —— 其功能更加强大,因为 JSON 架构 (Schema) 是直接编码在类型系统中的。

C++ (nlohmann) 与 Rust (serde) 的对比

// C++ 使用 nlohmann::json —— 运行时字段访问
#include <nlohmann/json.hpp>
using json = nlohmann::json;

struct Fan {
    std::string logical_id;
    std::vector<std::string> sensor_ids;
};

Fan parse_fan(const json& j) {
    Fan f;
    f.logical_id = j.at("LogicalID").get<std::string>();    // 如果缺失则抛出异常
    if (j.contains("SDRSensorIdHexes")) {                   // 手动处理默认值
        f.sensor_ids = j["SDRSensorIdHexes"].get<std::vector<std::string>>();
    }
    return f;
}
#![allow(unused)]
fn main() {
// Rust 使用 serde —— 编译期架构,自动字段映射
use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fan {
    pub logical_id: String,
    #[serde(rename = "SDRSensorIdHexes", default)]  // 将 JSON 键映射至 Rust 字段
    pub sensor_ids: Vec<String>,                     // 如果缺失则默认为空 Vec
    #[serde(default)]
    pub sensor_names: Vec<String>,                   // 如果缺失则默认为空 Vec
}

// 仅需一行即可替代整个解析函数:
let fan: Fan = serde_json::from_str(json_str)?;
}

常见的 serde 属性(摘自生产环境 Rust 代码示例)

属性用途C++ 等效做法
#[serde(default)]字段缺失时使用 Default::default()if (j.contains(key)) { ... } else { default; }
#[serde(rename = "Key")]将 JSON 键名映射至 Rust 字段名手动访问 j.at("Key")
#[serde(flatten)]将未知的键吸收进 HashMap 中for (auto& [k,v] : j.items()) { ... }
#[serde(skip)]不参与序列化/反序列化该字段不将其存储在 JSON 中
#[serde(tag = "type")]内部标记型枚举(判别式字段)if (j["type"] == "gpu") { ... }

真实示例:完整的配置结构体

#![allow(unused)]
fn main() {
// 示例:diag.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagConfig {
    pub sku: SkuConfig,
    #[serde(default)]
    pub level: DiagLevel,            // 字段缺失 → 使用 DiagLevel::default()
    #[serde(default)]
    pub modules: ModuleConfig,       // 字段缺失 → 使用 ModuleConfig::default()
    #[serde(default)]
    pub output_dir: String,          // 字段缺失 → 使用 ""
    #[serde(default, flatten)]
    pub options: HashMap<String, serde_json::Value>,  // 吸收所有未知的键
}

// 加载逻辑仅需 3 行(对比 C++ 下使用 nlohmann 约需 20 多行):
let content = std::fs::read_to_string(path)?;
let config: DiagConfig = serde_json::from_str(&content)?;
Ok(config)
}

使用 #[serde(tag = "type")] 反序列化枚举

#![allow(unused)]
fn main() {
// 示例:components.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]                   // JSON 格式示例:{"type": "Gpu", "product": ...}
pub enum PcieDeviceKind {
    Gpu { product: GpuProduct, manufacturer: GpuManufacturer },
    Nic { product: NicProduct, manufacturer: NicManufacturer },
    NvmeDrive { drive_type: StorageDriveType, capacity_gb: u32 },
    // ... 还有 9 个以上的变体
}
// serde 会自动根据 "type" 字段的值分派反序列化逻辑 —— 无需编写手动 if/else 链
}

其 C++ 等效做法通常是:if (j["type"] == "Gpu") { parse_gpu(j); } else if (j["type"] == "Nic") { parse_nic(j); } ...


练习:使用 serde 进行 JSON 反序列化

  • 定义一个 ServerConfig 结构体,使其能够从如下 JSON 中反序列化:
{
    "hostname": "diag-node-01",
    "port": 8080,
    "debug": true,
    "modules": ["accel_diag", "nic_diag", "cpu_diag"]
}
  • 使用 #[derive(Deserialize)] 和 serde_json::from_str() 对其进行解析。
  • 为 debug 字段添加 #[serde(default)] 属性,使其在字段缺失时默认为 false。
  • 加分项:添加一个具有 #[serde(default)] 属性的 enum DiagLevel { Quick, Full, Extended } 字段,使其默认值为 Quick。

起始代码(需要先执行 cargo add serde --features derive 和 cargo add serde_json):

use serde::Deserialize;

// TODO: 定义 DiagLevel 枚举并实现 Default Trait

// TODO: 定义带有 serde 属性的 ServerConfig 结构体

fn main() {
    let json_input = r#"{
        "hostname": "diag-node-01",
        "port": 8080,
        "debug": true,
        "modules": ["accel_diag", "nic_diag", "cpu_diag"]
    }"#;

    // TODO: 执行反序列化并打印配置信息
    // TODO: 尝试解析缺失 "debug" 字段的 JSON —— 验证其默认值是否为 false
}

答案 (点击展开)
use serde::Deserialize;

#[derive(Debug, Deserialize, Default)]
enum DiagLevel {
    #[default]
    Quick,
    Full,
    Extended,
}

#[derive(Debug, Deserialize)]
struct ServerConfig {
    hostname: String,
    port: u16,
    #[serde(default)]       // 若字段缺失,则默认为 false
    debug: bool,
    modules: Vec<String>,
    #[serde(default)]       // 若字段缺失,则默认为 DiagLevel::Quick
    level: DiagLevel,
}

fn main() {
    let json_input = r#"{
        "hostname": "diag-node-01",
        "port": 8080,
        "debug": true,
        "modules": ["accel_diag", "nic_diag", "cpu_diag"]
    }"#;

    let config: ServerConfig = serde_json::from_str(json_input)
        .expect("解析 JSON 失败");
    println!("{config:#?}");

    // 测试缺失可选字段的情况
    let minimal = r#"{
        "hostname": "node-02",
        "port": 9090,
        "modules": []
    }"#;
    let config2: ServerConfig = serde_json::from_str(minimal)
        .expect("解析最简 JSON 失败");
    println!("debug (默认值): {}", config2.debug);    // false
    println!("level (默认值): {:?}", config2.level);  // Quick
}
// 输出示例:
// ServerConfig {
//     hostname: "diag-node-01",
//     port: 8080,
//     debug: true,
//     modules: ["accel_diag", "nic_diag", "cpu_diag"],
//     level: Quick,
// }
// debug (默认值): false
// level (默认值): Quick

折叠赋值“金字塔”

English Original

利用闭包折叠赋值“金字塔”

你将学到: Rust 基于表达式的语法和闭包如何将 C++ 中深层嵌套的 if/else 校验链,扁平化为整洁、线性的代码。

  • 在 C++ 中,为了给变量赋值,往往需要编写多块 if/else 链,特别是在涉及校验或回退 (fallback) 逻辑时。Rust 基于表达式的语法和闭包可以将这些逻辑折叠为扁平的线性代码。

模式 1:利用 if 表达式进行元组赋值

// C++ —— 通过多块 if/else 链设置三个变量
uint32_t fault_code;
const char* der_marker;
const char* action;
if (is_c44ad) {
    fault_code = 32709; der_marker = "CSI_WARN"; action = "No action";
} else if (error.is_hardware_error()) {
    fault_code = 67956; der_marker = "CSI_ERR"; action = "Replace GPU";
} else {
    fault_code = 32709; der_marker = "CSI_WARN"; action = "No action";
}
#![allow(unused)]
fn main() {
// Rust 等效写法:accel_fieldiag.rs
// 通过单一表达式同时为三个变量赋值:
let (fault_code, der_marker, recommended_action) = if is_c44ad {
    (32709u32, "CSI_WARN", "无操作")
} else if error.is_hardware_error() {
    (67956u32, "CSI_ERR", "更换 GPU")
} else {
    (32709u32, "CSI_WARN", "无操作")
---

## 模式 2:用于易错链式调用的 IIFE (立即调用函数表达式)
```cpp
// C++ —— JSON 导航中的“死亡金字塔”
std::string get_part_number(const nlohmann::json& root) {
    if (root.contains("SystemInfo")) {
        auto& sys = root["SystemInfo"];
        if (sys.contains("BaseboardFru")) {
            auto& bb = sys["BaseboardFru"];
            if (bb.contains("ProductPartNumber")) {
                return bb["ProductPartNumber"].get<std::string>();
            }
        }
    }
    return "UNKNOWN";
}
}
#![allow(unused)]
fn main() {
// Rust 等效写法:framework.rs
// 闭包 + ? 运算符将金字塔结构折叠为线性代码:
let part_number = (|| -> Option<String> {
    let path = self.args.sysinfo.as_ref()?;
    let content = std::fs::read_to_string(path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
    let ppn = json
        .get("SystemInfo")?
        .get("BaseboardFru")?
        .get("ProductPartNumber")?
        .as_str()?;
    Some(ppn.to_string())
})()
.unwrap_or_else(|| "UNKNOWN".to_string());
}

该闭包创建了一个 Option<String> 作用域,在此作用域内,? 可以在任何步骤提前退出。随后,.unwrap_or_else() 在代码最后仅提供一次回退值即可。


模式 3:用迭代器链式调用 替代 手动循环 + push_back

// C++ —— 使用中间变量的手动循环
std::vector<std::tuple<std::vector<std::string>, std::string, std::string>> gpu_info;
for (const auto& [key, info] : gpu_pcie_map) {
    std::vector<std::string> bdfs;
    // ... 将 bdf_path 解析为 bdfs
    std::string serial = info.serial_number.value_or("UNKNOWN");
    std::string model = info.model_number.value_or(model_name);
    gpu_info.push_back({bdfs, serial, model});
}
#![allow(unused)]
fn main() {
// Rust 等效写法:peripherals.rs
// 单一链式调用:values() -> map -> collect
let gpu_info: Vec<(Vec<String>, String, String, String)> = self
    .gpu_pcie_map
    .values()
    .map(|info| {
        let bdfs: Vec<String> = info.bdf_path
            .split(')')
            .filter(|s| !s.is_empty())
            .map(|s| s.trim_start_matches('(').to_string())
            .collect();
        let serial = info.serial_number.clone()
            .unwrap_or_else(|| "UNKNOWN".to_string());
        let model = info.model_number.clone()
            .unwrap_or_else(|| model_name.to_string());
        let gpu_bdf = format!("{}:{}:{}.{}",
            info.bdf.segment, info.bdf.bus, info.bdf.device, info.bdf.function);
        (bdfs, serial, model, gpu_bdf)
    })
    .collect();
}

模式 4:.filter().collect() 替代 循环 + if (condition) continue

// C++
std::vector<TestResult*> failures;
for (auto& t : test_results) {
    if (!t.is_pass()) {
        failures.push_back(&t);
    }
}
#![allow(unused)]
fn main() {
// Rust —— 摘自 accel_diag/src/healthcheck.rs
pub fn failed_tests(&self) -> Vec<&TestResult> {
    self.test_results.iter().filter(|t| !t.is_pass()).collect()
}
}

总结:何时使用何种模式

C++ 模式Rust 替代方案核心优势
多块变量赋值let (a, b) = if ... { } else { };所有变量以原子化方式进行绑定
嵌套的 if (contains) 金字塔带有 ? 运算符的 IIFE 闭包线性、扁平、提前退出
for 循环 + push_back`.iter().map(
for + if (cond) continue`.iter().filter(
for + if + break (查找第一个)`.iter().find_map(

终极练习:诊断事件流水线

🔴 挑战 —— 该练习综合了枚举 (Enums)、Trait、迭代器、错误处理以及泛型。

你将构建一个简化版的诊断事件处理流水线,这与生产环境 Rust 代码中使用的模式非常相似。

要求:

  1. 定义一个 enum Severity { Info, Warning, Critical },实现 Display Trait;并定义一个 struct DiagEvent,其中包含 source: String、severity: Severity、message: String 以及 fault_code: u32 字段。
  2. 定义一个 trait EventFilter,其中包含一个 fn should_include(&self, event: &DiagEvent) -> bool 方法。
  3. 实现两个过滤器:SeverityFilter(仅包含严重程度大于或等于给定值的事件)和 SourceFilter(仅包含来自特定源字符串的事件)。
  4. 编写一个 fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> 函数,该函数对所有通过了全部过滤器的事件返回格式化的报告行。
  5. 编写一个 fn parse_event(line: &str) -> Result<DiagEvent, String> 函数,该函数能解析格式如 "source:severity:fault_code:message" 的字符串行(对于错误的输入返回 Err)。

起始代码:

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
    Info,
    Warning,
    Critical,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        todo!()
    }
}

#[derive(Debug, Clone)]
struct DiagEvent {
    source: String,
    severity: Severity,
    message: String,
    fault_code: u32,
}

trait EventFilter {
    fn should_include(&self, event: &DiagEvent) -> bool;
}

struct SeverityFilter {
    min_severity: Severity,
}
// TODO: 为 SeverityFilter 实现 EventFilter

struct SourceFilter {
    source: String,
}
// TODO: 为 SourceFilter 实现 EventFilter

fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> {
    // TODO: 过滤通过了全部过滤器的事件,并格式化为:
    // "[SEVERITY] source (FC:fault_code): message"
    todo!()
}

fn parse_event(line: &str) -> Result<DiagEvent, String> {
    // 解析 "source:severity:fault_code:message"
    // 出现非法输入时返回 Err
    todo!()
}
}

fn main() {
    let raw_lines = vec![
        "accel_diag:Critical:67956:检测到 ECC 不可纠正错误",
        "nic_diag:Warning:32709:链路速度下降",
        "accel_diag:Info:10001:自检通过",
        "cpu_diag:Critical:55012:热节流激活",
        "accel_diag:Warning:32710:PCIe 链路宽度降低",
    ];

    // 解析所有行,收集成功的记录并报告解析错误
    let events: Vec<DiagEvent> = raw_lines.iter()
        .filter_map(|line| match parse_event(line) {
            Ok(e) => Some(e),
            Err(e) => { eprintln!("解析错误: {e}"); None }
        })
        .collect();

    // 应用过滤器:仅提取来自 accel_diag 且严重程度为 Warning 或更高级别的事件
    let sev_filter = SeverityFilter { min_severity: Severity::Warning };
    let src_filter = SourceFilter { source: "accel_diag".to_string() };
    let filters: Vec<&dyn EventFilter> = vec![&sev_filter, &src_filter];

    let report = process_events(&events, &filters);
    for line in &report {
        println!("{line}");
    }
    println!("--- 匹配到 {} 条事件 ---", report.len());
}

答案 (点击展开)
#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
    Info,
    Warning,
    Critical,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Severity::Info => write!(f, "INFO"),
            Severity::Warning => write!(f, "WARNING"),
            Severity::Critical => write!(f, "CRITICAL"),
        }
    }
}

impl Severity {
    fn from_str(s: &str) -> Result<Self, String> {
        match s {
            "Info" => Ok(Severity::Info),
            "Warning" => Ok(Severity::Warning),
            "Critical" => Ok(Severity::Critical),
            other => Err(format!("未知严重程度: {other}")),
        }
    }
}

#[derive(Debug, Clone)]
struct DiagEvent {
    source: String,
    severity: Severity,
    message: String,
    fault_code: u32,
}

trait EventFilter {
    fn should_include(&self, event: &DiagEvent) -> bool;
}

struct SeverityFilter {
    min_severity: Severity,
}

impl EventFilter for SeverityFilter {
    fn should_include(&self, event: &DiagEvent) -> bool {
        event.severity >= self.min_severity
    }
}

struct SourceFilter {
    source: String,
}

impl EventFilter for SourceFilter {
    fn should_include(&self, event: &DiagEvent) -> bool {
        event.source == self.source
    }
}

fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> {
    events.iter()
        .filter(|e| filters.iter().all(|f| f.should_include(e)))
        .map(|e| format!("[{}] {} (FC:{}): {}", e.severity, e.source, e.fault_code, e.message))
        .collect()
}
}

fn parse_event(line: &str) -> Result<DiagEvent, String> {
    let parts: Vec<&str> = line.splitn(4, ':').collect();
    if parts.len() != 4 {
        return Err(format!("应输入由冒号分隔的 4 个字段,实际获取到 {} 个", parts.len()));
    }
    let fault_code = parts[2].parse::<u32>()
        .map_err(|e| format!("非法的故障代码 '{}': {e}", parts[2]))?;
    Ok(DiagEvent {
        source: parts[0].to_string(),
        severity: Severity::from_str(parts[1])?,
        fault_code,
        message: parts[3].to_string(),
    })
}

fn main() {
    let raw_lines = vec![
        "accel_diag:Critical:67956:检测到 ECC 不可纠正错误",
        "nic_diag:Warning:32709:链路速度下降",
        "accel_diag:Info:10001:自检通过",
        "cpu_diag:Critical:55012:热节流激活",
        "accel_diag:Warning:32710:PCIe 链路宽度降低",
    ];

    let events: Vec<DiagEvent> = raw_lines.iter()
        .filter_map(|line| match parse_event(line) {
            Ok(e) => Some(e),
            Err(e) => { eprintln!("解析错误: {e}"); None }
        })
        .collect();

    let sev_filter = SeverityFilter { min_severity: Severity::Warning };
    let src_filter = SourceFilter { source: "accel_diag".to_string() };
    let filters: Vec<&dyn EventFilter> = vec![&sev_filter, &src_filter];

    let report = process_events(&events, &filters);
    for line in &report {
        println!("{line}");
    }
    println!("--- 匹配到 {} 条事件 ---", report.len());
}
// 输出示例:
// [CRITICAL] accel_diag (FC:67956): 检测到 ECC 不可纠正错误
// [WARNING] accel_diag (FC:32710): PCIe 链路宽度降低
// --- 匹配到 2 条事件 ---

日志与追踪生态系统

English Original

日志与追踪:从 syslog/printf 到 log + tracing

你将学到: Rust 的两层日志架构(外观层 + 后端层)、log 与 tracing crate、带有 Span(跨度)的结构化日志,以及这些如何取代 printf/syslog 调试。

  • C++ 诊断代码通常使用 printf、syslog 或自定义日志框架。
  • Rust 拥有一套标准化的两层日志架构:外观层 (Facade) crate (log 或 tracing)以及 后端层 (Backend)(即实际的日志记录器实现)。

log 外观层 —— Rust 通用的日志 API

log crate 提供的宏与 syslog 的严重程度级别相对应。库(Libraries)通常使用 log 宏,而二进制程序(Binaries)则负责选择具体的后端实现:

// Cargo.toml
// [dependencies]
// log = "0.4"
// env_logger = "0.11"    # 众多后端中的一种

use log::{info, warn, error, debug, trace};

fn check_sensor(id: u32, temp: f64) {
    trace!("正在读取传感器 {id}");           // 最细粒度
    debug!("传感器 {id} 原始值: {temp}"); // 开发阶段的细节

    if temp > 85.0 {
        warn!("传感器 {id} 温度过高: {temp}°C");
    }
    if temp > 95.0 {
        error!("传感器 {id} 严重报警: {temp}°C —— 正在启动关机程序");
    }
    info!("传感器 {id} 检查完成");     // 正常运行信息
}

fn main() {
    // 初始化后端 —— 通常在 main() 中执行一次
    env_logger::init();  // 由 RUST_LOG 环境变量控制

    check_sensor(0, 72.5);
    check_sensor(1, 91.0);
---

```bash
通过环境变量控制日志级别
RUST_LOG=debug cargo run          # 显示 debug 及以上级别
RUST_LOG=warn cargo run           # 仅显示 warn 和 error
RUST_LOG=my_crate=trace cargo run # 针对特定模块进行过滤
RUST_LOG=my_crate::gpu=debug,warn cargo run  # 混合不同级别的过滤

C++ 对比

C++Rust (log)说明
printf("DEBUG: %s\n", msg)debug!("{msg}")在编译期检查格式
syslog(LOG_ERR, "...")error!("...")后端层决定日志输出的目的地
在 log 调用处使用 #ifdef DEBUG在 max_level 下,trace! / debug! 会在编译时被剔除禁用时无运行时开销
自定义的 Logger::log(level, msg)log::info!("...") —— 所有的 crate 均使用同一个 API通用外观,可更换后端
各文件的日志详细程度配置RUST_LOG=crate::module=level基于环境变量配置,无需重新编译

tracing crate —— 带有跨度的结构化日志

tracing 是对 log 的扩展,加入了 结构化字段 (Structured Fields) 和 Span(跨度,即带有计时信息的作用域)。这在诊断代码中尤为实用,你可以跟踪具体的上下文:

// Cargo.toml
// [dependencies]
// tracing = "0.1"
// tracing-subscriber = { version = "0.3", features = ["env-filter"] }

use tracing::{info, warn, error, instrument, info_span};

#[instrument(skip(data), fields(gpu_id = gpu_id, data_len = data.len()))]
fn run_gpu_test(gpu_id: u32, data: &[u8]) -> Result<(), String> {
    info!("正在启动 GPU 测试");

    let span = info_span!("ecc_check", gpu_id);
    let _guard = span.enter();  // 此作用域内的所有日志均会自动带上 gpu_id

    if data.is_empty() {
        error!(gpu_id, "未提供测试数据");
        return Err("数据为空".to_string());
    }

    // 结构化字段 —— 机器可解析,不仅仅是字符串插值
    info!(
        gpu_id,
        temp_celsius = 72.5,
        ecc_errors = 0,
        "ECC 检查通过"
    );

    Ok(())
}

fn main() {
    // 初始化 tracing 记录器
    tracing_subscriber::fmt()
        .with_env_filter("debug")  // 或者使用 RUST_LOG 环境变量
        .with_target(true)          // 显示模块路径
        .with_thread_ids(true)      // 显示线程 ID
        .init();

    let _ = run_gpu_test(0, &[1, 2, 3]);
}

使用 tracing-subscriber 的输出示例:

#![allow(unused)]
fn main() {
2026-02-15T10:30:00.123Z DEBUG ThreadId(01) run_gpu_test{gpu_id=0 data_len=3}: my_crate: 正在启动 GPU 测试
2026-02-15T10:30:00.124Z  INFO ThreadId(01) run_gpu_test{gpu_id=0 data_len=3}:ecc_check{gpu_id=0}: my_crate: ECC 检查通过 gpu_id=0 temp_celsius=72.5 ecc_errors=0
}

#[instrument] —— 自动创建 Span

#[instrument] 属性会自动创建一个以函数名命名的 Span,并将其参数作为结构化字段:

#![allow(unused)]
fn main() {
use tracing::instrument;

#[instrument]
fn parse_sel_record(record_id: u16, sensor_type: u8, data: &[u8]) -> Result<(), String> {
    // 此函数内的每一条日志都会自动包含:
    // record_id、sensor_type 以及 data(如果实现了 Debug)
    tracing::debug!("正在解析 SEL 记录");
    Ok(())
}

// skip: 在 Span 中排除大型或敏感的参数
// fields: 添加计算得出的字段
#[instrument(skip(raw_buffer), fields(buf_len = raw_buffer.len()))]
fn decode_ipmi_response(raw_buffer: &[u8]) -> Result<Vec<u8>, String> {
    tracing::trace!("正在解码 {} 字节", raw_buffer.len());
    Ok(raw_buffer.to_vec())
}
}

log 对比 tracing —— 该选哪一个

维度logtracing
复杂程度简单 —— 仅 5 个宏更丰富 —— 包含 Span、字段、Instrument 属性
结构化数据仅限字符串插值键值字段:info!(gpu_id = 0, "msg")
计时 / Span否是 —— #[instrument], span.enter()
异步支持基础一等公民 —— Span 可跨越 .await 进行传播
兼容性通用的外观层兼容 log(提供 log 桥接)
适用场景简单的应用、库 (Library)诊断工具、异步代码、可观测性相关项目

建议:在生产级别的诊断类项目(即需要结构化输出的诊断工具)中使用 tracing。在希望依赖最小化的简单库中使用 log。tracing 包含一个兼容层,因此使用 log 宏的库仍然可以配合 tracing 记录器 (Subscriber) 工作。

后端选项

后端 Crate输出适用场景
env_loggerstderr,带颜色开发阶段、简单的 CLI 工具
tracing-subscriberstderr,格式化输出使用 tracing 的生产环境
syslog系统 syslogLinux 系统服务
tracing-journaldsystemd journal由 systemd 管理的服务
tracing-appender轮转的日志文件长期运行的守护进程
tracing-opentelemetryOpenTelemetry 收集器分布式追踪

18. C++ → Rust 语义深入对比

English Original

C++ → Rust 语义深潜

你将学到: 针对那些没有明显 Rust 等效项的 C++ 概念,提供详细的映射指南 —— 包括四种命名强制转换 (Named Casts)、SFINAE 与 Trait 限定、CRTP 与关联类型 (Associated Types),以及翻译过程中的其他常见摩擦点。

下文各节映射了那些在 Rust 中没有明显 1:1 等效项的 C++ 概念。在翻译工作中,这些差异经常会让 C++ 程序员感到困惑。

类型转换层次结构:四种 C++ Cast → Rust 等效项

C++ 拥有四种命名的强制转换 (Named Casts)。Rust 则使用不同且更为明确的机制来替代它们:

// C++ 类型转换层次结构
int i = static_cast<int>(3.14);            // 1. 数值转换 / 向上转换 (Up-cast)
Derived* d = dynamic_cast<Derived*>(base); // 2. 运行时向下转换 (Downcasting)
int* p = const_cast<int*>(cp);              // 3. 强制去掉 const 属性
auto* raw = reinterpret_cast<char*>(&obj); // 4. 位级别的重新解释
C++ 类型转换Rust 等效项安全性说明
static_cast (数值)as 关键字安全,但可能发生截断/绕回let i = 3.14_f64 as i32; —— 截断为 3
static_cast (数值, 已检查)From/Into安全,编译期验证let i: i32 = 42_u8.into(); —— 仅限拓宽转换
static_cast (数值, 易错)TryFrom/TryInto安全,返回 Resultlet i: u8 = 300_u16.try_into()?; —— 返回 Err
dynamic_cast (向下转换)枚举上的 match / Any::downcast_ref安全枚举使用模式匹配;Trait 对象使用 Any
const_cast无等效项Rust 无法在安全代码中将 & 转换为 &mut。使用 Cell/RefCell 实现内部可变性
reinterpret_caststd::mem::transmuteunsafe重新解释位模式。几乎总是错误的方案 —— 优先使用 from_le_bytes() 等

#![allow(unused)]
fn main() {
// Rust 等效写法示例:

// 1. 数值转换 —— 相比于 `as`,优先选择 From/Into
let widened: u32 = 42_u8.into();             // 绝不会失败的拓宽转换 —— 应当优先使用
let truncated = 300_u16 as u8;                // ⚠ 绕回到 44!导致静默数据丢失
let checked: Result<u8, _> = 300_u16.try_into(); // Err —— 安全的可失败转换

// 2. 向下转换 (Downcast):枚举 (首选) 或 Any (需要遮蔽类型时)
use std::any::Any;

fn handle_any(val: &dyn Any) {
    if let Some(s) = val.downcast_ref::<String>() {
        println!("获取到字符串: {s}");
    } else if let Some(n) = val.downcast_ref::<i32>() {
        println!("获取到整数: {n}");
    }
}

// 3. "const_cast" → 内部可变性 (无需 unsafe)
use std::cell::Cell;
struct Sensor {
    read_count: Cell<u32>,  // 即使在 &self 中也可进行修改
}
impl Sensor {
    fn read(&self) -> f64 {
        self.read_count.set(self.read_count.get() + 1); // 接收的是 &self,而非 &mut self
        42.0
    }
}

// 4. reinterpret_cast → transmute (几乎从不需要使用)
// 请优先选择安全的替代方案:
let bytes: [u8; 4] = 0x12345678_u32.to_ne_bytes();  // ✅ 安全
let val = u32::from_ne_bytes(bytes);                   // ✅ 安全
// unsafe { std::mem::transmute::<u32, [u8; 4]>(val) } // ❌ 应当避免
}

准则:在地道的 Rust 中,as 应当极少出现(拓宽转换应使用 From/Into,收缩转换应使用 TryFrom/TryInto),transmute 应当是非常例外的需求,而 const_cast 没有等效项,因为内部可变性类型已经使其变得没有必要。


预处理器 → cfg、特性标志 (Feature Flags) 和 macro_rules!

C++ 严重依赖预处理器进行条件编译、常量定义和代码生成。Rust 将所有这些功能替换为了一等公民的语言特性。

#define 常量 → const 或 const fn

// C++
#define MAX_RETRIES 5
#define BUFFER_SIZE (1024 * 64)
#define SQUARE(x) ((x) * (x))  // 宏 —— 属于文本替换,没有类型安全性
#![allow(unused)]
fn main() {
// Rust —— 类型安全、有作用域、并非文本替换
const MAX_RETRIES: u32 = 5;
const BUFFER_SIZE: usize = 1024 * 64;
const fn square(x: u32) -> u32 { x * x }  // 在编译期进行求值

// 可在常量上下文中使用:
const AREA: u32 = square(12);  // 编译期计算
static BUFFER: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
}

#ifdef / #if → #[cfg()] 和 cfg!()

// C++
#ifdef DEBUG
    log_verbose("步骤 1 已完成");
#endif

#if defined(LINUX) && !defined(ARM)
    use_x86_path();
#else
    use_generic_path();
#endif
#![allow(unused)]
fn main() {
// Rust —— 基于属性的条件编译
#[cfg(debug_assertions)]
fn log_verbose(msg: &str) { eprintln!("[详细日志] {msg}"); }

#[cfg(not(debug_assertions))]
fn log_verbose(_msg: &str) { /* 在 release 模式下会被优化掉 */ }

// 组合条件:
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn use_x86_path() { /* ... */ }

#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
fn use_generic_path() { /* ... */ }

// 运行时检查(条件依然是编译期确定的,但可以像普通表达式一样使用):
if cfg!(target_os = "windows") {
    println!("正在 Windows 上运行");
}
}

Cargo.toml 中的特性标志 (Feature Flags)

# Cargo.toml —— 用于替代 #ifdef FEATURE_FOO
[features]
default = ["json"]
json = ["dep:serde_json"]       # 可选依赖
verbose-logging = []            # 不带额外依赖的标志
gpu-support = ["dep:cuda-sys"]  # 可选的 GPU 支持
#![allow(unused)]
fn main() {
// 根据特性标志编写条件代码:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
    serde_json::from_str(data).map_err(Error::from)
}

#[cfg(feature = "verbose-logging")]
macro_rules! verbose {
    ($($arg:tt)*) => { eprintln!("[详细日志] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose-logging"))]
macro_rules! verbose {
    ($($arg:tt)*) => { }; // 编译为空
}
}

#define MACRO(x) → macro_rules!

// C++ —— 属于文本替换,众所周知非常容易出错
#define DIAG_CHECK(cond, msg) \
    do { if (!(cond)) { log_error(msg); return false; } } while(0)
#![allow(unused)]
fn main() {
// Rust —— 遵循卫生性 (Hygienic)、经过类型检查、操作语法树
macro_rules! diag_check {
    ($cond:expr, $msg:expr) => {
        if !($cond) {
            log_error($msg);
            return Err(DiagError::CheckFailed($msg.to_string()));
        }
    };
}

fn run_test() -> Result<(), DiagError> {
    diag_check!(temperature < 85.0, "GPU 温度过高");
    diag_check!(voltage > 0.8, "导轨电压过低");
    Ok(())
}
}
C++ 预处理器Rust 等效方案优势
#define PI 3.14const PI: f64 = 3.14;强类型、有作用域、对调试器可见
#define MAX(a,b) ((a)>(b)?(a):(b))macro_rules! 或泛型 fn max<T: Ord>不存在多次求值引发的 Bug
#ifdef DEBUG#[cfg(debug_assertions)]由编译器检查,无拼写错误风险
#ifdef FEATURE_X#[cfg(feature = "x")]由 Cargo 管理特性;支持依赖关系感知
#include "header.h"mod module; + use module::Item;没有包含守卫 (Include Guards),没有循环引用
#pragma once不需要每一个 .rs 文件都是一个模块 —— 仅被包含一次

头文件与 #include → 模块与 use

在 C++ 中,编译模型围绕着文本包含 (Textual Inclusion) 展开:

// widget.h —— 每一个使用 Widget 的翻译单元都需要包含此文件
#pragma once
#include <string>
#include <vector>

class Widget {
public:
    Widget(std::string name);
    void activate();
private:
    std::string name_;
    std::vector<int> data_;
};
// widget.cpp —— 独立的定义部分
#include "widget.h"
Widget::Widget(std::string name) : name_(std::move(name)) {}
void Widget::activate() { /* ... */ }

而在 Rust 中,不存在头文件,没有前置声明 (Forward Declarations),也没有包含守卫:

#![allow(unused)]
fn main() {
// src/widget.rs —— 声明与定义均在同一个文件中
pub struct Widget {
    name: String,         // 默认是私有的
    data: Vec<i32>,
}

impl Widget {
    pub fn new(name: String) -> Self {
        Widget { name, data: Vec::new() }
    }
    pub fn activate(&self) { /* ... */ }
}
}
// src/main.rs —— 通过模块路径导入
mod widget;  // 告诉编译器需要包含 src/widget.rs
use widget::Widget;

fn main() {
    let w = Widget::new("传感器".to_string());
    w.activate();
}

C++Rust为什么 Rust 更好
#include "foo.h"父级模块中的 mod foo; + use foo::Item;没有文本包含,没有违反 ODR (单一定义原则) 的风险
#pragma once / 包含守卫不需要每一个 .rs 文件都是一个模块 —— 仅会被编译一次
前置声明 (Forward declarations)不需要编译器可以看见整个 Crate;其定义顺序并不重要
class Foo; (不完整类型)不需要不存在声明与定义分离的情况
每个类对应 .h + .cpp单个 .rs 文件没有因声明与定义不匹配而导致的 Bug
using namespace std;use std::collections::HashMap;始终保持明确性 —— 不会造成全局命名空间污染
嵌套的 namespace a::b嵌套的 mod a { mod b { } } 或 a/b.rs文件系统与模块树镜像对应

friend 与 访问控制 → 模块可见性

C++ 使用 friend 来授予特定的类或函数访问私有成员的权限。Rust 中没有 friend 关键字 —— 相反,私有属性是以模块为作用域 of 的:

// C++
class Engine {
    friend class Car;   // Car 可以访问私有成员
    int rpm_;
    void set_rpm(int r) { rpm_ = r; }
public:
    int rpm() const { return rpm_; }
};
// Rust —— 位于同一模块中的项可以访问所有字段,无需 `friend`
mod vehicle {
    pub struct Engine {
        rpm: u32,  // 对该模块内部可见(而非仅对该结构体可见!)
    }

    impl Engine {
        pub fn new() -> Self { Engine { rpm: 0 } }
        pub fn rpm(&self) -> u32 { self.rpm }
    }

    pub struct Car {
        engine: Engine,
    }

    impl Car {
        pub fn new() -> Self { Car { engine: Engine::new() } }
        pub fn accelerate(&mut self) {
            self.engine.rpm = 3000; // ✅ 位于同一模块 —— 可直接访问字段
        }
        pub fn rpm(&self) -> u32 {
            self.engine.rpm  // ✅ 位于同一模块 —— 可读取私有字段
        }
    }
}

fn main() {
    let mut car = vehicle::Car::new();
    car.accelerate();
    // car.engine.rpm = 9000;  // ❌ 编译错误:`engine` 字段是私有的
    println!("RPM: {}", car.rpm()); // ✅ 调用 Car 的公开方法
}

C++ 访问级别Rust 等效方案作用域
private(默认,无关键字)仅限在同一模块内部访问
protected没有直接等效项使用 pub(super) 供父级模块访问
publicpub在所有位置均可访问
friend class Foo将 Foo 放在同一个模块中模块级私有权取代了 friend
—pub(crate)在整个 Crate 内部可见,但对外部依赖项不可见
—pub(super)仅对父级模块可见
—pub(in crate::path)在特定的子模块树内部可见

核心洞察:C++ 的私有权是基于类 (Class) 的。Rust 的私有权是基于模块 (Module) 的。这意味着你可以通过选择哪些类型属于同一个模块来控制访问权限 —— 放在一起的类型具有访问彼此私有字段的完整权限。


volatile → 原子 (Atomics) 以及 read_volatile/write_volatile

在 C++ 中,volatile 告诉编译器不要将读/写操作优化掉 —— 这通常用于内存映射 (memory-mapped) 的硬件寄存器。Rust 中没有 volatile 关键字。

// C++: 用于硬件寄存器的 volatile
volatile uint32_t* const GPIO_REG = reinterpret_cast<volatile uint32_t*>(0x4002'0000);
*GPIO_REG = 0x01;              // 此写入操作不会被优化掉
uint32_t val = *GPIO_REG;     // 此读取操作不会被优化掉
#![allow(unused)]
fn main() {
// Rust: 明确的 volatile 操作 —— 仅限在 unsafe 代码中使用
use std::ptr;

const GPIO_REG: *mut u32 = 0x4002_0000 as *mut u32;

// 安全性:GPIO_REG 是一个有效的内存映射 I/O 地址
unsafe {
    ptr::write_volatile(GPIO_REG, 0x01);   // 写入操作不会被优化掉
    let val = ptr::read_volatile(GPIO_REG); // 读取操作不会被优化掉
}
}

针对并发共享状态(这是 volatile 在 C++ 中的另一个常见用法),Rust 使用原子:

// C++: volatile 对于线程安全来说是不足够的(这是常见的错误!)
volatile bool stop_flag = false;  // ❌ 存在数据竞争 —— 在 C++11 之后属于未定义行为 (UB)

// 正确的 C++ 写法:
std::atomic<bool> stop_flag{false};
#![allow(unused)]
fn main() {
// Rust: 原子是跨线程共享可变状态的唯一方案
use std::sync::atomic::{AtomicBool, Ordering};

static STOP_FLAG: AtomicBool = AtomicBool::new(false);

// 在另一个线程中:
STOP_FLAG.store(true, Ordering::Release);

// 检查:
if STOP_FLAG.load(Ordering::Acquire) {
    println!("正在停止");
}
}
C++ 用法Rust 等效方案说明
针对硬件寄存器的 volatileptr::read_volatile / ptr::write_volatile需要 unsafe —— 适用于 MMIO
针对线程信号的 volatileAtomicBool / AtomicU32 等C++ 在这种场景下使用 volatile 也是错误的!
std::atomic<T>std::sync::atomic::AtomicT相同的语义,相同的内存顺序 (Orderings)
std::atomic<T>::load(memory_order_acquire)AtomicT::load(Ordering::Acquire)1:1 映射

static 变量 → static、const、LazyLock、OnceLock

基础的 static 与 const

// C++
const int MAX_RETRIES = 5;                    // 编译期常量
static std::string CONFIG_PATH = "/etc/app";  // 静态初始化 —— 初始化顺序未定义!
#![allow(unused)]
fn main() {
// Rust
const MAX_RETRIES: u32 = 5;                   // 编译期常量,会被内联
static CONFIG_PATH: &str = "/etc/app";         // 'static 生命周期,固定地址
}

静态初始化顺序困境 (Static Initialization Order Fiasco)

C++ 存在一个众所周知的问题:不同编译单元中的全局构造函数执行顺序是未指定的。Rust 完全避免了这一问题 —— static 值必须是编译期常量(没有构造函数)。

对于运行时初始化的全局变量,请使用 LazyLock (Rust 1.80+) 或 OnceLock:

#![allow(unused)]
fn main() {
use std::sync::LazyLock;

// 等效于 C++ 的 `static std::regex` —— 在首次访问时初始化,且是线程安全的
static CONFIG_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"^[a-z]+_diag$").expect("非法的正则表达式")
});

fn is_valid_diag(name: &str) -> bool {
    CONFIG_REGEX.is_match(name)  // 首次调用时初始化;后续调用速度较快
}
}
#![allow(unused)]
fn main() {
use std::sync::OnceLock;

// OnceLock:仅初始化一次,可使用运行时数据进行设置
static DB_CONN: OnceLock<String> = OnceLock::new();

fn init_db(connection_string: &str) {
    DB_CONN.set(connection_string.to_string())
        .expect("DB_CONN 已经完成初始化");
}

fn get_db() -> &'static str {
    DB_CONN.get().expect("数据库尚未初始化")
}
}
C++Rust说明
const int X = 5;const X: i32 = 5;均在编译期确定。Rust 要求显式类型标注
constexpr int X = 5;const X: i32 = 5;Rust 的 const 始终属于 constexpr
文件作用域的 static int count = 0;static COUNT: AtomicI32 = AtomicI32::new(0);可变的 static 变量需要 unsafe 或原子操作
static std::string s = "hi";static S: &str = "hi"; 或 LazyLock<String>简单场景下不存在运行时构造函数
static MyObj obj; (复杂初始化)`static OBJ: LazyLock = LazyLock::new(
thread_localthread_local! { static X: Cell<u32> = Cell::new(0); }语义相同

constexpr → const fn

C++ 的 constexpr 用于标记可以在编译期求值的函数和变量。Rust 处于相同的目的,使用了 const fn 和 const:

// C++
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int val = factorial(5);  // 编译期计算结果为 120
#![allow(unused)]
fn main() {
// Rust
const fn factorial(n: u32) -> u32 {
    if n <= 1 { 1 } else { n * factorial(n - 1) }
}
const VAL: u32 = factorial(5);  // 编译期计算结果为 120

// 同样适用于数组长度和 match 模式:
const LOOKUP: [u32; 5] = [factorial(1), factorial(2), factorial(3),
                           factorial(4), factorial(5)];
}
C++Rust说明
constexpr int f()const fn f() -> i32同样的意图 —— 可在编译期求值
constexpr 变量const 变量Rust 的 const 始终属于编译期常量
consteval (C++20)无直接等效项const fn 也可以在运行时运行
if constexpr (C++17)无直接等效项(使用 cfg! 或泛型)Trait 特化 (Specialization) 覆盖了部分用例
constinit (C++20)使用 const 初始化器的 static 变量Rust 的 static 变量默认必须进行 const 初始化

const fn 当前的局限性(截至 Rust 1.82 已稳定):

  • 不支持 Trait 方法(无法在常量上下文中对 Vec 调用 .len())
  • 不支持堆分配(Box::new、Vec::new 均不是 const)
  • 不支持浮点运算 —— 已在 Rust 1.82 中稳定
  • 无法使用 for 循环(请使用递归,或者配合手动索引使用 while)

SFINAE 与 enable_if → Trait 限定(Trait Bounds)与 where 子句

在 C++ 中,SFINAE (Substitution Failure Is Not An Error,替换失败并非错误) 是条件化泛型编程背后的核心机制。虽然它功能强大,但其可读性差也是众所周知的。Rust 完全使用 Trait 限定 (Trait Bounds) 替代了这一机制:

// C++:基于 SFINAE 的条件函数 (C++20 之前)
template<typename T,
         std::enable_if_t<std::is_integral_v<T>, int> = 0>
T double_it(T val) { return val * 2; }

template<typename T,
         std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
T double_it(T val) { return val * 2.0; }

// C++20 概念 (Concepts) —— 更加整洁,但依然比较冗长:
template<std::integral T>
T double_it(T val) { return val * 2; }
#![allow(unused)]
fn main() {
// Rust:Trait 限定 —— 可读性好、可组合,且拥有出色的错误提示
use std::ops::Mul;

fn double_it<T: Mul<Output = T> + From<u8>>(val: T) -> T {
    val * T::from(2)
}

// 或者针对复杂的限定使用 where 子句:
fn process<T>(val: T) -> String
where
    T: std::fmt::Display + Clone + Send,
{
    format!("正在处理:{}", val)
}

// 通过不同的 impl 实现条件行为(取代了 SFINAE 重载):
trait Describable {
    fn describe(&self) -> String;
}

impl Describable for u32 {
    fn describe(&self) -> String { format!("整数: {self}") }
}

impl Describable for f64 {
    fn describe(&self) -> String { format!("浮点数: {self:.2}") }
}
}
C++ 模板元编程Rust 等效方案可读性
std::enable_if_t<cond>where T: Trait🟢 语义清晰
std::is_integral_v<T>数值 Trait 限定或特定类型限定🟢 没有 _v / _t 后缀
SFINAE 重载集合独立的 impl Trait for ConcreteType 块🟢 每个 impl 相互独立
if constexpr (std::is_same_v<T, int>)通过 Trait impl 实现特化🟢 编译期分派
C++20 concepttrait🟢 意图几乎完全一致
requires 子句where 子句🟢 位置相同,语法相似
模板内部深处触发编译失败在调用处因 Trait 不匹配触发编译失败🟢 不会产生长达 200 行的错误级联

核心洞察:C++ 概念 (Concepts, C++20) 是与 Rust Trait 最为接近的概念。如果你熟悉 C++20 的概念,可以将 Rust Trait 看作是自 1.0 版本起就已经作为一等公民存在的、拥有一致实现模型(Trait impls)而非鸭子类型 (Duck Typing) 的“概念”。


std::function → 函数指针、impl Fn 以及 Box<dyn Fn>

C++ 的 std::function<R(Args...)> 是一种类型擦除 (Type-erased) 的可调用对象。Rust 提供了三种方案,每种方案都有其优缺点:

// C++:通用方案(堆分配、类型擦除)
#include <functional>
std::function<int(int)> make_adder(int n) {
    return [n](int x) { return x + n; };
}
#![allow(unused)]
fn main() {
// Rust 方案 1:函数指针 —— 简单、无捕获、无分配
fn add_one(x: i32) -> i32 { x + 1 }
let f: fn(i32) -> i32 = add_one;
println!("{}", f(5)); // 输出 6

// Rust 方案 2:impl Fn —— 单态化、零开销、可捕获
fn apply(val: i32, f: impl Fn(i32) -> i32) -> i32 { f(val) }
let n = 10;
let result = apply(5, |x| x + n);  // 闭包捕获了变量 `n`

// Rust 方案 3:Box<dyn Fn> —— 类型擦除、堆分配(类似于 std::function)
fn make_adder(n: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + n)
}
let adder = make_adder(10);
println!("{}", adder(5));  // 输出 15

// 存储异构的可调用对象(类似于 vector<function<int(int)>>):
let callbacks: Vec<Box<dyn Fn(i32) -> i32>> = vec![
    Box::new(|x| x + 1),
    Box::new(|x| x * 2),
    Box::new(make_adder(100)),
];
for cb in &callbacks {
    println!("{}", cb(5));  // 分别输出 6, 10, 105
}
}
使用场景C++ 等效方案Rust 选型
顶级函数,无捕获函数指针fn(Args) -> Ret
接受可调用对象的泛型函数模板参数impl Fn(Args) -> Ret (静态分派)
泛型中的 Trait 限定template<typename F>F: Fn(Args) -> Ret
存储可调用对象,类型擦除std::function<R(Args)>Box<dyn Fn(Args) -> Ret>
会修改状态的回调函数带有可变 lambda 的 std::functionBox<dyn FnMut(Args) -> Ret>
仅限调用一次的回调 (一次性消耗)被移动的 std::functionBox<dyn FnOnce(Args) -> Ret>

性能提示:impl Fn 具有零开销(单态化,类似于 C++ 模板)。而 Box<dyn Fn> 拥有与 std::function 相同的开销(虚函数表 + 堆分配)。除非你需要存储异构的可调用对象,否则请优先使用 impl Fn。


容器映射:C++ STL → Rust std::collections

C++ STL 容器Rust 等效容器说明
std::vector<T>Vec<T>几乎完全一致的 API。Rust 默认会检查索引是否越界
std::array<T, N>[T; N]栈分配的固定大小数组
std::deque<T>std::collections::VecDeque<T>环形缓冲区。在两端进行 push/pop 均非常高效
std::list<T>std::collections::LinkedList<T>在 Rust 中极少使用 —— Vec 几乎总是在性能上胜出
std::forward_list<T>无直接等效项请使用 Vec 或 VecDeque
std::unordered_map<K, V>std::collections::HashMap<K, V>默认使用 SipHash(具备抗 DoS 攻击能力)
std::map<K, V>std::collections::BTreeMap<K, V>B-树;Key 是有序的;要求 K: Ord
std::unordered_set<T>std::collections::HashSet<T>要求 T: Hash + Eq
std::set<T>std::collections::BTreeSet<T>有序集合;要求 T: Ord
std::priority_queue<T>std::collections::BinaryHeap<T>默认为最大堆(与 C++ 一致)
std::stack<T>使用 .push() / .pop() 的 Vec<T>无需独立的栈类型
std::queue<T>使用 .push_back() / .pop_front() 的 VecDeque<T>无需独立的队列类型
std::stringString保证是 UTF-8 编码,非 null 结尾
std::string_view&str借用的 UTF-8 字符串切片
std::span<T> (C++20)&[T] / &mut [T]Rust 切片自 1.0 起就是一等公民
std::tuple<A, B, C>(A, B, C)一等公民语法,支持解构
std::pair<A, B>(A, B)仅包含两个元素的元组
std::bitset<N>标准库无等效项请使用 bitvec crate,或者 [u8; N/8]

关键差异点:

  • Rust 的 HashMap/HashSet 要求 K: Hash + Eq —— 编译器会在类型层面强制执行此要求,而不像 C++ 那样会在使用不可哈希的 Key 时在 STL 内部抛出深层的模板错误。
  • Vec 索引 (v[i]) 默认在越界时会触发 panic。建议使用 .get(i) 返回 Option<&T>,或者利用迭代器来完全避免越界检查。
  • 不存在 std::multimap 或 std::multiset —— 请使用 HashMap<K, Vec<V>> 或 BTreeMap<K, Vec<V>>。

异常安全性 → Panic 安全性

C++ 定义了三个级别的异常安全性(Abraham 保证):

C++ 级别含义Rust 等效概念
无抛出 (No-throw)函数绝不会抛出异常函数绝不会触发 panic(返回 Result)
强保证 (Strong)如果抛出异常,状态保持不变所有权模型使这一点变得非常自然 —— 如果 ? 提前返回,部分构建的值会被销毁
基本保证 (Basic)如果抛出异常,不变性依然维持Rust 的默认行为 —— Drop 会运行,不产生泄漏

Rust 所有权模型如何提供帮助

#![allow(unused)]
fn main() {
// 免费获得的强保证 —— 如果 file.write() 失败,config 保持不变
fn update_config(config: &mut Config, path: &str) -> Result<(), Error> {
    let new_data = fetch_from_network()?; // Err → 提前返回,config 未受影响
    let validated = validate(new_data)?;   // Err → 提前返回,config 未受影响
    *config = validated;                   // 仅在成功时到达此处(提交修改)
    Ok(())
}
}

在 C++ 中,为了实现强保证,需要手动进行回滚或使用 “copy-and-swap” 原语。在 Rust 中,? 的传播机制使得大多数代码默认就具备了强保证。

catch_unwind —— Rust 版的 catch(...)

#![allow(unused)]
fn main() {
use std::panic;

// 捕获 panic(类似于 C++ 中的 catch(...))—— 极少使用
let result = panic::catch_unwind(|| {
    // 可能会触发 panic 的代码
    let v = vec![1, 2, 3];
    v[10]  // Panic!(索引越界)
});

match result {
    Ok(val) => println!("获取到: {val}"),
    Err(_) => eprintln!("捕获到一次 panic —— 已完成清理"),
}
}

UnwindSafe —— 将类型标记为 panic 安全

#![allow(unused)]
fn main() {
use std::panic::UnwindSafe;

// 位于 &mut 之后的类型默认不是 UnwindSafe 的 —— 因为 panic 可能会
// 使其处于某种被部分修改的状态
fn safe_execute<F: FnOnce() + UnwindSafe>(f: F) {
    let _ = std::panic::catch_unwind(f);
}

// 当你已经对代码进行了审计,可以使用 AssertUnwindSafe 来覆盖默认行为:
use std::panic::AssertUnwindSafe;
let mut data = vec![1, 2, 3];
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
    data.push(4);
}));
}
C++ 异常模式Rust 等效方案
throw MyException()return Err(MyError::...) (推荐) 或 panic!("...")
try { } catch (const E& e)match result { Ok(v) => ..., Err(e) => ... } 或 ?
catch (...)std::panic::catch_unwind(...)
noexcept-> Result<T, E>(错误是值,而非异常)
栈展开过程中的 RAII 清理在 panic 展开期间会运行 Drop::drop()
std::uncaught_exceptions()std::thread::panicking()
-fno-exceptions 编译标志在 Cargo.toml 的 [profile] 中设置 panic = "abort"

底线:在 Rust 中,大多数代码使用 Result<T, E> 而非异常,这使得错误路径变得明确且可组合。panic! 仅保留给 Bug(例如 assert! 失败),而不用于处理常规错误。这意味着“异常安全性”在很大程度上不再是一个难题 —— 所有权系统会自动处理清理工作。


C++ 到 Rust 的迁移模式

快速参考:C++ → Rust 惯用写法映射表

C++ 模式Rust 惯用写法说明
class Derived : public Baseenum Variant { A {...}, B {...} }针对封闭集合,优先选择枚举
virtual void method() = 0trait MyTrait { fn method(&self); }用于开放/可扩展的接口
dynamic_cast<Derived*>(ptr)match value { Variant::A(data) => ..., }穷尽性检查,无运行时失败风险
vector<unique_ptr<Base>>Vec<Box<dyn Trait>>仅当确实需要多态性时使用
shared_ptr<T>Rc<T> 或 Arc<T>优先考虑 Box<T> 或所有权值
enable_shared_from_this<T>Arena 模式(Vec<T> + 索引)从根本上消除引用循环
每个类中都有 Base* m_pFrameworkfn execute(&mut self, ctx: &mut Context)传递上下文,不要存储指针
try { } catch (...) { }match result { Ok(v) => ..., Err(e) => ... }或是使用 ? 进行错误传播
std::optional<T>Option<T>强制要求 match,不会遗忘 None 的情况
const std::string& 参数&str 参数同时兼容 String 与 &str
enum class Foo { A, B, C }enum Foo { A, B, C }Rust 枚举还可以携带数据
auto x = std::move(obj)let x = obj;移动是默认行为,无需 std::move
CMake + make + lintcargo build / test / clippy / fmt一个工具搞定所有事情

迁移策略

  1. 从数据类型开始:首先翻译结构体和枚举 —— 这将迫使你思考所有权问题。
  2. 将工厂模式转换为枚举:如果一个工厂类会创建不同的派生类型,它可能应该被替换为 enum + match。
  3. 将上帝对象 (God Objects) 拆分为组合结构体:将相关的字段分组到聚焦的结构体中。
  4. 用借用替代指针:将存储的 Base* 指针转换为带有生命周期限定的 &'a T 借用。
  5. 谨慎使用 Box<dyn Trait>:仅将其用于插件系统和测试中的 Mock 模拟。
  6. 让编译器引导你:Rust 的错误提示非常出色 —— 请务必仔细阅读它们。

19. Rust 宏:从预处理器到元编程

English Original

Rust 宏:从预处理器到元编程

你将学到: Rust 宏的工作原理、何时应使用宏而非函数或泛型,以及它们是如何替代 C/C++ 预处理器的。在本章结束时,你将能够编写自己的 macro_rules! 宏,并理解 #[derive(Debug)] 的底层原理。

宏是你在 Rust 中最早接触到的事物之一(第一行代码中的 println!("hello")),但也是大多数课程最后才会讲解的内容。本章旨在填补这一空白。

为什么需要宏

函数和泛型处理了 Rust 中大部分的代码复用工作。而在类型系统无法触及的领域,宏填补了这些空白:

需求函数/泛型?宏?原因
计算一个值✅ fn max<T: Ord>(a: T, b: T) -> T—类型系统足以胜任
接受可变数量的参数❌ Rust 不支持变长参数函数 (Variadic Functions)✅ println!("{} {}", a, b)宏可以接受任意数量的标记 (Tokens)
生成重复的 impl 块❌ 单靠泛型无法实现✅ macro_rules!宏在编译期生成代码
在编译期运行代码❌ const fn 的功能有限✅ 过程宏 (Procedural Macros)可以在编译期运行完整的 Rust 代码
条件性地包含代码❌✅ #[cfg(...)]属性宏可以控制编译过程

如果你来自 C/C++ 背景,可以将宏看作是预处理器的唯一正确替代方案 —— 不同之处在于,Rust 宏操作的是语法树 (Syntax Tree) 而非原始文本,因此它们具有卫生性(Hygiene,无意外的命名冲突),并且具备类型感知能力。

致 C 开发者: Rust 宏完全替代了 #define。Rust 中不存在文本预处理器。关于预处理器到 Rust 的完整映射关系,请参阅 第 18 章。


使用 macro_rules! 编写声明式宏

声明式宏(也被称为“示例宏”)是 Rust 中最常见的宏形式。它们在语法上使用模式匹配,类似于在值上使用 match。

基本语法

macro_rules! say_hello {
    () => {
        println!("你好!");
    };
}

fn main() {
    say_hello!();  // 展开为:println!("你好!");
}

名称后的 ! 用于告诉调用者(以及编译器)这是一个宏调用。

带参数的模式匹配

宏使用“片段说明符 (Fragment Specifiers)”在标记树 (Token Trees) 上进行匹配:

macro_rules! greet {
    // 模式 1:无参数
    () => {
        println!("你好,世界!");
    };
    // 模式 2:有一个表达式参数
    ($name:expr) => {
        println!("你好,{}!", $name);
    };
}

fn main() {
    greet!();           // "你好,世界!"
    greet!("Rust");     // "你好,Rust!"
}

片段说明符参考

说明符匹配项示例
$x:expr任何表达式42, a + b, foo()
$x:ty类型i32, Vec<String>, &str
$x:ident标识符foo, my_var
$x:pat模式Some(x), _, (a, b)
$x:stmt语句let x = 5;
$x:block代码块{ println!("hi"); 42 }
$x:literal字面量42, "hello", true
$x:tt单个标记树任何内容 —— 通配符
$x:item项 (fn, struct, impl, 等)fn foo() {}

重复 (Repetition) —— 杀手锏功能

C/C++ 的宏无法进行循环操作。Rust 宏则可以重复特定的模式:

macro_rules! make_vec {
    // 匹配零个或多个由逗号分隔的表达式
    ( $( $element:expr ),* ) => {
        {
            let mut v = Vec::new();
            $( v.push($element); )*  // 对每一个匹配到的元素重复此操作
            v
        }
    };
}

fn main() {
    let v = make_vec![1, 2, 3, 4, 5];
    println!("{v:?}");  // [1, 2, 3, 4, 5]
}

$( ... ),* 这一语法表示“匹配零个或多个符合此模式的内容,并以逗号进行分隔”。在展开部分中的 $( ... )* 则会对每一个匹配结果重复展开一次。

这正是标准库中 vec![] 的实现方式。 实际的源代码如下:

#![allow(unused)]
fn main() {
macro_rules! vec {
    () => { Vec::new() };
    ($elem:expr; $n:expr) => { vec::from_elem($elem, $n) };
    ($($x:expr),+ $(,)?) => { <[_]>::into_vec(Box::new([$($x),+])) };
}
}

末尾的 $(,)? 允许可选的尾随逗号。

重复操作符

操作符含义示例
$( ... )*零个或多个vec![], vec![1], vec![1, 2, 3]
$( ... )+一个或多个要求至少有一个元素
$( ... )?零个或一个可选的元素

实战示例:hashmap! 构造器

标准库提供了 vec![] 但没有提供 hashmap!{}。让我们动手写一个:

macro_rules! hashmap {
    ( $( $key:expr => $value:expr ),* $(,)? ) => {
        {
            let mut map = std::collections::HashMap::new();
            $( map.insert($key, $value); )*
            map
        }
    };
}

fn main() {
    let scores = hashmap! {
        "Alice" => 95,
        "Bob" => 87,
        "Carol" => 92,  // 幸亏有了 $(,)?,尾随逗号也是可以接受的
    };
    println!("{scores:?}");
}

实战示例:诊断检查宏

一种在嵌入式/诊断代码中常见的模式 —— 检查某个条件并在不满足时返回错误:

#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
enum DiagError {
    #[error("检查失败: {0}")]
    CheckFailed(String),
}

macro_rules! diag_check {
    ($cond:expr, $msg:expr) => {
        if !($cond) {
            return Err(DiagError::CheckFailed($msg.to_string()));
        }
    };
}

fn run_diagnostics(temp: f64, voltage: f64) -> Result<(), DiagError> {
    diag_check!(temp < 85.0, "GPU 温度过高");
    diag_check!(voltage > 0.8, "导轨电压过低");
    diag_check!(voltage < 1.5, "导轨电压过高");
    println!("所有检查均已通过");
    Ok(())
}
}

C/C++ 对比:

// C 预处理器 —— 仅是文本替换,没有类型安全,没有卫生性
#define DIAG_CHECK(cond, msg) \
    do { if (!(cond)) { log_error(msg); return -1; } } while(0)

Rust 版本返回一个正式的 Result 类型,不存在多次求值 (Double-evaluation) 的风险,且编译器会检查 $cond 是否确实是一个 bool 类型的表达式。


卫生性 (Hygiene):为什么 Rust 宏是安全的

C/C++ 的宏 Bug 通常源于命名冲突:

// C:非常危险 —— 宏内部的 `x` 可能会遮蔽调用者的 `x`
#define SQUARE(x) ((x) * (x))
int x = 5;
int result = SQUARE(x++);  // 未定义行为 (UB):x 被递增了两次!

Rust 宏是有卫生性 (Hygienic) 的 —— 在宏内部创建的变量不会泄露到外部:

macro_rules! make_x {
    () => {
        let x = 42;  // 此处的 `x` 仅在宏展开的作用域内有效
    };
}

fn main() {
    let x = 10;
    make_x!();
    println!("{x}");  // 输出 10,而非 42 —— 卫生性防止了命名冲突
}

编译器会将宏内部的 x 与调用者的 x 视为不同的变量,即使它们的名称相同。这在使用 C 预处理器时是不可能实现的。


常用的标准库宏

自第 1 章开始你就一直在使用这些宏 —— 以下是它们的实际作用:

宏作用展开后(简化版)
println!("{}", x)格式化并打印至标准输出 + 换行std::io::_print(format_args!(...))
eprintln!("{}", x)打印至标准错误 + 换行同上,但在标准错误中输出
format!("{}", x)格式化并生成一个 String分配并返回一个 String
vec![1, 2, 3]创建一个包含指定元素的 VecVec::from([1, 2, 3]) (近似于此)
todo!()标记尚未完成的代码panic!("尚未实现")
unimplemented!()标记故意不予实现的代码panic!("未实现")
unreachable!()标记编译器无法证明不可达的代码panic!("不可达")
assert!(cond)当条件为 false 时触发 panicif !cond { panic!(...) }
assert_eq!(a, b)当两者不相等时触发 panic在失败时显示这两个值
dbg!(expr)将表达式及其值打印至 stderr 并返回该值eprintln!("[文件:行号] 表达式 = {:#?}", &expr); expr
include_str!("file.txt")在编译期将文件内容嵌入为 &str在编译期间读取该文件
include_bytes!("data.bin")在编译期将文件内容嵌入为 &[u8]在编译期间读取该文件
cfg!(condition)获取编译期的条件判定结果(作为 bool 值)根据目标环境返回 true 或 false
env!("VAR")在编译期读取环境变量如果该变量未设置,则编译失败
concat!("a", "b")在编译期拼接字面量"ab"

dbg! —— 你每天都会用到的调试宏

fn factorial(n: u32) -> u32 {
    if dbg!(n <= 1) {     // 输出:[src/main.rs:2] n <= 1 = false
        dbg!(1)           // 输出:[src/main.rs:3] 1 = 1
    } else {
        dbg!(n * factorial(n - 1))  // 输出中间计算过程的值
    }
}

fn main() {
    dbg!(factorial(4));   // 打印包含文件:行号在内的所有递归调用
}

dbg! 会返回它所包裹的值,因此你可以将其插入在任何位置而不影响程序原有的行为。它在标准错误 (stderr) 中输出(而非 stdout),因此不会干扰程序的输出结果。在提交代码之前,请务必移除所有的 dbg! 调用。


格式化字符串语法

由于 println!、format!、eprintln! 和 write! 都使用同一套格式化机制,这里提供一份快速参考指南:

#![allow(unused)]
fn main() {
let name = "传感器";
let value = 3.14159;
let count = 42;

println!("{name}");                    // 通过变量名引用 (Rust 1.58+)
println!("{}", name);                  // 位置参数
println!("{value:.2}");                // 保留两位小数:"3.14"
println!("{count:>10}");               // 右对齐,宽度为 10:"        42"
println!("{count:0>10}");              // 左侧补零:"0000000042"
println!("{count:#06x}");              // 带前缀的十六进制:"0x002a"
println!("{count:#010b}");             // 带前缀的二进制:"0b00101010"
println!("{value:?}");                 // 调试格式 (Debug)
println!("{value:#?}");                // 易读的调试格式 (Pretty-printed Debug)
}

致 C 开发者: 可以将其视为类型安全的 printf —— 编译器会检查 {:.2} 是否应用在了浮点数而非字符串上。绝不会出现 %s / %d 类型不匹配的 Bug。

致 C++ 开发者: 这取代了 std::cout << std::fixed << std::setprecision(2) << value 这种写法,取而代之的是单一且易读的格式化字符串。


派生宏 (Derive Macros)

在本书的绝大多数结构体中你都能看到 #[derive(...)]:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}
}

#[derive(Debug)] 是派生宏 —— 一种特殊的过程宏,它能够自动生成 Trait 的实现代码。以下是它实际生成的代码(简化版):

#![allow(unused)]
fn main() {
// #[derive(Debug)] 为 Point 结构体生成的代码:
impl std::fmt::Debug for Point {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Point")
            .field("x", &self.x)
            .field("y", &self.y)
            .finish()
    }
}
}

如果没有 #[derive(Debug)],你就必须为每一个结构体手动编写这样的 impl 块。

常用的派生 Trait

派生项生成的代码作用何时使用
Debug{:?} 格式化支持几乎总是使用 —— 开启调试打印支持
Clone.clone() 方法当你需要复制值时
Copy赋值时的隐式复制小型、仅限栈存储的类型(整数、[f64; 3])
PartialEq / Eq== 与 != 运算符当你需要进行相等性比较时
PartialOrd / Ord<, >, <=, >= 运算符当你需要对值进行排序时
Hash供 HashMap/HashSet 使用的哈希值用作 Map Key 的类型
DefaultType::default() 构造器具有合理的零值或空值的类型
serde::Serialize / DeserializeJSON/TOML 等序列化支持跨 API 边界传输的数据类型

派生决策树

我应该通过派生来实现它吗?
  │
  ├── 我的类型是否仅包含实现了该 Trait 的子类型?
  │     ├── 是 → #[derive] 将能正常工作
  │     └── 否 → 手动编写 impl(或者跳过它)
  │
  └── 用户是否会理所当然地预期该类型具备这种行为?
        ├── 是 → 进行派生 (Debug, Clone, PartialEq 几乎总是合理的)
        └── 否 → 不要派生(例如,不要为一个包含文件句柄的类型派生 Copy)

C++ 对比: #[derive(Clone)] 类似于自动生成一个正确的拷贝构造函数。#[derive(PartialEq)] 类似于自动生成一个对所有字段进行比较的 operator== —— 这在 C++20 中由 = default 的太空船运算符 (Spaceship Operator) 最终提供。


属性宏 (Attribute Macros)

属性宏会对它们所附加的项进行转换向。你已经使用过其中的好几个了:

#![allow(unused)]
fn main() {
#[test]                    // 将一个函数标记为测试函数
fn test_addition() {
    assert_eq!(2 + 2, 4);
}

#[cfg(target_os = "linux")] // 有条件地包含此函数
fn linux_only() { /* ... */ }

#[derive(Debug)]            // 自动生成 Debug 实现
struct MyType { /* ... */ }

#[allow(dead_code)]         // 抑制编译器警告
fn unused_helper() { /* ... */ }

#[must_use]                 // 如果返回值被丟弃,则发出警告
fn compute_checksum(data: &[u8]) -> u32 { /* ... */ }
}

常见的内置属性:

属性用途
#[test]标记为测试函数
#[cfg(...)]条件编译
#[derive(...)]自动生成 Trait 实现
#[allow(...)] / #[deny(...)] / #[warn(...)]控制 Lint 级别
#[must_use]对未使用的返回值发出警告
#[inline] / #[inline(always)]提示编译器内联该函数
#[repr(C)]使用 C 兼容的内存布局 (用于 FFI)
#[no_mangle]不要混淆符号名称 (用于 FFI)
#[deprecated]标记为已弃用(可附带可选的消息)

针对 C/C++ 开发者: 属性宏取代了预处理指令 (#pragma、__attribute__((...))) 以及特定于编译器的扩展。它们是语言语法的一部分,而非强行挂载的扩展。


过程宏 (Procedural Macros) (概念概览)

过程宏 (“Proc macros”) 是作为独立的 Rust 程序编写的。它们在编译期间运行并生成代码。它们比 macro_rules! 更加强大,但实现起来也更为复杂。

过程宏共有三种类型:

类型语法形式示例作用
函数式宏my_macro!(...)sql!(SELECT * FROM users)解析自定义语法,生成 Rust 代码
派生宏#[derive(MyTrait)]#[derive(Serialize)]根据结构体定义生成 Trait 实现
属性宏#[my_attr]#[tokio::main], #[instrument]对所修饰的项进行转换

你已经使用过过程宏了

  • 来自 thiserror 的 #[derive(Error)] —— 为错误枚举生成 Display 与 From 的实现。
  • 来自 serde 的 #[derive(Serialize, Deserialize)] —— 生成序列化/反序列化代码。
  • #[tokio::main] —— 将 async fn main() 转换为运行时的设置代码。
  • #[test] —— 由测试框架注册的内置过程宏。

何时编写你自己的过程宏

在学习本课程期间,你可能并不需要编写过程宏。只有在下列场景中它们才会派上用场:

  • 你需要在编译期检查结构体字段/枚举变体(派生宏)。
  • 你正在构建一种领域特定语言 (DSL)(函数式宏)。
  • 你需要转换函数的签名(属性宏)。

对于大部分代码来说,使用 macro_rules! 或普通函数就已经足够了。

C++ 对比: 过程宏填补了 C++ 中代码生成器、模板元编程以及像 protoc 之类的外部工具所扮演的角色。不同之处在于,过程宏是 Cargo 构建流水线的一部分 —— 既不需要外部构建步骤,也不需要 CMake 的自定义命令。


何时该使用哪种方案:宏 vs 函数 vs 泛型

需要生成代码吗?
  │
  ├── 否 → 使用普通函数或泛型函数
  │         (更简单,拥有更佳的错误提示与 IDE 支持)
  │
  └── 是 ─┬── 参数数量可变吗?
            │     └── 是 → 使用 macro_rules! (例如 println!、vec!)
            │
            ├── 需要为多个类型生成重复的 impl 块吗?
            │     └── 是 → 使用带有重复机制的 macro_rules!
            │
            ├── 需要检查结构体字段吗?
            │     └── 是 → 派生宏 (过程宏)
            │
            ├── 需要自定义语法 (DSL) 吗?
            │     └── 是 → 函数式过程宏
            │
            └── 需要转换一个函数/结构体吗?
                  └── 是 → 属性过程宏

通用准则: 如果普通函数或泛型能够胜任,就不要使用宏。宏的错误提示较差,且在宏体内部缺乏 IDE 的自动补全支持,调试起来也更为困难。


练习

🟢 练习 1:min! 宏

编写一个 min! 宏,要求:

  • min!(a, b) 返回两个值中较小的一个。
  • min!(a, b, c) 返回三个值中最小的一个。
  • 适用于任何实现了 PartialOrd 的类型。

提示: 你需要在 macro_rules! 中编写两个匹配分支。

解决方案 (点击展开)
macro_rules! min {
    ($a:expr, $b:expr) => {
        if $a < $b { $a } else { $b }
    };
    ($a:expr, $b:expr, $c:expr) => {
        min!(min!($a, $b), $c)
    };
}

fn main() {
    println!("{}", min!(3, 7));        // 3
    println!("{}", min!(9, 2, 5));     // 2
    println!("{}", min!(1.5, 0.3));    // 0.3
}

注意: 对于生产环境的代码,应当优先使用 std::cmp::min 或 a.min(b)。此练习仅用于通过演示多分支宏的机制。


🟡 练习 2:从零开始编写 hashmap!

在不查看上方示例的前提下,尝试编写一个 hashmap! 宏,要求:

  • 利用 key => value 键值对创建一个 HashMap。
  • 支持尾随逗号。
  • 适用于任何可哈希的键类型。

使用以下代码进行测试:

#![allow(unused)]
fn main() {
let m = hashmap! {
    "name" => "Alice",
    "role" => "工程师",
};
assert_eq!(m["name"], "Alice");
assert_eq!(m.len(), 2);
}
解决方案 (点击展开)
use std::collections::HashMap;

macro_rules! hashmap {
    ( $( $key:expr => $val:expr ),* $(,)? ) => {{
        let mut map = HashMap::new();
        $( map.insert($key, $val); )*
        map
    }};
}

fn main() {
    let m = hashmap! {
        "name" => "Alice",
        "role" => "工程师",
    };
    assert_eq!(m["name"], "Alice");
    assert_eq!(m.len(), 2);
    println!("测试通过!");
}

🟡 练习 3:用于浮点数比较的 assert_approx_eq!

编写一个 assert_approx_eq!(a, b, epsilon) 宏,如果 |a - b| > epsilon 则触发 panic。在精确相等判断失效的浮点数计算测试中,这个宏非常有用。

使用以下代码进行测试:

#![allow(unused)]
fn main() {
assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);        // 应该通过
assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4); // 应该通过
// assert_approx_eq!(1.0, 2.0, 0.5);              // 应该触发 panic
}
解决方案 (点击展开)
macro_rules! assert_approx_eq {
    ($a:expr, $b:expr, $eps:expr) => {
        let (a, b, eps) = ($a as f64, $b as f64, $eps as f64);
        let diff = (a - b).abs();
        if diff > eps {
            panic!(
                "断言失败:|{} - {}| = {} > {} (epsilon)",
                a, b, diff, eps
            );
        }
    };
}

fn main() {
    assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);
    assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4);
    println!("所有浮点数比较均已通过!");
}

🔴 练习 4:impl_display_for_enum!

编写一个宏,为简单的类 C 枚举生成 Display Trait 的实现。已知:

#![allow(unused)]
fn main() {
impl_display_for_enum! {
    enum Color {
        Red => "红色",
        Green => "绿色",
        Blue => "蓝色",
    }
}
}

该宏应当同时生成 enum Color { Red, Green, Blue } 的定义,以及将每个变体映射到其对应字符串的 impl Display for Color 实现。

提示: 你需要同时使用 $( ... ),* 重复机制和多个片段说明符。

解决方案 (点击展开)
use std::fmt;

macro_rules! impl_display_for_enum {
    (enum $name:ident { $( $variant:ident => $display:expr ),* $(,)? }) => {
        #[derive(Debug, Clone, Copy, PartialEq)]
        enum $name {
            $( $variant ),*
        }

        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                match self {
                    $( $name::$variant => write!(f, "{}", $display), )*
                }
            }
        }
    };
}

impl_display_for_enum! {
    enum Color {
        Red => "红色",
        Green => "绿色",
        Blue => "蓝色",
    }
}

fn main() {
    let c = Color::Green;
    println!("颜色: {c}");          // "颜色: 绿色"
    println!("调试打印: {c:?}");    // "调试打印: Green"
    assert_eq!(format!("{}", Color::Red), "红色");
    println!("所有测试已通过!");
}

Rust Bootstrap Course for C/C++ Programmers

Course Overview

  • Course overview
    • The case for Rust (from both C and C++ perspectives)
    • Local installation
    • Types, functions, control flow, pattern matching
    • Modules, cargo
    • Traits, generics
    • Collections, error handling
    • Closures, memory management, lifetimes, smart pointers
    • Concurrency
    • Unsafe Rust, including Foreign Function Interface (FFI)
    • no_std and embedded Rust essentials for firmware teams
    • Case studies: real-world C++ to Rust translation patterns
  • We’ll not cover async Rust in this course — see the companion Async Rust Training for a full treatment of futures, executors, Pin, tokio, and production async patterns

Self-Study Guide

This material works both as an instructor-led course and for self-study. If you’re working through it on your own, here’s how to get the most out of it:

Pacing recommendations:

ChaptersTopicSuggested TimeCheckpoint
1–4Setup, types, control flow1 dayYou can write a CLI temperature converter
5–7Data structures, ownership1–2 daysYou can explain why let s2 = s1 invalidates s1
8–9Modules, error handling1 dayYou can create a multi-file project that propagates errors with ?
10–12Traits, generics, closures1–2 daysYou can write a generic function with trait bounds
13–14Concurrency, unsafe/FFI1 dayYou can write a thread-safe counter with Arc<Mutex<T>>
15–16Deep divesAt your own paceReference material — read when relevant
17–19Best practices & referenceAt your own paceConsult as you write real code

How to use the exercises:

  • Every chapter has hands-on exercises marked with difficulty: 🟢 Starter, 🟡 Intermediate, 🔴 Challenge
  • Always try the exercise before expanding the solution. Struggling with the borrow checker is part of learning — the compiler’s error messages are your teacher
  • If you’re stuck for more than 15 minutes, expand the solution, study it, then close it and try again from scratch
  • The Rust Playground lets you run code without a local install

When you hit a wall:

  • Read the compiler error message carefully — Rust’s errors are exceptionally helpful
  • Re-read the relevant section; concepts like ownership (ch7) often click on the second pass
  • The Rust standard library docs are excellent — search for any type or method
  • For async patterns, see the companion Async Rust Training

Table of Contents

Part I — Foundations

1. Introduction and Motivation

2. Getting Started

3. Basic Types and Variables

4. Control Flow

5. Data Structures and Collections

6. Pattern Matching and Enums

7. Ownership and Memory Management

8. Modules and Crates

9. Error Handling

10. Traits and Generics

11. Type System Advanced Features

12. Functional Programming

13. Concurrency

14. Unsafe Rust and FFI

Part II — Deep Dives

15. no_std — Rust for Bare Metal

16. Case Studies: Real-World C++ to Rust Translation

Part III — Best Practices & Reference

17. Best Practices

18. C++ → Rust Semantic Deep Dives

19. Rust Macros

Why C/C++ Developers Need Rust

What you’ll learn:

  • The full list of problems Rust eliminates — memory safety, undefined behavior, data races, and more
  • Why shared_ptr, unique_ptr, and other C++ mitigations are bandaids, not solutions
  • Concrete C and C++ vulnerability examples that are structurally impossible in safe Rust

Want to skip straight to code? Jump to Show me some code

What Rust Eliminates — The Complete List

Before diving into examples, here’s the executive summary. Safe Rust structurally prevents every issue in this list — not through discipline, tooling, or code review, but through the type system and compiler:

Eliminated IssueCC++How Rust Prevents It
Buffer overflows / underflows✅✅All arrays, slices, and strings carry bounds; indexing is checked at runtime
Memory leaks (no GC needed)✅✅Drop trait = RAII done right; automatic cleanup, no Rule of Five
Dangling pointers✅✅Lifetime system proves references outlive their referent at compile time
Use-after-free✅✅Ownership system makes this a compile error
Use-after-move—✅Moves are destructive — the original binding ceases to exist
Uninitialized variables✅✅All variables must be initialized before use; compiler enforces it
Integer overflow / underflow UB✅✅Debug builds panic on overflow; release builds wrap (defined behavior either way)
NULL pointer dereferences / SEGVs✅✅No null pointers; Option<T> forces explicit handling
Data races✅✅Send/Sync traits + borrow checker make data races a compile error
Uncontrolled side-effects✅✅Immutability by default; mutation requires explicit mut
No inheritance (better maintainability)—✅Traits + composition replace class hierarchies; promotes reuse without coupling
No exceptions; predictable control flow—✅Errors are values (Result<T, E>); impossible to ignore, no hidden throw paths
Iterator invalidation—✅Borrow checker forbids mutating a collection while iterating
Reference cycles / leaked finalizers—✅Ownership is tree-shaped; Rc cycles are opt-in and catchable with Weak
No forgotten mutex unlocks✅✅Mutex<T> wraps the data; lock guard is the only way to access it
Undefined behavior (general)✅✅Safe Rust has zero undefined behavior; unsafe blocks are explicit and auditable

Bottom line: These aren’t aspirational goals enforced by coding standards. They are compile-time guarantees. If your code compiles, these bugs cannot exist.


The Problems Shared by C and C++

Want to skip the examples? Jump to How Rust Addresses All of This or straight to Show me some code

Both languages share a core set of memory safety problems that are the root cause of over 70% of CVEs (Common Vulnerabilities and Exposures):

Buffer overflows

C arrays, pointers, and strings have no intrinsic bounds. It is trivially easy to exceed them:

#include <stdlib.h>
#include <string.h>

void buffer_dangers() {
    char buffer[10];
    strcpy(buffer, "This string is way too long!");  // Buffer overflow

    int arr[5] = {1, 2, 3, 4, 5};
    int *ptr = arr;           // Loses size information
    ptr[10] = 42;             // No bounds check — undefined behavior
}

In C++, std::vector::operator[] still performs no bounds checking. Only .at() does — and who catches the exception?

Dangling pointers and use-after-free

int *bar() {
    int i = 42;
    return &i;    // Returns address of stack variable — dangling!
}

void use_after_free() {
    char *p = (char *)malloc(20);
    free(p);
    *p = '\0';   // Use after free — undefined behavior
}

Uninitialized variables and undefined behavior

C and C++ both allow uninitialized variables. The resulting values are indeterminate, and reading them is undefined behavior:

int x;               // Uninitialized
if (x > 0) { ... }  // UB — x could be anything

Integer overflow is defined in C for unsigned types but undefined for signed types. In C++, signed overflow is also undefined behavior. Both compilers can and do exploit this for “optimizations” that break programs in surprising ways.

NULL pointer dereferences

int *ptr = NULL;
*ptr = 42;           // SEGV — but the compiler won't stop you

In C++, std::optional<T> helps but is verbose and often bypassed with .value() which throws.

The visualization: shared problems

graph TD
    ROOT["C/C++ Memory Safety Issues"] --> BUF["Buffer Overflows"]
    ROOT --> DANGLE["Dangling Pointers"]
    ROOT --> UAF["Use-After-Free"]
    ROOT --> UNINIT["Uninitialized Variables"]
    ROOT --> NULL["NULL Dereferences"]
    ROOT --> UB["Undefined Behavior"]
    ROOT --> RACE["Data Races"]

    BUF --> BUF1["No bounds on arrays/pointers"]
    DANGLE --> DANGLE1["Returning stack addresses"]
    UAF --> UAF1["Reusing freed memory"]
    UNINIT --> UNINIT1["Indeterminate values"]
    NULL --> NULL1["No forced null checks"]
    UB --> UB1["Signed overflow, aliasing"]
    RACE --> RACE1["No compile-time safety"]

    style ROOT fill:#ff6b6b,color:#000
    style BUF fill:#ffa07a,color:#000
    style DANGLE fill:#ffa07a,color:#000
    style UAF fill:#ffa07a,color:#000
    style UNINIT fill:#ffa07a,color:#000
    style NULL fill:#ffa07a,color:#000
    style UB fill:#ffa07a,color:#000
    style RACE fill:#ffa07a,color:#000

C++ Adds More Problems on Top

C audience: You can skip ahead to How Rust Addresses These Issues if you don’t use C++.

Want to skip straight to code? Jump to Show me some code

C++ introduced smart pointers, RAII, move semantics, and exceptions to address C’s problems. These are bandaids, not cures — they shift the failure mode from “crash at runtime” to “subtler bug at runtime”:

unique_ptr and shared_ptr — bandaids, not solutions

C++ smart pointers are a significant improvement over raw malloc/free, but they don’t solve the underlying problems:

C++ MitigationWhat It FixesWhat It Doesn’t Fix
std::unique_ptrPrevents leaks via RAIIUse-after-move still compiles; leaves a zombie nullptr
std::shared_ptrShared ownershipReference cycles leak silently; weak_ptr discipline is manual
std::optionalReplaces some null use.value() throws if empty — hidden control flow
std::string_viewAvoids copiesDangling if the source string is freed — no lifetime checking
Move semanticsEfficient transfersMoved-from objects are in a “valid but unspecified state” — UB waiting to happen
RAIIAutomatic cleanupRequires the Rule of Five to get right; one mistake breaks everything
// unique_ptr: use-after-move compiles cleanly
std::unique_ptr<int> ptr = std::make_unique<int>(42);
std::unique_ptr<int> ptr2 = std::move(ptr);
std::cout << *ptr;  // Compiles! Undefined behavior at runtime.
                     // In Rust, this is a compile error: "value used after move"
// shared_ptr: reference cycles leak silently
struct Node {
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> parent;  // Cycle! Destructor never called.
};
auto a = std::make_shared<Node>();
auto b = std::make_shared<Node>();
a->next = b;
b->parent = a;  // Memory leak — ref count never reaches 0
                 // In Rust, Rc<T> + Weak<T> makes cycles explicit and breakable

Use-after-move — the silent killer

C++ std::move is not a move — it’s a cast. The original object remains in a “valid but unspecified state”. The compiler lets you keep using it:

auto vec = std::make_unique<std::vector<int>>({1, 2, 3});
auto vec2 = std::move(vec);
vec->size();  // Compiles! But dereferencing nullptr — crash at runtime

In Rust, moves are destructive. The original binding is gone:

#![allow(unused)]
fn main() {
let vec = vec![1, 2, 3];
let vec2 = vec;           // Move — vec is consumed
// vec.len();             // Compile error: value used after move
}

Iterator invalidation — real bugs from production C++

These aren’t contrived examples — they represent real bug patterns found in large C++ codebases:

// BUG 1: erase without reassigning iterator (undefined behavior)
while (it != pending_faults.end()) {
    if (*it != nullptr && (*it)->GetId() == fault->GetId()) {
        pending_faults.erase(it);   // ← iterator invalidated!
        removed_count++;            //   next loop uses dangling iterator
    } else {
        ++it;
    }
}
// Fix: it = pending_faults.erase(it);
// BUG 2: index-based erase skips elements
for (auto i = 0; i < entries.size(); i++) {
    if (config_status == ConfigDisable::Status::Disabled) {
        entries.erase(entries.begin() + i);  // ← shifts elements
    }                                         //   i++ skips the shifted one
}
// BUG 3: one erase path correct, the other isn't
while (it != incomplete_ids.end()) {
    if (current_action == nullptr) {
        incomplete_ids.erase(it);  // ← BUG: iterator not reassigned
        continue;
    }
    it = incomplete_ids.erase(it); // ← Correct path
}

These compile without any warning. In Rust, the borrow checker makes all three a compile error — you cannot mutate a collection while iterating over it, period.

Exception safety and the dynamic_cast/new pattern

Modern C++ codebases still lean heavily on patterns that have no compile-time safety:

// Typical C++ factory pattern — every branch is a potential bug
DriverBase* driver = nullptr;
if (dynamic_cast<ModelA*>(device)) {
    driver = new DriverForModelA(framework);
} else if (dynamic_cast<ModelB*>(device)) {
    driver = new DriverForModelB(framework);
}
// What if driver is still nullptr? What if new throws? Who owns driver?

In a typical 100K-line C++ codebase you might find hundreds of dynamic_cast calls (each a potential runtime failure), hundreds of raw new calls (each a potential leak), and hundreds of virtual/override methods (vtable overhead everywhere).

Dangling references and lambda captures

int& get_reference() {
    int x = 42;
    return x;  // Dangling reference — compiles, UB at runtime
}

auto make_closure() {
    int local = 42;
    return [&local]() { return local; };  // Dangling capture!
}

The visualization: C++ additional problems

graph TD
    ROOT["C++ Additional Problems<br/>(on top of C issues)"] --> UAM["Use-After-Move"]
    ROOT --> CYCLE["Reference Cycles"]
    ROOT --> ITER["Iterator Invalidation"]
    ROOT --> EXC["Exception Safety"]
    ROOT --> TMPL["Template Error Messages"]

    UAM --> UAM1["std::move leaves zombie<br/>Compiles without warning"]
    CYCLE --> CYCLE1["shared_ptr cycles leak<br/>Destructor never called"]
    ITER --> ITER1["erase() invalidates iterators<br/>Real production bugs"]
    EXC --> EXC1["Partial construction<br/>new without try/catch"]
    TMPL --> TMPL1["30+ lines of nested<br/>template instantiation errors"]

    style ROOT fill:#ff6b6b,color:#000
    style UAM fill:#ffa07a,color:#000
    style CYCLE fill:#ffa07a,color:#000
    style ITER fill:#ffa07a,color:#000
    style EXC fill:#ffa07a,color:#000
    style TMPL fill:#ffa07a,color:#000

How Rust Addresses All of This

Every problem listed above — from both C and C++ — is prevented by Rust’s compile-time guarantees:

ProblemRust’s Solution
Buffer overflowsSlices carry length; indexing is bounds-checked
Dangling pointers / use-after-freeLifetime system proves references are valid at compile time
Use-after-moveMoves are destructive — compiler refuses to let you touch the original
Memory leaksDrop trait = RAII without the Rule of Five; automatic, correct cleanup
Reference cyclesOwnership is tree-shaped; Rc + Weak makes cycles explicit
Iterator invalidationBorrow checker forbids mutating a collection while borrowing it
NULL pointersNo null. Option<T> forces explicit handling via pattern matching
Data racesSend/Sync traits make data races a compile error
Uninitialized variablesAll variables must be initialized; compiler enforces it
Integer UBDebug panics on overflow; release wraps (both defined behavior)
ExceptionsNo exceptions; Result<T, E> is visible in type signatures, propagated with ?
Inheritance complexityTraits + composition; no Diamond Problem, no vtable fragility
Forgotten mutex unlocksMutex<T> wraps the data; lock guard is the only access path
#![allow(unused)]
fn main() {
fn rust_prevents_everything() {
    // ✅ No buffer overflow — bounds checked
    let arr = [1, 2, 3, 4, 5];
    // arr[10];  // panic at runtime, never UB

    // ✅ No use-after-move — compile error
    let data = vec![1, 2, 3];
    let moved = data;
    // data.len();  // error: value used after move

    // ✅ No dangling pointer — lifetime error
    // let r;
    // { let x = 5; r = &x; }  // error: x does not live long enough

    // ✅ No null — Option forces handling
    let maybe: Option<i32> = None;
    // maybe.unwrap();  // panic, but you'd use match or if let instead

    // ✅ No data race — compile error
    // let mut shared = vec![1, 2, 3];
    // std::thread::spawn(|| shared.push(4));  // error: closure may outlive
    // shared.push(5);                         //   borrowed value
}
}

Rust’s safety model — the full picture

graph TD
    RUST["Rust Safety Guarantees"] --> OWN["Ownership System"]
    RUST --> BORROW["Borrow Checker"]
    RUST --> TYPES["Type System"]
    RUST --> TRAITS["Send/Sync Traits"]

    OWN --> OWN1["No use-after-free<br/>No use-after-move<br/>No double-free"]
    BORROW --> BORROW1["No dangling references<br/>No iterator invalidation<br/>No data races through refs"]
    TYPES --> TYPES1["No NULL (Option&lt;T&gt;)<br/>No exceptions (Result&lt;T,E&gt;)<br/>No uninitialized values"]
    TRAITS --> TRAITS1["No data races<br/>Send = safe to transfer<br/>Sync = safe to share"]

    style RUST fill:#51cf66,color:#000
    style OWN fill:#91e5a3,color:#000
    style BORROW fill:#91e5a3,color:#000
    style TYPES fill:#91e5a3,color:#000
    style TRAITS fill:#91e5a3,color:#000

Quick Reference: C vs C++ vs Rust

ConceptCC++RustKey Difference
Memory managementmalloc()/free()unique_ptr, shared_ptrBox<T>, Rc<T>, Arc<T>Automatic, no cycles, no zombies
Arraysint arr[10]std::vector<T>, std::array<T>Vec<T>, [T; N]Bounds checking by default
Stringschar* with \0std::string, string_viewString, &strUTF-8 guaranteed, lifetime-checked
Referencesint* (raw)T&, T&& (move)&T, &mut TLifetime + borrow checking
PolymorphismFunction pointersVirtual functions, inheritanceTraits, trait objectsComposition over inheritance
GenericsMacros / void*TemplatesGenerics + trait boundsClear error messages
Error handlingReturn codes, errnoExceptions, std::optionalResult<T, E>, Option<T>No hidden control flow
NULL safetyptr == NULLnullptr, std::optional<T>Option<T>Forced null checking
Thread safetyManual (pthreads)Manual (std::mutex, etc.)Compile-time Send/SyncData races impossible
Build systemMake, CMakeCMake, Make, etc.CargoIntegrated toolchain
Undefined behaviorRampantSubtle (signed overflow, aliasing)Zero in safe codeSafety guaranteed

Speaker intro and general approach

What you’ll learn: Course structure, the interactive format, and how familiar C/C++ concepts map to Rust equivalents. This chapter sets expectations and gives you a roadmap for the rest of the book.

  • Speaker intro
    • Principal Firmware Architect in Microsoft SCHIE (Silicon and Cloud Hardware Infrastructure Engineering) team
    • Industry veteran with expertise in security, systems programming (firmware, operating systems, hypervisors), CPU and platform architecture, and C++ systems
    • Started programming in Rust in 2017 (@AWS EC2), and have been in love with the language ever since
  • This course is intended to be as interactive as possible
    • Assumption: You know C, C++, or both
    • Examples are deliberately designed to map familiar concepts to Rust equivalents
    • Please feel free to ask clarifying questions at any point of time
  • Speaker is looking forward to continued engagement with teams

The case for Rust

Want to skip straight to code? Jump to Show me some code

Whether you’re coming from C or C++, the core pain points are the same: memory safety bugs that compile cleanly but crash, corrupt, or leak at runtime.

  • Over 70% of CVEs are caused by memory safety issues — buffer overflows, dangling pointers, use-after-free
  • C++ shared_ptr, unique_ptr, RAII, and move semantics are steps in the right direction, but they are bandaids, not cures — they leave use-after-move, reference cycles, iterator invalidation, and exception safety gaps wide open
  • Rust provides the performance you rely on from C/C++, but with compile-time guarantees for safety

📖 Deep dive: See Why C/C++ Developers Need Rust for concrete vulnerability examples, the complete list of what Rust eliminates, and why C++ smart pointers aren’t enough


How does Rust address these issues?

Buffer overflows and bounds violations

  • All Rust arrays, slices, and strings have explicit bounds associated with them. The compiler inserts checks to ensure that any bounds violation results in a runtime crash (panic in Rust terms) — never undefined behavior

Dangling pointers and references

  • Rust introduces lifetimes and borrow checking to eliminate dangling references at compile time
  • No dangling pointers, no use-after-free — the compiler simply won’t let you

Use-after-move

  • Rust’s ownership system makes moves destructive — once you move a value, the compiler refuses to let you use the original. No zombie objects, no “valid but unspecified state”

Resource management

  • Rust’s Drop trait is RAII done right — the compiler automatically frees resources when they go out of scope, and prevents use-after-move which C++ RAII cannot
  • No Rule of Five needed (no copy ctor, move ctor, copy assign, move assign, destructor to define)

Error handling

  • Rust has no exceptions. All errors are values (Result<T, E>), making error handling explicit and visible in the type signature

Iterator invalidation

  • Rust’s borrow checker forbids modifying a collection while iterating over it. You simply cannot write the bugs that plague C++ codebases:
#![allow(unused)]
fn main() {
// Rust equivalent of erase-during-iteration: retain()
pending_faults.retain(|f| f.id != fault_to_remove.id);

// Or: collect into a new Vec (functional style)
let remaining: Vec<_> = pending_faults
    .into_iter()
    .filter(|f| f.id != fault_to_remove.id)
    .collect();
}

Data races

  • The type system prevents data races at compile time through the Send and Sync traits

Memory Safety Visualization

Rust Ownership — Safe by Design

#![allow(unused)]
fn main() {
fn safe_rust_ownership() {
    // Move is destructive: original is gone
    let data = vec![1, 2, 3];
    let data2 = data;           // Move happens
    // data.len();              // Compile error: value used after move
    
    // Borrowing: safe shared access
    let owned = String::from("Hello, World!");
    let slice: &str = &owned;  // Borrow — no allocation
    println!("{}", slice);     // Always safe
    
    // No dangling references possible
    /*
    let dangling_ref;
    {
        let temp = String::from("temporary");
        dangling_ref = &temp;  // Compile error: temp doesn't live long enough
    }
    */
}
}
graph TD
    A[Rust Ownership Safety] --> B[Destructive Moves]
    A --> C[Automatic Memory Management]
    A --> D[Compile-time Lifetime Checking]
    A --> E[No Exceptions — Result Types]
    
    B --> B1["Use-after-move is compile error"]
    B --> B2["No zombie objects"]
    
    C --> C1["Drop trait = RAII done right"]
    C --> C2["No Rule of Five needed"]
    
    D --> D1["Borrow checker prevents dangling"]
    D --> D2["References always valid"]
    
    E --> E1["Result<T,E> — errors in types"]
    E --> E2["? operator for propagation"]
    
    style A fill:#51cf66,color:#000
    style B fill:#91e5a3,color:#000
    style C fill:#91e5a3,color:#000
    style D fill:#91e5a3,color:#000
    style E fill:#91e5a3,color:#000

Memory Layout: Rust References

graph TD
    RM1[Stack] --> RP1["&i32 ref"]
    RM2[Stack/Heap] --> RV1["i32 value = 42"]
    RP1 -.->|"Safe reference — Lifetime checked"| RV1
    RM3[Borrow Checker] --> RC1["Prevents dangling refs at compile time"]
    
    style RC1 fill:#51cf66,color:#000
    style RP1 fill:#91e5a3,color:#000

Box<T> Heap Allocation Visualization

#![allow(unused)]
fn main() {
fn box_allocation_example() {
    // Stack allocation
    let stack_value = 42;
    
    // Heap allocation with Box
    let heap_value = Box::new(42);
    
    // Moving ownership
    let moved_box = heap_value;
    // heap_value is no longer accessible
}
}
graph TD
    subgraph "Stack Frame"
        SV["stack_value: 42"]
        BP["heap_value: Box<i32>"]
        BP2["moved_box: Box<i32>"]
    end
    
    subgraph "Heap"
        HV["42"]
    end
    
    BP -->|"Owns"| HV
    BP -.->|"Move ownership"| BP2
    BP2 -->|"Now owns"| HV
    
    subgraph "After Move"
        BP_X["heap_value: [WARNING] MOVED"]
        BP2_A["moved_box: Box<i32>"]
    end
    
    BP2_A -->|"Owns"| HV
    
    style BP_X fill:#ff6b6b,color:#000
    style HV fill:#91e5a3,color:#000
    style BP2_A fill:#51cf66,color:#000

Slice Operations Visualization

#![allow(unused)]
fn main() {
fn slice_operations() {
    let data = vec![1, 2, 3, 4, 5, 6, 7, 8];
    
    let full_slice = &data[..];        // [1,2,3,4,5,6,7,8]
    let partial_slice = &data[2..6];   // [3,4,5,6]
    let from_start = &data[..4];       // [1,2,3,4]
    let to_end = &data[3..];           // [4,5,6,7,8]
}
}
graph TD
    V["Vec: [1, 2, 3, 4, 5, 6, 7, 8]"]
    V --> FS["&data[..] → all elements"]
    V --> PS["&data[2..6] → [3, 4, 5, 6]"]
    V --> SS["&data[..4] → [1, 2, 3, 4]"]
    V --> ES["&data[3..] → [4, 5, 6, 7, 8]"]
    
    style V fill:#e3f2fd,color:#000
    style FS fill:#91e5a3,color:#000
    style PS fill:#91e5a3,color:#000
    style SS fill:#91e5a3,color:#000
    style ES fill:#91e5a3,color:#000

Other Rust USPs and features

  • No data races between threads (compile-time Send/Sync checking)
  • No use-after-move (unlike C++ std::move which leaves zombie objects)
  • No uninitialized variables
    • All variables must be initialized before use
  • No trivial memory leaks
    • Drop trait = RAII done right, no Rule of Five needed
    • Compiler automatically releases memory when it goes out of scope
  • No forgotten locks on mutexes
    • Lock guards are the only way to access the data (Mutex<T> wraps the data, not the access)
  • No exception handling complexity
    • Errors are values (Result<T, E>), visible in function signatures, propagated with ?
  • Excellent support for type inference, enums, pattern matching, zero cost abstractions
  • Built-in support for dependency management, building, testing, formatting, linting
    • cargo replaces make/CMake + lint + test frameworks

Quick Reference: Rust vs C/C++

ConceptCC++RustKey Difference
Memory managementmalloc()/free()unique_ptr, shared_ptrBox<T>, Rc<T>, Arc<T>Automatic, no cycles
Arraysint arr[10]std::vector<T>, std::array<T>Vec<T>, [T; N]Bounds checking by default
Stringschar* with \0std::string, string_viewString, &strUTF-8 guaranteed, lifetime-checked
Referencesint* ptrT&, T&& (move)&T, &mut TBorrow checking, lifetimes
PolymorphismFunction pointersVirtual functions, inheritanceTraits, trait objectsComposition over inheritance
Generic programmingMacros (void*)TemplatesGenerics + trait boundsBetter error messages
Error handlingReturn codes, errnoExceptions, std::optionalResult<T, E>, Option<T>No hidden control flow
NULL/null safetyptr == NULLnullptr, std::optional<T>Option<T>Forced null checking
Thread safetyManual (pthreads)Manual synchronizationCompile-time guaranteesData races impossible
Build systemMake, CMakeCMake, Make, etc.CargoIntegrated toolchain
Undefined behaviorRuntime crashesSubtle UB (signed overflow, aliasing)Compile-time errorsSafety guaranteed

Enough talk already: Show me some code

What you’ll learn: Your first Rust program — fn main(), println!(), and how Rust macros differ fundamentally from C/C++ preprocessor macros. By the end you’ll be able to write, compile, and run simple Rust programs.

fn main() {
    println!("Hello world from Rust");
}
  • The above syntax should be similar to anyone familiar with C-style languages
    • All functions in Rust begin with the fn keyword
    • The default entry point for executables is main()
    • The println! looks like a function, but is actually a macro. Macros in Rust are very different from C/C++ preprocessor macros — they are hygienic, type-safe, and operate on the syntax tree rather than text substitution
  • Two great ways to quickly try out Rust snippets:
    • Online: Rust Playground — paste code, hit Run, share results. No install needed
    • Local REPL: Install evcxr_repl for an interactive Rust REPL (like Python’s REPL, but for Rust):
cargo install --locked evcxr_repl
evcxr   # Start the REPL, type Rust expressions interactively

Rust Local installation

  • Rust can be locally installed using the following methods
    • Windows: https://static.rust-lang.org/rustup/dist/x86_64-pc-windows-msvc/rustup-init.exe
    • Linux / WSL: curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
  • The Rust ecosystem is composed of the following components
    • rustc is the standalone compiler, but it’s seldom used directly
    • The preferred tool, cargo is the Swiss Army knife and is used for dependency management, building, testing, formatting, linting, etc.
    • The Rust toolchain comes in the stable, beta and nightly (experimental) channels, but we’ll stick with stable. Use the rustup update command to upgrade the stable installation that’s released every six weeks
  • We’ll also install the rust-analyzer plug-in for VSCode

Rust packages (crates)

  • Rust binaries are created using packages (hereby called crates)
    • A crate may either be standalone, or may have dependency on other crates. The crates for the dependencies can be local or remote. Third-party crates are typically downloaded from a centralized repository called crates.io.
    • The cargo tool automatically handles the downloading of crates and their dependencies. This is conceptually equivalent to linking to C-libraries
    • Crate dependencies are expressed in a file called Cargo.toml. It also defines the target type for the crate: standalone executable, static library, dynamic library (uncommon)
    • Reference: https://doc.rust-lang.org/cargo/reference/cargo-targets.html

Cargo vs Traditional C Build Systems

Dependency Management Comparison

graph TD
    subgraph "Traditional C Build Process"
        CC["C Source Files<br/>(.c, .h)"]
        CM["Manual Makefile<br/>or CMake"]
        CL["Linker"]
        CB["Final Binary"]
        
        CC --> CM
        CM --> CL
        CL --> CB
        
        CDep["Manual dependency<br/>management"]
        CLib1["libcurl-dev<br/>(apt install)"]
        CLib2["libjson-dev<br/>(apt install)"]
        CInc["Manual include paths<br/>-I/usr/include/curl"]
        CLink["Manual linking<br/>-lcurl -ljson"]
        
        CDep --> CLib1
        CDep --> CLib2
        CLib1 --> CInc
        CLib2 --> CInc
        CInc --> CM
        CLink --> CL
        
        C_ISSUES["[ERROR] Version conflicts<br/>[ERROR] Platform differences<br/>[ERROR] Missing dependencies<br/>[ERROR] Linking order matters<br/>[ERROR] No automated updates"]
    end
    
    subgraph "Rust Cargo Build Process"
        RS["Rust Source Files<br/>(.rs)"]
        CT["Cargo.toml<br/>[dependencies]<br/>reqwest = '0.11'<br/>serde_json = '1.0'"]
        CRG["Cargo Build System"]
        RB["Final Binary"]
        
        RS --> CRG
        CT --> CRG
        CRG --> RB
        
        CRATES["crates.io<br/>(Package registry)"]
        DEPS["Automatic dependency<br/>resolution"]
        LOCK["Cargo.lock<br/>(Version pinning)"]
        
        CRATES --> DEPS
        DEPS --> CRG
        CRG --> LOCK
        
        R_BENEFITS["[OK] Semantic versioning<br/>[OK] Automatic downloads<br/>[OK] Cross-platform<br/>[OK] Transitive dependencies<br/>[OK] Reproducible builds"]
    end
    
    style C_ISSUES fill:#ff6b6b,color:#000
    style R_BENEFITS fill:#91e5a3,color:#000
    style CM fill:#ffa07a,color:#000
    style CDep fill:#ffa07a,color:#000
    style CT fill:#91e5a3,color:#000
    style CRG fill:#91e5a3,color:#000
    style DEPS fill:#91e5a3,color:#000
    style CRATES fill:#91e5a3,color:#000

Cargo Project Structure

my_project/
|-- Cargo.toml          # Project configuration (like package.json)
|-- Cargo.lock          # Exact dependency versions (auto-generated)
|-- src/
|   |-- main.rs         # Main entry point for binary
|   |-- lib.rs          # Library root (if creating a library)
|   `-- bin/            # Additional binary targets
|-- tests/              # Integration tests
|-- examples/           # Example code
|-- benches/            # Benchmarks
`-- target/             # Build artifacts (like C's build/ or obj/)
    |-- debug/          # Debug builds (fast compile, slow runtime)
    `-- release/        # Release builds (slow compile, fast runtime)

Common Cargo Commands

graph LR
    subgraph "Project Lifecycle"
        NEW["cargo new my_project<br/>[FOLDER] Create new project"]
        CHECK["cargo check<br/>[SEARCH] Fast syntax check"]
        BUILD["cargo build<br/>[BUILD] Compile project"]
        RUN["cargo run<br/>[PLAY] Build and execute"]
        TEST["cargo test<br/>[TEST] Run all tests"]
        
        NEW --> CHECK
        CHECK --> BUILD
        BUILD --> RUN
        BUILD --> TEST
    end
    
    subgraph "Advanced Commands"
        UPDATE["cargo update<br/>[CHART] Update dependencies"]
        FORMAT["cargo fmt<br/>[SPARKLES] Format code"]
        LINT["cargo clippy<br/>[WRENCH] Lint and suggestions"]
        DOC["cargo doc<br/>[BOOKS] Generate documentation"]
        PUBLISH["cargo publish<br/>[PACKAGE] Publish to crates.io"]
    end
    
    subgraph "Build Profiles"
        DEBUG["cargo build<br/>(debug profile)<br/>Fast compile<br/>Slow runtime<br/>Debug symbols"]
        RELEASE["cargo build --release<br/>(release profile)<br/>Slow compile<br/>Fast runtime<br/>Optimized"]
    end
    
    style NEW fill:#a3d5ff,color:#000
    style CHECK fill:#91e5a3,color:#000
    style BUILD fill:#ffa07a,color:#000
    style RUN fill:#ffcc5c,color:#000
    style TEST fill:#c084fc,color:#000
    style DEBUG fill:#94a3b8,color:#000
    style RELEASE fill:#ef4444,color:#000

Example: cargo and crates

  • In this example, we have a standalone executable crate with no other dependencies
  • Use the following commands to create a new crate called helloworld
cargo new helloworld
cd helloworld
cat Cargo.toml
  • By default, cargo run will compile and run the debug (unoptimized) version of the crate. To execute the release version, use cargo run --release
  • Note that actual binary file resides under the target folder under the debug or release folder
  • We might have also noticed a file called Cargo.lock in the same folder as the source. It is automatically generated and should not be modified by hand
    • We will revisit the specific purpose of Cargo.lock later

Built-in Rust types

What you’ll learn: Rust’s fundamental types (i32, u64, f64, bool, char), type inference, explicit type annotations, and how they compare to C/C++ primitive types. No implicit conversions — Rust requires explicit casts.

  • Rust has type inference, but also allows explicit specification of the type
DescriptionTypeExample
Signed integersi8, i16, i32, i64, i128, isize-1, 42, 1_00_000, 1_00_000i64
Unsigned integersu8, u16, u32, u64, u128, usize0, 42, 42u32, 42u64
Floating pointf32, f640.0, 0.42
Unicodechar‘a’, ‘$’
Booleanbooltrue, false
  • Rust permits arbitrary use of _ between numbers for ease of reading

Rust type specification and assignment

  • Rust uses the let keyword to assign values to variables. The type of the variable can be optionally specified after a :
fn main() {
    let x : i32 = 42;
    // These two assignments are logically equivalent
    let y : u32 = 42;
    let z = 42u32;
}
  • Function parameters and return values (if any) require an explicit type. The following takes a u8 parameter and returns u32
#![allow(unused)]
fn main() {
fn foo(x : u8) -> u32
{
    return x as u32 * x as u32;
}
}
  • Unused variables are prefixed with _ to avoid compiler warnings

Rust type specification and inference

fn secret_of_life_u32(x : u32) {
    println!("The u32 secret_of_life is {}", x);
}

fn secret_of_life_u8(x : u8) {
    println!("The u8 secret_of_life is {}", x);
}

fn main() {
    let a = 42; // The let keyword assigns a value; type of a is u32
    let b = 42; // The let keyword assigns a value; inferred type of b is u8
    secret_of_life_u32(a);
    secret_of_life_u8(b);
}

Rust variables and mutability

  • Rust variables are immutable by default unless the mut keyword is used to denote that a variable is mutable. For example, the following code will not compile unless the let a = 42 is changed to let mut a = 42
fn main() {
    let a = 42; // Must be changed to let mut a = 42 to permit the assignment below 
    a = 43;  // Will not compile unless the above is changed
}
  • Rust permits the reuse of the variable names (shadowing)
fn main() {
    let a = 42;
    {
        let a = 43; //OK: Different variable with the same name
    }
    // a = 43; // Not permitted
    let a = 43; // Ok: New variable and assignment
}

Rust if keyword

What you’ll learn: Rust’s control flow constructs — if/else as expressions, loop/while/for, match, and how they differ from C/C++ counterparts. The key insight: most Rust control flow returns values.

  • In Rust, if is actually an expression, i.e., it can be used to assign values, but it also behaves like a statement. ▶ Try it
fn main() {
    let x = 42;
    if x < 42 {
        println!("Smaller than the secret of life");
    } else if x == 42 {
        println!("Is equal to the secret of life");
    } else {
        println!("Larger than the secret of life");
    }
    let is_secret_of_life = if x == 42 {true} else {false};
    println!("{}", is_secret_of_life);
}

Rust loops using while and for

  • The while keyword can be used to loop while an expression is true
fn main() {
    let mut x = 40;
    while x != 42 {
        x += 1;
    }
}
  • The for keyword can be used to iterate over ranges
fn main() {
    // Will not print 43; use 40..=43 to include last element
    for x in 40..43 {
        println!("{}", x);
    } 
}

Rust loops using loop

  • The loop keyword creates an infinite loop until a break is encountered
fn main() {
    let mut x = 40;
    // Change the below to 'here: loop to specify optional label for the loop
    loop {
        if x == 42 {
            break; // Use break x; to return the value of x
        }
        x += 1;
    }
}
  • The break statement can include an optional expression that can be used to assign the value of a loop expression
  • The continue keyword can be used to return to the top of the loop
  • Loop labels can be used with break or continue and are useful when dealing with nested loops

Rust expression blocks

  • Rust expression blocks are simply a sequence of expressions enclosed in {}. The evaluated value is simply the last expression in the block
fn main() {
    let x = {
        let y = 40;
        y + 2 // Note: ; must be omitted
    };
    // Notice the Python style printing
    println!("{x}");
}
  • Rust style is to use this to omit the return keyword in functions
fn is_secret_of_life(x: u32) -> bool {
    // Same as if x == 42 {true} else {false}
    x == 42 // Note: ; must be omitted 
}
fn main() {
    println!("{}", is_secret_of_life(42));
}

Rust array type

What you’ll learn: Rust’s core data structures — arrays, tuples, slices, strings, structs, Vec, and HashMap. This is a dense chapter; focus on understanding String vs &str and how structs work. You’ll revisit references and borrowing in depth in chapter 7.

  • Arrays contain a fixed number of elements of the same type
    • Like all other Rust types, arrays are immutable by default (unless mut is used)
    • Arrays are indexed using [] and are bounds checked. The len() method can be used to obtain the length of the array
    fn get_index(y : usize) -> usize {
        y+1        
    }
    
    fn main() {
        // Initializes an array of 3 elements and sets all to 42
        let a : [u8; 3] = [42; 3];
        // Alternative syntax
        // let a = [42u8, 42u8, 42u8];
        for x in a {
            println!("{x}");
        }
        let y = get_index(a.len());
        // Commenting out the below will cause a panic
        //println!("{}", a[y]);
    }

Rust array type continued

  • Arrays can be nested
    • Rust has several built-in formatters for printing. In the below, the :? is the debug print formatter. The :#? formatter can be used for pretty print. These formatters can be customized per type (more on this later)
    fn main() {
        let a = [
            [40, 0], // Define a nested array
            [41, 0],
            [42, 1],
        ];
        for x in a {
            println!("{x:?}");
        }
    }

Rust tuples

  • Tuples have a fixed size and can group arbitrary types into a single compound type
    • The constituent types can be indexed by their relative location (.0, .1, .2, …). An empty tuple, i.e., () is called the unit value and is the equivalent of a void return value
    • Rust supports tuple destructuring to make it easy to bind variables to individual elements
fn get_tuple() -> (u32, bool) {
    (42, true)        
}

fn main() {
   let t : (u8, bool) = (42, true);
   let u : (u32, bool) = (43, false);
   println!("{}, {}", t.0, t.1);
   println!("{}, {}", u.0, u.1);
   let (num, flag) = get_tuple(); // Tuple destructuring
   println!("{num}, {flag}");
}

Rust references

  • References in Rust are roughly equivalent to pointers in C with some key differences
    • It is legal to have any number of read-only (immutable) references to a variable at any point of time. A reference cannot outlive the variable scope (this is a key concept called lifetime; discussed in detail later)
    • Only a single writable (mutable) reference to a mutable variable is permitted and it must not overlap with any other reference.
fn main() {
    let mut a = 42;
    {
        let b = &a;
        let c = b;
        println!("{} {}", *b, *c); // The compiler automatically dereferences *c
        
        let d = &mut a;
        
        /* 
         * Uncommenting the line below would be cause the 
         * program to not compile, because `b` is used 
         * while the mutable reference `d` is live in the current scope
         * 
         * You cannot have a mutable and immutable reference in use in the same scope
         * at the same time!
         */
        // println!("{}", *b);
    }
    let d = &mut a; // Ok: b and c are not in scope
    *d = 43;
}

Rust slices

  • Rust references can be used to create subsets of arrays
    • Unlike arrays, which have a static fixed length determined at compile time, slices can be of arbitrary size. Internally, slices are implemented as a “fat-pointer” that contains the length of the slice and a pointer to the starting element in the original array
fn main() {
    let a = [40, 41, 42, 43];
    let b = &a[1..a.len()]; // A slice starting with the second element in the original
    let c = &a[1..]; // Same as the above
    let d = &a[..]; // Same as &a[0..] or &a[0..a.len()]
    println!("{b:?} {c:?} {d:?}");
}

Rust constants and statics

  • The const keyword can be used to define a constant value. Constant values are evaluated at compile time and are inlined into the program
  • The static keyword is used to define the equivalent of global variables in languages like C/C++ Static variables have an addressable memory location and are created once and last the entire lifetime of the program
const SECRET_OF_LIFE: u32 = 42;
static GLOBAL_VARIABLE : u32 = 2;
fn main() {
    println!("The secret of life is {}", SECRET_OF_LIFE);
    println!("Value of global variable is {GLOBAL_VARIABLE}")
}

Rust strings: String vs &str

  • Rust has two string types that serve different purposes
    • String — owned, heap-allocated, growable (like C’s malloc’d buffer, or C++’s std::string)
    • &str — borrowed, lightweight reference (like C’s const char* with length, or C++’s std::string_view — but &str is lifetime-checked so it can never dangle)
    • Unlike C’s null-terminated strings, Rust strings track their length and are guaranteed valid UTF-8

For C++ developers: String ≈ std::string, &str ≈ std::string_view. Unlike std::string_view, a &str is guaranteed valid for its entire lifetime by the borrow checker.

String vs &str: Owned vs Borrowed

Production patterns: See JSON handling: nlohmann::json → serde for how string handling works with serde in production code.

AspectC char*C++ std::stringRust StringRust &str
MemoryManual (malloc/free)Heap-allocated, owns bufferHeap-allocated, auto-freedBorrowed reference (lifetime-checked)
MutabilityAlways mutable via pointerMutableMutable with mutAlways immutable
Size infoNone (relies on '\0')Tracks length and capacityTracks length and capacityTracks length (fat pointer)
EncodingUnspecified (usually ASCII)Unspecified (usually ASCII)Guaranteed valid UTF-8Guaranteed valid UTF-8
Null terminatorRequiredRequired (c_str())Not usedNot used
fn main() {
    // &str - string slice (borrowed, immutable, usually a string literal)
    let greeting: &str = "Hello";  // Points to read-only memory

    // String - owned, heap-allocated, growable
    let mut owned = String::from(greeting);  // Copies data to heap
    owned.push_str(", World!");        // Grow the string
    owned.push('!');                   // Append a single character

    // Converting between String and &str
    let slice: &str = &owned;          // String -> &str (free, just a borrow)
    let owned2: String = slice.to_string();  // &str -> String (allocates)
    let owned3: String = String::from(slice); // Same as above

    // String concatenation (note: + consumes the left operand)
    let hello = String::from("Hello");
    let world = String::from(", World!");
    let combined = hello + &world;  // hello is moved (consumed), world is borrowed
    // println!("{hello}");  // Won't compile: hello was moved

    // Use format! to avoid move issues
    let a = String::from("Hello");
    let b = String::from("World");
    let combined = format!("{a}, {b}!");  // Neither a nor b is consumed

    println!("{combined}");
}

Why You Cannot Index Strings with []

fn main() {
    let s = String::from("hello");
    // let c = s[0];  // Won't compile! Rust strings are UTF-8, not byte arrays

    // Safe alternatives:
    let first_char = s.chars().next();           // Option<char>: Some('h')
    let as_bytes = s.as_bytes();                 // &[u8]: raw UTF-8 bytes
    let substring = &s[0..1];                    // &str: "h" (byte range, must be valid UTF-8 boundary)

    println!("First char: {:?}", first_char);
    println!("Bytes: {:?}", &as_bytes[..5]);
}

Exercise: String manipulation

🟢 Starter

  • Write a function fn count_words(text: &str) -> usize that counts the number of whitespace-separated words in a string
  • Write a function fn longest_word(text: &str) -> &str that returns the longest word (hint: you’ll need to think about lifetimes – why does the return type need to be &str and not String?)
Solution (click to expand)
fn count_words(text: &str) -> usize {
    text.split_whitespace().count()
}

fn longest_word(text: &str) -> &str {
    text.split_whitespace()
        .max_by_key(|word| word.len())
        .unwrap_or("")
}

fn main() {
    let text = "the quick brown fox jumps over the lazy dog";
    println!("Word count: {}", count_words(text));       // 9
    println!("Longest word: {}", longest_word(text));     // "jumps"
}

Rust structs

  • The struct keyword declares a user-defined struct type
    • struct members can either be named, or anonymous (tuple structs)
  • Unlike languages like C++, there’s no notion of “data inheritance” in Rust
fn main() {
    struct MyStruct {
        num: u32,
        is_secret_of_life: bool,
    }
    let x = MyStruct {
        num: 42,
        is_secret_of_life: true,
    };
    let y = MyStruct {
        num: x.num,
        is_secret_of_life: x.is_secret_of_life,
    };
    let z = MyStruct { num: x.num, ..x }; // The .. means copy remaining
    println!("{} {} {}", x.num, y.is_secret_of_life, z.num);
}

Rust tuple structs

  • Rust tuple structs are similar to tuples and individual fields don’t have names
    • Like tuples, individual elements are accessed using .0, .1, .2, …. A common use case for tuple structs is to wrap primitive types to create custom types. This can be useful to avoid mixing differing values of the same type
struct WeightInGrams(u32);
struct WeightInMilligrams(u32);
fn to_weight_in_grams(kilograms: u32) -> WeightInGrams {
    WeightInGrams(kilograms * 1000)
}

fn to_weight_in_milligrams(w : WeightInGrams) -> WeightInMilligrams  {
    WeightInMilligrams(w.0 * 1000)
}

fn main() {
    let x = to_weight_in_grams(42);
    let y = to_weight_in_milligrams(x);
    // let z : WeightInGrams = x;  // Won't compile: x was moved into to_weight_in_milligrams()
    // let a : WeightInGrams = y;   // Won't compile: type mismatch (WeightInMilligrams vs WeightInGrams)
}

Note: The #[derive(...)] attribute automatically generates common trait implementations for structs and enums. You’ll see this used throughout the course:

#[derive(Debug, Clone, PartialEq)]
struct Point { x: i32, y: i32 }

fn main() {
    let p = Point { x: 1, y: 2 };
    println!("{:?}", p);           // Debug: works because of #[derive(Debug)]
    let p2 = p.clone();           // Clone: works because of #[derive(Clone)]
    assert_eq!(p, p2);            // PartialEq: works because of #[derive(PartialEq)]
}

We’ll cover the trait system in depth later, but #[derive(Debug)] is so useful that you should add it to nearly every struct and enum you create.

Rust Vec type

  • The Vec<T> type implements a dynamic heap allocated buffer (similar to manually managed malloc/realloc arrays in C, or C++’s std::vector)
    • Unlike arrays with fixed size, Vec can grow and shrink at runtime
    • Vec owns its data and automatically manages memory allocation/deallocation
  • Common operations: push(), pop(), insert(), remove(), len(), capacity()
fn main() {
    let mut v = Vec::new();    // Empty vector, type inferred from usage
    v.push(42);                // Add element to end - Vec<i32>
    v.push(43);                
    
    // Safe iteration (preferred)
    for x in &v {              // Borrow elements, don't consume vector
        println!("{x}");
    }
    
    // Initialization shortcuts
    let mut v2 = vec![1, 2, 3, 4, 5];           // Macro for initialization
    let v3 = vec![0; 10];                       // 10 zeros
    
    // Safe access methods (preferred over indexing)
    match v2.get(0) {
        Some(first) => println!("First: {first}"),
        None => println!("Empty vector"),
    }
    
    // Useful methods
    println!("Length: {}, Capacity: {}", v2.len(), v2.capacity());
    if let Some(last) = v2.pop() {             // Remove and return last element
        println!("Popped: {last}");
    }
    
    // Dangerous: direct indexing (can panic!)
    // println!("{}", v2[100]);  // Would panic at runtime
}

Production patterns: See Avoiding unchecked indexing for safe .get() patterns from production Rust code.

Rust HashMap type

  • HashMap implements generic key -> value lookups (a.k.a. dictionary or map)
fn main() {
    use std::collections::HashMap;  // Need explicit import, unlike Vec
    let mut map = HashMap::new();       // Allocate an empty HashMap
    map.insert(40, false);  // Type is inferred as int -> bool
    map.insert(41, false);
    map.insert(42, true);
    for (key, value) in map {
        println!("{key} {value}");
    }
    let map = HashMap::from([(40, false), (41, false), (42, true)]);
    if let Some(x) = map.get(&43) {
        println!("43 was mapped to {x:?}");
    } else {
        println!("No mapping was found for 43");
    }
    let x = map.get(&43).or(Some(&false));  // Default value if key isn't found
    println!("{x:?}"); 
}

Exercise: Vec and HashMap

🟢 Starter

  • Create a HashMap<u32, bool> with a few entries (make sure that some values are true and others are false). Loop over all elements in the hashmap and put the keys into one Vec and the values into another
Solution (click to expand)
use std::collections::HashMap;

fn main() {
    let map = HashMap::from([(1, true), (2, false), (3, true), (4, false)]);
    let mut keys = Vec::new();
    let mut values = Vec::new();
    for (k, v) in &map {
        keys.push(*k);
        values.push(*v);
    }
    println!("Keys:   {keys:?}");
    println!("Values: {values:?}");

    // Alternative: use iterators with unzip()
    let (keys2, values2): (Vec<u32>, Vec<bool>) = map.into_iter().unzip();
    println!("Keys (unzip):   {keys2:?}");
    println!("Values (unzip): {values2:?}");
}

Deep Dive: C++ References vs Rust References

For C++ developers: C++ programmers often assume Rust &T works like C++ T&. While superficially similar, there are fundamental differences that cause confusion. C developers can skip this section — Rust references are covered in Ownership and Borrowing.

1. No Rvalue References or Universal References

In C++, && has two meanings depending on context:

// C++: && means different things:
int&& rref = 42;           // Rvalue reference — binds to temporaries
void process(Widget&& w);   // Rvalue reference — caller must std::move

// Universal (forwarding) reference — deduced template context:
template<typename T>
void forward(T&& arg) {     // NOT an rvalue ref! Deduced as T& or T&&
    inner(std::forward<T>(arg));  // Perfect forwarding
}

In Rust: none of this exists. && is simply the logical AND operator.

#![allow(unused)]
fn main() {
// Rust: && is just boolean AND
let a = true && false; // false

// Rust has NO rvalue references, no universal references, no perfect forwarding.
// Instead:
//   - Move is the default for non-Copy types (no std::move needed)
//   - Generics + trait bounds replace universal references
//   - No temporary-binding distinction — values are values

fn process(w: Widget) { }      // Takes ownership (like C++ value param + implicit move)
fn process_ref(w: &Widget) { } // Borrows immutably (like C++ const T&)
fn process_mut(w: &mut Widget) { } // Borrows mutably (like C++ T&, but exclusive)
}
C++ ConceptRust EquivalentNotes
T& (lvalue ref)&T or &mut TRust splits into shared vs exclusive
T&& (rvalue ref)Just TTake by value = take ownership
T&& in template (universal ref)impl Trait or <T: Trait>Generics replace forwarding
std::move(x)x (just use it)Move is the default
std::forward<T>(x)No equivalent neededNo universal references to forward

2. Moves Are Bitwise — No Move Constructors

In C++, moving is a user-defined operation (move constructor / move assignment). In Rust, moving is always a bitwise memcpy of the value, and the source is invalidated:

#![allow(unused)]
fn main() {
// Rust move = memcpy the bytes, mark source as invalid
let s1 = String::from("hello");
let s2 = s1; // Bytes of s1 are copied to s2's stack slot
              // s1 is now invalid — compiler enforces this
// println!("{s1}"); // ❌ Compile error: value used after move
}
// C++ move = call the move constructor (user-defined!)
std::string s1 = "hello";
std::string s2 = std::move(s1); // Calls string's move ctor
// s1 is now a "valid but unspecified state" zombie
std::cout << s1; // Compiles! Prints... something (empty string, usually)

Consequences:

  • Rust has no Rule of Five (no copy ctor, move ctor, copy=, move=, destructor to define)
  • No moved-from “zombie” state — the compiler simply prevents access
  • No noexcept considerations for moves — bitwise copy can’t throw

3. Auto-Deref: The Compiler Sees Through Indirection

Rust automatically dereferences through multiple layers of pointers/wrappers via the Deref trait. This has no C++ equivalent:

#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex};

// Nested wrapping: Arc<Mutex<Vec<String>>>
let data = Arc::new(Mutex::new(vec!["hello".to_string()]));

// In C++, you'd need explicit unlocking and manual dereferencing at each layer.
// In Rust, the compiler auto-derefs through Arc → Mutex → MutexGuard → Vec:
let guard = data.lock().unwrap(); // Arc auto-derefs to Mutex
let first: &str = &guard[0];      // MutexGuard→Vec (Deref), Vec[0] (Index),
                                   // &String→&str (Deref coercion)
println!("First: {first}");

// Method calls also auto-deref:
let boxed_string = Box::new(String::from("hello"));
println!("Length: {}", boxed_string.len());  // Box→String, then String::len()
// No need for (*boxed_string).len() or boxed_string->len()
}

Deref coercion also applies to function arguments — the compiler inserts dereferences to make types match:

fn greet(name: &str) {
    println!("Hello, {name}");
}

fn main() {
    let owned = String::from("Alice");
    let boxed = Box::new(String::from("Bob"));
    let arced = std::sync::Arc::new(String::from("Carol"));

    greet(&owned);  // &String → &str  (1 deref coercion)
    greet(&boxed);  // &Box<String> → &String → &str  (2 deref coercions)
    greet(&arced);  // &Arc<String> → &String → &str  (2 deref coercions)
    greet("Dave");  // &str already — no coercion needed
}
// In C++ you'd need .c_str() or explicit conversions for each case.

The Deref chain: When you call x.method(), Rust’s method resolution tries the receiver type T, then &T, then &mut T. If no match, it dereferences via the Deref trait and repeats with the target type. This continues through multiple layers — which is why Box<Vec<T>> “just works” like a Vec<T>. Deref coercion (for function arguments) is a separate but related mechanism that automatically converts &Box<String> to &str by chaining Deref impls.

4. No Null References, No Optional References

// C++: references can't be null, but pointers can, and the distinction is blurry
Widget& ref = *ptr;  // If ptr is null → UB
Widget* opt = nullptr;  // "optional" reference via pointer
#![allow(unused)]
fn main() {
// Rust: references are ALWAYS valid — guaranteed by the borrow checker
// No way to create a null or dangling reference in safe code
let r: &i32 = &42; // Always valid

// "Optional reference" is explicit:
let opt: Option<&Widget> = None; // Clear intent, no null pointer
if let Some(w) = opt {
    w.do_something(); // Only reachable when present
}
}

5. References Cannot Be Reseated

// C++: a reference is an alias — it can't be rebound
int a = 1, b = 2;
int& r = a;
r = b;  // This ASSIGNS b's value to a — it does NOT rebind r!
// a is now 2, r still refers to a
#![allow(unused)]
fn main() {
// Rust: let bindings can shadow, but references follow different rules
let a = 1;
let b = 2;
let r = &a;
// r = &b;   // ❌ Cannot assign to immutable variable
let r = &b;  // ✅ But you can SHADOW r with a new binding
             // The old binding is gone, not reseated

// With mut:
let mut r = &a;
r = &b;      // ✅ r now points to b — this IS rebinding (not assignment through)
}

Mental model: In C++, a reference is a permanent alias for one object. In Rust, a reference is a value (a pointer with lifetime guarantees) that follows normal variable binding rules — immutable by default, rebindable only if declared mut.

Rust enum types

What you’ll learn: Rust enums as discriminated unions (tagged unions done right), match for exhaustive pattern matching, and how enums replace C++ class hierarchies and C tagged unions with compiler-enforced safety.

  • Enum types are discriminated unions, i.e., they are a sum type of several possible different types with a tag that identifies the specific variant
    • For C developers: enums in Rust can carry data (tagged unions done right — the compiler tracks which variant is active)
    • For C++ developers: Rust enums are like std::variant but with exhaustive pattern matching, no std::get exceptions, and no std::visit boilerplate
    • The size of the enum is that of the largest possible type. The individual variants are not related to one another and can have completely different types
    • enum types are one of the most powerful features of the language — they replace entire class hierarchies in C++ (more on this in the Case Studies)
fn main() {
    enum Numbers {
        Zero,
        SmallNumber(u8),
        BiggerNumber(u32),
        EvenBiggerNumber(u64),
    }
    let a = Numbers::Zero;
    let b = Numbers::SmallNumber(42);
    let c : Numbers = a; // Ok -- the type of a is Numbers
    let d : Numbers = b; // Ok -- the type of b is Numbers
}

Rust match statement

  • The Rust match is the equivalent of the C “switch” on steroids
    • match can be used for pattern matching on simple data types, struct, enum
    • The match statement must be exhaustive, i.e., they must cover all possible cases for a given type. The _ can be used a wildcard for the “all else” case
    • match can yield a value, but all arms (=>) must return a value of the same type
fn main() {
    let x = 42;
    // In this case, the _ covers all numbers except the ones explicitly listed
    let is_secret_of_life = match x {
        42 => true, // return type is boolean value
        _ => false, // return type boolean value
        // This won't compile because return type isn't boolean
        // _ => 0  
    };
    println!("{is_secret_of_life}");
}

Rust match statement

  • match supports ranges, boolean filters, and if guard statements
fn main() {
    let x = 42;
    match x {
        // Note that the =41 ensures the inclusive range
        0..=41 => println!("Less than the secret of life"),
        42 => println!("Secret of life"),
        _ => println!("More than the secret of life"),
    }
    let y = 100;
    match y {
        100 if x == 43 => println!("y is 100% not secret of life"),
        100 if x == 42 => println!("y is 100% secret of life"),
        _ => (),    // Do nothing
    }
}

Rust match statement

  • match and enums are often combined together
    • The match statement can “bind” the contained value to a variable. Use _ if the value is a don’t care
    • The matches! macro can be used to match to specific variant
fn main() {
    enum Numbers {
        Zero,
        SmallNumber(u8),
        BiggerNumber(u32),
        EvenBiggerNumber(u64),
    }
    let b = Numbers::SmallNumber(42);
    match b {
        Numbers::Zero => println!("Zero"),
        Numbers::SmallNumber(value) => println!("Small number {value}"),
        Numbers::BiggerNumber(_) | Numbers::EvenBiggerNumber(_) => println!("Some BiggerNumber or EvenBiggerNumber"),
    }
    
    // Boolean test for specific variants
    if matches!(b, Numbers::Zero | Numbers::SmallNumber(_)) {
        println!("Matched Zero or small number");
    }
}

Rust match statement

  • match can also perform matches using destructuring and slices
fn main() {
    struct Foo {
        x: (u32, bool),
        y: u32
    }
    let f = Foo {x: (42, true), y: 100};
    match f {
        // Capture the value of x into a variable called tuple
        Foo{y: 100, x : tuple} => println!("Matched x: {tuple:?}"),
        _ => ()
    }
    let a = [40, 41, 42];
    match a {
        // Last element of slice must be 42. @ is used to bind the match
        [rest @ .., 42] => println!("{rest:?}"),
        // First element of the slice must be 42. @ is used to bind the match
        [42, rest @ ..] => println!("{rest:?}"),
        _ => (),
    }
}

Exercise: Implement add and subtract using match and enum

🟢 Starter

  • Write a function that implements arithmetic operations on unsigned 64-bit numbers
  • Step 1: Define an enum for operations:
#![allow(unused)]
fn main() {
enum Operation {
    Add(u64, u64),
    Subtract(u64, u64),
}
}
  • Step 2: Define a result enum:
#![allow(unused)]
fn main() {
enum CalcResult {
    Ok(u64),                    // Successful result
    Invalid(String),            // Error message for invalid operations
}
}
  • Step 3: Implement calculate(op: Operation) -> CalcResult
    • For Add: return Ok(sum)
    • For Subtract: return Ok(difference) if first >= second, otherwise Invalid(“Underflow”)
  • Hint: Use pattern matching in your function:
#![allow(unused)]
fn main() {
match op {
    Operation::Add(a, b) => { /* your code */ },
    Operation::Subtract(a, b) => { /* your code */ },
}
}
Solution (click to expand)
enum Operation {
    Add(u64, u64),
    Subtract(u64, u64),
}

enum CalcResult {
    Ok(u64),
    Invalid(String),
}

fn calculate(op: Operation) -> CalcResult {
    match op {
        Operation::Add(a, b) => CalcResult::Ok(a + b),
        Operation::Subtract(a, b) => {
            if a >= b {
                CalcResult::Ok(a - b)
            } else {
                CalcResult::Invalid("Underflow".to_string())
            }
        }
    }
}

fn main() {
    match calculate(Operation::Add(10, 20)) {
        CalcResult::Ok(result) => println!("10 + 20 = {result}"),
        CalcResult::Invalid(msg) => println!("Error: {msg}"),
    }
    match calculate(Operation::Subtract(5, 10)) {
        CalcResult::Ok(result) => println!("5 - 10 = {result}"),
        CalcResult::Invalid(msg) => println!("Error: {msg}"),
    }
}
// Output:
// 10 + 20 = 30
// Error: Underflow

Rust associated methods

  • impl can define methods associated for types like struct, enum, etc
    • The methods may optionally take self as a parameter. self is conceptually similar to passing a pointer to the struct as the first parameter in C, or this in C++
    • The reference to self can be immutable (default: &self), mutable (&mut self), or self (transferring ownership)
    • The Self keyword can be used a shortcut to imply the type
struct Point {x: u32, y: u32}
impl Point {
    fn new(x: u32, y: u32) -> Self {
        Point {x, y}
    }
    fn increment_x(&mut self) {
        self.x += 1;
    }
}
fn main() {
    let mut p = Point::new(10, 20);
    p.increment_x();
}

Exercise: Point add and transform

🟡 Intermediate — requires understanding move vs borrow from method signatures

  • Implement the following associated methods for Point
    • add() will take another Point and will increment the x and y values in place (hint: use &mut self)
    • transform() will consume an existing Point (hint: use self) and return a new Point by squaring the x and y
Solution (click to expand)
struct Point { x: u32, y: u32 }

impl Point {
    fn new(x: u32, y: u32) -> Self {
        Point { x, y }
    }
    fn add(&mut self, other: &Point) {
        self.x += other.x;
        self.y += other.y;
    }
    fn transform(self) -> Point {
        Point { x: self.x * self.x, y: self.y * self.y }
    }
}

fn main() {
    let mut p1 = Point::new(2, 3);
    let p2 = Point::new(10, 20);
    p1.add(&p2);
    println!("After add: x={}, y={}", p1.x, p1.y);           // x=12, y=23
    let p3 = p1.transform();
    println!("After transform: x={}, y={}", p3.x, p3.y);     // x=144, y=529
    // p1 is no longer accessible — transform() consumed it
}

Rust lifetime and borrowing

What you’ll learn: How Rust’s lifetime system ensures references never dangle — from implicit lifetimes through explicit annotations to the three elision rules that make most code annotation-free. Understanding lifetimes here is essential before moving on to smart pointers in the next section.

  • Rust enforces a single mutable reference and any number of immutable references
    • The lifetime of any reference must be at least as long as the original owning lifetime. These are implicit lifetimes and are inferred by the compiler (see https://doc.rust-lang.org/nomicon/lifetime-elision.html)
fn borrow_mut(x: &mut u32) {
    *x = 43;
}
fn main() {
    let mut x = 42;
    let y = &mut x;
    borrow_mut(y);
    let _z = &x; // Permitted because the compiler knows y isn't subsequently used
    //println!("{y}"); // Will not compile if this is uncommented
    borrow_mut(&mut x); // Permitted because _z isn't used 
    let z = &x; // Ok -- mutable borrow of x ended after borrow_mut() returned
    println!("{z}");
}

Rust lifetime annotations

  • Explicit lifetime annotations are needed when dealing with multiple lifetimes
    • Lifetimes are denoted with ' and can be any identifier ('a, 'b, 'static, etc.)
    • The compiler needs help when it can’t figure out how long references should live
  • Common scenario: Function returns a reference, but which input does it come from?
#[derive(Debug)]
struct Point {x: u32, y: u32}

// Without lifetime annotation, this won't compile:
// fn left_or_right(pick_left: bool, left: &Point, right: &Point) -> &Point

// With lifetime annotation - all references share the same lifetime 'a
fn left_or_right<'a>(pick_left: bool, left: &'a Point, right: &'a Point) -> &'a Point {
    if pick_left { left } else { right }
}

// More complex: different lifetimes for inputs
fn get_x_coordinate<'a, 'b>(p1: &'a Point, _p2: &'b Point) -> &'a u32 {
    &p1.x  // Return value lifetime tied to p1, not p2
}

fn main() {
    let p1 = Point {x: 20, y: 30};
    let result;
    {
        let p2 = Point {x: 42, y: 50};
        result = left_or_right(true, &p1, &p2);
        // This works because we use result before p2 goes out of scope
        println!("Selected: {result:?}");
    }
    // This would NOT work - result references p2 which is now gone:
    // println!("After scope: {result:?}");
}

Rust lifetime annotations

  • Lifetime annotations are also needed for references in data structures
use std::collections::HashMap;
#[derive(Debug)]
struct Point {x: u32, y: u32}
struct Lookup<'a> {
    map: HashMap<u32, &'a Point>,
}
fn main() {
    let p = Point{x: 42, y: 42};
    let p1 = Point{x: 50, y: 60};
    let mut m = Lookup {map : HashMap::new()};
    m.map.insert(0, &p);
    m.map.insert(1, &p1);
    {
        let p3 = Point{x: 60, y:70};
        //m.map.insert(3, &p3); // Will not compile
        // p3 is dropped here, but m will outlive
    }
    for (k, v) in m.map {
        println!("{v:?}");
    }
    // m is dropped here
    // p1 and p are dropped here in that order
} 

Exercise: First word with lifetimes

🟢 Starter — practice lifetime elision in action

Write a function fn first_word(s: &str) -> &str that returns the first whitespace-delimited word from a string. Think about why this compiles without explicit lifetime annotations (hint: elision rule #1 and #2).

Solution (click to expand)
fn first_word(s: &str) -> &str {
    // The compiler applies elision rules:
    // Rule 1: input &str gets lifetime 'a → fn first_word(s: &'a str) -> &str
    // Rule 2: single input lifetime → output gets same → fn first_word(s: &'a str) -> &'a str
    match s.find(' ') {
        Some(pos) => &s[..pos],
        None => s,
    }
}

fn main() {
    let text = "hello world foo";
    let word = first_word(text);
    println!("First word: {word}");  // "hello"
    
    let single = "onlyone";
    println!("First word: {}", first_word(single));  // "onlyone"
}

Exercise: Slice storage with lifetimes

🟡 Intermediate — your first encounter with lifetime annotations

  • Create a structure that stores references to the slice of a &str
    • Create a long &str and store references slices from it inside the structure
    • Write a function that accepts the structure and returns the contained slice
// TODO: Create a structure to store a reference to a slice
struct SliceStore {

}
fn main() {
    let s = "This is long string";
    let s1 = &s[0..];
    let s2 = &s[1..2];
    // let slice = struct SliceStore {...};
    // let slice2 = struct SliceStore {...};
}
Solution (click to expand)
struct SliceStore<'a> {
    slice: &'a str,
}

impl<'a> SliceStore<'a> {
    fn new(slice: &'a str) -> Self {
        SliceStore { slice }
    }

    fn get_slice(&self) -> &'a str {
        self.slice
    }
}

fn main() {
    let s = "This is a long string";
    let store1 = SliceStore::new(&s[0..4]);   // "This"
    let store2 = SliceStore::new(&s[5..7]);   // "is"
    println!("store1: {}", store1.get_slice());
    println!("store2: {}", store2.get_slice());
}
// Output:
// store1: This
// store2: is

Lifetime Elision Rules Deep Dive

C programmers often ask: “If lifetimes are so important, why don’t most Rust functions have 'a annotations?” The answer is lifetime elision — the compiler applies three deterministic rules to infer lifetimes automatically.

The Three Elision Rules

The Rust compiler applies these rules in order to function signatures. If all output lifetimes are determined after applying the rules, no annotations are needed.

flowchart TD
    A["Function signature<br/>with references"] --> R1
    R1["Rule 1: Each input<br/>reference gets its own<br/>lifetime<br/><br/>fn f(&amp;str, &amp;str)<br/>→ fn f&lt;'a,'b&gt;(&amp;'a str,<br/>&amp;'b str)"]
    R1 --> R2
    R2["Rule 2: If exactly ONE<br/>input lifetime, assign it<br/>to ALL outputs<br/><br/>fn f(&amp;str) → &amp;str<br/>→ fn f&lt;'a&gt;(&amp;'a str)<br/>→ &amp;'a str"]
    R2 --> R3
    R3["Rule 3: If one input is<br/>&amp;self or &amp;mut self,<br/>assign its lifetime to<br/>ALL outputs<br/><br/>fn f(&amp;self, &amp;str) → &amp;str<br/>→ fn f&lt;'a&gt;(&amp;'a self, &amp;str)<br/>→ &amp;'a str"]
    R3 --> CHECK{{"All output<br/>lifetimes<br/>determined?"}}
    CHECK -->|Yes| OK["✅ No annotations<br/>needed"]
    CHECK -->|No| ERR["❌ Compile error:<br/>must annotate<br/>manually"]
    
    style OK fill:#91e5a3,color:#000
    style ERR fill:#ff6b6b,color:#000

Rule-by-Rule Examples

Rule 1 — each input reference gets its own lifetime parameter:

#![allow(unused)]
fn main() {
// What you write:
fn first_word(s: &str) -> &str { ... }

// What the compiler sees after Rule 1:
fn first_word<'a>(s: &'a str) -> &str { ... }
// Only one input lifetime → Rule 2 applies
}

Rule 2 — single input lifetime propagates to all outputs:

#![allow(unused)]
fn main() {
// After Rule 2:
fn first_word<'a>(s: &'a str) -> &'a str { ... }
// ✅ All output lifetimes determined — no annotation needed!
}

Rule 3 — &self lifetime propagates to outputs:

#![allow(unused)]
fn main() {
// What you write:
impl SliceStore<'_> {
    fn get_slice(&self) -> &str { self.slice }
}

// What the compiler sees after Rules 1 + 3:
impl SliceStore<'_> {
    fn get_slice<'a>(&'a self) -> &'a str { self.slice }
}
// ✅ No annotation needed — &self lifetime used for output
}

When elision fails — you must annotate:

#![allow(unused)]
fn main() {
// Two input references, no &self → Rules 2 and 3 don't apply
// fn longest(a: &str, b: &str) -> &str  ← WON'T COMPILE

// Fix: tell the compiler which input the output borrows from
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
    if a.len() >= b.len() { a } else { b }
}
}

C Programmer Mental Model

In C, every pointer is independent — the programmer mentally tracks which allocation each pointer refers to, and the compiler trusts you completely. In Rust, lifetimes make this tracking explicit and compiler-verified:

CRustWhat happens
char* get_name(struct User* u)fn get_name(&self) -> &strRule 3 elides: output borrows from self
char* concat(char* a, char* b)fn concat<'a>(a: &'a str, b: &'a str) -> &'a strMust annotate — two inputs
void process(char* in, char* out)fn process(input: &str, output: &mut String)No output reference — no lifetime needed
char* buf; /* who owns this? */Compile error if lifetime is wrongCompiler catches dangling pointers

The 'static Lifetime

'static means the reference is valid for the entire program duration. It’s the Rust equivalent of a C global or string literal:

#![allow(unused)]
fn main() {
// String literals are always 'static — they live in the binary's read-only section
let s: &'static str = "hello";  // Same as: static const char* s = "hello"; in C

// Constants are also 'static
static GREETING: &str = "hello";

// Common in trait bounds for thread spawning:
fn spawn<F: FnOnce() + Send + 'static>(f: F) { /* ... */ }
// 'static here means: "the closure must not borrow any local variables"
// (either move them in, or use only 'static data)
}

Exercise: Predict the Elision

🟡 Intermediate

For each function signature below, predict whether the compiler can elide lifetimes. If not, add the necessary annotations:

#![allow(unused)]
fn main() {
// 1. Can the compiler elide?
fn trim_prefix(s: &str) -> &str { &s[1..] }

// 2. Can the compiler elide?
fn pick(flag: bool, a: &str, b: &str) -> &str {
    if flag { a } else { b }
}

// 3. Can the compiler elide?
struct Parser { data: String }
impl Parser {
    fn next_token(&self) -> &str { &self.data[..5] }
}

// 4. Can the compiler elide?
fn split_at(s: &str, pos: usize) -> (&str, &str) {
    (&s[..pos], &s[pos..])
}
}
Solution (click to expand)
// 1. YES — Rule 1 gives 'a to s, Rule 2 propagates to output
fn trim_prefix(s: &str) -> &str { &s[1..] }

// 2. NO — Two input references, no &self. Must annotate:
fn pick<'a>(flag: bool, a: &'a str, b: &'a str) -> &'a str {
    if flag { a } else { b }
}

// 3. YES — Rule 1 gives 'a to &self, Rule 3 propagates to output
impl Parser {
    fn next_token(&self) -> &str { &self.data[..5] }
}

// 4. YES — Rule 1 gives 'a to s (only one input reference),
//    Rule 2 propagates to BOTH outputs. Both slices borrow from s.
fn split_at(s: &str, pos: usize) -> (&str, &str) {
    (&s[..pos], &s[pos..])
}

Rust Box<T>

What you’ll learn: Rust’s smart pointer types — Box<T> for heap allocation, Rc<T> for shared ownership, and Cell<T>/RefCell<T> for interior mutability. These build on the ownership and lifetime concepts from the previous sections. You’ll also see a brief introduction to Weak<T> for breaking reference cycles.

Why Box<T>? In C, you use malloc/free for heap allocation. In C++, std::unique_ptr<T> wraps new/delete. Rust’s Box<T> is the equivalent — a heap-allocated, single-owner pointer that is automatically freed when it goes out of scope. Unlike malloc, there’s no matching free to forget. Unlike unique_ptr, there’s no use-after-move — the compiler prevents it entirely.

When to use Box vs stack allocation:

  • The contained type is large and you don’t want to copy it on the stack

  • You need a recursive type (e.g., a linked list node that contains itself)

  • You need trait objects (Box<dyn Trait>)

  • Box<T> can be use to create a pointer to a heap allocated type. The pointer is always a fixed size regardless of the type of <T>

fn main() {
    // Creates a pointer to an integer (with value 42) created on the heap
    let f = Box::new(42);
    println!("{} {}", *f, f);
    // Cloning a box creates a new heap allocation
    let mut g = f.clone();
    *g = 43;
    println!("{f} {g}");
    // g and f go out of scope here and are automatically deallocated
}
graph LR
    subgraph "Stack"
        F["f: Box&lt;i32&gt;"]
        G["g: Box&lt;i32&gt;"]
    end

    subgraph "Heap"
        HF["42"]
        HG["43"]
    end

    F -->|"owns"| HF
    G -->|"owns (cloned)"| HG

    style F fill:#51cf66,color:#000,stroke:#333
    style G fill:#51cf66,color:#000,stroke:#333
    style HF fill:#91e5a3,color:#000,stroke:#333
    style HG fill:#91e5a3,color:#000,stroke:#333

Ownership and Borrowing Visualization

C/C++ vs Rust: Pointer and Ownership Management

// C - Manual memory management, potential issues
void c_pointer_problems() {
    int* ptr1 = malloc(sizeof(int));
    *ptr1 = 42;
    
    int* ptr2 = ptr1;  // Both point to same memory
    int* ptr3 = ptr1;  // Three pointers to same memory
    
    free(ptr1);        // Frees the memory
    
    *ptr2 = 43;        // Use after free - undefined behavior!
    *ptr3 = 44;        // Use after free - undefined behavior!
}

For C++ developers: Smart pointers help, but don’t prevent all issues:

// C++ - Smart pointers help, but don't prevent all issues
void cpp_pointer_issues() {
    auto ptr1 = std::make_unique<int>(42);
    
    // auto ptr2 = ptr1;  // Compile error: unique_ptr not copyable
    auto ptr2 = std::move(ptr1);  // OK: ownership transferred
    
    // But C++ still allows use-after-move:
    // std::cout << *ptr1;  // Compiles! But undefined behavior!
    
    // shared_ptr aliasing:
    auto shared1 = std::make_shared<int>(42);
    auto shared2 = shared1;  // Both own the data
    // Who "really" owns it? Neither. Ref count overhead everywhere.
}
#![allow(unused)]
fn main() {
// Rust - Ownership system prevents these issues
fn rust_ownership_safety() {
    let data = Box::new(42);  // data owns the heap allocation
    
    let moved_data = data;    // Ownership transferred to moved_data
    // data is no longer accessible - compile error if used
    
    let borrowed = &moved_data;  // Immutable borrow
    println!("{}", borrowed);    // Safe to use
    
    // moved_data automatically freed when it goes out of scope
}
}
graph TD
    subgraph "C/C++ Memory Management Issues"
        CP1["int* ptr1"] --> CM["Heap Memory<br/>value: 42"]
        CP2["int* ptr2"] --> CM
        CP3["int* ptr3"] --> CM
        CF["free(ptr1)"] --> CM_F["[ERROR] Freed Memory"]
        CP2 -.->|"Use after free<br/>Undefined Behavior"| CM_F
        CP3 -.->|"Use after free<br/>Undefined Behavior"| CM_F
    end
    
    subgraph "Rust Ownership System"
        RO1["data: Box<i32>"] --> RM["Heap Memory<br/>value: 42"]
        RO1 -.->|"Move ownership"| RO2["moved_data: Box<i32>"]
        RO2 --> RM
        RO1_X["data: [WARNING] MOVED<br/>Cannot access"]
        RB["&moved_data<br/>Immutable borrow"] -.->|"Safe reference"| RM
        RD["Drop automatically<br/>when out of scope"] --> RM
    end
    
    style CM_F fill:#ff6b6b,color:#000
    style CP2 fill:#ff6b6b,color:#000
    style CP3 fill:#ff6b6b,color:#000
    style RO1_X fill:#ffa07a,color:#000
    style RO2 fill:#51cf66,color:#000
    style RB fill:#91e5a3,color:#000
    style RD fill:#91e5a3,color:#000

Borrowing Rules Visualization

#![allow(unused)]
fn main() {
fn borrowing_rules_example() {
    let mut data = vec![1, 2, 3, 4, 5];
    
    // Multiple immutable borrows - OK
    let ref1 = &data;
    let ref2 = &data;
    println!("{:?} {:?}", ref1, ref2);  // Both can be used
    
    // Mutable borrow - exclusive access
    let ref_mut = &mut data;
    ref_mut.push(6);
    // ref1 and ref2 can't be used while ref_mut is active
    
    // After ref_mut is done, immutable borrows work again
    let ref3 = &data;
    println!("{:?}", ref3);
}
}
graph TD
    subgraph "Rust Borrowing Rules"
        D["mut data: Vec<i32>"]
        
        subgraph "Phase 1: Multiple Immutable Borrows [OK]"
            IR1["&data (ref1)"]
            IR2["&data (ref2)"]
            D --> IR1
            D --> IR2
            IR1 -.->|"Read-only access"| MEM1["Memory: [1,2,3,4,5]"]
            IR2 -.->|"Read-only access"| MEM1
        end
        
        subgraph "Phase 2: Exclusive Mutable Borrow [OK]"
            MR["&mut data (ref_mut)"]
            D --> MR
            MR -.->|"Exclusive read/write"| MEM2["Memory: [1,2,3,4,5,6]"]
            BLOCK["[ERROR] Other borrows blocked"]
        end
        
        subgraph "Phase 3: Immutable Borrows Again [OK]"
            IR3["&data (ref3)"]
            D --> IR3
            IR3 -.->|"Read-only access"| MEM3["Memory: [1,2,3,4,5,6]"]
        end
    end
    
    subgraph "What C/C++ Allows (Dangerous)"
        CP["int* ptr"]
        CP2["int* ptr2"]
        CP3["int* ptr3"]
        CP --> CMEM["Same Memory"]
        CP2 --> CMEM
        CP3 --> CMEM
        RACE["[ERROR] Data races possible<br/>[ERROR] Use after free possible"]
    end
    
    style MEM1 fill:#91e5a3,color:#000
    style MEM2 fill:#91e5a3,color:#000
    style MEM3 fill:#91e5a3,color:#000
    style BLOCK fill:#ffa07a,color:#000
    style RACE fill:#ff6b6b,color:#000
    style CMEM fill:#ff6b6b,color:#000

Interior Mutability: Cell<T> and RefCell<T>

Recall that by default variables are immutable in Rust. Sometimes it’s desirable to have most of a type read-only while permitting write access to a single field.

#![allow(unused)]
fn main() {
struct Employee {
    employee_id : u64,   // This must be immutable
    on_vacation: bool,   // What if we wanted to permit write-access to this field, but make employee_id immutable?
}
}
  • Recall that Rust permits a single mutable reference to a variable and any number of immutable references — enforced at compile-time
  • What if we wanted to pass an immutable vector of employees, but allow the on_vacation field to be updated, while ensuring employee_id cannot be mutated?

Cell<T> — interior mutability for Copy types

  • Cell<T> provides interior mutability, i.e., write access to specific elements of references that are otherwise read-only
  • Works by copying values in and out (requires T: Copy for .get())

RefCell<T> — interior mutability with runtime borrow checking

  • RefCell<T> provides a variation that works with references
    • Enforces Rust borrow-checks at runtime instead of compile-time
    • Allows a single mutable borrow, but panics if there are any other references outstanding
    • Use .borrow() for immutable access and .borrow_mut() for mutable access

When to Choose Cell vs RefCell

CriterionCell<T>RefCell<T>
Works withCopy types (integers, bools, floats)Any type (String, Vec, structs)
Access patternCopies values in/out (.get(), .set())Borrows in place (.borrow(), .borrow_mut())
Failure modeCannot fail — no runtime checksPanics if you borrow mutably while another borrow is active
OverheadZero — just copies bytesSmall — tracks borrow state at runtime
Use whenYou need a mutable flag, counter, or small value inside an immutable structYou need to mutate a String, Vec, or complex type inside an immutable struct

Shared Ownership: Rc<T>

Rc<T> allows reference-counted shared ownership of immutable data. What if we wanted to store the same Employee in multiple places without copying?

#[derive(Debug)]
struct Employee {
    employee_id: u64,
}
fn main() {
    let mut us_employees = vec![];
    let mut all_global_employees = Vec::<Employee>::new();
    let employee = Employee { employee_id: 42 };
    us_employees.push(employee);
    // Won't compile — employee was already moved
    //all_global_employees.push(employee);
}

Rc<T> solves the problem by allowing shared immutable access:

  • The contained type is automatically dereferenced
  • The type is dropped when the reference count goes to 0
use std::rc::Rc;
#[derive(Debug)]
struct Employee {employee_id: u64}
fn main() {
    let mut us_employees = vec![];
    let mut all_global_employees = vec![];
    let employee = Employee { employee_id: 42 };
    let employee_rc = Rc::new(employee);
    us_employees.push(employee_rc.clone());
    all_global_employees.push(employee_rc.clone());
    let employee_one = all_global_employees.get(0); // Shared immutable reference
    for e in us_employees {
        println!("{}", e.employee_id);  // Shared immutable reference
    }
    println!("{employee_one:?}");
}

For C++ developers: Smart Pointer Mapping

C++ Smart PointerRust EquivalentKey Difference
std::unique_ptr<T>Box<T>Rust’s version is the default — move is language-level, not opt-in
std::shared_ptr<T>Rc<T> (single-thread) / Arc<T> (multi-thread)No atomic overhead for Rc; use Arc only when sharing across threads
std::weak_ptr<T>Weak<T> (from Rc::downgrade() or Arc::downgrade())Same purpose: break reference cycles

Key distinction: In C++, you choose to use smart pointers. In Rust, owned values (T) and borrowing (&T) cover most use cases — reach for Box/Rc/Arc only when you need heap allocation or shared ownership.

Breaking Reference Cycles with Weak<T>

Rc<T> uses reference counting — if two Rc values point to each other, neither will ever be dropped (a cycle). Weak<T> solves this:

use std::rc::{Rc, Weak};

struct Node {
    value: i32,
    parent: Option<Weak<Node>>,  // Weak reference — doesn't prevent drop
}

fn main() {
    let parent = Rc::new(Node { value: 1, parent: None });
    let child = Rc::new(Node {
        value: 2,
        parent: Some(Rc::downgrade(&parent)),  // Weak ref to parent
    });

    // To use a Weak, try to upgrade it — returns Option<Rc<T>>
    if let Some(parent_rc) = child.parent.as_ref().unwrap().upgrade() {
        println!("Parent value: {}", parent_rc.value);
    }
    println!("Parent strong count: {}", Rc::strong_count(&parent)); // 1, not 2
}

Weak<T> is covered in more depth in Avoiding Excessive clone(). For now, the key takeaway: use Weak for “back-references” in tree/graph structures to avoid memory leaks.


Combining Rc with Interior Mutability

The real power emerges when you combine Rc<T> (shared ownership) with Cell<T> or RefCell<T> (interior mutability). This lets multiple owners read and modify shared data:

PatternUse case
Rc<RefCell<T>>Shared, mutable data (single-threaded)
Arc<Mutex<T>>Shared, mutable data (multi-threaded — see ch13)
Rc<Cell<T>>Shared, mutable Copy types (simple flags, counters)

Exercise: Shared ownership and interior mutability

🟡 Intermediate

  • Part 1 (Rc): Create an Employee struct with employee_id: u64 and name: String. Place it in an Rc<Employee> and clone it into two separate Vecs (us_employees and global_employees). Print from both vectors to show they share the same data.
  • Part 2 (Cell): Add an on_vacation: Cell<bool> field to Employee. Pass an immutable &Employee reference to a function and toggle on_vacation from inside that function — without making the reference mutable.
  • Part 3 (RefCell): Replace name: String with name: RefCell<String> and write a function that appends a suffix to the employee’s name through an &Employee (immutable reference).

Starter code:

use std::cell::{Cell, RefCell};
use std::rc::Rc;

#[derive(Debug)]
struct Employee {
    employee_id: u64,
    name: RefCell<String>,
    on_vacation: Cell<bool>,
}

fn toggle_vacation(emp: &Employee) {
    // TODO: Flip on_vacation using Cell::set()
}

fn append_title(emp: &Employee, title: &str) {
    // TODO: Borrow name mutably via RefCell and push_str the title
}

fn main() {
    // TODO: Create an employee, wrap in Rc, clone into two Vecs,
    // call toggle_vacation and append_title, print results
}
Solution (click to expand)
use std::cell::{Cell, RefCell};
use std::rc::Rc;

#[derive(Debug)]
struct Employee {
    employee_id: u64,
    name: RefCell<String>,
    on_vacation: Cell<bool>,
}

fn toggle_vacation(emp: &Employee) {
    emp.on_vacation.set(!emp.on_vacation.get());
}

fn append_title(emp: &Employee, title: &str) {
    emp.name.borrow_mut().push_str(title);
}

fn main() {
    let emp = Rc::new(Employee {
        employee_id: 42,
        name: RefCell::new("Alice".to_string()),
        on_vacation: Cell::new(false),
    });

    let mut us_employees = vec![];
    let mut global_employees = vec![];
    us_employees.push(Rc::clone(&emp));
    global_employees.push(Rc::clone(&emp));

    // Toggle vacation through an immutable reference
    toggle_vacation(&emp);
    println!("On vacation: {}", emp.on_vacation.get()); // true

    // Append title through an immutable reference
    append_title(&emp, ", Sr. Engineer");
    println!("Name: {}", emp.name.borrow()); // "Alice, Sr. Engineer"

    // Both Vecs see the same data (Rc shares ownership)
    println!("US: {:?}", us_employees[0].name.borrow());
    println!("Global: {:?}", global_employees[0].name.borrow());
    println!("Rc strong count: {}", Rc::strong_count(&emp));
}
// Output:
// On vacation: true
// Name: Alice, Sr. Engineer
// US: "Alice, Sr. Engineer"
// Global: "Alice, Sr. Engineer"
// Rc strong count: 3

Rust memory management

What you’ll learn: Rust’s ownership system — the single most important concept in the language. After this chapter you’ll understand move semantics, borrowing rules, and the Drop trait. If you grasp this chapter, the rest of Rust follows naturally. If you’re struggling, re-read it — ownership clicks on the second pass for most C/C++ developers.

  • Memory management in C/C++ is a source of bugs:
    • In C: memory is allocated with malloc() and freed with free(). No checks against dangling pointers, use-after-free, or double-free
    • In C++: RAII (Resource Acquisition Is Initialization) and smart pointers help, but std::move(ptr) compiles even after the move — use-after-move is UB
  • Rust makes RAII foolproof:
    • Move is destructive — the compiler refuses to let you touch the moved-from variable
    • No Rule of Five needed (no copy ctor, move ctor, copy assign, move assign, destructor)
    • Rust gives complete control of memory allocation, but enforces safety at compile time
    • This is done by a combination of mechanisms including ownership, borrowing, mutability and lifetimes
    • Rust runtime allocations can happen both on the stack and the heap

For C++ developers — Smart Pointer Mapping:

C++RustSafety Improvement
std::unique_ptr<T>Box<T>No use-after-move possible
std::shared_ptr<T>Rc<T> (single-thread)No reference cycles by default
std::shared_ptr<T> (thread-safe)Arc<T>Explicit thread-safety
std::weak_ptr<T>Weak<T>Must check validity
Raw pointer*const T / *mut TOnly in unsafe blocks

For C developers: Box<T> replaces malloc/free pairs. Rc<T> replaces manual reference counting. Raw pointers exist but are confined to unsafe blocks.

Rust ownership, borrowing and lifetimes

  • Recall that Rust only permits a single mutable reference to a variable and multiple read-only references
    • The initial declaration of the variable establishes ownership
    • Subsequent references borrow from the original owner. The rule is that the scope of the borrow can never exceed the owning scope. In other words, the lifetime of a borrow cannot exceed the owning lifetime
fn main() {
    let a = 42; // Owner
    let b = &a; // First borrow
    {
        let aa = 42;
        let c = &a; // Second borrow; a is still in scope
        // Ok: c goes out of scope here
        // aa goes out of scope here
    }
    // let d = &aa; // Will not compile unless aa is moved to outside scope
    // b implicitly goes out of scope before a
    // a goes out of scope last
}
  • Rust can pass parameters to methods using several different mechanisms
    • By value (copy): Typically types that can be trivially copied (ex: u8, u32, i8, i32)
    • By reference: This is the equivalent of passing a pointer to the actual value. This is also commonly known as borrowing, and the reference can be immutable (&), or mutable (&mut)
    • By moving: This transfers “ownership” of the value to the function. The caller can no longer reference the original value
fn foo(x: &u32) {
    println!("{x}");
}
fn bar(x: u32) {
    println!("{x}");
}
fn main() {
    let a = 42;
    foo(&a);    // By reference
    bar(a);     // By value (copy)
}
  • Rust prohibits dangling references from methods
    • References returned by methods must still be in scope
    • Rust will automatically drop a reference when it goes out of scope.
fn no_dangling() -> &u32 {
    // lifetime of a begins here
    let a = 42;
    // Won't compile. lifetime of a ends here
    &a
}

fn ok_reference(a: &u32) -> &u32 {
    // Ok because the lifetime of a always exceeds ok_reference()
    a
}
fn main() {
    let a = 42;     // lifetime of a begins here
    let b = ok_reference(&a);
    // lifetime of b ends here
    // lifetime of a ends here
}

Rust move semantics

  • By default, Rust assignment transfers ownership
fn main() {
    let s = String::from("Rust");    // Allocate a string from the heap
    let s1 = s; // Transfer ownership to s1. s is invalid at this point
    println!("{s1}");
    // This will not compile
    //println!("{s}");
    // s1 goes out of scope here and the memory is deallocated
    // s goes out of scope here, but nothing happens because it doesn't own anything
}
graph LR
    subgraph "Before: let s1 = s"
        S["s (stack)<br/>ptr"] -->|"owns"| H1["Heap: R u s t"]
    end

    subgraph "After: let s1 = s"
        S_MOVED["s (stack)<br/>⚠️ MOVED"] -.->|"invalid"| H2["Heap: R u s t"]
        S1["s1 (stack)<br/>ptr"] -->|"now owns"| H2
    end

    style S_MOVED fill:#ff6b6b,color:#000,stroke:#333
    style S1 fill:#51cf66,color:#000,stroke:#333
    style H2 fill:#91e5a3,color:#000,stroke:#333

After let s1 = s, ownership transfers to s1. The heap data stays put — only the stack pointer moves. s is now invalid.


Rust move semantics and borrowing

fn foo(s : String) {
    println!("{s}");
    // The heap memory pointed to by s will be deallocated here
}
fn bar(s : &String) {
    println!("{s}");
    // Nothing happens -- s is borrowed
}
fn main() {
    let s = String::from("Rust string move example");    // Allocate a string from the heap
    foo(s); // Transfers ownership; s is invalid now
    // println!("{s}");  // will not compile
    let t = String::from("Rust string borrow example");
    bar(&t);    // t continues to hold ownership
    println!("{t}"); 
}

Rust move semantics and ownership

  • It is possible to transfer ownership by moving
    • It is illegal to reference outstanding references after the move is completed
    • Consider borrowing if a move is not desirable
struct Point {
    x: u32,
    y: u32,
}
fn consume_point(p: Point) {
    println!("{} {}", p.x, p.y);
}
fn borrow_point(p: &Point) {
    println!("{} {}", p.x, p.y);
}
fn main() {
    let p = Point {x: 10, y: 20};
    // Try flipping the two lines
    borrow_point(&p);
    consume_point(p);
}

Rust Clone

  • The clone() method can be used to copy the original memory. The original reference continues to be valid (the downside is that we have 2x the allocation)
fn main() {
    let s = String::from("Rust");    // Allocate a string from the heap
    let s1 = s.clone(); // Copy the string; creates a new allocation on the heap
    println!("{s1}");  
    println!("{s}");
    // s1 goes out of scope here and the memory is deallocated
    // s goes out of scope here, and the memory is deallocated
}
graph LR
    subgraph "After: let s1 = s.clone()"
        S["s (stack)<br/>ptr"] -->|"owns"| H1["Heap: R u s t"]
        S1["s1 (stack)<br/>ptr"] -->|"owns (copy)"| H2["Heap: R u s t"]
    end

    style S fill:#51cf66,color:#000,stroke:#333
    style S1 fill:#51cf66,color:#000,stroke:#333
    style H1 fill:#91e5a3,color:#000,stroke:#333
    style H2 fill:#91e5a3,color:#000,stroke:#333

clone() creates a separate heap allocation. Both s and s1 are valid — each owns its own copy.

Rust Copy trait

  • Rust implements copy semantics for built-in types using the Copy trait
    • Examples include u8, u32, i8, i32, etc. Copy semantics use “pass by value”
    • User defined data types can optionally opt into copy semantics using the derive macro with to automatically implement the Copy trait
    • The compiler will allocate space for the copy following a new assignment
// Try commenting this out to see the change in let p1 = p; belw
#[derive(Copy, Clone, Debug)]   // We'll discuss this more later
struct Point{x: u32, y:u32}
fn main() {
    let p = Point {x: 42, y: 40};
    let p1 = p;     // This will perform a copy now instead of move
    println!("p: {p:?}");
    println!("p1: {p:?}");
    let p2 = p1.clone();    // Semantically the same as copy
}

Rust Drop trait

  • Rust automatically calls the drop() method at the end of scope
    • drop is part of a generic trait called Drop. The compiler provides a blanket NOP implementation for all types, but types can override it. For example, the String type overrides it to release heap-allocated memory
    • For C developers: this replaces the need for manual free() calls — resources are automatically released when they go out of scope (RAII)
  • Key safety: You cannot call .drop() directly (the compiler forbids it). Instead, use drop(obj) which moves the value into the function, runs its destructor, and prevents any further use — eliminating double-free bugs

For C++ developers: Drop maps directly to C++ destructors (~ClassName()):

C++ destructorRust Drop
Syntax~MyClass() { ... }impl Drop for MyType { fn drop(&mut self) { ... } }
When calledEnd of scope (RAII)End of scope (same)
Called on moveSource left in “valid but unspecified” state — destructor still runs on the moved-from objectSource is gone — no destructor call on moved-from value
Manual callobj.~MyClass() (dangerous, rarely used)drop(obj) (safe — takes ownership, calls drop, prevents further use)
OrderReverse declaration orderReverse declaration order (same)
Rule of FiveMust manage copy ctor, move ctor, copy assign, move assign, destructorOnly Drop — compiler handles move semantics, and Clone is opt-in
Virtual dtor needed?Yes, if deleting through base pointerNo — no inheritance, so no slicing problem
struct Point {x: u32, y:u32}

// Equivalent to: ~Point() { printf("Goodbye point x:%u, y:%u\n", x, y); }
impl Drop for Point {
    fn drop(&mut self) {
        println!("Goodbye point x:{}, y:{}", self.x, self.y);
    }
}
fn main() {
    let p = Point{x: 42, y: 42};
    {
        let p1 = Point{x:43, y: 43};
        println!("Exiting inner block");
        // p1.drop() called here — like C++ end-of-scope destructor
    }
    println!("Exiting main");
    // p.drop() called here
}

Exercise: Move, Copy and Drop

🟡 Intermediate — experiment freely; the compiler will guide you

  • Create your own experiments with Point with and without Copy in #[derive(Debug)] in the below make sure you understand the differences. The idea is to get a solid understanding of how move vs. copy works, so make sure to ask
  • Implement a custom Drop for Point that sets x and y to 0 in drop. This is a pattern that’s useful for releasing locks and other resources for example
struct Point{x: u32, y: u32}
fn main() {
    // Create Point, assign it to a different variable, create a new scope,
    // pass point to a function, etc.
}
Solution (click to expand)
#[derive(Debug)]
struct Point { x: u32, y: u32 }

impl Drop for Point {
    fn drop(&mut self) {
        println!("Dropping Point({}, {})", self.x, self.y);
        self.x = 0;
        self.y = 0;
        // Note: setting to 0 in drop demonstrates the pattern,
        // but you can't observe these values after drop completes
    }
}

fn consume(p: Point) {
    println!("Consuming: {:?}", p);
    // p is dropped here
}

fn main() {
    let p1 = Point { x: 10, y: 20 };
    let p2 = p1;  // Move — p1 is no longer valid
    // println!("{:?}", p1);  // Won't compile: p1 was moved

    {
        let p3 = Point { x: 30, y: 40 };
        println!("p3 in inner scope: {:?}", p3);
        // p3 is dropped here (end of scope)
    }

    consume(p2);  // p2 is moved into consume and dropped there
    // println!("{:?}", p2);  // Won't compile: p2 was moved

    // Now try: add #[derive(Copy, Clone)] to Point (and remove the Drop impl)
    // and observe how p1 remains valid after let p2 = p1;
}
// Output:
// p3 in inner scope: Point { x: 30, y: 40 }
// Dropping Point(30, 40)
// Consuming: Point { x: 10, y: 20 }
// Dropping Point(10, 20)

Testing Patterns for C++ Programmers

What you’ll learn: Rust’s built-in test framework — #[test], #[should_panic], Result-returning tests, builder patterns for test data, trait-based mocking, property testing with proptest, snapshot testing with insta, and integration test organization. Zero-config testing that replaces Google Test + CMake.

C++ testing typically relies on external frameworks (Google Test, Catch2, Boost.Test) with complex build integration. Rust’s test framework is built into the language and toolchain — no dependencies, no CMake integration, no test runner configuration.

Test attributes beyond #[test]

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_pass() {
        assert_eq!(2 + 2, 4);
    }

    // Expect a panic — equivalent to GTest's EXPECT_DEATH
    #[test]
    #[should_panic]
    fn out_of_bounds_panics() {
        let v = vec![1, 2, 3];
        let _ = v[10]; // Panics — test passes
    }

    // Expect a panic with a specific message substring
    #[test]
    #[should_panic(expected = "index out of bounds")]
    fn specific_panic_message() {
        let v = vec![1, 2, 3];
        let _ = v[10];
    }

    // Tests that return Result<(), E> — use ? instead of unwrap()
    #[test]
    fn test_with_result() -> Result<(), String> {
        let value: u32 = "42".parse().map_err(|e| format!("{e}"))?;
        assert_eq!(value, 42);
        Ok(())
    }

    // Ignore slow tests by default — run with `cargo test -- --ignored`
    #[test]
    #[ignore]
    fn slow_integration_test() {
        std::thread::sleep(std::time::Duration::from_secs(10));
    }
}
}
cargo test                          # Run all non-ignored tests
cargo test -- --ignored             # Run only ignored tests
cargo test -- --include-ignored     # Run ALL tests including ignored
cargo test test_name                # Run tests matching a name pattern
cargo test -- --nocapture           # Show println! output during tests
cargo test -- --test-threads=1      # Run tests serially (for shared state)

Test helpers: builder pattern for test data

In C++ you’d use Google Test fixtures (class MyTest : public ::testing::Test). In Rust, use builder functions or the Default trait:

#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
    use super::*;

    // Builder function — creates test data with sensible defaults
    fn make_gpu_event(severity: Severity, fault_code: u32) -> DiagEvent {
        DiagEvent {
            source: "accel_diag".to_string(),
            severity,
            message: format!("Test event FC:{fault_code}"),
            fault_code,
        }
    }

    // Reusable test fixture — a set of pre-built events
    fn sample_events() -> Vec<DiagEvent> {
        vec![
            make_gpu_event(Severity::Critical, 67956),
            make_gpu_event(Severity::Warning, 32709),
            make_gpu_event(Severity::Info, 10001),
        ]
    }

    #[test]
    fn filter_critical_events() {
        let events = sample_events();
        let critical: Vec<_> = events.iter()
            .filter(|e| e.severity == Severity::Critical)
            .collect();
        assert_eq!(critical.len(), 1);
        assert_eq!(critical[0].fault_code, 67956);
    }
}
}

Mocking with traits

In C++, mocking requires frameworks like Google Mock or manual virtual overrides. In Rust, define a trait for the dependency and swap implementations in tests:

#![allow(unused)]
fn main() {
// Production trait
trait SensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String>;
}

// Production implementation
struct HwSensorReader;
impl SensorReader for HwSensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
        // Real hardware call...
        Ok(72.5)
    }
}

// Test mock — returns predictable values
#[cfg(test)]
struct MockSensorReader {
    temperatures: std::collections::HashMap<u32, f64>,
}

#[cfg(test)]
impl SensorReader for MockSensorReader {
    fn read_temperature(&self, sensor_id: u32) -> Result<f64, String> {
        self.temperatures.get(&sensor_id)
            .copied()
            .ok_or_else(|| format!("Unknown sensor {sensor_id}"))
    }
}

// Function under test — generic over the reader
fn check_overtemp(reader: &impl SensorReader, ids: &[u32], threshold: f64) -> Vec<u32> {
    ids.iter()
        .filter(|&&id| reader.read_temperature(id).unwrap_or(0.0) > threshold)
        .copied()
        .collect()
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn detect_overtemp_sensors() {
        let mut mock = MockSensorReader { temperatures: Default::default() };
        mock.temperatures.insert(0, 72.5);
        mock.temperatures.insert(1, 91.0);  // Over threshold
        mock.temperatures.insert(2, 65.0);

        let hot = check_overtemp(&mock, &[0, 1, 2], 80.0);
        assert_eq!(hot, vec![1]);
    }
}
}

Temporary files and directories in tests

C++ tests often use platform-specific temp directories. Rust has tempfile:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// tempfile = "3"

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::NamedTempFile;
    use std::io::Write;

    #[test]
    fn parse_config_from_file() -> Result<(), Box<dyn std::error::Error>> {
        // Create a temp file that's auto-deleted when dropped
        let mut file = NamedTempFile::new()?;
        writeln!(file, r#"{{"sku": "ServerNode", "level": "Quick"}}"#)?;

        let config = load_config(file.path().to_str().unwrap())?;
        assert_eq!(config.sku, "ServerNode");
        Ok(())
        // file is deleted here — no cleanup code needed
    }
}
}

Property-based testing with proptest

Instead of writing specific test cases, describe properties that should hold for all inputs. proptest generates random inputs and finds minimal failing cases:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// proptest = "1"

#[cfg(test)]
mod tests {
    use proptest::prelude::*;

    fn parse_and_format(n: u32) -> String {
        format!("{n}")
    }

    proptest! {
        #[test]
        fn roundtrip_u32(n: u32) {
            let formatted = parse_and_format(n);
            let parsed: u32 = formatted.parse().unwrap();
            prop_assert_eq!(n, parsed);
        }

        #[test]
        fn string_contains_no_null(s in "[a-zA-Z0-9 ]{0,100}") {
            prop_assert!(!s.contains('\0'));
        }
    }
}
}

Snapshot testing with insta

For tests that produce complex output (JSON, formatted strings), insta auto-generates and manages reference snapshots:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies]
// insta = { version = "1", features = ["json"] }

#[cfg(test)]
mod tests {
    use insta::assert_json_snapshot;

    #[test]
    fn der_entry_format() {
        let entry = DerEntry {
            fault_code: 67956,
            component: "GPU".to_string(),
            message: "ECC error detected".to_string(),
        };
        // First run: creates a snapshot file in tests/snapshots/
        // Subsequent runs: compares against the saved snapshot
        assert_json_snapshot!(entry);
    }
}
}
cargo insta test              # Run tests and review new/changed snapshots
cargo insta review            # Interactive review of snapshot changes

C++ vs Rust testing comparison

C++ (Google Test)RustNotes
TEST(Suite, Name) { }#[test] fn name() { }No suite/class hierarchy needed
ASSERT_EQ(a, b)assert_eq!(a, b)Built-in macro, no framework needed
ASSERT_NEAR(a, b, eps)assert!((a - b).abs() < eps)Or use approx crate
EXPECT_THROW(expr, type)#[should_panic(expected = "...")]Or catch_unwind for fine control
EXPECT_DEATH(expr, "msg")#[should_panic(expected = "msg")]
class Fixture : public ::testing::TestBuilder functions + DefaultNo inheritance needed
Google Mock MOCK_METHODTrait + test implMore explicit, no macro magic
INSTANTIATE_TEST_SUITE_P (parameterized)proptest! or macro-generated tests
SetUp() / TearDown()RAII via Drop — cleanup is automaticVariables dropped at end of test
Separate test binary + CMakecargo test — zero config
ctest --output-on-failurecargo test -- --nocapture

Integration tests: the tests/ directory

Unit tests live inside #[cfg(test)] modules alongside your code. Integration tests live in a separate tests/ directory at the crate root and test your library’s public API as an external consumer would:

my_crate/
├── src/
│   └── lib.rs          # Your library code
├── tests/
│   ├── smoke.rs        # Each .rs file is a separate test binary
│   ├── regression.rs
│   └── common/
│       └── mod.rs      # Shared test helpers (NOT a test itself)
└── Cargo.toml
#![allow(unused)]
fn main() {
// tests/smoke.rs — tests your crate as an external user would
use my_crate::DiagEngine;  // Only public API is accessible

#[test]
fn engine_starts_successfully() {
    let engine = DiagEngine::new("test_config.json");
    assert!(engine.is_ok());
}

#[test]
fn engine_rejects_invalid_config() {
    let engine = DiagEngine::new("nonexistent.json");
    assert!(engine.is_err());
}
}
#![allow(unused)]
fn main() {
// tests/common/mod.rs — shared helpers, NOT compiled as a test binary
pub fn setup_test_environment() -> tempfile::TempDir {
    let dir = tempfile::tempdir().unwrap();
    std::fs::write(dir.path().join("config.json"), r#"{"log_level": "debug"}"#).unwrap();
    dir
}
}
#![allow(unused)]
fn main() {
// tests/regression.rs — can use shared helpers
mod common;

#[test]
fn regression_issue_42() {
    let env = common::setup_test_environment();
    let engine = my_crate::DiagEngine::new(
        env.path().join("config.json").to_str().unwrap()
    );
    assert!(engine.is_ok());
}
}

Running integration tests:

cargo test                          # Runs unit AND integration tests
cargo test --test smoke             # Run only tests/smoke.rs
cargo test --test regression        # Run only tests/regression.rs
cargo test --lib                    # Run ONLY unit tests (skip integration)

Key difference from unit tests: Integration tests cannot access private functions or pub(crate) items. This forces you to verify that your public API is sufficient — a valuable design signal. In C++ terms, it’s like testing against only the public header with no friend access.


Rust crates and modules

What you’ll learn: How Rust organizes code into modules and crates — privacy-by-default visibility, pub modifiers, workspaces, and the crates.io ecosystem. Replaces C/C++ header files, #include, and CMake dependency management.

  • Modules are the fundamental organizational unit of code within crates
    • Each source file (.rs) is its own module, and can create nested modules using the mod keyword.
    • All types in a (sub-) module are private by default, and aren’t externally visible within the same crate unless they are explicitly marked as pub (public). The scope of pub can be further restricted to pub(crate), etc
    • Even if a type is public, it doesn’t automatically become visible within the scope of another module unless it’s imported using the use keyword. Child submodules can reference types in the parent scope using the use super::
    • Source files (.rs) aren’t automatically included in the crate unless they are explicitly listed in main.rs (executable) or lib.rs

Exercise: Modules and functions

  • We’ll take a look at modifying our hello world to call another function
    • As previously mentioned, function are defined with the fn keyword. The -> keyword declares that the function returns a value (the default is void) with the type u32 (unsigned 32-bit integer)
    • Functions are scoped by module, i.e., two functions with exact same name in two modules won’t have a name collision
      • The module scoping extends to all types (for example, a struct foo in mod a { struct foo; } is a distinct type (a::foo) from mod b { struct foo; } (b::foo))

Starter code — complete the functions:

mod math {
    // TODO: implement pub fn add(a: u32, b: u32) -> u32
}

fn greet(name: &str) -> String {
    // TODO: return "Hello, <name>! The secret number is <math::add(21,21)>"
    todo!()
}

fn main() {
    println!("{}", greet("Rustacean"));
}
Solution (click to expand)
mod math {
    pub fn add(a: u32, b: u32) -> u32 {
        a + b
    }
}

fn greet(name: &str) -> String {
    format!("Hello, {}! The secret number is {}", name, math::add(21, 21))
}

fn main() {
    println!("{}", greet("Rustacean"));
}
// Output: Hello, Rustacean! The secret number is 42

Workspaces and crates (packages)

  • Any significant Rust project should use workspaces to organize component crates
    • A workspace is simply a collection of local crates that will be used to build the target binaries. The Cargo.toml at the workspace root should have a pointer to the constituent packages (crates)
[workspace]
resolver = "2"
members = ["package1", "package2"]
workspace_root/
|-- Cargo.toml      # Workspace configuration
|-- package1/
|   |-- Cargo.toml  # Package 1 configuration
|   `-- src/
|       `-- lib.rs  # Package 1 source code
|-- package2/
|   |-- Cargo.toml  # Package 2 configuration
|   `-- src/
|       `-- main.rs # Package 2 source code

Exercise: Using workspaces and package dependencies

  • We’ll create a simple package and use it from our hello world program`
  • Create the workspace directory
mkdir workspace
cd workspace
  • Create a file called Cargo.toml and add the following to it. This creates an empty workspace
[workspace]
resolver = "2"
members = []
  • Add the packages (cargo new --lib specifies a library instead of an executable`)
cargo new hello
cargo new --lib hellolib

Exercise: Using workspaces and package dependencies

  • Take a look at the generated Cargo.toml in hello and hellolib. Notice that both of them have been to the upper level Cargo.toml
  • The presence of lib.rs in hellolib implies a library package (see https://doc.rust-lang.org/cargo/reference/cargo-targets.html for customization options)
  • Adding a dependency on hellolib in Cargo.toml for hello
[dependencies]
hellolib = {path = "../hellolib"}
  • Using add() from hellolib
fn main() {
    println!("Hello, world! {}", hellolib::add(21, 21));
}
Solution (click to expand)

The complete workspace setup:

# Terminal commands
mkdir workspace && cd workspace

# Create workspace Cargo.toml
cat > Cargo.toml << 'EOF'
[workspace]
resolver = "2"
members = ["hello", "hellolib"]
EOF

cargo new hello
cargo new --lib hellolib
# hello/Cargo.toml — add dependency
[dependencies]
hellolib = {path = "../hellolib"}
#![allow(unused)]
fn main() {
// hellolib/src/lib.rs — already has add() from cargo new --lib
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}
}
// hello/src/main.rs
fn main() {
    println!("Hello, world! {}", hellolib::add(21, 21));
}
// Output: Hello, world! 42

Using community crates from crates.io

  • Rust has a vibrant ecosystem of community crates (see https://crates.io/)
    • The Rust philosophy is to keep the standard library compact and outsource functionality to community crates
    • There is no hard and fast rule about using community crates, but the rule of thumb should be to ensure that the crate has a decent maturity level (indicated by the version number), and that it’s being actively maintained. Reach out to internal sources if in doubt about a crate
  • Every crate published on crates.io has a major and minor version
    • Crates are expected to observe the major and minor SemVer guidelines defined here: https://doc.rust-lang.org/cargo/reference/semver.html
    • The TL;DR version is that there should be no breaking changes for the same minor version. For example, v0.11 must be compatible with v0.15 (but v0.20 may have breaking changes)

Crates dependencies and SemVer

  • Crates can define dependencies on a specific versions of a crate, specific minor or major version, or don’t care. The following examples show the Cargo.toml entries for declaring a dependency on the rand crate
  • At least 0.10.0, but anything < 0.11.0 is fine
[dependencies]
rand = { version = "0.10.0"}
  • Only 0.10.0, and nothing else
[dependencies]
rand = { version = "=0.10.0"}
  • Don’t care; cargo will select the latest version
[dependencies]
rand = { version = "*"}
  • Reference: https://doc.rust-lang.org/cargo/reference/specifying-dependencies.html

Exercise: Using the rand crate

  • Modify the helloworld example to print a random number
  • Use cargo add rand to add a dependency
  • Use https://docs.rs/rand/latest/rand/ as a reference for the API

Starter code — add this to main.rs after running cargo add rand:

use rand::RngExt;

fn main() {
    let mut rng = rand::rng();
    // TODO: Generate and print a random u32 in 1..=100
    // TODO: Generate and print a random bool
    // TODO: Generate and print a random f64
}
Solution (click to expand)
use rand::RngExt;

fn main() {
    let mut rng = rand::rng();
    let n: u32 = rng.random_range(1..=100);
    println!("Random number (1-100): {n}");

    // Generate a random boolean
    let b: bool = rng.random();
    println!("Random bool: {b}");

    // Generate a random float between 0.0 and 1.0
    let f: f64 = rng.random();
    println!("Random float: {f:.4}");
}

Cargo.toml and Cargo.lock

  • As mentioned previously, Cargo.lock is automatically generated from Cargo.toml
    • The main idea behind Cargo.lock is to ensure reproducible builds. For example, if Cargo.toml had specified a version of 0.10.0, cargo is free to choose any version that is < 0.11.0
    • Cargo.lock contains the specific version of the rand crate that was used during the build.
    • The recommendation is to include Cargo.lock in the git repo to ensure reproducible builds

Cargo test feature

  • Rust unit tests reside in the same source file (by convention), and are usually grouped into separate module
    • The test code is never included in the actual binary. This is made possible by the cfg (configuration) feature. Configurations are useful for creating platform specific code (Linux vs. Windows) for example
    • Tests can be executed with cargo test. Reference: https://doc.rust-lang.org/reference/conditional-compilation.html
#![allow(unused)]
fn main() {
pub fn add(left: u64, right: u64) -> u64 {
    left + right
}
// Will be included only during testing
#[cfg(test)]
mod tests {
    use super::*; // This makes all types in the parent scope visible
    #[test]
    fn it_works() {
        let result = add(2, 2); // Alternatively, super::add(2, 2);
        assert_eq!(result, 4);
    }
}
}

Other Cargo features

  • cargo has several other useful features including:
    • cargo clippy is a great way of linting Rust code. In general, warnings should be fixed (or rarely suppressed if really warranted)
    • cargo format executes the rustfmt tool to format source code. Using the tool ensures standard formatting of checked-in code and puts an end to debates about style
    • cargo doc can be used to generate documentation from the /// style comments. The documentation for all crates on crates.io was generated using this method

Build Profiles: Controlling Optimization

In C, you pass -O0, -O2, -Os, -flto to gcc/clang. In Rust, you configure build profiles in Cargo.toml:

# Cargo.toml — build profile configuration

[profile.dev]
opt-level = 0          # No optimization (fast compile, like -O0)
debug = true           # Full debug symbols (like -g)

[profile.release]
opt-level = 3          # Maximum optimization (like -O3)
lto = "fat"            # Link-Time Optimization (like -flto)
strip = true           # Strip symbols (like the strip command)
codegen-units = 1      # Single codegen unit — slower compile, better optimization
panic = "abort"        # No unwind tables (smaller binary)
C/GCC FlagCargo.toml KeyValues
-O0 / -O2 / -O3opt-level0, 1, 2, 3, "s", "z"
-fltoltofalse, "thin", "fat"
-g / no -gdebugtrue, false, "line-tables-only"
strip commandstrip"none", "debuginfo", "symbols", true/false
—codegen-units1 = best opt, slowest compile
cargo build              # Uses [profile.dev]
cargo build --release    # Uses [profile.release]

Build Scripts (build.rs): Linking C Libraries

In C, you use Makefiles or CMake to link libraries and run code generation. Rust uses a build.rs file at the crate root:

// build.rs — runs before compiling the crate

fn main() {
    // Link a system C library (like -lbmc_ipmi in gcc)
    println!("cargo::rustc-link-lib=bmc_ipmi");

    // Where to find the library (like -L/usr/lib/bmc)
    println!("cargo::rustc-link-search=/usr/lib/bmc");

    // Re-run if the C header changes
    println!("cargo::rerun-if-changed=wrapper.h");
}

You can even compile C source files directly from a Rust crate:

# Cargo.toml
[build-dependencies]
cc = "1"  # C compiler integration
// build.rs
fn main() {
    cc::Build::new()
        .file("src/c_helpers/ipmi_raw.c")
        .include("/usr/include/bmc")
        .compile("ipmi_raw");   // Produces libipmi_raw.a, linked automatically
    println!("cargo::rerun-if-changed=src/c_helpers/ipmi_raw.c");
}
C / Make / CMakeRust build.rs
-lfooprintln!("cargo::rustc-link-lib=foo")
-L/pathprintln!("cargo::rustc-link-search=/path")
Compile C sourcecc::Build::new().file("foo.c").compile("foo")
Generate codeWrite files to $OUT_DIR, then include!()

Cross-Compilation

In C, cross-compilation requires installing a separate toolchain (arm-linux-gnueabihf-gcc) and configuring Make/CMake. In Rust:

# Install a cross-compilation target
rustup target add aarch64-unknown-linux-gnu

# Cross-compile
cargo build --target aarch64-unknown-linux-gnu --release

Specify the linker in .cargo/config.toml:

[target.aarch64-unknown-linux-gnu]
linker = "aarch64-linux-gnu-gcc"
C Cross-CompileRust Equivalent
apt install gcc-aarch64-linux-gnurustup target add aarch64-unknown-linux-gnu + install linker
CC=aarch64-linux-gnu-gcc make.cargo/config.toml [target.X] linker = "..."
#ifdef __aarch64__#[cfg(target_arch = "aarch64")]
Separate Makefile targetscargo build --target ...

Feature Flags: Conditional Compilation

C uses #ifdef and -DFOO for conditional compilation. Rust uses feature flags defined in Cargo.toml:

# Cargo.toml
[features]
default = ["json"]         # Enabled by default
json = ["dep:serde_json"]  # Optional dependency
verbose = []               # Flag with no dependency
gpu = ["dep:cuda-sys"]     # Optional GPU support
#![allow(unused)]
fn main() {
// Code gated on features:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
    serde_json::from_str(data).map_err(Error::from)
}

#[cfg(feature = "verbose")]
macro_rules! verbose {
    ($($arg:tt)*) => { eprintln!("[VERBOSE] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose"))]
macro_rules! verbose {
    ($($arg:tt)*) => {}; // Compiles to nothing
}
}
C PreprocessorRust Feature Flags
gcc -DDEBUGcargo build --features verbose
#ifdef DEBUG#[cfg(feature = "verbose")]
#define MAX 100const MAX: u32 = 100;
#ifdef __linux__#[cfg(target_os = "linux")]

Integration Tests vs Unit Tests

Unit tests live next to the code with #[cfg(test)]. Integration tests live in tests/ and test your crate’s public API only:

#![allow(unused)]
fn main() {
// tests/smoke_test.rs — no #[cfg(test)] needed
use my_crate::parse_config;

#[test]
fn parse_valid_config() {
    let config = parse_config("test_data/valid.json").unwrap();
    assert_eq!(config.max_retries, 5);
}
}
AspectUnit Tests (#[cfg(test)])Integration Tests (tests/)
LocationSame file as codeSeparate tests/ directory
AccessPrivate + public itemsPublic API only
Run commandcargo testcargo test --test smoke_test

Testing Patterns and Strategies

C firmware teams typically write tests in CUnit, CMocka, or custom frameworks with a lot of boilerplate. Rust’s built-in test harness is far more capable. This section covers patterns you’ll need for production code.

#[should_panic] — Testing Expected Failures

#![allow(unused)]
fn main() {
// Test that certain conditions cause panics (like C's assert failures)
#[test]
#[should_panic(expected = "index out of bounds")]
fn test_bounds_check() {
    let v = vec![1, 2, 3];
    let _ = v[10];  // Should panic
}

#[test]
#[should_panic(expected = "temperature exceeds safe limit")]
fn test_thermal_shutdown() {
    fn check_temperature(celsius: f64) {
        if celsius > 105.0 {
            panic!("temperature exceeds safe limit: {celsius}°C");
        }
    }
    check_temperature(110.0);
}
}

#[ignore] — Slow or Hardware-Dependent Tests

#![allow(unused)]
fn main() {
// Mark tests that require special conditions (like C's #ifdef HARDWARE_TEST)
#[test]
#[ignore = "requires GPU hardware"]
fn test_gpu_ecc_scrub() {
    // This test only runs on machines with GPUs
    // Run with: cargo test -- --ignored
    // Run with: cargo test -- --include-ignored  (runs ALL tests)
}
}

Result-Returning Tests (replacing unwrap chains)

#![allow(unused)]
fn main() {
// Instead of many unwrap() calls that hide the actual failure:
#[test]
fn test_config_parsing() -> Result<(), Box<dyn std::error::Error>> {
    let json = r#"{"hostname": "node-01", "port": 8080}"#;
    let config: ServerConfig = serde_json::from_str(json)?;  // ? instead of unwrap()
    assert_eq!(config.hostname, "node-01");
    assert_eq!(config.port, 8080);
    Ok(())  // Test passes if we reach here without error
}
}

Test Fixtures with Builder Functions

C uses setUp()/tearDown() functions. Rust uses helper functions and Drop:

#![allow(unused)]
fn main() {
struct TestFixture {
    temp_dir: std::path::PathBuf,
    config: Config,
}

impl TestFixture {
    fn new() -> Self {
        let temp_dir = std::env::temp_dir().join(format!("test_{}", std::process::id()));
        std::fs::create_dir_all(&temp_dir).unwrap();
        let config = Config {
            log_dir: temp_dir.clone(),
            max_retries: 3,
            ..Default::default()
        };
        Self { temp_dir, config }
    }
}

impl Drop for TestFixture {
    fn drop(&mut self) {
        // Automatic cleanup — like C's tearDown() but can't be forgotten
        let _ = std::fs::remove_dir_all(&self.temp_dir);
    }
}

#[test]
fn test_with_fixture() {
    let fixture = TestFixture::new();
    // Use fixture.config, fixture.temp_dir...
    assert!(fixture.temp_dir.exists());
    // fixture is automatically dropped here → cleanup runs
}
}

Mocking Traits for Hardware Interfaces

In C, mocking hardware requires preprocessor tricks or function pointer swapping. In Rust, traits make this natural:

#![allow(unused)]
fn main() {
// Production trait for IPMI communication
trait IpmiTransport {
    fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String>;
}

// Real implementation (used in production)
struct RealIpmi { /* BMC connection details */ }
impl IpmiTransport for RealIpmi {
    fn send_command(&self, cmd: u8, data: &[u8]) -> Result<Vec<u8>, String> {
        // Actually talks to BMC hardware
        todo!("Real IPMI call")
    }
}

// Mock implementation (used in tests)
struct MockIpmi {
    responses: std::collections::HashMap<u8, Vec<u8>>,
}
impl IpmiTransport for MockIpmi {
    fn send_command(&self, cmd: u8, _data: &[u8]) -> Result<Vec<u8>, String> {
        self.responses.get(&cmd)
            .cloned()
            .ok_or_else(|| format!("No mock response for cmd 0x{cmd:02x}"))
    }
}

// Generic function that works with both real and mock
fn read_sensor_temperature(transport: &dyn IpmiTransport) -> Result<f64, String> {
    let response = transport.send_command(0x2D, &[])?;
    if response.len() < 2 {
        return Err("Response too short".into());
    }
    Ok(response[0] as f64 + (response[1] as f64 / 256.0))
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_temperature_reading() {
        let mut mock = MockIpmi { responses: std::collections::HashMap::new() };
        mock.responses.insert(0x2D, vec![72, 128]); // 72.5°C

        let temp = read_sensor_temperature(&mock).unwrap();
        assert!((temp - 72.5).abs() < 0.01);
    }

    #[test]
    fn test_short_response() {
        let mock = MockIpmi { responses: std::collections::HashMap::new() };
        // No response configured → error
        assert!(read_sensor_temperature(&mock).is_err());
    }
}
}

Property-Based Testing with proptest

Instead of testing specific values, test properties that must always hold:

#![allow(unused)]
fn main() {
// Cargo.toml: [dev-dependencies] proptest = "1"
use proptest::prelude::*;

fn parse_sensor_id(s: &str) -> Option<u32> {
    s.strip_prefix("sensor_")?.parse().ok()
}

fn format_sensor_id(id: u32) -> String {
    format!("sensor_{id}")
}

proptest! {
    #[test]
    fn roundtrip_sensor_id(id in 0u32..10000) {
        // Property: format then parse should give back the original
        let formatted = format_sensor_id(id);
        let parsed = parse_sensor_id(&formatted);
        prop_assert_eq!(parsed, Some(id));
    }

    #[test]
    fn parse_rejects_garbage(s in "[^s].*") {
        // Property: strings not starting with 's' should never parse
        let result = parse_sensor_id(&s);
        prop_assert!(result.is_none());
    }
}
}

C vs Rust Testing Comparison

C TestingRust Equivalent
CUnit, CMocka, custom frameworkBuilt-in #[test] + cargo test
setUp() / tearDown()Builder function + Drop trait
#ifdef TEST mock functionsTrait-based dependency injection
assert(x == y)assert_eq!(x, y) with auto diff output
Separate test executableSame binary, conditional compilation with #[cfg(test)]
valgrind --leak-check=full ./testcargo test (memory safe by default) + cargo miri test
Code coverage: gcov / lcovcargo tarpaulin or cargo llvm-cov
Test discovery: manual registrationAutomatic — any #[test] fn is discovered

Rust Option and Result key takeaways

What you’ll learn: Idiomatic error handling patterns — safe alternatives to unwrap(), the ? operator for propagation, custom error types, and when to use anyhow vs thiserror in production code.

  • Option and Result are an integral part of idiomatic Rust
  • Safe alternatives to unwrap():
#![allow(unused)]
fn main() {
// Option<T> safe alternatives
let value = opt.unwrap_or(default);              // Provide fallback value
let value = opt.unwrap_or_else(|| compute());    // Lazy computation for fallback
let value = opt.unwrap_or_default();             // Use Default trait implementation
let value = opt.expect("descriptive message");   // Only when panic is acceptable

// Result<T, E> safe alternatives  
let value = result.unwrap_or(fallback);          // Ignore error, use fallback
let value = result.unwrap_or_else(|e| handle(e)); // Handle error, return fallback
let value = result.unwrap_or_default();          // Use Default trait
}
  • Pattern matching for explicit control:
#![allow(unused)]
fn main() {
match some_option {
    Some(value) => println!("Got: {}", value),
    None => println!("No value found"),
}

match some_result {
    Ok(value) => process(value),
    Err(error) => log_error(error),
}
}
  • Use ? operator for error propagation: Short-circuit and bubble up errors
#![allow(unused)]
fn main() {
fn process_file(path: &str) -> Result<String, std::io::Error> {
    let content = std::fs::read_to_string(path)?; // Automatically returns error
    Ok(content.to_uppercase())
}
}
  • Transformation methods:
    • map(): Transform the success value Ok(T) -> Ok(U) or Some(T) -> Some(U)
    • map_err(): Transform the error type Err(E) -> Err(F)
    • and_then(): Chain operations that can fail
  • Use in your own APIs: Prefer Result<T, E> over exceptions or error codes
  • References: Option docs | Result docs

Rust Common Pitfalls and Debugging Tips

  • Borrowing issues: Most common beginner mistake
    • “cannot borrow as mutable” -> Only one mutable reference allowed at a time
    • “borrowed value does not live long enough” -> Reference outlives the data it points to
    • Fix: Use scopes {} to limit reference lifetimes, or clone data when needed
  • Missing trait implementations: “method not found” errors
    • Fix: Add #[derive(Debug, Clone, PartialEq)] for common traits
    • Use cargo check to get better error messages than cargo run
  • Integer overflow in debug mode: Rust panics on overflow
    • Fix: Use wrapping_add(), saturating_add(), or checked_add() for explicit behavior
  • String vs &str confusion: Different types for different use cases
    • Use &str for string slices (borrowed), String for owned strings
    • Fix: Use .to_string() or String::from() to convert &str to String
  • Fighting the borrow checker: Don’t try to outsmart it
    • Fix: Restructure code to work with ownership rules rather than against them
    • Consider using Rc<RefCell<T>> for complex sharing scenarios (sparingly)

Error Handling Examples: Good vs Bad

#![allow(unused)]
fn main() {
// [ERROR] BAD: Can panic unexpectedly
fn bad_config_reader() -> String {
    let config = std::env::var("CONFIG_FILE").unwrap(); // Panic if not set!
    std::fs::read_to_string(config).unwrap()           // Panic if file missing!
}

// [OK] GOOD: Handles errors gracefully
fn good_config_reader() -> Result<String, ConfigError> {
    let config_path = std::env::var("CONFIG_FILE")
        .unwrap_or_else(|_| "default.conf".to_string()); // Fallback to default
    
    let content = std::fs::read_to_string(config_path)
        .map_err(ConfigError::FileRead)?;                // Convert and propagate error
    
    Ok(content)
}

// [OK] EVEN BETTER: With proper error types
use thiserror::Error;

#[derive(Error, Debug)]
enum ConfigError {
    #[error("Failed to read config file: {0}")]
    FileRead(#[from] std::io::Error),
    
    #[error("Invalid configuration: {message}")]
    Invalid { message: String },
}
}

Let’s break down what’s happening here. ConfigError has just two variants — one for I/O errors and one for validation errors. This is the right starting point for most modules:

ConfigError variantHoldsCreated by
FileRead(io::Error)The original I/O error#[from] auto-converts via ?
Invalid { message }A human-readable explanationYour validation code

Now you can write functions that return Result<T, ConfigError>:

#![allow(unused)]
fn main() {
fn read_config(path: &str) -> Result<String, ConfigError> {
    let content = std::fs::read_to_string(path)?;  // io::Error → ConfigError::FileRead
    if content.is_empty() {
        return Err(ConfigError::Invalid {
            message: "config file is empty".to_string(),
        });
    }
    Ok(content)
}
}

🟢 Self-study checkpoint: Before continuing, make sure you can answer:

  1. Why does ? on the read_to_string call work? (Because #[from] generates impl From<io::Error> for ConfigError)
  2. What happens if you add a third variant MissingKey(String) — what code changes? (Just add the variant; existing code still compiles)

Crate-Level Error Types and Result Aliases

As your project grows beyond a single file, you’ll combine multiple module-level errors into a crate-level error type. This is the standard pattern in production Rust. Let’s build up from the ConfigError above.

In real-world Rust projects, every crate (or significant module) defines its own Error enum and a Result type alias. This is the idiomatic pattern — analogous to how in C++ you’d define a per-library exception hierarchy and using Result = std::expected<T, Error>.

The pattern

#![allow(unused)]
fn main() {
// src/error.rs  (or at the top of lib.rs)
use thiserror::Error;

/// Every error this crate can produce.
#[derive(Error, Debug)]
pub enum Error {
    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),          // auto-converts via From

    #[error("JSON parse error: {0}")]
    Json(#[from] serde_json::Error),     // auto-converts via From

    #[error("Invalid sensor id: {0}")]
    InvalidSensor(u32),                  // domain-specific variant

    #[error("Timeout after {ms} ms")]
    Timeout { ms: u64 },
}

/// Crate-wide Result alias — saves typing throughout the crate.
pub type Result<T> = core::result::Result<T, Error>;
}

How it simplifies every function

Without the alias you’d write:

#![allow(unused)]
fn main() {
// Verbose — error type repeated everywhere
fn read_sensor(id: u32) -> Result<f64, crate::Error> { ... }
fn parse_config(path: &str) -> Result<Config, crate::Error> { ... }
}

With the alias:

#![allow(unused)]
fn main() {
// Clean — just `Result<T>`
use crate::{Error, Result};

fn read_sensor(id: u32) -> Result<f64> {
    if id > 128 {
        return Err(Error::InvalidSensor(id));
    }
    let raw = std::fs::read_to_string(format!("/dev/sensor/{id}"))?; // io::Error → Error::Io
    let value: f64 = raw.trim().parse()
        .map_err(|_| Error::InvalidSensor(id))?;
    Ok(value)
}
}

The #[from] attribute on Io generates this impl for free:

#![allow(unused)]
fn main() {
// Auto-generated by thiserror's #[from]
impl From<std::io::Error> for Error {
    fn from(source: std::io::Error) -> Self {
        Error::Io(source)
    }
}
}

That’s what makes ? work: when a function returns std::io::Error and your function returns Result<T> (your alias), the compiler calls From::from() to convert it automatically.

Composing module-level errors

Larger crates split errors by module, then compose them at the crate root:

#![allow(unused)]
fn main() {
// src/config/error.rs
#[derive(thiserror::Error, Debug)]
pub enum ConfigError {
    #[error("Missing key: {0}")]
    MissingKey(String),
    #[error("Invalid value for '{key}': {reason}")]
    InvalidValue { key: String, reason: String },
}

// src/error.rs  (crate-level)
#[derive(thiserror::Error, Debug)]
pub enum Error {
    #[error(transparent)]               // delegates Display to inner error
    Config(#[from] crate::config::ConfigError),

    #[error("I/O error: {0}")]
    Io(#[from] std::io::Error),
}
pub type Result<T> = core::result::Result<T, Error>;
}

Callers can still match on specific config errors:

#![allow(unused)]
fn main() {
match result {
    Err(Error::Config(ConfigError::MissingKey(k))) => eprintln!("Add '{k}' to config"),
    Err(e) => eprintln!("Other error: {e}"),
    Ok(v) => use_value(v),
}
}

C++ comparison

ConceptC++Rust
Error hierarchyclass AppError : public std::runtime_error#[derive(thiserror::Error)] enum Error { ... }
Return errorstd::expected<T, Error> or throwfn foo() -> Result<T>
Convert errorManual try/catch + rethrow#[from] + ? — zero boilerplate
Result aliastemplate<class T> using Result = std::expected<T, Error>;pub type Result<T> = core::result::Result<T, Error>;
Error messageOverride what()#[error("...")] — compiled into Display impl

Connecting enums to Option and Result

What you’ll learn: How Rust replaces null pointers with Option<T> and exceptions with Result<T, E>, and how the ? operator makes error propagation concise. This is Rust’s most distinctive pattern — errors are values, not hidden control flow.

  • Remember the enum type we learned earlier? Rust’s Option and Result are simply enums defined in the standard library:
#![allow(unused)]
fn main() {
// This is literally how Option is defined in std:
enum Option<T> {
    Some(T),  // Contains a value
    None,     // No value
}

// And Result:
enum Result<T, E> {
    Ok(T),    // Success with value
    Err(E),   // Error with details
}
}
  • This means everything you learned about pattern matching with match works directly with Option and Result
  • There is no null pointer in Rust – Option<T> is the replacement, and the compiler forces you to handle the None case

C++ Comparison: Exceptions vs Result

C++ PatternRust EquivalentAdvantage
throw std::runtime_error(msg)Err(MyError::Runtime(msg))Error in return type — can’t forget to handle
try { } catch (...) { }match result { Ok(v) => ..., Err(e) => ... }No hidden control flow
std::optional<T>Option<T>Exhaustive match required — can’t forget None
noexcept annotationDefault — all Rust functions are “noexcept”Exceptions don’t exist
errno / return codesResult<T, E>Type-safe, can’t ignore

Rust Option type

  • The Rust Option type is an enum with only two variants: Some<T> and None
    • The idea is that this represents a nullable type, i.e., it either contains a valid value of that type (Some<T>), or has no valid value (None)
    • The Option type is used in APIs where the result of an operation either succeeds and returns a valid value or it fails (but the specific error is irrelevant). For example, consider parsing a string for an integer value
fn main() {
    // Returns Option<usize>
    let a = "1234".find("1");
    match a {
        Some(a) => println!("Found 1 at index {a}"),
        None => println!("Couldn't find 1")
    }
}

Rust Option type

  • Rust Option can be processed in various ways
    • unwrap() panics if the Option<T> is None and returns T otherwise and it is the least preferred approach
    • or() can be used to return an alternative value if let lets us test for Some<T>

Production patterns: See Safe value extraction with unwrap_or and Functional transforms: map, map_err, find_map for real-world examples from production Rust code.

fn main() {
  // This return an Option<usize>
  let a = "1234".find("1");
  println!("{a:?} {}", a.unwrap());
  let a = "1234".find("5").or(Some(42));
  println!("{a:?}");
  if let Some(a) = "1234".find("1") {
      println!("{a}");
  } else {
    println!("Not found in string");
  }
  // This will panic
  // "1234".find("5").unwrap();
}

Rust Result type

  • Result is an enum type similar to Option with two variants: Ok<T> or Err<E>
    • Result is used extensively in Rust APIs that can fail. The idea is that on success, functions will return a Ok<T>, or they will return a specific error Err<T>
  use std::num::ParseIntError;
  fn main() {
  let a : Result<i32, ParseIntError>  = "1234z".parse();
  match a {
      Ok(n) => println!("Parsed {n}"),
      Err(e) => println!("Parsing failed {e:?}"),
  }
  let a : Result<i32, ParseIntError>  = "1234z".parse().or(Ok(-1));
  println!("{a:?}");
  if let Ok(a) = "1234".parse::<i32>() {
    println!("Let OK {a}");  
  }
  // This will panic
  //"1234z".parse().unwrap();
}

Option and Result: Two Sides of the Same Coin

Option and Result are deeply related — Option<T> is essentially Result<T, ()> (a result where the error carries no information):

Option<T>Result<T, E>Meaning
Some(value)Ok(value)Success — value is present
NoneErr(error)Failure — no value (Option) or error details (Result)

Converting between them:

fn main() {
    let opt: Option<i32> = Some(42);
    let res: Result<i32, &str> = opt.ok_or("value was None");  // Option → Result
    
    let res: Result<i32, &str> = Ok(42);
    let opt: Option<i32> = res.ok();  // Result → Option (discards error)
    
    // They share many of the same methods:
    // .map(), .and_then(), .unwrap_or(), .unwrap_or_else(), .is_some()/is_ok()
}

Rule of thumb: Use Option when absence is normal (e.g., looking up a key). Use Result when failure needs explanation (e.g., file I/O, parsing).

Exercise: log() function implementation with Option

🟢 Starter

  • Implement a log() function that accepts an Option<&str> parameter. If the parameter is None, it should print a default string
  • The function should return a Result with () for both success and error (in this case we’ll never have an error)
Solution (click to expand)
fn log(message: Option<&str>) -> Result<(), ()> {
    match message {
        Some(msg) => println!("LOG: {msg}"),
        None => println!("LOG: (no message provided)"),
    }
    Ok(())
}

fn main() {
    let _ = log(Some("System initialized"));
    let _ = log(None);
    
    // Alternative using unwrap_or:
    let msg: Option<&str> = None;
    println!("LOG: {}", msg.unwrap_or("(default message)"));
}
// Output:
// LOG: System initialized
// LOG: (no message provided)
// LOG: (default message)

Rust error handling

  • Rust errors can be irrecoverable (fatal) or recoverable. Fatal errors result in a ``panic```
    • In general, situation that result in panics should be avoided. panics are caused by bugs in the program, including exceeding index bounds, calling unwrap() on an Option<None>, etc.
    • It is OK to have explicit panics for conditions that should be impossible. The panic! or assert! macros can be used for sanity checks
fn main() {
   let x : Option<u32> = None;
   // println!("{x}", x.unwrap()); // Will panic
   println!("{}", x.unwrap_or(0));  // OK -- prints 0
   let x = 41;
   //assert!(x == 42); // Will panic
   //panic!("Something went wrong"); // Unconditional panic
   let _a = vec![0, 1];
   // println!("{}", a[2]); // Out of bounds panic; use a.get(2) which will return Option<T>
}

Error Handling: C++ vs Rust

C++ Exception-Based Error Handling Problems

// C++ error handling - exceptions create hidden control flow
#include <fstream>
#include <stdexcept>

std::string read_config(const std::string& path) {
    std::ifstream file(path);
    if (!file.is_open()) {
        throw std::runtime_error("Cannot open: " + path);
    }
    std::string content;
    // What if getline throws? Is file properly closed?
    // With RAII yes, but what about other resources?
    std::getline(file, content);
    return content;  // What if caller doesn't try/catch?
}

int main() {
    // ERROR: Forgot to wrap in try/catch!
    auto config = read_config("nonexistent.txt");
    // Exception propagates silently, program crashes
    // Nothing in the function signature warned us
    return 0;
}
graph TD
    subgraph "C++ Error Handling Issues"
        CF["Function Call"]
        CR["throw exception<br/>or return code"]
        CIGNORE["[ERROR] Exception not caught<br/>or return code ignored"]
        CCHECK["try/catch or check"]
        CERROR["Hidden control flow<br/>throws not in signature"]
        CERRNO["No compile-time<br/>enforcement"]
        
        CF --> CR
        CR --> CIGNORE
        CR --> CCHECK
        CCHECK --> CERROR
        CERROR --> CERRNO
        
        CPROBLEMS["[ERROR] Exceptions invisible in types<br/>[ERROR] Hidden control flow<br/>[ERROR] Easy to forget try/catch<br/>[ERROR] Exception safety is hard<br/>[ERROR] noexcept is opt-in"]
    end
    
    subgraph "Rust Result<T, E> System"
        RF["Function Call"]
        RR["Result<T, E><br/>Ok(value) | Err(error)"]
        RMUST["[OK] Must handle<br/>Compile error if ignored"]
        RMATCH["Pattern matching<br/>match, if let, ?"]
        RDETAIL["Detailed error info<br/>Custom error types"]
        RSAFE["Type-safe<br/>No global state"]
        
        RF --> RR
        RR --> RMUST
        RMUST --> RMATCH
        RMATCH --> RDETAIL
        RDETAIL --> RSAFE
        
        RBENEFITS["[OK] Forced error handling<br/>[OK] Type-safe errors<br/>[OK] Detailed error info<br/>[OK] Composable with ?<br/>[OK] Zero runtime cost"]
    end
    
    style CPROBLEMS fill:#ff6b6b,color:#000
    style RBENEFITS fill:#91e5a3,color:#000
    style CIGNORE fill:#ff6b6b,color:#000
    style RMUST fill:#91e5a3,color:#000

Result<T, E> Visualization

// Rust error handling - comprehensive and forced
use std::fs::File;
use std::io::Read;

fn read_file_content(filename: &str) -> Result<String, std::io::Error> {
    let mut file = File::open(filename)?;  // ? automatically propagates errors
    let mut contents = String::new();
    file.read_to_string(&mut contents)?;
    Ok(contents)  // Success case
}

fn main() {
    match read_file_content("example.txt") {
        Ok(content) => println!("File content: {}", content),
        Err(error) => println!("Failed to read file: {}", error),
        // Compiler forces us to handle both cases!
    }
}
graph TD
    subgraph "Result<T, E> Flow"
        START["Function starts"]
        OP1["File::open()"]
        CHECK1{{"Result check"}}
        OP2["file.read_to_string()"]
        CHECK2{{"Result check"}}
        SUCCESS["Ok(contents)"]
        ERROR1["Err(io::Error)"]
        ERROR2["Err(io::Error)"]
        
        START --> OP1
        OP1 --> CHECK1
        CHECK1 -->|"Ok(file)"| OP2
        CHECK1 -->|"Err(e)"| ERROR1
        OP2 --> CHECK2
        CHECK2 -->|"Ok(())"| SUCCESS
        CHECK2 -->|"Err(e)"| ERROR2
        
        ERROR1 --> PROPAGATE["? operator<br/>propagates error"]
        ERROR2 --> PROPAGATE
        PROPAGATE --> CALLER["Caller must<br/>handle error"]
    end
    
    subgraph "Pattern Matching Options"
        MATCH["match result"]
        IFLET["if let Ok(val) = result"]
        UNWRAP["result.unwrap()<br/>[WARNING] Panics on error"]
        EXPECT["result.expect(msg)<br/>[WARNING] Panics with message"]
        UNWRAP_OR["result.unwrap_or(default)<br/>[OK] Safe fallback"]
        QUESTION["result?<br/>[OK] Early return"]
        
        MATCH --> SAFE1["[OK] Handles both cases"]
        IFLET --> SAFE2["[OK] Handles error case"]
        UNWRAP_OR --> SAFE3["[OK] Always returns value"]
        QUESTION --> SAFE4["[OK] Propagates to caller"]
        UNWRAP --> UNSAFE1["[ERROR] Can panic"]
        EXPECT --> UNSAFE2["[ERROR] Can panic"]
    end
    
    style SUCCESS fill:#91e5a3,color:#000
    style ERROR1 fill:#ffa07a,color:#000
    style ERROR2 fill:#ffa07a,color:#000
    style SAFE1 fill:#91e5a3,color:#000
    style SAFE2 fill:#91e5a3,color:#000
    style SAFE3 fill:#91e5a3,color:#000
    style SAFE4 fill:#91e5a3,color:#000
    style UNSAFE1 fill:#ff6b6b,color:#000
    style UNSAFE2 fill:#ff6b6b,color:#000

Rust error handling

  • Rust uses the enum Result<T, E> enum for recoverable error handling
    • The Ok<T> variant contains the result in case of success and Err<E> contains the error
fn main() {
    let x = "1234x".parse::<u32>();
    match x {
        Ok(x) => println!("Parsed number {x}"),
        Err(e) => println!("Parsing error {e:?}"),
    }
    let x  = "1234".parse::<u32>();
    // Same as above, but with valid number
    if let Ok(x) = &x {
        println!("Parsed number {x}")
    } else if let Err(e) = &x {
        println!("Error: {e:?}");
    }
}

Rust error handling

  • The try-operator ? is a convenient short hand for the match Ok / Err pattern
    • Note the method must return Result<T, E> to enable use of ?
    • The type for Result<T, E> can be changed. In the example below, we return the same error type (std::num::ParseIntError) returned by str::parse()
fn double_string_number(s : &str) -> Result<u32, std::num::ParseIntError> {
   let x = s.parse::<u32>()?; // Returns immediately in case of an error
   Ok(x*2)
}
fn main() {
    let result = double_string_number("1234");
    println!("{result:?}");
    let result = double_string_number("1234x");
    println!("{result:?}");
}

Rust error handling

  • Errors can be mapped to other types, or to default values (https://doc.rust-lang.org/std/result/enum.Result.html#method.unwrap_or_default)
#![allow(unused)]
fn main() {
// Changes the error type to () in case of error
fn double_string_number(s : &str) -> Result<u32, ()> {
   let x = s.parse::<u32>().map_err(|_|())?; // Returns immediately in case of an error
   Ok(x*2)
}
}
#![allow(unused)]
fn main() {
fn double_string_number(s : &str) -> Result<u32, ()> {
   let x = s.parse::<u32>().unwrap_or_default(); // Defaults to 0 in case of parse error
   Ok(x*2)
}
}
#![allow(unused)]
fn main() {
fn double_optional_number(x : Option<u32>) -> Result<u32, ()> {
    // ok_or converts Option<None> to Result<u32, ()> in the below
    x.ok_or(()).map(|x|x*2) // .map() is applied only on Ok(u32)
}
}

Exercise: error handling

🟡 Intermediate

  • Implement a log() function with a single u32 parameter. If the parameter is not 42, return an error. The Result<> for success and error type is ()
  • Invoke log() function that exits with the same Result<> type if log() return an error. Otherwise print a message saying that log was successfully called
fn log(x: u32) -> ?? {

}

fn call_log(x: u32) -> ?? {
    // Call log(x), then exit immediately if it return an error
    println!("log was successfully called");
}

fn main() {
    call_log(42);
    call_log(43);
}
Solution (click to expand)
fn log(x: u32) -> Result<(), ()> {
    if x == 42 {
        Ok(())
    } else {
        Err(())
    }
}

fn call_log(x: u32) -> Result<(), ()> {
    log(x)?;  // Exit immediately if log() returns an error
    println!("log was successfully called with {x}");
    Ok(())
}

fn main() {
    let _ = call_log(42);  // Prints: log was successfully called with 42
    let _ = call_log(43);  // Returns Err(()), nothing printed
}
// Output:
// log was successfully called with 42

Rust generics

What you’ll learn: Generic type parameters, monomorphization (zero-cost generics), trait bounds, and how Rust generics compare to C++ templates — with better error messages and no SFINAE.

  • Generics allow the same algorithm or data structure to be reused across data types
    • The generic parameter appears as an identifier within <>, e.g.: <T>. The parameter can have any legal identifier name, but is typically kept short for brevity
    • The compiler performs monomorphization at compile time, i.e., it generates a new type for every variation of T that is encountered
// Returns a tuple of type <T> composed of left and right of type <T>
fn pick<T>(x: u32, left: T, right: T) -> (T, T) {
   if x == 42 {
    (left, right) 
   } else {
    (right, left)
   }
}
fn main() {
    let a = pick(42, true, false);
    let b = pick(42, "hello", "world");
    println!("{a:?}, {b:?}");
}

Rust generics

  • Generics can also be applied to data types and associated methods. It is possible to specialize the implementation for a specific <T> (example: f32 vs. u32)
#[derive(Debug)] // We will discuss this later
struct Point<T> {
    x : T,
    y : T,
}
impl<T> Point<T> {
    fn new(x: T, y: T) -> Self {
        Point {x, y}
    }
    fn set_x(&mut self, x: T) {
         self.x = x;       
    }
    fn set_y(&mut self, y: T) {
         self.y = y;       
    }
}
impl Point<f32> {
    fn is_secret(&self) -> bool {
        self.x == 42.0
    }    
}
fn main() {
    let mut p = Point::new(2, 4); // i32
    let q = Point::new(2.0, 4.0); // f32
    p.set_x(42);
    p.set_y(43);
    println!("{p:?} {q:?} {}", q.is_secret());
}

Exercise: Generics

🟢 Starter

  • Modify the Point type to use two different types (T and U) for x and y
Solution (click to expand)
#[derive(Debug)]
struct Point<T, U> {
    x: T,
    y: U,
}

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

fn main() {
    let p1 = Point::new(42, 3.14);        // Point<i32, f64>
    let p2 = Point::new("hello", true);   // Point<&str, bool>
    let p3 = Point::new(1u8, 1000u64);    // Point<u8, u64>
    println!("{p1:?}");
    println!("{p2:?}");
    println!("{p3:?}");
}
// Output:
// Point { x: 42, y: 3.14 }
// Point { x: "hello", y: true }
// Point { x: 1, y: 1000 }

Combining Rust traits and generics

  • Traits can be used to place restrictions on generic types (constraints)
  • The constraint can be specified using a : after the generic type parameter, or using where. The following defines a generic function get_area that takes any type T as long as it implements the ComputeArea trait
#![allow(unused)]
fn main() {
    trait ComputeArea {
        fn area(&self) -> u64;
    }
    fn get_area<T: ComputeArea>(t: &T) -> u64 {
        t.area()
    }
}

Combining Rust traits and generics

  • It is possible to have multiple trait constraints
trait Fish {}
trait Mammal {}
struct Shark;
struct Whale;
impl Fish for Shark {}
impl Fish for Whale {}
impl Mammal for Whale {}
fn only_fish_and_mammals<T: Fish + Mammal>(_t: &T) {}
fn main() {
    let w = Whale {};
    only_fish_and_mammals(&w);
    let _s = Shark {};
    // Won't compile
    only_fish_and_mammals(&_s);
}

Rust traits constraints in data types

  • Trait constraints can be combined with generics in data types
  • In the following example, we define the PrintDescription trait and a generic struct Shape with a member constrained by the trait
#![allow(unused)]
fn main() {
trait PrintDescription {
    fn print_description(&self);
}
struct Shape<S: PrintDescription> {
    shape: S,
}
// Generic Shape implementation for any type that implements PrintDescription
impl<S: PrintDescription> Shape<S> {
    fn print(&self) {
        self.shape.print_description();
    }
}
}

Exercise: Trait constraints and generics

🟡 Intermediate

  • Implement a struct with a generic member cipher that implements CipherText
#![allow(unused)]
fn main() {
trait CipherText {
    fn encrypt(&self);
}
// TO DO
//struct Cipher<>

}
  • Next, implement a method called encrypt on the struct impl that invokes encrypt on cipher
#![allow(unused)]
fn main() {
// TO DO
impl for Cipher<> {}
}
  • Next, implement CipherText on two structs called CipherOne and CipherTwo (just println() is fine). Create CipherOne and CipherTwo, and use Cipher to invoke them
Solution (click to expand)
trait CipherText {
    fn encrypt(&self);
}

struct Cipher<T: CipherText> {
    cipher: T,
}

impl<T: CipherText> Cipher<T> {
    fn encrypt(&self) {
        self.cipher.encrypt();
    }
}

struct CipherOne;
struct CipherTwo;

impl CipherText for CipherOne {
    fn encrypt(&self) {
        println!("CipherOne encryption applied");
    }
}

impl CipherText for CipherTwo {
    fn encrypt(&self) {
        println!("CipherTwo encryption applied");
    }
}

fn main() {
    let c1 = Cipher { cipher: CipherOne };
    let c2 = Cipher { cipher: CipherTwo };
    c1.encrypt();
    c2.encrypt();
}
// Output:
// CipherOne encryption applied
// CipherTwo encryption applied

Rust type state pattern and generics

  • Rust types can be used to enforce state machine transitions at compile time

    • Consider a Drone with say two states: Idle and Flying. In the Idle state, the only permitted method is takeoff(). In the Flying state, we permit land()
  • One approach is to model the state machine using something like the following

#![allow(unused)]
fn main() {
enum DroneState {
    Idle,
    Flying
}
struct Drone {x: u64, y: u64, z: u64, state: DroneState}  // x, y, z are coordinates
}
  • This requires a lot of runtime checks to enforce the state machine semantics — ▶ try it to see why

Rust type state pattern generics

  • Generics allows us to enforce the state machine at compile time. This requires using a special generic called PhantomData<T>
  • The PhantomData<T> is a zero-sized marker data type. In this case, we use it to represent the Idle and Flying states, but it has zero runtime size
  • Notice that the takeoff and land methods take self as a parameter. This is referred to as consuming (contrast with &self which uses borrowing). Basically, once we call the takeoff() on Drone<Idle>, we can only get back a Drone<Flying> and viceversa
#![allow(unused)]
fn main() {
struct Drone<T> {x: u64, y: u64, z: u64, state: PhantomData<T> }
impl Drone<Idle> {
    fn takeoff(self) -> Drone<Flying> {...}
}
impl Drone<Flying> {
    fn land(self) -> Drone<Idle> { ...}
}
}
- [▶ Try it in the Rust Playground](https://play.rust-lang.org/)

Rust type state pattern generics

  • Key takeaways:
    • States can be represented using structs (zero-size)
    • We can combine the state T with PhantomData<T> (zero-size)
    • Implementing the methods for a particular stage of the state machine is now just a matter of impl State<T>
    • Use a method that consumes self to transition from one state to another
    • This gives us zero cost abstractions. The compiler can enforce the state machine at compile time and it’s impossible to call methods unless the state is right

Rust builder pattern

  • The consume self can be useful for builder patterns
  • Consider a GPIO configuration with several dozen pins. The pins can be configured to high or low (default is low)
#![allow(unused)]
fn main() {
#[derive(default)]
enum PinState {
    #[default]
    Low,
    High,
} 
#[derive(default)]
struct GPIOConfig {
    pin0: PinState,
    pin1: PinState
    ... 
}
}
  • The builder pattern can be used to construct a GPIO configuration by chaining — ▶ Try it

Rust traits

What you’ll learn: Traits — Rust’s answer to interfaces, abstract base classes, and operator overloading. You’ll learn how to define traits, implement them for your types, and use dynamic dispatch (dyn Trait) vs static dispatch (generics). For C++ developers: traits replace virtual functions, CRTP, and concepts. For C developers: traits are the structured way Rust does polymorphism.

  • Rust traits are similar to interfaces in other languages
    • Traits define methods that must be defined by types that implement the trait.
fn main() {
    trait Pet {
        fn speak(&self);
    }
    struct Cat;
    struct Dog;
    impl Pet for Cat {
        fn speak(&self) {
            println!("Meow");
        }
    }
    impl Pet for Dog {
        fn speak(&self) {
            println!("Woof!")
        }
    }
    let c = Cat{};
    let d = Dog{};
    c.speak();  // There is no "is a" relationship between Cat and Dog
    d.speak(); // There is no "is a" relationship between Cat and Dog
}

Traits vs C++ Concepts and Interfaces

Traditional C++ Inheritance vs Rust Traits

// C++ - Inheritance-based polymorphism
class Animal {
public:
    virtual void speak() = 0;  // Pure virtual function
    virtual ~Animal() = default;
};

class Cat : public Animal {  // "Cat IS-A Animal"
public:
    void speak() override {
        std::cout << "Meow" << std::endl;
    }
};

void make_sound(Animal* animal) {  // Runtime polymorphism
    animal->speak();  // Virtual function call
}
#![allow(unused)]
fn main() {
// Rust - Composition over inheritance with traits
trait Animal {
    fn speak(&self);
}

struct Cat;  // Cat is NOT an Animal, but IMPLEMENTS Animal behavior

impl Animal for Cat {  // "Cat CAN-DO Animal behavior"
    fn speak(&self) {
        println!("Meow");
    }
}

fn make_sound<T: Animal>(animal: &T) {  // Static polymorphism
    animal.speak();  // Direct function call (zero cost)
}
}
graph TD
    subgraph "C++ Object-Oriented Hierarchy"
        CPP_ANIMAL["Animal<br/>(Abstract base class)"]
        CPP_CAT["Cat : public Animal<br/>(IS-A relationship)"]
        CPP_DOG["Dog : public Animal<br/>(IS-A relationship)"]
        
        CPP_ANIMAL --> CPP_CAT
        CPP_ANIMAL --> CPP_DOG
        
        CPP_VTABLE["Virtual function table<br/>(Runtime dispatch)"]
        CPP_HEAP["Often requires<br/>heap allocation"]
        CPP_ISSUES["[ERROR] Deep inheritance trees<br/>[ERROR] Diamond problem<br/>[ERROR] Runtime overhead<br/>[ERROR] Tight coupling"]
    end
    
    subgraph "Rust Trait-Based Composition"
        RUST_TRAIT["trait Animal<br/>(Behavior definition)"]
        RUST_CAT["struct Cat<br/>(Data only)"]
        RUST_DOG["struct Dog<br/>(Data only)"]
        
        RUST_CAT -.->|"impl Animal for Cat<br/>(CAN-DO behavior)"| RUST_TRAIT
        RUST_DOG -.->|"impl Animal for Dog<br/>(CAN-DO behavior)"| RUST_TRAIT
        
        RUST_STATIC["Static dispatch<br/>(Compile-time)"]
        RUST_STACK["Stack allocation<br/>possible"]
        RUST_BENEFITS["[OK] No inheritance hierarchy<br/>[OK] Multiple trait impls<br/>[OK] Zero runtime cost<br/>[OK] Loose coupling"]
    end
    
    style CPP_ISSUES fill:#ff6b6b,color:#000
    style RUST_BENEFITS fill:#91e5a3,color:#000
    style CPP_VTABLE fill:#ffa07a,color:#000
    style RUST_STATIC fill:#91e5a3,color:#000

Trait Bounds and Generic Constraints

#![allow(unused)]
fn main() {
use std::fmt::Display;
use std::ops::Add;

// C++ template equivalent (less constrained)
// template<typename T>
// T add_and_print(T a, T b) {
//     // No guarantee T supports + or printing
//     return a + b;  // Might fail at compile time
// }

// Rust - explicit trait bounds
fn add_and_print<T>(a: T, b: T) -> T 
where 
    T: Display + Add<Output = T> + Copy,
{
    println!("Adding {} + {}", a, b);  // Display trait
    a + b  // Add trait
}
}
graph TD
    subgraph "Generic Constraints Evolution"
        UNCONSTRAINED["fn process<T>(data: T)<br/>[ERROR] T can be anything"]
        SINGLE_BOUND["fn process<T: Display>(data: T)<br/>[OK] T must implement Display"]
        MULTI_BOUND["fn process<T>(data: T)<br/>where T: Display + Clone + Debug<br/>[OK] Multiple requirements"]
        
        UNCONSTRAINED --> SINGLE_BOUND
        SINGLE_BOUND --> MULTI_BOUND
    end
    
    subgraph "Trait Bound Syntax"
        INLINE["fn func<T: Trait>(param: T)"]
        WHERE_CLAUSE["fn func<T>(param: T)<br/>where T: Trait"]
        IMPL_PARAM["fn func(param: impl Trait)"]
        
        COMPARISON["Inline: Simple cases<br/>Where: Complex bounds<br/>impl: Concise syntax"]
    end
    
    subgraph "Compile-time Magic"
        GENERIC_FUNC["Generic function<br/>with trait bounds"]
        TYPE_CHECK["Compiler verifies<br/>trait implementations"]
        MONOMORPH["Monomorphization<br/>(Create specialized versions)"]
        OPTIMIZED["Fully optimized<br/>machine code"]
        
        GENERIC_FUNC --> TYPE_CHECK
        TYPE_CHECK --> MONOMORPH
        MONOMORPH --> OPTIMIZED
        
        EXAMPLE["add_and_print::<i32><br/>add_and_print::<f64><br/>(Separate functions generated)"]
        MONOMORPH --> EXAMPLE
    end
    
    style UNCONSTRAINED fill:#ff6b6b,color:#000
    style SINGLE_BOUND fill:#ffa07a,color:#000
    style MULTI_BOUND fill:#91e5a3,color:#000
    style OPTIMIZED fill:#91e5a3,color:#000

C++ Operator Overloading → Rust std::ops Traits

In C++, you overload operators by writing free functions or member functions with special names (operator+, operator<<, operator[], etc.). In Rust, every operator maps to a trait in std::ops (or std::fmt for output). You implement the trait instead of writing a magic-named function.

Side-by-side: + operator

// C++: operator overloading as a member or free function
struct Vec2 {
    double x, y;
    Vec2 operator+(const Vec2& rhs) const {
        return {x + rhs.x, y + rhs.y};
    }
};

Vec2 a{1.0, 2.0}, b{3.0, 4.0};
Vec2 c = a + b;  // calls a.operator+(b)
#![allow(unused)]
fn main() {
use std::ops::Add;

#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }

impl Add for Vec2 {
    type Output = Vec2;                     // Associated type — the result of +
    fn add(self, rhs: Vec2) -> Vec2 {
        Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
    }
}

let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b;  // calls <Vec2 as Add>::add(a, b)
println!("{c:?}"); // Vec2 { x: 4.0, y: 6.0 }
}

Key differences from C++

AspectC++Rust
MechanismMagic function names (operator+)Implement a trait (impl Add for T)
DiscoveryGrep for operator+ or read the headerLook at trait impls — IDE support excellent
Return typeFree choiceFixed by the Output associated type
ReceiverUsually takes const T& (borrows)Takes self by value (moves!) by default
SymmetryCan write impl operator+(int, Vec2)Must add impl Add<Vec2> for i32 (foreign trait rules apply)
<< for printingoperator<<(ostream&, T) — overload for any streamimpl fmt::Display for T — one canonical to_string representation

The self by value gotcha

In Rust, Add::add(self, rhs) takes self by value. For Copy types (like Vec2 above, which derives Copy) this is fine — the compiler copies. But for non-Copy types, + consumes the operands:

#![allow(unused)]
fn main() {
let s1 = String::from("hello ");
let s2 = String::from("world");
let s3 = s1 + &s2;  // s1 is MOVED into s3!
// println!("{s1}");  // ❌ Compile error: value used after move
println!("{s2}");     // ✅ s2 was only borrowed (&s2)
}

This is why String + &str works but &str + &str does not — Add is only implemented for String + &str, consuming the left-hand String to reuse its buffer. This has no C++ analogue: std::string::operator+ always creates a new string.

Full mapping: C++ operators → Rust traits

C++ OperatorRust TraitNotes
operator+std::ops::AddOutput associated type
operator-std::ops::Sub
operator*std::ops::MulNot pointer deref — that’s Deref
operator/std::ops::Div
operator%std::ops::Rem
operator- (unary)std::ops::Neg
operator! / operator~std::ops::NotRust uses ! for both logical and bitwise NOT (no ~ operator)
operator&, |, ^BitAnd, BitOr, BitXor
operator<<, >> (shift)Shl, ShrNOT stream I/O!
operator+=std::ops::AddAssignTakes &mut self (not self)
operator[]std::ops::Index / IndexMutReturns &Output / &mut Output
operator()Fn / FnMut / FnOnceClosures implement these; you cannot impl Fn directly
operator==PartialEq (+ Eq)In std::cmp, not std::ops
operator<PartialOrd (+ Ord)In std::cmp
operator<< (stream)fmt::Displayprintln!("{}", x)
operator<< (debug)fmt::Debugprintln!("{:?}", x)
operator boolNo direct equivalentUse impl From<T> for bool or a named method like .is_empty()
operator T() (implicit conversion)No implicit conversionsUse From/Into traits (explicit)

Guardrails: what Rust prevents

  1. No implicit conversions: C++ operator int() can cause silent, surprising casts. Rust has no implicit conversion operators — use From/Into and call .into() explicitly.
  2. No overloading && / ||: C++ allows it (breaking short-circuit semantics!). Rust does not.
  3. No overloading =: Assignment is always a move or copy, never user-defined. Compound assignment (+=) IS overloadable via AddAssign, etc.
  4. No overloading ,: C++ allows operator,() — one of the most infamous C++ footguns. Rust does not.
  5. No overloading & (address-of): Another C++ footgun (std::addressof exists to work around it). Rust’s & always means “borrow.”
  6. Coherence rules: You can only implement Add<Foreign> for your own type, or Add<YourType> for a foreign type — never Add<Foreign> for Foreign. This prevents conflicting operator definitions across crates.

Bottom line: In C++, operator overloading is powerful but largely unregulated — you can overload almost anything, including comma and address-of, and implicit conversions can trigger silently. Rust gives you the same expressiveness for arithmetic and comparison operators via traits, but blocks the historically dangerous overloads and forces all conversions to be explicit.


Rust traits

  • Rust allows implementing a user defined trait on even built-in types like u32 in this example. However, either the trait or the type must belong to the crate
trait IsSecret {
  fn is_secret(&self);
}
// The IsSecret trait belongs to the crate, so we are OK
impl IsSecret for u32 {
  fn is_secret(&self) {
      if *self == 42 {
          println!("Is secret of life");
      }
  }
}

fn main() {
  42u32.is_secret();
  43u32.is_secret();
}

Rust traits

  • Traits support interface inheritance and default implementations
trait Animal {
  // Default implementation
  fn is_mammal(&self) -> bool {
    true
  }
}
trait Feline : Animal {
  // Default implementation
  fn is_feline(&self) -> bool {
    true
  }
}

struct Cat;
// Use default implementations. Note that all traits for the supertrait must be individually implemented
impl Feline for Cat {}
impl Animal for Cat {}
fn main() {
  let c = Cat{};
  println!("{} {}", c.is_mammal(), c.is_feline());
}

Exercise: Logger trait implementation

🟡 Intermediate

  • Implement a Log trait with a single method called log() that accepts a u64
    • Implement two different loggers SimpleLogger and ComplexLogger that implement the Log trait. One should output “Simple logger” with the u64 and the other should output “Complex logger” with the u64
Solution (click to expand)
trait Log {
    fn log(&self, value: u64);
}

struct SimpleLogger;
struct ComplexLogger;

impl Log for SimpleLogger {
    fn log(&self, value: u64) {
        println!("Simple logger: {value}");
    }
}

impl Log for ComplexLogger {
    fn log(&self, value: u64) {
        println!("Complex logger: {value} (hex: 0x{value:x}, binary: {value:b})");
    }
}

fn main() {
    let simple = SimpleLogger;
    let complex = ComplexLogger;
    simple.log(42);
    complex.log(42);
}
// Output:
// Simple logger: 42
// Complex logger: 42 (hex: 0x2a, binary: 101010)

Rust trait associated types

#[derive(Debug)]
struct Small(u32);
#[derive(Debug)]
struct Big(u32);
trait Double {
    type T;
    fn double(&self) -> Self::T;
}

impl Double for Small {
    type T = Big;
    fn double(&self) -> Self::T {
        Big(self.0 * 2)
    }
}
fn main() {
    let a = Small(42);
    println!("{:?}", a.double());
}

Rust trait impl

  • impl can be used with traits to accept any type that implements a trait
trait Pet {
    fn speak(&self);
}
struct Dog {}
struct Cat {}
impl Pet for Dog {
    fn speak(&self) {println!("Woof!")}
}
impl Pet for Cat {
    fn speak(&self) {println!("Meow")}
}
fn pet_speak(p: &impl Pet) {
    p.speak();
}
fn main() {
    let c = Cat {};
    let d = Dog {};
    pet_speak(&c);
    pet_speak(&d);
}

Rust trait impl

  • impl can be also be used be used in a return value
trait Pet {}
struct Dog;
struct Cat;
impl Pet for Cat {}
impl Pet for Dog {}
fn cat_as_pet() -> impl Pet {
    let c = Cat {};
    c
}
fn dog_as_pet() -> impl Pet {
    let d = Dog {};
    d
}
fn main() {
    let p = cat_as_pet();
    let d = dog_as_pet();
}

Rust dynamic traits

  • Dynamic traits can be used to invoke the trait functionality without knowing the underlying type. This is known as type erasure
trait Pet {
    fn speak(&self);
}
struct Dog {}
struct Cat {x: u32}
impl Pet for Dog {
    fn speak(&self) {println!("Woof!")}
}
impl Pet for Cat {
    fn speak(&self) {println!("Meow")}
}
fn pet_speak(p: &dyn Pet) {
    p.speak();
}
fn main() {
    let c = Cat {x: 42};
    let d = Dog {};
    pet_speak(&c);
    pet_speak(&d);
}

Choosing Between impl Trait, dyn Trait, and Enums

These three approaches all achieve polymorphism but with different trade-offs:

ApproachDispatchPerformanceHeterogeneous collections?When to use
impl Trait / genericsStatic (monomorphized)Zero-cost — inlined at compile timeNo — each slot has one concrete typeDefault choice. Function arguments, return types
dyn TraitDynamic (vtable)Small overhead per call (~1 pointer indirection)Yes — Vec<Box<dyn Trait>>When you need mixed types in a collection, or plugin-style extensibility
enumMatchZero-cost — known variants at compile timeYes — but only known variantsWhen the set of variants is closed and known at compile time
#![allow(unused)]
fn main() {
trait Shape {
    fn area(&self) -> f64;
}
struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }
impl Shape for Circle { fn area(&self) -> f64 { std::f64::consts::PI * self.radius * self.radius } }
impl Shape for Rect   { fn area(&self) -> f64 { self.w * self.h } }

// Static dispatch — compiler generates separate code for each type
fn print_area(s: &impl Shape) { println!("{}", s.area()); }

// Dynamic dispatch — one function, works with any Shape behind a pointer
fn print_area_dyn(s: &dyn Shape) { println!("{}", s.area()); }

// Enum — closed set, no trait needed
enum ShapeEnum { Circle(f64), Rect(f64, f64) }
impl ShapeEnum {
    fn area(&self) -> f64 {
        match self {
            ShapeEnum::Circle(r) => std::f64::consts::PI * r * r,
            ShapeEnum::Rect(w, h) => w * h,
        }
    }
}
}

For C++ developers: impl Trait is like C++ templates (monomorphized, zero-cost). dyn Trait is like C++ virtual functions (vtable dispatch). Rust enums with match are like std::variant with std::visit — but exhaustive matching is enforced by the compiler.

Rule of thumb: Start with impl Trait (static dispatch). Reach for dyn Trait only when you need heterogeneous collections or can’t know the concrete type at compile time. Use enum when you own all the variants.

Rust From and Into traits

What you’ll learn: Rust’s type conversion traits — From<T> and Into<T> for infallible conversions, TryFrom and TryInto for fallible ones. Implement From and get Into for free. Replaces C++ conversion operators and constructors.

  • From and Into are complementary traits to facilitate type conversion
  • Types normally implement the From trait. The String::from() converts from “&str” to String, and the compiler can automatically derive &str.into
struct Point {x: u32, y: u32}
// Construct a Point from a tuple
impl From<(u32, u32)> for Point {
    fn from(xy : (u32, u32)) -> Self {
        Point {x : xy.0, y: xy.1}       // Construct Point using the tuple elements
    }
}
fn main() {
    let s = String::from("Rust");
    let x = u32::from(true);
    let p = Point::from((40, 42));
    // let p : Point = (40.42)::into(); // Alternate form of the above
    println!("s: {s} x:{x} p.x:{} p.y {}", p.x, p.y);   
}

Exercise: From and Into

  • Implement a From trait for Point to convert into a type called TransposePoint. TransposePoint swaps the x and y elements of Point
Solution (click to expand)
struct Point { x: u32, y: u32 }
struct TransposePoint { x: u32, y: u32 }

impl From<Point> for TransposePoint {
    fn from(p: Point) -> Self {
        TransposePoint { x: p.y, y: p.x }
    }
}

fn main() {
    let p = Point { x: 10, y: 20 };
    let tp = TransposePoint::from(p);
    println!("Transposed: x={}, y={}", tp.x, tp.y);  // x=20, y=10

    // Using .into() — works automatically when From is implemented
    let p2 = Point { x: 3, y: 7 };
    let tp2: TransposePoint = p2.into();
    println!("Transposed: x={}, y={}", tp2.x, tp2.y);  // x=7, y=3
}
// Output:
// Transposed: x=20, y=10
// Transposed: x=7, y=3

Rust Default trait

  • Default can be used to implement default values for a type
    • Types can use the Derive macro with Default or provide a custom implementation
#[derive(Default, Debug)]
struct Point {x: u32, y: u32}
#[derive(Debug)]
struct CustomPoint {x: u32, y: u32}
impl Default for CustomPoint {
    fn default() -> Self {
        CustomPoint {x: 42, y: 42}
    }
}
fn main() {
    let x = Point::default();   // Creates a Point{0, 0}
    println!("{x:?}");
    let y = CustomPoint::default();
    println!("{y:?}");
}

Rust Default trait

  • Default trait has several use cases including
    • Performing a partial copy and using default initialization for rest
    • Default alternative for Option types in methods like unwrap_or_default()
#[derive(Debug)]
struct CustomPoint {x: u32, y: u32}
impl Default for CustomPoint {
    fn default() -> Self {
        CustomPoint {x: 42, y: 42}
    }
}
fn main() {
    let x = CustomPoint::default();
    // Override y, but leave rest of elements as the default
    let y = CustomPoint {y: 43, ..CustomPoint::default()};
    println!("{x:?} {y:?}");
    let z : Option<CustomPoint> = None;
    // Try changing the unwrap_or_default() to unwrap()
    println!("{:?}", z.unwrap_or_default());
}

Other Rust type conversions

  • Rust doesn’t support implicit type conversions and as can be used for explicit conversions
  • as should be sparingly used because it’s subject to loss of data by narrowing and so forth. In general, it’s preferable to use into() or from() where possible
fn main() {
    let f = 42u8;
    // let g : u32 = f;    // Will not compile
    let g = f as u32;      // Ok, but not preferred. Subject to rules around narrowing
    let g : u32 = f.into(); // Most preferred form; infallible and checked by the compiler
    // let k : u8 = g.into();  // Fails to compile; narrowing can result in loss of data
    
    // Attempting a narrowing operation requires use of try_into
    if let Ok(k) = TryInto::<u8>::try_into(g) {
        println!("{k}");
    }
}

Iterator Power Tools Reference

What you’ll learn: Advanced iterator combinators beyond filter/map/collect — enumerate, zip, chain, flat_map, scan, windows, and chunks. Essential for replacing C-style indexed for loops with safe, expressive Rust iterators.

The basic filter/map/collect chain covers many cases, but Rust’s iterator library is far richer. This section covers the tools you’ll reach for daily — especially when translating C loops that manually track indices, accumulate results, or process data in fixed-size chunks.

Quick Reference Table

MethodC EquivalentWhat it doesReturns
enumerate()for (int i=0; ...)Pairs each element with its index(usize, T)
zip(other)Parallel arrays with same indexPairs elements from two iterators(A, B)
chain(other)Process array1 then array2Concatenates two iteratorsT
flat_map(f)Nested loopsMaps then flattens one levelU
windows(n)for (int i=0; i<len-n+1; i++) &arr[i..i+n]Overlapping slices of size n&[T]
chunks(n)Process n elements at a timeNon-overlapping slices of size n&[T]
fold(init, f)int acc = init; for (...) acc = f(acc, x);Reduce to single valueAcc
scan(init, f)Running accumulator with outputLike fold but yields intermediate resultsOption<B>
take(n) / skip(n)Start loop at offset / limitFirst n / skip first n elementsT
take_while(f) / skip_while(f)while (pred) {...}Take/skip while predicate holdsT
peekable()Lookahead with arr[i+1]Allows .peek() without consumingT
step_by(n)for (i=0; i<len; i+=n)Take every nth elementT
unzip()Split parallel arraysCollect pairs into two collections(A, B)
sum() / product()Accumulate sum/productReduce with + or *T
min() / max()Find extremesReturn Option<T>Option<T>
any(f) / all(f)bool found = false; for (...) ...Short-circuit boolean searchbool
position(f)for (i=0; ...) if (pred) return i;Index of first matchOption<usize>

enumerate — Index + Value (replaces C index loops)

fn main() {
    let sensors = ["GPU_TEMP", "CPU_TEMP", "FAN_RPM", "PSU_WATT"];

    // C style: for (int i = 0; i < 4; i++) printf("[%d] %s\n", i, sensors[i]);
    for (i, name) in sensors.iter().enumerate() {
        println!("[{i}] {name}");
    }

    // Find the index of a specific sensor
    let gpu_idx = sensors.iter().position(|&s| s == "GPU_TEMP");
    println!("GPU sensor at index: {gpu_idx:?}");  // Some(0)
}

zip — Parallel Iteration (replaces parallel array loops)

fn main() {
    let names = ["accel_diag", "nic_diag", "cpu_diag"];
    let statuses = [true, false, true];
    let durations_ms = [1200, 850, 3400];

    // C: for (int i=0; i<3; i++) printf("%s: %s (%d ms)\n", names[i], ...);
    for ((name, passed), ms) in names.iter().zip(&statuses).zip(&durations_ms) {
        let status = if *passed { "PASS" } else { "FAIL" };
        println!("{name}: {status} ({ms} ms)");
    }
}

chain — Concatenate Iterators

fn main() {
    let critical = vec!["ECC error", "Thermal shutdown"];
    let warnings = vec!["Link degraded", "Fan slow"];

    // Process all events in priority order
    let all_events: Vec<_> = critical.iter().chain(warnings.iter()).collect();
    println!("{all_events:?}");
    // ["ECC error", "Thermal shutdown", "Link degraded", "Fan slow"]
}

flat_map — Flatten Nested Results

fn main() {
    let lines = vec!["gpu:42:ok", "nic:99:fail", "cpu:7:ok"];

    // Extract all numeric values from colon-separated lines
    let numbers: Vec<u32> = lines.iter()
        .flat_map(|line| line.split(':'))
        .filter_map(|token| token.parse::<u32>().ok())
        .collect();
    println!("{numbers:?}");  // [42, 99, 7]
}

windows and chunks — Sliding and Fixed-Size Groups

fn main() {
    let temps = [65, 68, 72, 71, 75, 80, 78, 76];

    // windows(3): overlapping groups of 3 (like a sliding average)
    // C: for (int i = 0; i <= len-3; i++) avg(arr[i], arr[i+1], arr[i+2]);
    let moving_avg: Vec<f64> = temps.windows(3)
        .map(|w| w.iter().sum::<i32>() as f64 / 3.0)
        .collect();
    println!("Moving avg: {moving_avg:.1?}");

    // chunks(2): non-overlapping groups of 2
    // C: for (int i = 0; i < len; i += 2) process(arr[i], arr[i+1]);
    for pair in temps.chunks(2) {
        println!("Chunk: {pair:?}");
    }

    // chunks_exact(2): same but panics if remainder exists
    // Also: .remainder() gives leftover elements
}

fold and scan — Accumulation

fn main() {
    let values = [10, 20, 30, 40, 50];

    // fold: single final result (like C's accumulator loop)
    let sum = values.iter().fold(0, |acc, &x| acc + x);
    println!("Sum: {sum}");  // 150

    // Build a string with fold
    let csv = values.iter()
        .fold(String::new(), |acc, x| {
            if acc.is_empty() { format!("{x}") }
            else { format!("{acc},{x}") }
        });
    println!("CSV: {csv}");  // "10,20,30,40,50"

    // scan: like fold but yields intermediate results
    let running_sum: Vec<i32> = values.iter()
        .scan(0, |state, &x| {
            *state += x;
            Some(*state)
        })
        .collect();
    println!("Running sum: {running_sum:?}");  // [10, 30, 60, 100, 150]
}

Exercise: Sensor Data Pipeline

Given raw sensor readings (one per line, format "sensor_name:value:unit"), write an iterator pipeline that:

  1. Parses each line into (name, f64, unit)
  2. Filters out readings below a threshold
  3. Groups by sensor name using fold into a HashMap
  4. Prints the average reading per sensor
// Starter code
fn main() {
    let raw_data = vec![
        "gpu_temp:72.5:C",
        "cpu_temp:65.0:C",
        "gpu_temp:74.2:C",
        "fan_rpm:1200.0:RPM",
        "cpu_temp:63.8:C",
        "gpu_temp:80.1:C",
        "fan_rpm:1150.0:RPM",
    ];
    let threshold = 70.0;
    // TODO: Parse, filter values >= threshold, group by name, compute averages
}
Solution (click to expand)
use std::collections::HashMap;

fn main() {
    let raw_data = vec![
        "gpu_temp:72.5:C",
        "cpu_temp:65.0:C",
        "gpu_temp:74.2:C",
        "fan_rpm:1200.0:RPM",
        "cpu_temp:63.8:C",
        "gpu_temp:80.1:C",
        "fan_rpm:1150.0:RPM",
    ];
    let threshold = 70.0;

    // Parse → filter → group → average
    let grouped = raw_data.iter()
        .filter_map(|line| {
            let parts: Vec<&str> = line.splitn(3, ':').collect();
            if parts.len() == 3 {
                let value: f64 = parts[1].parse().ok()?;
                Some((parts[0], value, parts[2]))
            } else {
                None
            }
        })
        .filter(|(_, value, _)| *value >= threshold)
        .fold(HashMap::<&str, Vec<f64>>::new(), |mut acc, (name, value, _)| {
            acc.entry(name).or_default().push(value);
            acc
        });

    for (name, values) in &grouped {
        let avg = values.iter().sum::<f64>() / values.len() as f64;
        println!("{name}: avg={avg:.1} ({} readings)", values.len());
    }
}
// Output (order may vary):
// gpu_temp: avg=75.6 (3 readings)
// fan_rpm: avg=1175.0 (2 readings)

Rust iterators

  • The Iterator trait is used to implement iteration over user-defined types (https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)
    • In the example, we’ll implement an iterator for the Fibonacci sequence, which starts with 1, 1, 2, … and the successor is the sum of the previous two numbers
    • The associated type in the Iterator (type Item = u32;) defines the output type from our iterator (u32)
    • The next() method simply contains the logic for implementing our iterator. In this case, all state information is available in the Fibonacci structure
    • We could have implemented another trait called IntoIterator to implement the into_iter() method for more specialized iterators
    • https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=ab367dc2611e1b5a0bf98f1185b38f3f

Rust closures

What you’ll learn: Closures as anonymous functions, the three capture traits (Fn, FnMut, FnOnce), move closures, and how Rust closures compare to C++ lambdas — with automatic capture analysis instead of manual [&]/[=] specifications.

  • Closures are anonymous functions that can capture their environment
    • C++ equivalent: lambdas ([&](int x) { return x + 1; })
    • Key difference: Rust closures have three capture traits (Fn, FnMut, FnOnce) that the compiler selects automatically
    • C++ capture modes ([=], [&], [this]) are manual and error-prone (dangling [&]!)
    • Rust’s borrow checker prevents dangling captures at compile time
  • Closures can be identified by the || symbol. The parameters for the types are enclosed within the || and can use type inference
  • Closures are frequently used in conjunction with iterators (next topic)
fn add_one(x: u32) -> u32 {
    x + 1
}
fn main() {
    let add_one_v1 = |x : u32| {x + 1}; // Explicitly specified type
    let add_one_v2 = |x| {x + 1};   // Type is inferred from call site
    let add_one_v3 = |x| x+1;   // Permitted for single line functions
    println!("{} {} {} {}", add_one(42), add_one_v1(42), add_one_v2(42), add_one_v3(42) );
}

Exercise: Closures and capturing

🟡 Intermediate

  • Create a closure that captures a String from the enclosing scope and appends to it (hint: use move)
  • Create a vector of closures: Vec<Box<dyn Fn(i32) -> i32>> containing closures that add 1, multiply by 2, and square the input. Iterate over the vector and apply each closure to the number 5
Solution (click to expand)
fn main() {
    // Part 1: Closure that captures and appends to a String
    let mut greeting = String::from("Hello");
    let mut append = |suffix: &str| {
        greeting.push_str(suffix);
    };
    append(", world");
    append("!");
    println!("{greeting}");  // "Hello, world!"

    // Part 2: Vector of closures
    let operations: Vec<Box<dyn Fn(i32) -> i32>> = vec![
        Box::new(|x| x + 1),      // add 1
        Box::new(|x| x * 2),      // multiply by 2
        Box::new(|x| x * x),      // square
    ];

    let input = 5;
    for (i, op) in operations.iter().enumerate() {
        println!("Operation {i} on {input}: {}", op(input));
    }
}
// Output:
// Hello, world!
// Operation 0 on 5: 6
// Operation 1 on 5: 10
// Operation 2 on 5: 25

Rust iterators

  • Iterators are one of the most powerful features of Rust. They enable very elegant methods for performing operations on collections, including filtering (filter()), transformation (map()), filter and map (filter_and_map()), searching (find()) and much more
  • In the example below, the |&x| *x >= 42 is a closure that performs the same comparison. The |x| println!("{x}") is another closure
fn main() {
    let a = [0, 1, 2, 3, 42, 43];
    for x in &a {
        if *x >= 42 {
            println!("{x}");
        }
    }
    // Same as above
    a.iter().filter(|&x| *x >= 42).for_each(|x| println!("{x}"))
}

Rust iterators

  • A key feature of iterators is that most of them are lazy, i.e., they do not do anything until they are evaluated. For example, a.iter().filter(|&x| *x >= 42); wouldn’t have done anything without the for_each. The Rust compiler emits an explicit warning when it detects such a situation
fn main() {
    let a = [0, 1, 2, 3, 42, 43];
    // Add one to each element and print it
    let _ = a.iter().map(|x|x + 1).for_each(|x|println!("{x}"));
    let found = a.iter().find(|&x|*x == 42);
    println!("{found:?}");
    // Count elements
    let count = a.iter().count();
    println!("{count}");
}

Rust iterators

  • The collect() method can be used to gather the results into a separate collection
    • In the below the _ in Vec<_> is the equivalent of a wildcard character for the type returned by the map. For example, we can even return a String from map
fn main() {
    let a = [0, 1, 2, 3, 42, 43];
    let squared_a : Vec<_> = a.iter().map(|x|x*x).collect();
    for x in &squared_a {
        println!("{x}");
    }
    let squared_a_strings : Vec<_> = a.iter().map(|x|(x*x).to_string()).collect();
    // These are actually string representations
    for x in &squared_a_strings {
        println!("{x}");
    }
}

Exercise: Rust iterators

🟢 Starter

  • Create an integer array composed of odd and even elements. Iterate over the array and split it into two different vectors with even and odd elements in each
  • Can this be done in a single pass (hint: use partition())?
Solution (click to expand)
fn main() {
    let numbers = [1, 2, 3, 4, 5, 6, 7, 8, 9, 10];

    // Approach 1: Manual iteration
    let mut evens = Vec::new();
    let mut odds = Vec::new();
    for n in numbers {
        if n % 2 == 0 {
            evens.push(n);
        } else {
            odds.push(n);
        }
    }
    println!("Evens: {evens:?}");
    println!("Odds:  {odds:?}");

    // Approach 2: Single pass with partition()
    let (evens, odds): (Vec<i32>, Vec<i32>) = numbers
        .into_iter()
        .partition(|n| n % 2 == 0);
    println!("Evens (partition): {evens:?}");
    println!("Odds  (partition): {odds:?}");
}
// Output:
// Evens: [2, 4, 6, 8, 10]
// Odds:  [1, 3, 5, 7, 9]
// Evens (partition): [2, 4, 6, 8, 10]
// Odds  (partition): [1, 3, 5, 7, 9]

Production patterns: See Collapsing assignment pyramids with closures for real iterator chains (.map().collect(), .filter().collect(), .find_map()) from production Rust code.

Iterator power tools: the methods that replace C++ loops

The following iterator adapters are used extensively in production Rust code. C++ has <algorithm> and C++20 ranges, but Rust’s iterator chains are more composable and more commonly used.

enumerate — index + value (replaces for (int i = 0; ...))

#![allow(unused)]
fn main() {
let sensors = vec!["temp0", "temp1", "temp2"];
for (idx, name) in sensors.iter().enumerate() {
    println!("Sensor {idx}: {name}");
}
// Sensor 0: temp0
// Sensor 1: temp1
// Sensor 2: temp2
}

C++ equivalent: for (size_t i = 0; i < sensors.size(); ++i) { auto& name = sensors[i]; ... }

zip — pair elements from two iterators (replaces parallel index loops)

#![allow(unused)]
fn main() {
let names = ["gpu0", "gpu1", "gpu2"];
let temps = [72.5, 68.0, 75.3];

let report: Vec<String> = names.iter()
    .zip(temps.iter())
    .map(|(name, temp)| format!("{name}: {temp}°C"))
    .collect();
println!("{report:?}");
// ["gpu0: 72.5°C", "gpu1: 68.0°C", "gpu2: 75.3°C"]

// Stops at the shorter iterator — no out-of-bounds risk
}

C++ equivalent: for (size_t i = 0; i < std::min(names.size(), temps.size()); ++i) { ... }

flat_map — map + flatten nested collections

#![allow(unused)]
fn main() {
// Each GPU has multiple PCIe BDFs; collect all BDFs across all GPUs
let gpu_bdfs = vec![
    vec!["0000:01:00.0", "0000:02:00.0"],
    vec!["0000:41:00.0"],
    vec!["0000:81:00.0", "0000:82:00.0"],
];

let all_bdfs: Vec<&str> = gpu_bdfs.iter()
    .flat_map(|bdfs| bdfs.iter().copied())
    .collect();
println!("{all_bdfs:?}");
// ["0000:01:00.0", "0000:02:00.0", "0000:41:00.0", "0000:81:00.0", "0000:82:00.0"]
}

C++ equivalent: nested for loop pushing into a single vector.

chain — concatenate two iterators

#![allow(unused)]
fn main() {
let critical_gpus = vec!["gpu0", "gpu3"];
let warning_gpus = vec!["gpu1", "gpu5"];

// Process all flagged GPUs, critical first
for gpu in critical_gpus.iter().chain(warning_gpus.iter()) {
    println!("Flagged: {gpu}");
}
}

windows and chunks — sliding/fixed-size views over slices

#![allow(unused)]
fn main() {
let temps = [70, 72, 75, 73, 71, 68, 65];

// windows(3): sliding window of size 3 — detect trends
let rising = temps.windows(3)
    .any(|w| w[0] < w[1] && w[1] < w[2]);
println!("Rising trend detected: {rising}"); // true (70 < 72 < 75)

// chunks(2): fixed-size groups — process in pairs
for pair in temps.chunks(2) {
    println!("Pair: {pair:?}");
}
// Pair: [70, 72]
// Pair: [75, 73]
// Pair: [71, 68]
// Pair: [65]       ← last chunk can be smaller
}

C++ equivalent: manual index arithmetic with i and i+1/i+2.

fold — accumulate into a single value (replaces std::accumulate)

#![allow(unused)]
fn main() {
let errors = vec![
    ("gpu0", 3u32),
    ("gpu1", 0),
    ("gpu2", 7),
    ("gpu3", 1),
];

// Count total errors and build summary in one pass
let (total, summary) = errors.iter().fold(
    (0u32, String::new()),
    |(count, mut s), (name, errs)| {
        if *errs > 0 {
            s.push_str(&format!("{name}:{errs} "));
        }
        (count + errs, s)
    },
);
println!("Total errors: {total}, details: {summary}");
// Total errors: 11, details: gpu0:3 gpu2:7 gpu3:1
}

scan — stateful transform (running total, delta detection)

#![allow(unused)]
fn main() {
let readings = [100, 105, 103, 110, 108];

// Compute deltas between consecutive readings
let deltas: Vec<i32> = readings.iter()
    .scan(None::<i32>, |prev, &val| {
        let delta = prev.map(|p| val - p);
        *prev = Some(val);
        Some(delta)
    })
    .flatten()  // Remove the initial None
    .collect();
println!("Deltas: {deltas:?}"); // [5, -2, 7, -2]
}

Quick reference: C++ loop → Rust iterator

C++ PatternRust IteratorExample
for (int i = 0; i < v.size(); i++).enumerate()v.iter().enumerate()
Parallel iteration with index.zip()a.iter().zip(b.iter())
Nested loop → flat result.flat_map()vecs.iter().flat_map(|v| v.iter())
Concatenate two containers.chain()a.iter().chain(b.iter())
Sliding window v[i..i+n].windows(n)v.windows(3)
Process in fixed-size groups.chunks(n)v.chunks(4)
std::accumulate / manual accumulator.fold().fold(init, |acc, x| ...)
Running total / delta tracking.scan().scan(state, |s, x| ...)
while (it != end && count < n) { ++it; ++count; }.take(n).iter().take(5)
while (it != end && !pred(*it)) { ++it; }.skip_while().skip_while(|x| x < &threshold)
std::any_of.any().iter().any(|x| x > &limit)
std::all_of.all().iter().all(|x| x.is_valid())
std::none_of!.any()!iter.any(|x| x.failed())
std::count_if.filter().count().filter(|x| x > &0).count()
std::min_element / std::max_element.min() / .max().iter().max() → Option<&T>
std::unique.dedup() (on sorted)v.dedup() (in-place on Vec)

Exercise: Iterator chains

Given sensor data as Vec<(String, f64)> (name, temperature), write a single iterator chain that:

  1. Filters sensors with temp > 80.0
  2. Sorts them by temperature (descending)
  3. Formats each as "{name}: {temp}°C [ALARM]"
  4. Collects into Vec<String>

Hint: you’ll need .collect() before .sort_by(), since sorting requires a Vec.

Solution (click to expand)
fn alarm_report(sensors: &[(String, f64)]) -> Vec<String> {
    let mut hot: Vec<_> = sensors.iter()
        .filter(|(_, temp)| *temp > 80.0)
        .collect();
    hot.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap());
    hot.iter()
        .map(|(name, temp)| format!("{name}: {temp}°C [ALARM]"))
        .collect()
}

fn main() {
    let sensors = vec![
        ("gpu0".to_string(), 72.5),
        ("gpu1".to_string(), 85.3),
        ("gpu2".to_string(), 91.0),
        ("gpu3".to_string(), 78.0),
        ("gpu4".to_string(), 88.7),
    ];
    for line in alarm_report(&sensors) {
        println!("{line}");
    }
}
// Output:
// gpu2: 91°C [ALARM]
// gpu4: 88.7°C [ALARM]
// gpu1: 85.3°C [ALARM]

Rust iterators

  • The Iterator trait is used to implement iteration over user-defined types (https://doc.rust-lang.org/std/iter/trait.IntoIterator.html)
    • In the example, we’ll implement an iterator for the Fibonacci sequence, which starts with 1, 1, 2, … and the successor is the sum of the previous two numbers
    • The associated type in the Iterator (type Item = u32;) defines the output type from our iterator (u32)
    • The next() method simply contains the logic for implementing our iterator. In this case, all state information is available in the Fibonacci structure
    • We could have implemented another trait called IntoIterator to implement the into_iter() method for more specialized iterators
    • ▶ Try it in the Rust Playground

Rust concurrency

What you’ll learn: Rust’s concurrency model — threads, Send/Sync marker traits, Mutex<T>, Arc<T>, channels, and how the compiler prevents data races at compile time. No runtime overhead for thread safety you don’t use.

  • Rust has built-in support for concurrency, similar to std::thread in C++
    • Key difference: Rust prevents data races at compile time through Send and Sync marker traits
    • In C++, sharing a std::vector across threads without a mutex is UB but compiles fine. In Rust, it won’t compile.
    • Mutex<T> in Rust wraps the data, not just the access — you literally cannot read the data without locking
  • The thread::spawn() can be used to create a separate thread that executes the closure || in parallel
use std::thread;
use std::time::Duration;
fn main() {
    let handle = thread::spawn(|| {
        for i in 0..10 {
            println!("Count in thread: {i}!");
            thread::sleep(Duration::from_millis(5));
        }
    });

    for i in 0..5 {
        println!("Main thread: {i}");
        thread::sleep(Duration::from_millis(5));
    }

    handle.join().unwrap(); // The handle.join() ensures that the spawned thread exits
}

Rust concurrency

  • thread::scope() can be used in cases where it is necessary to borrow from the environment. This works because thread::scope waits until the internal thread returns
  • Try executing this exercise without thread::scope to see the issue
use std::thread;
fn main() {
  let a = [0, 1, 2];
  thread::scope(|scope| {
      scope.spawn(|| {
          for x in &a {
            println!("{x}");
          }
      });
  });
}

Rust concurrency

  • We can also use move to transfer ownership to the thread. For Copy types like [i32; 3], the move keyword copies the data into the closure, and the original remains usable
use std::thread;
fn main() {
  let mut a = [0, 1, 2];
  let handle = thread::spawn(move || {
      for x in a {
        println!("{x}");
      }
  });
  a[0] = 42;    // Doesn't affect the copy sent to the thread
  handle.join().unwrap();
}

Rust concurrency

  • Arc<T> can be used to share read-only references between multiple threads
    • Arc stands for Atomic Reference Counted. The reference isn’t released until the reference count reaches 0
    • Arc::clone() simply increases the reference count without cloning the data
use std::sync::Arc;
use std::thread;
fn main() {
    let a = Arc::new([0, 1, 2]);
    let mut handles = Vec::new();
    for i in 0..2 {
        let arc = Arc::clone(&a);
        handles.push(thread::spawn(move || {
            println!("Thread: {i} {arc:?}");
        }));
    }
    handles.into_iter().for_each(|h| h.join().unwrap());
}

Rust concurrency

  • Arc<T> can be combined with Mutex<T> to provide mutable references.
    • Mutex guards the protected data and ensures that only the thread holding the lock has access.
    • The MutexGuard is automatically released when it goes out of scope (RAII). Note: std::mem::forget can still leak a guard — so “impossible to forget to unlock” is more accurate than “impossible to leak.”
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let counter = Arc::new(Mutex::new(0));
    let mut handles = Vec::new();

    for _ in 0..5 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            let mut num = counter.lock().unwrap();
            *num += 1;
            // MutexGuard dropped here — lock released automatically
        }));
    }

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

    println!("Final count: {}", *counter.lock().unwrap());
    // Output: Final count: 5
}

Rust concurrency: RwLock

  • RwLock<T> allows multiple concurrent readers or one exclusive writer — the read/write lock pattern from C++ (std::shared_mutex)
    • Use RwLock when reads far outnumber writes (e.g., configuration, caches)
    • Use Mutex when read/write frequency is similar or critical sections are short
use std::sync::{Arc, RwLock};
use std::thread;

fn main() {
    let config = Arc::new(RwLock::new(String::from("v1.0")));
    let mut handles = Vec::new();

    // Spawn 5 readers — all can run concurrently
    for i in 0..5 {
        let config = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            let val = config.read().unwrap();  // Multiple readers OK
            println!("Reader {i}: {val}");
        }));
    }

    // One writer — blocks until all readers finish
    {
        let config = Arc::clone(&config);
        handles.push(thread::spawn(move || {
            let mut val = config.write().unwrap();  // Exclusive access
            *val = String::from("v2.0");
            println!("Writer: updated to {val}");
        }));
    }

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

Rust concurrency: Mutex poisoning

  • If a thread panics while holding a Mutex or RwLock, the lock becomes poisoned
    • Subsequent calls to .lock() return Err(PoisonError) — the data may be in an inconsistent state
    • You can recover with .into_inner() if you’re confident the data is still valid
    • This has no C++ equivalent — std::mutex has no poisoning concept; a panicking thread just leaves the lock held
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let data = Arc::new(Mutex::new(vec![1, 2, 3]));

    let data2 = Arc::clone(&data);
    let handle = thread::spawn(move || {
        let mut guard = data2.lock().unwrap();
        guard.push(4);
        panic!("oops!");  // Lock is now poisoned
    });

    let _ = handle.join();  // Thread panicked

    // Subsequent lock attempts return Err(PoisonError)
    match data.lock() {
        Ok(guard) => println!("Data: {guard:?}"),
        Err(poisoned) => {
            println!("Lock was poisoned! Recovering...");
            let guard = poisoned.into_inner();  // Access data anyway
            println!("Recovered data: {guard:?}");  // [1, 2, 3, 4] — push succeeded before panic
        }
    }
}

Rust concurrency: Atomics

  • For simple counters and flags, std::sync::atomic types avoid the overhead of a Mutex
    • AtomicBool, AtomicI32, AtomicU64, AtomicUsize, etc.
    • Equivalent to C++ std::atomic<T> — same memory ordering model (Relaxed, Acquire, Release, SeqCst)
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;

fn main() {
    let counter = Arc::new(AtomicU64::new(0));
    let mut handles = Vec::new();

    for _ in 0..10 {
        let counter = Arc::clone(&counter);
        handles.push(thread::spawn(move || {
            for _ in 0..1000 {
                counter.fetch_add(1, Ordering::Relaxed);
            }
        }));
    }

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

    println!("Counter: {}", counter.load(Ordering::SeqCst));
    // Output: Counter: 10000
}
PrimitiveWhen to useC++ equivalent
Mutex<T>General mutable shared statestd::mutex + manual data association
RwLock<T>Read-heavy workloadsstd::shared_mutex
Atomic*Simple counters, flags, lock-free patternsstd::atomic<T>
CondvarWait for a condition to become truestd::condition_variable

Rust concurrency: Condvar

  • Condvar (condition variable) lets a thread sleep until another thread signals that a condition has changed
    • Always paired with a Mutex — the pattern is: lock, check condition, wait if not ready, act when ready
    • Equivalent to C++ std::condition_variable / std::condition_variable::wait
    • Handles spurious wakeups — always re-check the condition in a loop (or use wait_while/wait_until)
use std::sync::{Arc, Condvar, Mutex};
use std::thread;

fn main() {
    let pair = Arc::new((Mutex::new(false), Condvar::new()));

    // Spawn a worker that waits for a signal
    let pair2 = Arc::clone(&pair);
    let worker = thread::spawn(move || {
        let (lock, cvar) = &*pair2;
        let mut ready = lock.lock().unwrap();
        // wait: sleeps until signaled (always re-check in a loop for spurious wakeups)
        while !*ready {
            ready = cvar.wait(ready).unwrap();
        }
        println!("Worker: condition met, proceeding!");
    });

    // Main thread does some work, then signals the worker
    thread::sleep(std::time::Duration::from_millis(100));
    {
        let (lock, cvar) = &*pair;
        let mut ready = lock.lock().unwrap();
        *ready = true;
        cvar.notify_one();  // Wake one waiting thread (notify_all() wakes all)
    }

    worker.join().unwrap();
}

When to use Condvar vs channels: Use Condvar when threads share mutable state and need to wait for a condition on that state (e.g., “buffer not empty”). Use channels (mpsc) when threads need to pass messages. Channels are generally easier to reason about.

Rust concurrency

  • Rust channels can be used to exchange messages between Sender and Receiver
    • This uses a paradigm called mpsc or Multi-producer, Single-Consumer
    • Both send() and recv() can block the thread
use std::sync::mpsc;

fn main() {
    let (tx, rx) = mpsc::channel();
    
    tx.send(10).unwrap();
    tx.send(20).unwrap();
    
    println!("Received: {:?}", rx.recv());
    println!("Received: {:?}", rx.recv());

    let tx2 = tx.clone();
    tx2.send(30).unwrap();
    println!("Received: {:?}", rx.recv());
}

Rust concurrency

  • Channels can be combined with threads
use std::sync::mpsc;
use std::thread;
use std::time::Duration;

fn main() {
    let (tx, rx) = mpsc::channel();
    for _ in 0..2 {
        let tx2 = tx.clone();
        thread::spawn(move || {
            let thread_id = thread::current().id();
            for i in 0..10 {
                tx2.send(format!("Message {i}")).unwrap();
                println!("{thread_id:?}: sent Message {i}");
            }
            println!("{thread_id:?}: done");
        });
    }

        // Drop the original sender so rx.iter() terminates when all cloned senders are dropped
    drop(tx);

    thread::sleep(Duration::from_millis(100));

    for msg in rx.iter() {
        println!("Main: got {msg}");
    }
}

Why Rust prevents data races: Send and Sync

  • Rust uses two marker traits to enforce thread safety at compile time:
    • Send: A type is Send if it can be safely transferred to another thread
    • Sync: A type is Sync if it can be safely shared (via &T) between threads
  • Most types are automatically Send + Sync. Notable exceptions:
    • Rc<T> is neither Send nor Sync (use Arc<T> for threads)
    • Cell<T> and RefCell<T> are not Sync (use Mutex<T> or RwLock<T>)
    • Raw pointers (*const T, *mut T) are neither Send nor Sync
  • This is why the compiler stops you from using Rc<T> across threads – it literally doesn’t implement Send
  • Arc<Mutex<T>> is the thread-safe equivalent of Rc<RefCell<T>>

Intuition (Jon Gjengset): Think of values as toys. Send = you can give your toy away to another child (thread) — transferring ownership is safe. Sync = you can let others play with your toy at the same time — sharing a reference is safe. An Rc<T> has a fragile (non-atomic) reference counter; handing it off or sharing it would corrupt the count, so it is neither Send nor Sync.

Exercise: Multi-threaded word count

🔴 Challenge — combines threads, Arc, Mutex, and HashMap

  • Given a Vec<String> of text lines, spawn one thread per line to count the words in that line
  • Use Arc<Mutex<HashMap<String, usize>>> to collect results
  • Print the total word count across all lines
  • Bonus: Try implementing this with channels (mpsc) instead of shared state
Solution (click to expand)
use std::collections::HashMap;
use std::sync::{Arc, Mutex};
use std::thread;

fn main() {
    let lines = vec![
        "the quick brown fox".to_string(),
        "jumps over the lazy dog".to_string(),
        "the fox is quick".to_string(),
    ];

    let word_counts: Arc<Mutex<HashMap<String, usize>>> =
        Arc::new(Mutex::new(HashMap::new()));

    let mut handles = vec![];
    for line in &lines {
        let line = line.clone();
        let counts = Arc::clone(&word_counts);
        handles.push(thread::spawn(move || {
            for word in line.split_whitespace() {
                let mut map = counts.lock().unwrap();
                *map.entry(word.to_lowercase()).or_insert(0) += 1;
            }
        }));
    }

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

    let counts = word_counts.lock().unwrap();
    let total: usize = counts.values().sum();
    println!("Word frequencies: {counts:#?}");
    println!("Total words: {total}");
}
// Output (order may vary):
// Word frequencies: {
//     "the": 3,
//     "quick": 2,
//     "brown": 1,
//     "fox": 2,
//     "jumps": 1,
//     "over": 1,
//     "lazy": 1,
//     "dog": 1,
//     "is": 1,
// }
// Total words: 13

Unsafe Rust

What you’ll learn: When and how to use unsafe — raw pointer dereferencing, FFI (Foreign Function Interface) for calling C from Rust and vice versa, CString/CStr for string interop, and how to write safe wrappers around unsafe code.

  • unsafe unlocks access to features that are normally disallowed by the Rust compiler
    • Dereferencing raw pointers
    • Accessing mutable static variables
    • https://doc.rust-lang.org/book/ch19-01-unsafe-rust.html
  • With great power comes great responsibility
    • unsafe tells the compiler “I, the programmer, take responsibility for upholding the invariants that the compiler normally guarantees”
    • Must guarantee no aliased mutable and immutable references, no dangling pointers, no invalid references, …
    • The use of unsafe should be limited to the smallest possible scope
    • All code using unsafe should have a “safety” comment describing the assumptions

Unsafe Rust examples

unsafe fn harmless() {}
fn main() {
    // Safety: We are calling a harmless unsafe function
    unsafe {
        harmless();
    }
    let a = 42u32;
    let p = &a as *const u32;
    // Safety: p is a valid pointer to a variable that will remain in scope
    unsafe {
        println!("{}", *p);
    }
    // Safety: Not safe; for illustration purposes only
    let dangerous_buffer = 0xb8000 as *mut u32;
    unsafe {
        println!("About to go kaboom!!!");
        *dangerous_buffer = 0; // This will SEGV on most modern machines
    }
}

Simple FFI example (Rust library function consumed by C)

FFI Strings: CString and CStr

FFI stands for Foreign Function Interface — the mechanism Rust uses to call functions written in other languages (such as C) and vice versa.

When interfacing with C code, Rust’s String and &str types (which are UTF-8 without null terminators) aren’t directly compatible with C strings (which are null-terminated byte arrays). Rust provides CString (owned) and CStr (borrowed) from std::ffi for this purpose:

TypeAnalogous toUse when
CStringString (owned)Creating a C string from Rust data
&CStr&str (borrowed)Receiving a C string from foreign code
#![allow(unused)]
fn main() {
use std::ffi::{CString, CStr};
use std::os::raw::c_char;

fn demo_ffi_strings() {
    // Creating a C-compatible string (adds null terminator)
    let c_string = CString::new("Hello from Rust").expect("CString::new failed");
    let ptr: *const c_char = c_string.as_ptr();

    // Converting a C string back to Rust (unsafe because we trust the pointer)
    // Safety: ptr is valid and null-terminated (we just created it above)
    let back_to_rust: &CStr = unsafe { CStr::from_ptr(ptr) };
    let rust_str: &str = back_to_rust.to_str().expect("Invalid UTF-8");
    println!("{}", rust_str);
}
}

Warning: CString::new() will return an error if the input contains interior null bytes (\0). Always handle the Result. You’ll see CStr used extensively in the FFI examples below.

  • FFI methods must be marked with #[no_mangle] to ensure that the compiler doesn’t mangle the name
  • We’ll compile the crate as a static library
    #[no_mangle] 
    pub extern "C" fn add(left: u64, right: u64) -> u64 {
        left + right
    }
    
  • We’ll compile the following C-code and link it against our static library.
    #include <stdio.h>
    #include <stdint.h>
    extern uint64_t add(uint64_t, uint64_t);
    int main() {
        printf("Add returned %llu\n", add(21, 21));
    }
    

Complex FFI example

  • In the following examples, we’ll create a Rust logging interface and expose it to [PYTHON] and C
    • We’ll see how the same interface can be used natively from Rust and C
    • We will explore the use of tools like cbindgen to generate header files for C
    • We will see how unsafe wrappers can act as a bridge to safe Rust code

Logger helper functions

#![allow(unused)]
fn main() {
fn create_or_open_log_file(log_file: &str, overwrite: bool) -> Result<File, String> {
    if overwrite {
        File::create(log_file).map_err(|e| e.to_string())
    } else {
        OpenOptions::new()
            .write(true)
            .append(true)
            .open(log_file)
            .map_err(|e| e.to_string())
    }
}

fn log_to_file(file_handle: &mut File, message: &str) -> Result<(), String> {
    file_handle
        .write_all(message.as_bytes())
        .map_err(|e| e.to_string())
}
}

Logger struct

#![allow(unused)]
fn main() {
struct SimpleLogger {
    log_level: LogLevel,
    file_handle: File,
}

impl SimpleLogger {
    fn new(log_file: &str, overwrite: bool, log_level: LogLevel) -> Result<Self, String> {
        let file_handle = create_or_open_log_file(log_file, overwrite)?;
        Ok(Self {
            file_handle,
            log_level,
        })
    }

    fn log_message(&mut self, log_level: LogLevel, message: &str) -> Result<(), String> {
        if log_level as u32 <= self.log_level as u32 {
            let timestamp = Local::now().format("%Y-%m-%d %H:%M:%S").to_string();
            let message = format!("Simple: {timestamp} {log_level} {message}\n");
            log_to_file(&mut self.file_handle, &message)
        } else {
            Ok(())
        }
    }
}
}

Testing

  • Testing functionality with Rust is trivial
    • Test methods are decorated with #[test], and aren’t part of the compiled binary
    • It’s easy to create mock methods for testing purposes
#![allow(unused)]
fn main() {
#[test]
fn testfunc() -> Result<(), String> {
    let mut logger = SimpleLogger::new("test.log", false, LogLevel::INFO)?;
    logger.log_message(LogLevel::TRACELEVEL1, "Hello world")?;
    logger.log_message(LogLevel::CRITICAL, "Critical message")?;
    Ok(()) // The compiler automatically drops logger here
}
}
cargo test

(C)-Rust FFI

  • cbindgen is a great tool for generating header files for exported Rust functions
    • Can be installed using cargo
cargo install cbindgen
cbindgen 
  • Function and structures can be exported using #[no_mangle] and #[repr(C)]
    • We’ll assume the common interface pattern passing in a ** to the actual implementation and returning 0 on success and non-zero on error
    • Opaque vs transparent structs: Our SimpleLogger is passed as an opaque pointer (*mut SimpleLogger) — the C side never accesses its fields, so #[repr(C)] is not needed. Use #[repr(C)] when C code needs to read/write struct fields directly:
#![allow(unused)]
fn main() {
// Opaque — C only holds a pointer, never inspects fields. No #[repr(C)] needed.
struct SimpleLogger { /* Rust-only fields */ }

// Transparent — C reads/writes fields directly. MUST use #[repr(C)].
#[repr(C)]
pub struct Point {
    pub x: f64,
    pub y: f64,
}
}
typedef struct SimpleLogger SimpleLogger;
uint32_t create_simple_logger(const char *file_name, struct SimpleLogger **out_logger);
uint32_t log_entry(struct SimpleLogger *logger, const char *message);
uint32_t drop_logger(struct SimpleLogger *logger);
  • Note that we need to a lot of sanity checks
  • We have to explicitly leak memory to prevent Rust from automatically deallocating
#![allow(unused)]
fn main() {
#[no_mangle] 
pub extern "C" fn create_simple_logger(file_name: *const std::os::raw::c_char, out_logger: *mut *mut SimpleLogger) -> u32 {
    use std::ffi::CStr;
    // Make sure pointer isn't NULL
    if file_name.is_null() || out_logger.is_null() {
        return 1;
    }
    // Safety: The passed in pointer is either NULL or 0-terminated by contract
    let file_name = unsafe {
        CStr::from_ptr(file_name)
    };
    let file_name = file_name.to_str();
    // Make sure that file_name doesn't have garbage characters
    if file_name.is_err() {
        return 1;
    }
    let file_name = file_name.unwrap();
    // Assume some defaults; we'll pass them in in real life
    let new_logger = SimpleLogger::new(file_name, false, LogLevel::CRITICAL);
    // Check that we were able to construct the logger
    if new_logger.is_err() {
        return 1;
    }
    let new_logger = Box::new(new_logger.unwrap());
    // This prevents the Box from being dropped when if goes out of scope
    let logger_ptr: *mut SimpleLogger = Box::leak(new_logger);
    // Safety: logger is non-null and logger_ptr is valid
    unsafe {
        *out_logger = logger_ptr;
    }
    return 0;
}
}
  • We have similar error checks in log_entry()
#![allow(unused)]
fn main() {
#[no_mangle]
pub extern "C" fn log_entry(logger: *mut SimpleLogger, message: *const std::os::raw::c_char) -> u32 {
    use std::ffi::CStr;
    if message.is_null() || logger.is_null() {
        return 1;
    }
    // Safety: message is non-null
    let message = unsafe {
        CStr::from_ptr(message)
    };
    let message = message.to_str();
    // Make sure that file_name doesn't have garbage characters
    if message.is_err() {
        return 1;
    }
    // Safety: logger is valid pointer previously constructed by create_simple_logger()
    unsafe {
        (*logger).log_message(LogLevel::CRITICAL, message.unwrap()).is_err() as u32
    }
}

#[no_mangle]
pub extern "C" fn drop_logger(logger: *mut SimpleLogger) -> u32 {
    if logger.is_null() {
        return 1;
    }
    // Safety: logger is valid pointer previously constructed by create_simple_logger()
    unsafe {
        // This constructs a Box<SimpleLogger>, which is dropped when it goes out of scope
        let _ = Box::from_raw(logger);
    }
    0
}
}
  • We can test our (C)-FFI using Rust, or by writing a (C)-program
#![allow(unused)]
fn main() {
#[test]
fn test_c_logger() {
    // The c".." creates a NULL terminated string
    let file_name = c"test.log".as_ptr() as *const std::os::raw::c_char;
    let mut c_logger: *mut SimpleLogger = std::ptr::null_mut();
    assert_eq!(create_simple_logger(file_name, &mut c_logger), 0);
    // This is the manual way to create c"..." strings
    let message = b"message from C\0".as_ptr() as *const std::os::raw::c_char;
    assert_eq!(log_entry(c_logger, message), 0);
    drop_logger(c_logger);
}
}
#include "logger.h"
...
int main() {
    SimpleLogger *logger = NULL;
    if (create_simple_logger("test.log", &logger) == 0) {
        log_entry(logger, "Hello from C");
        drop_logger(logger); /*Needed to close handle, etc.*/
    } 
    ...
}

Ensuring correctness of unsafe code

  • The TL;DR version is that using unsafe requires deliberate thought
    • Always document the safety assumptions made by the code and review it with experts
    • Use tools like cbindgen, Miri, Valgrind that can help verify correctness
    • Never let a panic unwind across an FFI boundary — this is UB. Use std::panic::catch_unwind at FFI entry points, or configure panic = "abort" in your profile
    • If a struct is shared across FFI, mark it #[repr(C)] to guarantee C-compatible memory layout
    • Consult https://doc.rust-lang.org/nomicon/intro.html (the “Rustonomicon” — the dark arts of unsafe Rust)
    • Seek help of internal experts

Verification tools: Miri vs Valgrind

C++ developers are familiar with Valgrind and sanitizers. Rust has those plus Miri, which is far more precise for Rust-specific UB:

MiriValgrindC++ sanitizers (ASan/MSan/UBSan)
What it catchesRust-specific UB: stacked borrows, invalid enum discriminants, uninitialized reads, aliasing violationsMemory leaks, use-after-free, invalid reads/writes, uninitialized memoryBuffer overflow, use-after-free, data races, UB
How it worksInterprets MIR (Rust’s mid-level IR) — no native executionInstruments compiled binary at runtimeCompile-time instrumentation
FFI support❌ Cannot cross FFI boundary (skips C calls)✅ Works on any compiled binary, including FFI✅ Works if C code also compiled with sanitizers
Speed~100x slower than native~10-50x slower~2-5x slower
When to usePure Rust unsafe code, data structure invariantsFFI code, full binary integration testsC/C++ side of FFI, performance-sensitive testing
Catches aliasing bugs✅ Stacked Borrows model❌Partially (TSan for data races)

Recommendation: Use both — Miri for pure Rust unsafe, Valgrind for FFI integration:

  • Miri — catches Rust-specific UB that Valgrind cannot see (aliasing violations, invalid enum values, stacked borrows):

    rustup +nightly component add miri
    cargo +nightly miri test                    # Run all tests under Miri
    cargo +nightly miri test -- test_name       # Run a specific test
    

    ⚠️ Miri requires nightly and cannot execute FFI calls. Isolate unsafe Rust logic into testable units.

  • Valgrind — the tool you already know, works on the compiled binary including FFI:

    sudo apt install valgrind
    cargo install cargo-valgrind
    cargo valgrind test                         # Run all tests under Valgrind
    

    Catches leaks in Box::leak / Box::from_raw patterns common in FFI code.

  • cargo-careful — runs tests with extra runtime checks enabled (between regular tests and Miri):

    cargo install cargo-careful
    cargo +nightly careful test
    

Unsafe Rust summary

  • cbindgen is a great tool for (C) FFI to Rust
    • Use bindgen for FFI-interfaces in the other direction (consult the extensive documentation)
  • Do not assume that your unsafe code is correct, or that it’s fine to use from safe Rust. It’s really easy to make mistakes, and even code that seemingly works correctly can be wrong for subtle reasons
    • Use tools to verify correctness
    • If still in doubt, reach out for expert advice
  • Make sure that your unsafe code has comments with an explicit documentation about assumptions and why it’s correct
    • Callers of unsafe code should have corresponding comments on safety as well, and observe restrictions

Exercise: Writing a safe FFI wrapper

🔴 Challenge — requires understanding unsafe blocks, raw pointers, and safe API design

  • Write a safe Rust wrapper around an unsafe FFI-style function. The exercise simulates calling a C function that writes a formatted string into a caller-provided buffer.
  • Step 1: Implement the unsafe function unsafe_greet that writes a greeting into a raw *mut u8 buffer
  • Step 2: Write a safe wrapper safe_greet that allocates a Vec<u8>, calls the unsafe function, and returns a String
  • Step 3: Add proper // Safety: comments to every unsafe block

Starter code:

use std::fmt::Write as _;

/// Simulates a C function: writes "Hello, <name>!" into buffer.
/// Returns the number of bytes written (excluding null terminator).
/// # Safety
/// - `buf` must point to at least `buf_len` writable bytes
/// - `name` must be a valid pointer to a null-terminated C string
unsafe fn unsafe_greet(buf: *mut u8, buf_len: usize, name: *const u8) -> isize {
    // TODO: Build greeting, copy bytes into buf, return length
    // Hint: use std::ffi::CStr::from_ptr or iterate bytes manually
    todo!()
}

/// Safe wrapper — no unsafe in the public API
fn safe_greet(name: &str) -> Result<String, String> {
    // TODO: Allocate a Vec<u8> buffer, create a null-terminated name,
    // call unsafe_greet inside an unsafe block with Safety comment,
    // convert the result back to a String
    todo!()
}

fn main() {
    match safe_greet("Rustacean") {
        Ok(msg) => println!("{msg}"),
        Err(e) => eprintln!("Error: {e}"),
    }
    // Expected output: Hello, Rustacean!
}
Solution (click to expand)
use std::ffi::CStr;

/// Simulates a C function: writes "Hello, <name>!" into buffer.
/// Returns the number of bytes written, or -1 if buffer too small.
/// # Safety
/// - `buf` must point to at least `buf_len` writable bytes
/// - `name` must be a valid pointer to a null-terminated C string
unsafe fn unsafe_greet(buf: *mut u8, buf_len: usize, name: *const u8) -> isize {
    // Safety: caller guarantees name is a valid null-terminated string
    let name_cstr = unsafe { CStr::from_ptr(name as *const std::os::raw::c_char) };
    let name_str = match name_cstr.to_str() {
        Ok(s) => s,
        Err(_) => return -1,
    };
    let greeting = format!("Hello, {}!", name_str);
    if greeting.len() > buf_len {
        return -1;
    }
    // Safety: buf points to at least buf_len writable bytes (caller guarantee)
    unsafe {
        std::ptr::copy_nonoverlapping(greeting.as_ptr(), buf, greeting.len());
    }
    greeting.len() as isize
}

/// Safe wrapper — no unsafe in the public API
fn safe_greet(name: &str) -> Result<String, String> {
    let mut buffer = vec![0u8; 256];
    // Create a null-terminated version of name for the C API
    let name_with_null: Vec<u8> = name.bytes().chain(std::iter::once(0)).collect();

    // Safety: buffer has 256 writable bytes, name_with_null is null-terminated
    let bytes_written = unsafe {
        unsafe_greet(buffer.as_mut_ptr(), buffer.len(), name_with_null.as_ptr())
    };

    if bytes_written < 0 {
        return Err("Buffer too small or invalid name".to_string());
    }

    String::from_utf8(buffer[..bytes_written as usize].to_vec())
        .map_err(|e| format!("Invalid UTF-8: {e}"))
}

fn main() {
    match safe_greet("Rustacean") {
        Ok(msg) => println!("{msg}"),
        Err(e) => eprintln!("Error: {e}"),
    }
}
// Output:
// Hello, Rustacean!

MMIO and Volatile Register Access

What you’ll learn: Type-safe hardware register access in embedded Rust — volatile MMIO patterns, register abstraction crates, and how Rust’s type system can encode register permissions that C’s volatile keyword cannot.

In C firmware, you access hardware registers via volatile pointers to specific memory addresses. Rust has equivalent mechanisms — but with type safety.

C volatile vs Rust volatile

// C — typical MMIO register access
#define GPIO_BASE     0x40020000
#define GPIO_MODER    (*(volatile uint32_t*)(GPIO_BASE + 0x00))
#define GPIO_ODR      (*(volatile uint32_t*)(GPIO_BASE + 0x14))

void toggle_led(void) {
    GPIO_ODR ^= (1 << 5);  // Toggle pin 5
}
#![allow(unused)]
fn main() {
// Rust — raw volatile (low-level, rarely used directly)
use core::ptr;

const GPIO_BASE: usize = 0x4002_0000;
const GPIO_ODR: *mut u32 = (GPIO_BASE + 0x14) as *mut u32;

/// # Safety
/// Caller must ensure GPIO_BASE is a valid mapped peripheral address.
unsafe fn toggle_led() {
    // SAFETY: GPIO_ODR is a valid memory-mapped register address.
    let current = unsafe { ptr::read_volatile(GPIO_ODR) };
    unsafe { ptr::write_volatile(GPIO_ODR, current ^ (1 << 5)) };
}
}

svd2rust — Type-Safe Register Access (the Rust way)

In practice, you never write raw volatile pointers. Instead, svd2rust generates a Peripheral Access Crate (PAC) from the chip’s SVD file (the same XML file used by your IDE’s debug view):

#![allow(unused)]
fn main() {
// Generated PAC code (you don't write this — svd2rust does)
// The PAC makes invalid register access a compile error

// Usage with PAC:
use stm32f4::stm32f401;  // PAC crate for your chip

fn configure_gpio(dp: stm32f401::Peripherals) {
    // Enable GPIOA clock — type-safe, no magic numbers
    dp.RCC.ahb1enr.modify(|_, w| w.gpioaen().enabled());

    // Set pin 5 to output — can't accidentally write to a read-only field
    dp.GPIOA.moder.modify(|_, w| w.moder5().output());

    // Toggle pin 5 — type-checked field access
    dp.GPIOA.odr.modify(|r, w| {
        // SAFETY: toggling a single bit in a valid register field.
        unsafe { w.bits(r.bits() ^ (1 << 5)) }
    });
}
}
C register accessRust PAC equivalent
#define REG (*(volatile uint32_t*)ADDR)PAC crate generated by svd2rust
`REG= BITMASK;`
value = REG;let val = periph.reg.read().field().bits()
Wrong register field → silent UBCompile error — field doesn’t exist
Wrong register width → silent UBType-checked — u8 vs u16 vs u32

Interrupt Handling and Critical Sections

C firmware uses __disable_irq() / __enable_irq() and ISR functions with void signatures. Rust provides type-safe equivalents.

C vs Rust Interrupt Patterns

// C — traditional interrupt handler
volatile uint32_t tick_count = 0;

void SysTick_Handler(void) {   // Naming convention is critical — get it wrong → HardFault
    tick_count++;
}

uint32_t get_ticks(void) {
    __disable_irq();
    uint32_t t = tick_count;   // Read inside critical section
    __enable_irq();
    return t;
}
#![allow(unused)]
fn main() {
// Rust — using cortex-m and critical sections
use core::cell::Cell;
use cortex_m::interrupt::{self, Mutex};

// Shared state protected by a critical-section Mutex
static TICK_COUNT: Mutex<Cell<u32>> = Mutex::new(Cell::new(0));

#[cortex_m_rt::exception]     // Attribute ensures correct vector table placement
fn SysTick() {                // Compile error if name doesn't match a valid exception
    interrupt::free(|cs| {    // cs = critical section token (proof IRQs disabled)
        let count = TICK_COUNT.borrow(cs).get();
        TICK_COUNT.borrow(cs).set(count + 1);
    });
}

fn get_ticks() -> u32 {
    interrupt::free(|cs| TICK_COUNT.borrow(cs).get())
}
}

RTIC — Real-Time Interrupt-driven Concurrency

For complex firmware with multiple interrupt priorities, RTIC (formerly RTFM) provides compile-time task scheduling with zero overhead:

#![allow(unused)]
fn main() {
#[rtic::app(device = stm32f4xx_hal::pac, dispatchers = [USART1])]
mod app {
    use stm32f4xx_hal::prelude::*;

    #[shared]
    struct Shared {
        temperature: f32,   // Shared between tasks — RTIC manages locking
    }

    #[local]
    struct Local {
        led: stm32f4xx_hal::gpio::Pin<'A', 5, stm32f4xx_hal::gpio::Output>,
    }

    #[init]
    fn init(cx: init::Context) -> (Shared, Local) {
        let dp = cx.device;
        let gpioa = dp.GPIOA.split();
        let led = gpioa.pa5.into_push_pull_output();
        (Shared { temperature: 25.0 }, Local { led })
    }

    // Hardware task: runs on SysTick interrupt
    #[task(binds = SysTick, shared = [temperature], local = [led])]
    fn tick(mut cx: tick::Context) {
        cx.local.led.toggle();
        cx.shared.temperature.lock(|temp| {
            // RTIC guarantees exclusive access here — no manual locking needed
            *temp += 0.1;
        });
    }
}
}

Why RTIC matters for C firmware devs:

  • The #[shared] annotation replaces manual mutex management
  • Priority-based preemption is configured at compile time — no runtime overhead
  • Deadlock-free by construction (the framework proves it at compile time)
  • ISR naming errors are compile errors, not runtime HardFaults

Panic Handler Strategies

In C, when something goes wrong in firmware, you typically reset or blink an LED. Rust’s panic handler gives you structured control:

#![allow(unused)]
fn main() {
// Strategy 1: Halt (for debugging — attach debugger, inspect state)
use panic_halt as _;  // Infinite loop on panic

// Strategy 2: Reset the MCU
use panic_reset as _;  // Triggers system reset

// Strategy 3: Log via probe (development)
use panic_probe as _;  // Sends panic info over debug probe (with defmt)

// Strategy 4: Log over defmt then halt
use defmt_panic as _;  // Rich panic messages over ITM/RTT

// Strategy 5: Custom handler (production firmware)
use core::panic::PanicInfo;

#[panic_handler]
fn panic(info: &PanicInfo) -> ! {
    // 1. Disable interrupts to prevent further damage
    cortex_m::interrupt::disable();

    // 2. Write panic info to a reserved RAM region (survives reset)
    // SAFETY: PANIC_LOG is a reserved memory region defined in linker script.
    unsafe {
        let log = 0x2000_0000 as *mut [u8; 256];
        // Write truncated panic message
        use core::fmt::Write;
        let mut writer = FixedWriter::new(&mut *log);
        let _ = write!(writer, "{}", info);
    }

    // 3. Trigger watchdog reset (or blink error LED)
    loop {
        cortex_m::asm::wfi();  // Wait for interrupt (low power while halted)
    }
}
}

Linker Scripts and Memory Layout

C firmware devs write linker scripts to define FLASH/RAM regions. Rust embedded uses the same concept via memory.x:

/* memory.x — placed at crate root, consumed by cortex-m-rt */
MEMORY
{
  /* Adjust for your MCU — these are STM32F401 values */
  FLASH : ORIGIN = 0x08000000, LENGTH = 512K
  RAM   : ORIGIN = 0x20000000, LENGTH = 96K
}

/* Optional: reserve space for panic log (see panic handler above) */
_panic_log_start = ORIGIN(RAM);
_panic_log_size  = 256;
# .cargo/config.toml — set the target and linker flags
[target.thumbv7em-none-eabihf]
runner = "probe-rs run --chip STM32F401RE"  # flash and run via debug probe
rustflags = [
    "-C", "link-arg=-Tlink.x",              # cortex-m-rt linker script
]

[build]
target = "thumbv7em-none-eabihf"            # Cortex-M4F with hardware FPU
C linker scriptRust equivalent
MEMORY { FLASH ..., RAM ... }memory.x at crate root
__attribute__((section(".data")))#[link_section = ".data"]
-T linker.ld in Makefile-C link-arg=-Tlink.x in .cargo/config.toml
__bss_start__, __bss_end__Handled by cortex-m-rt automatically
Startup assembly (startup.s)cortex-m-rt #[entry] macro

Writing embedded-hal Drivers

The embedded-hal crate defines traits for SPI, I2C, GPIO, UART, etc. Drivers written against these traits work on any MCU — this is Rust’s killer feature for embedded reuse.

C vs Rust: A Temperature Sensor Driver

// C — driver tightly coupled to STM32 HAL
#include "stm32f4xx_hal.h"

float read_temperature(I2C_HandleTypeDef* hi2c, uint8_t addr) {
    uint8_t buf[2];
    HAL_I2C_Mem_Read(hi2c, addr << 1, 0x00, I2C_MEMADD_SIZE_8BIT,
                     buf, 2, HAL_MAX_DELAY);
    int16_t raw = ((int16_t)buf[0] << 4) | (buf[1] >> 4);
    return raw * 0.0625;
}
// Problem: This driver ONLY works with STM32 HAL. Porting to Nordic = rewrite.
#![allow(unused)]
fn main() {
// Rust — driver works on ANY MCU that implements embedded-hal
use embedded_hal::i2c::I2c;

pub struct Tmp102<I2C> {
    i2c: I2C,
    address: u8,
}

impl<I2C: I2c> Tmp102<I2C> {
    pub fn new(i2c: I2C, address: u8) -> Self {
        Self { i2c, address }
    }

    pub fn read_temperature(&mut self) -> Result<f32, I2C::Error> {
        let mut buf = [0u8; 2];
        self.i2c.write_read(self.address, &[0x00], &mut buf)?;
        let raw = ((buf[0] as i16) << 4) | ((buf[1] as i16) >> 4);
        Ok(raw as f32 * 0.0625)
    }
}

// Works on STM32, Nordic nRF, ESP32, RP2040 — any chip with an embedded-hal I2C impl
}
graph TD
    subgraph "C Driver Architecture"
        CD["Temperature Driver"]
        CD --> STM["STM32 HAL"]
        CD -.->|"Port = REWRITE"| NRF["Nordic HAL"]
        CD -.->|"Port = REWRITE"| ESP["ESP-IDF"]
    end
    
    subgraph "Rust embedded-hal Architecture"
        RD["Temperature Driver<br/>impl&lt;I2C: I2c&gt;"]
        RD --> EHAL["embedded-hal::I2c trait"]
        EHAL --> STM2["stm32f4xx-hal"]
        EHAL --> NRF2["nrf52-hal"]
        EHAL --> ESP2["esp-hal"]
        EHAL --> RP2["rp2040-hal"]
        NOTE["Write driver ONCE,<br/>runs on ALL chips"]
    end
    
    style CD fill:#ffa07a,color:#000
    style RD fill:#91e5a3,color:#000
    style EHAL fill:#91e5a3,color:#000
    style NOTE fill:#91e5a3,color:#000

Global Allocator Setup

The alloc crate gives you Vec, String, Box — but you need to tell Rust where heap memory comes from. This is the equivalent of implementing malloc() for your platform:

#![no_std]
extern crate alloc;

use alloc::vec::Vec;
use alloc::string::String;
use embedded_alloc::LlffHeap as Heap;

#[global_allocator]
static HEAP: Heap = Heap::empty();

#[cortex_m_rt::entry]
fn main() -> ! {
    // Initialize the allocator with a memory region
    // (typically a portion of RAM not used by stack or static data)
    {
        const HEAP_SIZE: usize = 4096;
        static mut HEAP_MEM: [u8; HEAP_SIZE] = [0; HEAP_SIZE];
        // SAFETY: HEAP_MEM is only accessed here during init, before any allocation.
        unsafe { HEAP.init(HEAP_MEM.as_ptr() as usize, HEAP_SIZE) }
    }

    // Now you can use heap types!
    let mut log_buffer: Vec<u8> = Vec::with_capacity(256);
    let name: String = String::from("sensor_01");
    // ...

    loop {}
}
C heap setupRust equivalent
_sbrk() / custom malloc()#[global_allocator] + Heap::init()
configTOTAL_HEAP_SIZE (FreeRTOS)HEAP_SIZE constant
pvPortMalloc()alloc::vec::Vec::new() — automatic
Heap exhaustion → undefined behavioralloc_error_handler → controlled panic

Mixed no_std + std Workspaces

Real projects (like a large Rust workspace) often have:

  • no_std library crates for hardware-portable logic
  • std binary crates for the Linux application layer
workspace_root/
├── Cargo.toml              # [workspace] members = [...]
├── protocol/               # no_std — wire protocol, parsing
│   ├── Cargo.toml          # no default-features, no std
│   └── src/lib.rs          # #![no_std]
├── driver/                 # no_std — hardware abstraction
│   ├── Cargo.toml
│   └── src/lib.rs          # #![no_std], uses embedded-hal traits
├── firmware/               # no_std — MCU binary
│   ├── Cargo.toml          # depends on protocol, driver
│   └── src/main.rs         # #![no_std] #![no_main]
└── host_tool/              # std — Linux CLI tool
    ├── Cargo.toml          # depends on protocol (same crate!)
    └── src/main.rs         # Uses std::fs, std::net, etc.

The key pattern: the protocol crate uses #![no_std] so it compiles for both the MCU firmware and the Linux host tool. Shared code, zero duplication.

# protocol/Cargo.toml
[package]
name = "protocol"

[features]
default = []
std = []  # Optional: enable std-specific features when building for host

[dependencies]
serde = { version = "1", default-features = false, features = ["derive"] }
# Note: default-features = false drops serde's std dependency
#![allow(unused)]
fn main() {
// protocol/src/lib.rs
#![cfg_attr(not(feature = "std"), no_std)]

#[cfg(feature = "std")]
extern crate std;

extern crate alloc;
use alloc::vec::Vec;
use serde::{Serialize, Deserialize};

#[derive(Debug, Serialize, Deserialize)]
pub struct DiagPacket {
    pub sensor_id: u16,
    pub value: i32,
    pub fault_code: u16,
}

// This function works in both no_std and std contexts
pub fn parse_packet(data: &[u8]) -> Result<DiagPacket, &'static str> {
    if data.len() < 8 {
        return Err("packet too short");
    }
    Ok(DiagPacket {
        sensor_id: u16::from_le_bytes([data[0], data[1]]),
        value: i32::from_le_bytes([data[2], data[3], data[4], data[5]]),
        fault_code: u16::from_le_bytes([data[6], data[7]]),
    })
}
}

Exercise: Hardware Abstraction Layer Driver

Write a no_std driver for a hypothetical LED controller that communicates over SPI. The driver should be generic over any SPI implementation using embedded-hal.

Requirements:

  1. Define a LedController<SPI> struct
  2. Implement new(), set_brightness(led: u8, brightness: u8), and all_off()
  3. SPI protocol: send [led_index, brightness_value] as 2-byte transaction
  4. Write tests using a mock SPI implementation
#![allow(unused)]
fn main() {
// Starter code
#![no_std]
use embedded_hal::spi::SpiDevice;

pub struct LedController<SPI> {
    spi: SPI,
    num_leds: u8,
}

// TODO: Implement new(), set_brightness(), all_off()
// TODO: Create MockSpi for testing
}
Solution (click to expand)
#![allow(unused)]
#![no_std]
fn main() {
use embedded_hal::spi::SpiDevice;

pub struct LedController<SPI> {
    spi: SPI,
    num_leds: u8,
}

impl<SPI: SpiDevice> LedController<SPI> {
    pub fn new(spi: SPI, num_leds: u8) -> Self {
        Self { spi, num_leds }
    }

    pub fn set_brightness(&mut self, led: u8, brightness: u8) -> Result<(), SPI::Error> {
        if led >= self.num_leds {
            return Ok(()); // Silently ignore out-of-range LEDs
        }
        self.spi.write(&[led, brightness])
    }

    pub fn all_off(&mut self) -> Result<(), SPI::Error> {
        for led in 0..self.num_leds {
            self.spi.write(&[led, 0])?;
        }
        Ok(())
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    // Mock SPI that records all transactions
    struct MockSpi {
        transactions: Vec<Vec<u8>>,
    }

    // Minimal error type for mock
    #[derive(Debug)]
    struct MockError;
    impl embedded_hal::spi::Error for MockError {
        fn kind(&self) -> embedded_hal::spi::ErrorKind {
            embedded_hal::spi::ErrorKind::Other
        }
    }

    impl embedded_hal::spi::ErrorType for MockSpi {
        type Error = MockError;
    }

    impl SpiDevice for MockSpi {
        fn write(&mut self, buf: &[u8]) -> Result<(), Self::Error> {
            self.transactions.push(buf.to_vec());
            Ok(())
        }
        fn read(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
        fn transfer(&mut self, _r: &mut [u8], _w: &[u8]) -> Result<(), Self::Error> { Ok(()) }
        fn transfer_in_place(&mut self, _buf: &mut [u8]) -> Result<(), Self::Error> { Ok(()) }
        fn transaction(&mut self, _ops: &mut [embedded_hal::spi::Operation<'_, u8>]) -> Result<(), Self::Error> { Ok(()) }
    }

    #[test]
    fn test_set_brightness() {
        let mock = MockSpi { transactions: vec![] };
        let mut ctrl = LedController::new(mock, 4);
        ctrl.set_brightness(2, 128).unwrap();
        assert_eq!(ctrl.spi.transactions, vec![vec![2, 128]]);
    }

    #[test]
    fn test_all_off() {
        let mock = MockSpi { transactions: vec![] };
        let mut ctrl = LedController::new(mock, 3);
        ctrl.all_off().unwrap();
        assert_eq!(ctrl.spi.transactions, vec![
            vec![0, 0], vec![1, 0], vec![2, 0],
        ]);
    }

    #[test]
    fn test_out_of_range_led() {
        let mock = MockSpi { transactions: vec![] };
        let mut ctrl = LedController::new(mock, 2);
        ctrl.set_brightness(5, 255).unwrap(); // Out of range — ignored
        assert!(ctrl.spi.transactions.is_empty());
    }
}
}

Debugging Embedded Rust — probe-rs, defmt, and VS Code

C firmware developers typically debug with OpenOCD + GDB or vendor-specific IDEs (Keil, IAR, Segger Ozone). Rust’s embedded ecosystem has converged on probe-rs as the unified debug probe interface, replacing the OpenOCD + GDB stack with a single, Rust-native tool.

probe-rs — The All-in-One Debug Probe Tool

probe-rs replaces the OpenOCD + GDB combination. It supports CMSIS-DAP, ST-Link, J-Link, and other debug probes out of the box:

# Install probe-rs (includes cargo-flash and cargo-embed)
cargo install probe-rs-tools

# Flash and run your firmware
cargo flash --chip STM32F401RE --release

# Flash, run, and open RTT (Real-Time Transfer) console
cargo embed --chip STM32F401RE

probe-rs vs OpenOCD + GDB:

AspectOpenOCD + GDBprobe-rs
Install2 separate packages + scriptscargo install probe-rs-tools
Config.cfg files per board/probe--chip flag or Embed.toml
Console outputSemihosting (very slow)RTT (~10× faster)
Log frameworkprintfdefmt (structured, zero-cost)
Flash algorithmXML pack filesBuilt-in for 1000+ chips
GDB supportNativeprobe-rs gdb adapter

Embed.toml — Project Configuration

Instead of juggling .cfg and .gdbinit files, probe-rs uses a single config:

# Embed.toml — placed in your project root
[default.general]
chip = "STM32F401RETx"

[default.rtt]
enabled = true           # Enable Real-Time Transfer console
channels = [
    { up = 0, mode = "BlockIfFull", name = "Terminal" },
]

[default.flashing]
enabled = true           # Flash before running
restore_unwritten_bytes = false

[default.reset]
halt_afterwards = false  # Start running after flash + reset

[default.gdb]
enabled = false          # Set true to expose GDB server on :1337
gdb_connection_string = "127.0.0.1:1337"
# With Embed.toml, just run:
cargo embed              # Flash + RTT console — zero flags needed
cargo embed --release    # Release build

defmt — Deferred Formatting for Embedded Logging

defmt (deferred formatting) replaces printf debugging. Format strings are stored in the ELF file, not in flash — so log calls on the target send only an index + argument bytes. This makes logging 10–100× faster than printf and uses a fraction of the flash space:

#![no_std]
#![no_main]

use defmt::{info, warn, error, debug, trace};
use defmt_rtt as _; // RTT transport — links the defmt output to probe-rs

#[cortex_m_rt::entry]
fn main() -> ! {
    info!("Boot complete, firmware v{}", env!("CARGO_PKG_VERSION"));

    let sensor_id: u16 = 0x4A;
    let temperature: f32 = 23.5;

    // Format strings stay in ELF, not flash — near-zero overhead
    debug!("Sensor {:#06X}: {:.1}°C", sensor_id, temperature);

    if temperature > 80.0 {
        warn!("Overtemp on sensor {:#06X}: {:.1}°C", sensor_id, temperature);
    }

    loop {
        cortex_m::asm::wfi(); // Wait for interrupt
    }
}

// Custom types — derive defmt::Format instead of Debug
#[derive(defmt::Format)]
struct SensorReading {
    id: u16,
    value: i32,
    status: SensorStatus,
}

#[derive(defmt::Format)]
enum SensorStatus {
    Ok,
    Warning,
    Fault(u8),
}

// Usage:
// info!("Reading: {:?}", reading);  // <-- uses defmt::Format, NOT std Debug

defmt vs printf vs log:

FeatureC printf (semihosting)Rust log cratedefmt
Speed~100ms per callN/A (needs std)~1μs per call
Flash usageFull format stringsFull format stringsIndex only (bytes)
TransportSemihosting (halts CPU)Serial/UARTRTT (non-blocking)
Structured outputNoText onlyTyped, binary-encoded
no_stdVia semihostingFacade only (backends need std)✅ Native
Filter levelsManual #ifdefRUST_LOG=debugdefmt::println + features

VS Code Debug Configuration

With the probe-rs VS Code extension, you get full graphical debugging — breakpoints, variable inspection, call stack, and register view:

// .vscode/launch.json
{
    "version": "0.2.0",
    "configurations": [
        {
            "type": "probe-rs-debug",
            "request": "launch",
            "name": "Flash & Debug (probe-rs)",
            "chip": "STM32F401RETx",
            "coreConfigs": [
                {
                    "programBinary": "target/thumbv7em-none-eabihf/debug/${workspaceFolderBasename}",
                    "rttEnabled": true,
                    "rttChannelFormats": [
                        {
                            "channelNumber": 0,
                            "dataFormat": "Defmt",
                            "showTimestamps": true
                        }
                    ]
                }
            ],
            "connectUnderReset": true,
            "speed": 4000
        }
    ]
}

Install the extension:

#![allow(unused)]
fn main() {
ext install probe-rs.probe-rs-debugger
}

C Debugger Workflow vs Rust Embedded Debugging

graph LR
    subgraph "C Workflow (Traditional)"
        C1["Write code"] --> C2["make flash"]
        C2 --> C3["openocd -f board.cfg"]
        C3 --> C4["arm-none-eabi-gdb<br/>target remote :3333"]
        C4 --> C5["printf via semihosting<br/>(~100ms per call, halts CPU)"]
    end
    
    subgraph "Rust Workflow (probe-rs)"
        R1["Write code"] --> R2["cargo embed"]
        R2 --> R3["Flash + RTT console<br/>in one command"]
        R3 --> R4["defmt logs stream<br/>in real-time (~1μs)"]
        R2 -.->|"Or"| R5["VS Code F5<br/>Full GUI debugger"]
    end
    
    style C5 fill:#ffa07a,color:#000
    style R3 fill:#91e5a3,color:#000
    style R4 fill:#91e5a3,color:#000
    style R5 fill:#91e5a3,color:#000
C Debug ActionRust Equivalent
openocd -f board/st_nucleo_f4.cfgprobe-rs info (auto-detects probe + chip)
arm-none-eabi-gdb -x .gdbinitprobe-rs gdb --chip STM32F401RE
target remote :3333GDB connects to localhost:1337
monitor reset haltprobe-rs reset --chip ...
load firmware.elfcargo flash --chip ...
printf("debug: %d\n", val) (semihosting)defmt::info!("debug: {}", val) (RTT)
Keil/IAR GUI debuggerVS Code + probe-rs-debugger extension
Segger SystemViewdefmt + probe-rs RTT viewer

Cross-reference: For advanced unsafe patterns used in embedded drivers (pin projections, custom arena/slab allocators), see the companion Rust Patterns guide, sections “Pin Projections — Structural Pinning” and “Custom Allocators — Arena and Slab Patterns.”


no_std — Rust Without the Standard Library

What you’ll learn: How to write Rust for bare-metal and embedded targets using #![no_std] — the core and alloc crate split, panic handlers, and how this compares to embedded C without libc.

If you come from embedded C, you’re already used to working without libc or with a minimal runtime. Rust has a first-class equivalent: the #![no_std] attribute.

What is no_std?

When you add #![no_std] to the crate root, the compiler removes the implicit extern crate std; and links only against core (and optionally alloc).

LayerWhat it providesRequires OS / heap?
corePrimitive types, Option, Result, Iterator, math, slice, str, atomics, fmtNo — runs on bare metal
allocVec, String, Box, Rc, Arc, BTreeMapNeeds a global allocator, but no OS
stdHashMap, fs, net, thread, io, env, processYes — needs an OS

Rule of thumb for embedded devs: if your C project links against -lc and uses malloc, you can probably use core + alloc. If it runs on bare metal without malloc, stick with core only.

Declaring no_std

#![allow(unused)]
fn main() {
// src/lib.rs  (or src/main.rs for a binary with #![no_main])
#![no_std]

// You still get everything in `core`:
use core::fmt;
use core::result::Result;
use core::option::Option;

// If you have an allocator, opt in to heap types:
extern crate alloc;
use alloc::vec::Vec;
use alloc::string::String;
}

For a bare-metal binary you also need #![no_main] and a panic handler:

#![allow(unused)]
#![no_std]
#![no_main]

fn main() {
use core::panic::PanicInfo;

#[panic_handler]
fn panic(_info: &PanicInfo) -> ! {
    loop {} // hang on panic — replace with your board's reset/LED blink
}

// Entry point depends on your HAL / linker script
}

What you lose (and alternatives)

std featureno_std alternative
println!core::write! to a UART / defmt
HashMapheapless::FnvIndexMap (fixed capacity) or BTreeMap (with alloc)
Vecheapless::Vec (stack-allocated, fixed capacity)
Stringheapless::String or &str
std::io::Read/Writeembedded_io::Read/Write
thread::spawnInterrupt handlers, RTIC tasks
std::timeHardware timer peripherals
std::fsFlash / EEPROM drivers

Notable no_std crates for embedded

CratePurposeNotes
heaplessFixed-capacity Vec, String, Queue, MapNo allocator needed — all on the stack
defmtEfficient logging over probe/ITMLike printf but deferred formatting on the host
embedded-halHardware abstraction traits (SPI, I²C, GPIO, UART)Implement once, run on any MCU
cortex-mARM Cortex-M intrinsics & register accessLow-level, like CMSIS
cortex-m-rtRuntime / startup code for Cortex-MReplaces your startup.s
rticReal-Time Interrupt-driven ConcurrencyCompile-time task scheduling, zero overhead
embassyAsync executor for embeddedasync/await on bare metal
postcardno_std serde serialization (binary)Replaces serde_json when you can’t afford strings
thiserrorDerive macro for Error traitWorks in no_std since v2; prefer over anyhow
smoltcpno_std TCP/IP stackWhen you need networking without an OS

C vs Rust: bare-metal comparison

A typical embedded C blinky:

// C — bare metal, vendor HAL
#include "stm32f4xx_hal.h"

void SysTick_Handler(void) {
    HAL_GPIO_TogglePin(GPIOA, GPIO_PIN_5);
}

int main(void) {
    HAL_Init();
    __HAL_RCC_GPIOA_CLK_ENABLE();
    GPIO_InitTypeDef gpio = { .Pin = GPIO_PIN_5, .Mode = GPIO_MODE_OUTPUT_PP };
    HAL_GPIO_Init(GPIOA, &gpio);
    HAL_SYSTICK_Config(HAL_RCC_GetHCLKFreq() / 1000);
    while (1) {}
}

The Rust equivalent (using embedded-hal + a board crate):

#![no_std]
#![no_main]

use cortex_m_rt::entry;
use panic_halt as _; // panic handler: infinite loop
use stm32f4xx_hal::{pac, prelude::*};

#[entry]
fn main() -> ! {
    let dp = pac::Peripherals::take().unwrap();
    let gpioa = dp.GPIOA.split();
    let mut led = gpioa.pa5.into_push_pull_output();

    let rcc = dp.RCC.constrain();
    let clocks = rcc.cfgr.freeze();
    let mut delay = dp.TIM2.delay_ms(&clocks);

    loop {
        led.toggle();
        delay.delay_ms(500u32);
    }
}

Key differences for C devs:

  • Peripherals::take() returns Option — ensures the singleton pattern at compile time (no double-init bugs)
  • .split() moves ownership of individual pins — no risk of two modules driving the same pin
  • All register access is type-checked — you can’t accidentally write to a read-only register
  • The borrow checker prevents data races between main and interrupt handlers (with RTIC)

When to use no_std vs std

flowchart TD
    A[Does your target have an OS?] -->|Yes| B[Use std]
    A -->|No| C[Do you have a heap allocator?]
    C -->|Yes| D["Use #![no_std] + extern crate alloc"]
    C -->|No| E["Use #![no_std] with core only"]
    B --> F[Full Vec, HashMap, threads, fs, net]
    D --> G[Vec, String, Box, BTreeMap — no fs/net/threads]
    E --> H[Fixed-size arrays, heapless collections, no allocation]

Exercise: no_std ring buffer

🔴 Challenge — combines generics, MaybeUninit, and #[cfg(test)] in a no_std context

In embedded systems you often need a fixed-size ring buffer (circular buffer) that never allocates. Implement one using only core (no alloc, no std).

Requirements:

  • Generic over element type T: Copy
  • Fixed capacity N (const generic)
  • push(&mut self, item: T) — overwrites oldest element when full
  • pop(&mut self) -> Option<T> — returns oldest element
  • len(&self) -> usize
  • is_empty(&self) -> bool
  • Must compile with #![no_std]
#![allow(unused)]
fn main() {
// Starter code
#![no_std]

use core::mem::MaybeUninit;

pub struct RingBuffer<T: Copy, const N: usize> {
    buf: [MaybeUninit<T>; N],
    head: usize,  // next write position
    tail: usize,  // next read position
    count: usize,
}

impl<T: Copy, const N: usize> RingBuffer<T, N> {
    pub const fn new() -> Self {
        todo!()
    }
    pub fn push(&mut self, item: T) {
        todo!()
    }
    pub fn pop(&mut self) -> Option<T> {
        todo!()
    }
    pub fn len(&self) -> usize {
        todo!()
    }
    pub fn is_empty(&self) -> bool {
        todo!()
    }
}
}
Solution
#![allow(unused)]
#![no_std]

fn main() {
use core::mem::MaybeUninit;

pub struct RingBuffer<T: Copy, const N: usize> {
    buf: [MaybeUninit<T>; N],
    head: usize,
    tail: usize,
    count: usize,
}

impl<T: Copy, const N: usize> RingBuffer<T, N> {
    pub const fn new() -> Self {
        Self {
            // SAFETY: MaybeUninit does not require initialization
            buf: unsafe { MaybeUninit::uninit().assume_init() },
            head: 0,
            tail: 0,
            count: 0,
        }
    }

    pub fn push(&mut self, item: T) {
        self.buf[self.head] = MaybeUninit::new(item);
        self.head = (self.head + 1) % N;
        if self.count == N {
            // Buffer is full — overwrite oldest, advance tail
            self.tail = (self.tail + 1) % N;
        } else {
            self.count += 1;
        }
    }

    pub fn pop(&mut self) -> Option<T> {
        if self.count == 0 {
            return None;
        }
        // SAFETY: We only read positions that were previously written via push()
        let item = unsafe { self.buf[self.tail].assume_init() };
        self.tail = (self.tail + 1) % N;
        self.count -= 1;
        Some(item)
    }

    pub fn len(&self) -> usize {
        self.count
    }

    pub fn is_empty(&self) -> bool {
        self.count == 0
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn basic_push_pop() {
        let mut rb = RingBuffer::<u32, 4>::new();
        assert!(rb.is_empty());

        rb.push(10);
        rb.push(20);
        rb.push(30);
        assert_eq!(rb.len(), 3);

        assert_eq!(rb.pop(), Some(10));
        assert_eq!(rb.pop(), Some(20));
        assert_eq!(rb.pop(), Some(30));
        assert_eq!(rb.pop(), None);
    }

    #[test]
    fn overwrite_on_full() {
        let mut rb = RingBuffer::<u8, 3>::new();
        rb.push(1);
        rb.push(2);
        rb.push(3);
        // Buffer full: [1, 2, 3]

        rb.push(4); // Overwrites 1 → [4, 2, 3], tail advances
        assert_eq!(rb.len(), 3);
        assert_eq!(rb.pop(), Some(2)); // oldest surviving
        assert_eq!(rb.pop(), Some(3));
        assert_eq!(rb.pop(), Some(4));
        assert_eq!(rb.pop(), None);
    }
}
}

Why this matters for embedded C devs:

  • MaybeUninit is Rust’s equivalent of uninitialized memory — the compiler won’t insert zero-fills, just like char buf[N]; in C
  • The unsafe blocks are minimal (2 lines) and each has a // SAFETY: comment
  • The const fn new() means you can create ring buffers in static variables without a runtime constructor
  • The tests run on your host with cargo test even though the code is no_std

Case Study 3: Framework communication → Lifetime borrowing

What you’ll learn: How to convert C++ raw-pointer framework communication patterns to Rust’s lifetime-based borrowing system, eliminating dangling pointer risks while maintaining zero-cost abstractions.

The C++ Pattern: Raw Pointer to Framework

// C++ original: Every diagnostic module stores a raw pointer to the framework
class DiagBase {
protected:
    DiagFramework* m_pFramework;  // Raw pointer — who owns this?
public:
    DiagBase(DiagFramework* fw) : m_pFramework(fw) {}
    
    void LogEvent(uint32_t code, const std::string& msg) {
        m_pFramework->GetEventLog()->Record(code, msg);  // Hope it's still alive!
    }
};
// Problem: m_pFramework is a raw pointer with no lifetime guarantee
// If framework is destroyed while modules still reference it → UB

The Rust Solution: DiagContext with Lifetime Borrowing

#![allow(unused)]
fn main() {
// Example: module.rs — Borrow, don't store

/// Context passed to diagnostic modules during execution.
/// The lifetime 'a guarantees the framework outlives the context.
pub struct DiagContext<'a> {
    pub der_log: &'a mut EventLogManager,
    pub config: &'a ModuleConfig,
    pub framework_opts: &'a HashMap<String, String>,
}

/// Modules receive context as a parameter — never store framework pointers
pub trait DiagModule {
    fn id(&self) -> &str;
    fn execute(&mut self, ctx: &mut DiagContext) -> DiagResult<()>;
    fn pre_execute(&mut self, _ctx: &mut DiagContext) -> DiagResult<()> {
        Ok(())
    }
    fn post_execute(&mut self, _ctx: &mut DiagContext) -> DiagResult<()> {
        Ok(())
    }
}
}

Key Insight

  • C++ modules store a pointer to the framework (danger: what if the framework is destroyed first?)
  • Rust modules receive a context as a function parameter — the borrow checker guarantees the framework is alive during the call
  • No raw pointers, no lifetime ambiguity, no “hope it’s still alive”

Case Study 4: God object → Composable state

The C++ Pattern: Monolithic Framework Class

// C++ original: The framework is god object
class DiagFramework {
    // Health-monitor trap processing
    std::vector<AlertTriggerInfo> m_alertTriggers;
    std::vector<WarnTriggerInfo> m_warnTriggers;
    bool m_healthMonHasBootTimeError;
    uint32_t m_healthMonActionCounter;
    
    // GPU diagnostics
    std::map<uint32_t, GpuPcieInfo> m_gpuPcieMap;
    bool m_isRecoveryContext;
    bool m_healthcheckDetectedDevices;
    // ... 30+ more GPU-related fields
    
    // PCIe tree
    std::shared_ptr<CPcieTreeLinux> m_pPcieTree;
    
    // Event logging
    CEventLogMgr* m_pEventLogMgr;
    
    // ... several other methods
    void HandleGpuEvents();
    void HandleNicEvents();
    void RunGpuDiag();
    // Everything depends on everything
};

The Rust Solution: Composable State Structs

#![allow(unused)]
fn main() {
// Example: main.rs — State decomposed into focused structs

#[derive(Default)]
struct HealthMonitorState {
    alert_triggers: Vec<AlertTriggerInfo>,
    warn_triggers: Vec<WarnTriggerInfo>,
    health_monitor_action_counter: u32,
    health_monitor_has_boot_time_error: bool,
    // Only health-monitor-related fields
}

#[derive(Default)]
struct GpuDiagState {
    gpu_pcie_map: HashMap<u32, GpuPcieInfo>,
    is_recovery_context: bool,
    healthcheck_detected_devices: bool,
    // Only GPU-related fields
}

/// The framework composes these states rather than owning everything flat
struct DiagFramework {
    ctx: DiagContext,             // Execution context
    args: Args,                   // CLI arguments
    pcie_tree: Option<DeviceTree>,  // No shared_ptr needed
    event_log_mgr: EventLogManager,   // Owned, not raw pointer
    fc_manager: FcManager,        // Fault code management
    health: HealthMonitorState,   // Health-monitor state — its own struct
    gpu: GpuDiagState,           // GPU state — its own struct
}
}

Key Insight

  • Testability: Each state struct can be unit-tested independently
  • Readability: self.health.alert_triggers vs m_alertTriggers — clear ownership
  • Fearless refactoring: Changing GpuDiagState can’t accidentally affect health-monitor processing
  • No method soup: Functions that only need health-monitor state take &mut HealthMonitorState, not the entire framework

Case Study 5: Trait objects — when they ARE right

  • Not everything should be an enum! The diagnostic module plugin system is a genuine use case for trait objects
  • Why? Because diagnostic modules are open for extension — new modules can be added without modifying the framework
#![allow(unused)]
fn main() {
// Example: framework.rs — Vec<Box<dyn DiagModule>> is correct here
pub struct DiagFramework {
    modules: Vec<Box<dyn DiagModule>>,        // Runtime polymorphism
    pre_diag_modules: Vec<Box<dyn DiagModule>>,
    event_log_mgr: EventLogManager,
    // ...
}

impl DiagFramework {
    /// Register a diagnostic module — any type implementing DiagModule
    pub fn register_module(&mut self, module: Box<dyn DiagModule>) {
        info!("Registering module: {}", module.id());
        self.modules.push(module);
    }
}
}

When to Use Each Pattern

Use CasePatternWhy
Fixed set of variants known at compile timeenum + matchExhaustive checking, no vtable
Hardware event types (Degrade, Fatal, Boot, …)enum GpuEventKindAll variants known, performance matters
PCIe device types (GPU, NIC, Switch, …)enum PcieDeviceKindFixed set, each variant has different data
Plugin/module system (open for extension)Box<dyn Trait>New modules added without modifying framework
Test mockingBox<dyn Trait>Inject test doubles

Exercise: Think Before You Translate

Given this C++ code:

class Shape { public: virtual double area() = 0; };
class Circle : public Shape { double r; double area() override { return 3.14*r*r; } };
class Rect : public Shape { double w, h; double area() override { return w*h; } };
std::vector<std::unique_ptr<Shape>> shapes;

Question: Should the Rust translation use enum Shape or Vec<Box<dyn Shape>>?

Solution (click to expand)

Answer: enum Shape — because the set of shapes is closed (known at compile time). You’d only use Box<dyn Shape> if users could add new shape types at runtime.

// Correct Rust translation:
enum Shape {
    Circle { r: f64 },
    Rect { w: f64, h: f64 },
}

impl Shape {
    fn area(&self) -> f64 {
        match self {
            Shape::Circle { r } => std::f64::consts::PI * r * r,
            Shape::Rect { w, h } => w * h,
        }
    }
}

fn main() {
    let shapes: Vec<Shape> = vec![
        Shape::Circle { r: 5.0 },
        Shape::Rect { w: 3.0, h: 4.0 },
    ];
    for shape in &shapes {
        println!("Area: {:.2}", shape.area());
    }
}
// Output:
// Area: 78.54
// Area: 12.00

Translation metrics and lessons learned

What We Learned

  1. Default to enum dispatch — In ~100K lines of C++, only ~25 uses of Box<dyn Trait> were genuinely needed (plugin systems, test mocks). The other ~900 virtual methods became enums with match
  2. Arena pattern eliminates reference cycles — shared_ptr and enable_shared_from_this are symptoms of unclear ownership. Think about who owns the data first
  3. Pass context, don’t store pointers — Lifetime-bounded DiagContext<'a> is safer and clearer than storing Framework* in every module
  4. Decompose god objects — If a struct has 30+ fields, it’s probably 3-4 structs wearing a trenchcoat
  5. The compiler is your pair programmer — ~400 dynamic_cast calls meant ~400 potential runtime failures. Zero dynamic_cast equivalents in Rust means zero runtime type errors

The Hardest Parts

  • Lifetime annotations: Getting borrows right takes time when you’re used to raw pointers — but once it compiles, it’s correct
  • Fighting the borrow checker: Wanting &mut self in two places at once. Solution: decompose state into separate structs
  • Resisting literal translation: The temptation to write Vec<Box<dyn Base>> everywhere. Ask: “Is this set of variants closed?” → If yes, use enum

Recommendation for C++ Teams

  1. Start with a small, self-contained module (not the god object)
  2. Translate data structures first, then behavior
  3. Let the compiler guide you — its error messages are excellent
  4. Reach for enum before dyn Trait
  5. Use the Rust playground to prototype patterns before integrating

Case Study Overview: C++ to Rust Translation

What you’ll learn: Lessons from a real-world translation of ~100K lines of C++ to ~90K lines of Rust across ~20 crates. Five key transformation patterns and the architectural decisions behind them.

  • We translated a large C++ diagnostic system (~100K lines of C++) into a Rust implementation (~20 Rust crates, ~90K lines)
  • This section shows the actual patterns used — not toy examples, but real production code
  • The five key transformations:
#C++ PatternRust PatternImpact
1Class hierarchy + dynamic_castEnum dispatch + match~400 → 0 dynamic_casts
2shared_ptr / enable_shared_from_this treeArena + index linkageNo reference cycles
3Framework* raw pointer in every moduleDiagContext<'a> with lifetime borrowingCompile-time validity
4God objectComposable state structsTestable, modular
5vector<unique_ptr<Base>> everywhereTrait objects only where needed (~25 uses)Static dispatch default

Before and After Metrics

MetricC++ (Original)Rust (Rewrite)
dynamic_cast / type downcasts~4000
virtual / override methods~900~25 (Box<dyn Trait>)
Raw new allocations~2000 (all owned types)
shared_ptr / reference counting~10 (topology lib)0 (Arc only at FFI boundary)
enum class definitions~60~190 pub enum
Pattern matching expressionsN/A~750 match
God objects (>5K lines)20

Case Study 1: Inheritance hierarchy → Enum dispatch

The C++ Pattern: Event Class Hierarchy

// C++ original: Every GPU event type is a class inheriting from GpuEventBase
class GpuEventBase {
public:
    virtual ~GpuEventBase() = default;
    virtual void Process(DiagFramework* fw) = 0;
    uint16_t m_recordId;
    uint8_t  m_sensorType;
    // ... common fields
};

class GpuPcieDegradeEvent : public GpuEventBase {
public:
    void Process(DiagFramework* fw) override;
    uint8_t m_linkSpeed;
    uint8_t m_linkWidth;
};

class GpuPcieFatalEvent : public GpuEventBase { /* ... */ };
class GpuBootEvent : public GpuEventBase { /* ... */ };
// ... 10+ event classes inheriting from GpuEventBase

// Processing requires dynamic_cast:
void ProcessEvents(std::vector<std::unique_ptr<GpuEventBase>>& events,
                   DiagFramework* fw) {
    for (auto& event : events) {
        if (auto* degrade = dynamic_cast<GpuPcieDegradeEvent*>(event.get())) {
            // handle degrade...
        } else if (auto* fatal = dynamic_cast<GpuPcieFatalEvent*>(event.get())) {
            // handle fatal...
        }
        // ... 10 more branches
    }
}

The Rust Solution: Enum Dispatch

#![allow(unused)]
fn main() {
// Example: types.rs — No inheritance, no vtable, no dynamic_cast
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum GpuEventKind {
    PcieDegrade,
    PcieFatal,
    PcieUncorr,
    Boot,
    BaseboardState,
    EccError,
    OverTemp,
    PowerRail,
    ErotStatus,
    Unknown,
}
}
#![allow(unused)]
fn main() {
// Example: manager.rs — Separate typed Vecs, no downcasting needed
pub struct GpuEventManager {
    sku: SkuVariant,
    degrade_events: Vec<GpuPcieDegradeEvent>,   // Concrete type, not Box<dyn>
    fatal_events: Vec<GpuPcieFatalEvent>,
    uncorr_events: Vec<GpuPcieUncorrEvent>,
    boot_events: Vec<GpuBootEvent>,
    baseboard_events: Vec<GpuBaseboardEvent>,
    ecc_events: Vec<GpuEccEvent>,
    // ... each event type gets its own Vec
}

// Accessors return typed slices — zero ambiguity
impl GpuEventManager {
    pub fn degrade_events(&self) -> &[GpuPcieDegradeEvent] {
        &self.degrade_events
    }
    pub fn fatal_events(&self) -> &[GpuPcieFatalEvent] {
        &self.fatal_events
    }
}
}

Why Not Vec<Box<dyn GpuEvent>>?

  • The Wrong Approach (literal translation): Put all events in one heterogeneous collection, then downcast — this is what C++ does with vector<unique_ptr<Base>>
  • The Right Approach: Separate typed Vecs eliminate all downcasting. Each consumer asks for exactly the event type it needs
  • Performance: Separate Vecs give better cache locality (all degrade events are contiguous in memory)

Case Study 2: shared_ptr tree → Arena/index pattern

The C++ Pattern: Reference-Counted Tree

// C++ topology library: PcieDevice uses enable_shared_from_this 
// because parent and child nodes both need to reference each other
class PcieDevice : public std::enable_shared_from_this<PcieDevice> {
public:
    std::shared_ptr<PcieDevice> m_upstream;
    std::vector<std::shared_ptr<PcieDevice>> m_downstream;
    // ... device data
    
    void AddChild(std::shared_ptr<PcieDevice> child) {
        child->m_upstream = shared_from_this();  // Parent ↔ child cycle!
        m_downstream.push_back(child);
    }
};
// Problem: parent→child and child→parent create reference cycles
// Need weak_ptr to break cycles, but easy to forget

The Rust Solution: Arena with Index Linkage

#![allow(unused)]
fn main() {
// Example: components.rs — Flat Vec owns all devices
pub struct PcieDevice {
    pub base: PcieDeviceBase,
    pub kind: PcieDeviceKind,

    // Tree linkage via indices — no reference counting, no cycles
    pub upstream_idx: Option<usize>,      // Index into the arena Vec
    pub downstream_idxs: Vec<usize>,      // Indices into the arena Vec
}

// The "arena" is simply a Vec<PcieDevice> owned by the tree:
pub struct DeviceTree {
    devices: Vec<PcieDevice>,  // Flat ownership — one Vec owns everything
}

impl DeviceTree {
    pub fn parent(&self, device_idx: usize) -> Option<&PcieDevice> {
        self.devices[device_idx].upstream_idx
            .map(|idx| &self.devices[idx])
    }
    
    pub fn children(&self, device_idx: usize) -> Vec<&PcieDevice> {
        self.devices[device_idx].downstream_idxs
            .iter()
            .map(|&idx| &self.devices[idx])
            .collect()
    }
}
}

Key Insight

  • No shared_ptr, no weak_ptr, no enable_shared_from_this
  • No reference cycles possible — indices are just usize values
  • Better cache performance — all devices in contiguous memory
  • Simpler reasoning — one owner (the Vec), many viewers (indices)
graph LR
    subgraph "C++ shared_ptr Tree"
        A1["shared_ptr<Device>"] -->|"shared_ptr"| B1["shared_ptr<Device>"]
        B1 -->|"shared_ptr (parent)"| A1
        A1 -->|"shared_ptr"| C1["shared_ptr<Device>"]
        C1 -->|"shared_ptr (parent)"| A1
        style A1 fill:#ff6b6b,color:#000
        style B1 fill:#ffa07a,color:#000
        style C1 fill:#ffa07a,color:#000
    end

    subgraph "Rust Arena + Index"
        V["Vec<PcieDevice>"]
        V --> D0["[0] Root<br/>upstream: None<br/>down: [1,2]"]
        V --> D1["[1] Child<br/>upstream: Some(0)<br/>down: []"]
        V --> D2["[2] Child<br/>upstream: Some(0)<br/>down: []"]
        style V fill:#51cf66,color:#000
        style D0 fill:#91e5a3,color:#000
        style D1 fill:#91e5a3,color:#000
        style D2 fill:#91e5a3,color:#000
    end

Avoiding excessive clone()

What you’ll learn: Why .clone() is a code smell in Rust, how to restructure ownership to eliminate unnecessary copies, and the specific patterns that signal an ownership design problem.

  • Coming from C++, .clone() feels like a safe default — “just copy it”. But excessive cloning hides ownership problems and hurts performance.
  • Rule of thumb: If you’re cloning to satisfy the borrow checker, you probably need to restructure ownership instead.

When clone() is wrong

#![allow(unused)]
fn main() {
// BAD: Cloning a String just to pass it to a function that only reads it
fn log_message(msg: String) {  // Takes ownership unnecessarily
    println!("[LOG] {}", msg);
}
let message = String::from("GPU test passed");
log_message(message.clone());  // Wasteful: allocates a whole new String
log_message(message);           // Original consumed — clone was pointless
}
#![allow(unused)]
fn main() {
// GOOD: Accept a borrow — zero allocation
fn log_message(msg: &str) {    // Borrows, doesn't own
    println!("[LOG] {}", msg);
}
let message = String::from("GPU test passed");
log_message(&message);          // No clone, no allocation
log_message(&message);          // Can call again — message not consumed
}

Real example: returning &str instead of cloning

#![allow(unused)]
fn main() {
// Example: healthcheck.rs — returns a borrowed view, zero allocation
pub fn serial_or_unknown(&self) -> &str {
    self.serial.as_deref().unwrap_or(UNKNOWN_VALUE)
}

pub fn model_or_unknown(&self) -> &str {
    self.model.as_deref().unwrap_or(UNKNOWN_VALUE)
}
}

The C++ equivalent would return const std::string& or std::string_view — but in C++ neither is lifetime-checked. In Rust, the borrow checker guarantees the returned &str can’t outlive self.

Real example: static string slices — no heap at all

#![allow(unused)]
fn main() {
// Example: healthcheck.rs — compile-time string tables
const HBM_SCREEN_RECIPES: &[&str] = &[
    "hbm_ds_ntd", "hbm_ds_ntd_gfx", "hbm_dt_ntd", "hbm_dt_ntd_gfx",
    "hbm_burnin_8h", "hbm_burnin_24h",
];
}

In C++ this would typically be std::vector<std::string> (heap-allocated on first use). Rust’s &'static [&'static str] lives in read-only memory — zero runtime cost.

When clone() IS appropriate

SituationWhy clone is OKExample
Arc::clone() for threadingBumps ref count (~1 ns), doesn’t copy datalet flag = stop_flag.clone();
Moving data into a spawned threadThread needs its own copylet ctx = ctx.clone(); thread::spawn(move || { ... })
Extracting from &self fieldsCan’t move out of a borrowself.name.clone() when returning owned String
Small Copy types wrapped in Option.copied() is clearer than .clone()opt.get(0).copied() for Option<&u32> → Option<u32>

Real example: Arc::clone for thread sharing

#![allow(unused)]
fn main() {
// Example: workload.rs — Arc::clone is cheap (ref count bump)
let stop_flag = Arc::new(AtomicBool::new(false));
let stop_flag_clone = stop_flag.clone();   // ~1 ns, no data copied
let ctx_clone = ctx.clone();               // Clone context for move into thread

let sensor_handle = thread::spawn(move || {
    // ...uses stop_flag_clone and ctx_clone
});
}

Checklist: Should I clone?

  1. Can I accept &str / &T instead of String / T? → Borrow, don’t clone
  2. Can I restructure to avoid needing two owners? → Pass by reference or use scopes
  3. Is this Arc::clone()? → That’s fine, it’s O(1)
  4. Am I moving data into a thread/closure? → Clone is necessary
  5. Am I cloning in a hot loop? → Profile and consider borrowing or Cow<T>

Cow<'a, T>: Clone-on-Write — borrow when you can, clone when you must

Cow (Clone on Write) is an enum that holds either a borrowed reference or an owned value. It’s the Rust equivalent of “avoid allocation when possible, but allocate if you need to modify.” C++ has no direct equivalent — the closest is a function that returns const std::string& sometimes and std::string other times.

Why Cow exists

#![allow(unused)]
fn main() {
// Without Cow — you must choose: always borrow OR always clone
fn normalize(s: &str) -> String {          // Always allocates!
    if s.contains(' ') {
        s.replace(' ', "_")               // New String (allocation needed)
    } else {
        s.to_string()                     // Unnecessary allocation!
    }
}

// With Cow — borrow when unchanged, allocate only when modified
use std::borrow::Cow;

fn normalize(s: &str) -> Cow<'_, str> {
    if s.contains(' ') {
        Cow::Owned(s.replace(' ', "_"))    // Allocates (must modify)
    } else {
        Cow::Borrowed(s)                   // Zero allocation (passthrough)
    }
}
}

How Cow works

use std::borrow::Cow;

// Cow<'a, str> is essentially:
// enum Cow<'a, str> {
//     Borrowed(&'a str),     // Zero-cost reference
//     Owned(String),          // Heap-allocated owned value
// }

fn greet(name: &str) -> Cow<'_, str> {
    if name.is_empty() {
        Cow::Borrowed("stranger")         // Static string — no allocation
    } else if name.starts_with(' ') {
        Cow::Owned(name.trim().to_string()) // Modified — allocation needed
    } else {
        Cow::Borrowed(name)               // Passthrough — no allocation
    }
}

fn main() {
    let g1 = greet("Alice");     // Cow::Borrowed("Alice")
    let g2 = greet("");          // Cow::Borrowed("stranger")
    let g3 = greet(" Bob ");     // Cow::Owned("Bob")
    
    // Cow<str> implements Deref<Target = str>, so you can use it as &str:
    println!("Hello, {g1}!");    // Works — Cow auto-derefs to &str
    println!("Hello, {g2}!");
    println!("Hello, {g3}!");
}

Real-world use case: config value normalization

use std::borrow::Cow;

/// Normalize a SKU name: trim whitespace, lowercase.
/// Returns Cow::Borrowed if already normalized (zero allocation).
fn normalize_sku(sku: &str) -> Cow<'_, str> {
    let trimmed = sku.trim();
    if trimmed == sku && sku.chars().all(|c| c.is_lowercase() || !c.is_alphabetic()) {
        Cow::Borrowed(sku)   // Already normalized — no allocation
    } else {
        Cow::Owned(trimmed.to_lowercase())  // Needs modification — allocate
    }
}

fn main() {
    let s1 = normalize_sku("server-x1");   // Borrowed — zero alloc
    let s2 = normalize_sku("  Server-X1 "); // Owned — must allocate
    println!("{s1}, {s2}"); // "server-x1, server-x1"
}

When to use Cow

SituationUse Cow?
Function returns input unchanged most of the time✅ Yes — avoid unnecessary clones
Parsing/normalizing strings (trim, lowercase, replace)✅ Yes — often input is already valid
Always modifying — every code path allocates❌ No — just return String
Simple pass-through (never modifies)❌ No — just return &str
Data stored in a struct long-term❌ No — use String (owned)

C++ comparison: Cow<str> is like a function that returns std::variant<std::string_view, std::string> — except with automatic deref and no boilerplate to access the value.


Weak<T>: Breaking Reference Cycles — Rust’s weak_ptr

Weak<T> is the Rust equivalent of C++ std::weak_ptr<T>. It holds a non-owning reference to an Rc<T> or Arc<T> value. The value can be deallocated while Weak references still exist — calling upgrade() returns None if the value is gone.

Why Weak exists

Rc<T> and Arc<T> create reference cycles if two values point to each other — neither ever reaches refcount 0, so neither is dropped (memory leak). Weak breaks the cycle:

use std::rc::{Rc, Weak};
use std::cell::RefCell;

#[derive(Debug)]
struct Node {
    value: String,
    parent: RefCell<Weak<Node>>,      // Weak — doesn't prevent parent from dropping
    children: RefCell<Vec<Rc<Node>>>,  // Strong — parent owns children
}

impl Node {
    fn new(value: &str) -> Rc<Node> {
        Rc::new(Node {
            value: value.to_string(),
            parent: RefCell::new(Weak::new()),
            children: RefCell::new(Vec::new()),
        })
    }

    fn add_child(parent: &Rc<Node>, child: &Rc<Node>) {
        // Child gets a weak reference to parent (no cycle)
        *child.parent.borrow_mut() = Rc::downgrade(parent);
        // Parent gets a strong reference to child
        parent.children.borrow_mut().push(Rc::clone(child));
    }
}

fn main() {
    let root = Node::new("root");
    let child = Node::new("child");
    Node::add_child(&root, &child);

    // Access parent from child via upgrade()
    if let Some(parent) = child.parent.borrow().upgrade() {
        println!("Child's parent: {}", parent.value); // "root"
    }
    
    println!("Root strong count: {}", Rc::strong_count(&root));  // 1
    println!("Root weak count: {}", Rc::weak_count(&root));      // 1
}

C++ comparison

// C++ — weak_ptr to break shared_ptr cycle
struct Node {
    std::string value;
    std::weak_ptr<Node> parent;                  // Weak — no ownership
    std::vector<std::shared_ptr<Node>> children;  // Strong — owns children

    static auto create(const std::string& v) {
        return std::make_shared<Node>(Node{v, {}, {}});
    }
};

auto root = Node::create("root");
auto child = Node::create("child");
child->parent = root;          // weak_ptr assignment
root->children.push_back(child);

if (auto p = child->parent.lock()) {   // lock() → shared_ptr or null
    std::cout << "Parent: " << p->value << std::endl;
}
C++RustNotes
shared_ptr<T>Rc<T> (single-thread) / Arc<T> (multi-thread)Same semantics
weak_ptr<T>Weak<T> from Rc::downgrade() / Arc::downgrade()Same semantics
weak_ptr::lock() → shared_ptr or nullWeak::upgrade() → Option<Rc<T>>None if dropped
shared_ptr::use_count()Rc::strong_count()Same meaning

When to use Weak

SituationPattern
Parent ↔ child tree relationshipsParent holds Rc<Child>, child holds Weak<Parent>
Observer pattern / event listenersEvent source holds Weak<Observer>, observer holds Rc<Source>
Cache that doesn’t prevent deallocationHashMap<Key, Weak<Value>> — entries go stale naturally
Breaking cycles in graph structuresCross-links use Weak, tree edges use Rc/Arc

Prefer the arena pattern (Case Study 2) over Rc/Weak for tree structures in new code. Vec<T> + indices is simpler, faster, and has zero reference-counting overhead. Use Rc/Weak when you need shared ownership with dynamic lifetimes.


Copy vs Clone, PartialEq vs Eq — when to derive what

  • Copy ≈ C++ trivially copyable (no custom copy ctor/dtor). Types like int, enum, and simple POD structs — the compiler generates a bitwise memcpy automatically. In Rust, Copy is the same idea: assignment let b = a; does an implicit bitwise copy and both variables remain valid.
  • Clone ≈ C++ copy constructor / operator= deep-copy. When a C++ class has a custom copy constructor (e.g., to deep-copy a std::vector member), the equivalent in Rust is implementing Clone. You must call .clone() explicitly — Rust never hides an expensive copy behind =.
  • Key distinction: In C++, both trivial copies and deep copies happen implicitly via the same = syntax. Rust forces you to choose: Copy types copy silently (cheap), non-Copy types move by default, and you must opt in to an expensive duplicate with .clone().
  • Similarly, C++ operator== doesn’t distinguish between types where a == a always holds (like integers) and types where it doesn’t (like float with NaN). Rust encodes this in PartialEq vs Eq.

Copy vs Clone

CopyClone
How it worksBitwise memcpy (implicit)Custom logic (explicit .clone())
When it happensOn assignment: let b = a;Only when you call .clone()
After copy/cloneBoth a and b are validBoth a and b are valid
Without eitherlet b = a; moves a (a is gone)let b = a; moves a (a is gone)
Allowed forTypes with no heap dataAny type
C++ analogyTrivially copyable / POD types (no custom copy ctor)Custom copy constructor (deep copy)

Real example: Copy — simple enums

#![allow(unused)]
fn main() {
// From fan_diag/src/sensor.rs — all unit variants, fits in 1 byte
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
pub enum FanStatus {
    #[default]
    Normal,
    Low,
    High,
    Missing,
    Failed,
    Unknown,
}

let status = FanStatus::Normal;
let copy = status;   // Implicit copy — status is still valid
println!("{:?} {:?}", status, copy);  // Both work
}

Real example: Copy — enum with integer payloads

#![allow(unused)]
fn main() {
// Example: healthcheck.rs — u32 payloads are Copy, so the whole enum is too
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum HealthcheckStatus {
    Pass,
    ProgramError(u32),
    DmesgError(u32),
    RasError(u32),
    OtherError(u32),
    Unknown,
}
}

Real example: Clone only — struct with heap data

#![allow(unused)]
fn main() {
// Example: components.rs — String prevents Copy
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct FruData {
    pub technology: DeviceTechnology,
    pub physical_location: String,      // ← String: heap-allocated, can't Copy
    pub expected: bool,
    pub removable: bool,
}
// let a = fru_data;   → MOVES (a is gone)
// let a = fru_data.clone();  → CLONES (fru_data still valid, new heap allocation)
}

The rule: Can it be Copy?

Does the type contain String, Vec, Box, HashMap,
Rc, Arc, or any other heap-owning type?
    YES → Clone only (cannot be Copy)
    NO  → You CAN derive Copy (and should, if the type is small)

PartialEq vs Eq

PartialEqEq
What it gives you== and != operatorsMarker: “equality is reflexive”
Reflexive? (a == a)Not guaranteedGuaranteed
Why it mattersf32::NAN != f32::NANHashMap keys require Eq
When to deriveAlmost alwaysWhen the type has no f32/f64 fields
C++ analogyoperator==No direct equivalent (C++ doesn’t check)

Real example: Eq — used as HashMap key

#![allow(unused)]
fn main() {
// From hms_trap/src/cpu_handler.rs — Hash requires Eq
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum CpuFaultType {
    InvalidFaultType,
    CpuCperFatalErr,
    CpuLpddr5UceErr,
    CpuC2CUceFatalErr,
    // ...
}
// Used as: HashMap<CpuFaultType, FaultHandler>
// HashMap keys must be Eq + Hash — PartialEq alone won't compile
}

Real example: No Eq possible — type contains f32

#![allow(unused)]
fn main() {
// Example: types.rs — f32 prevents Eq
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TemperatureSensors {
    pub warning_threshold: Option<f32>,   // ← f32 has NaN ≠ NaN
    pub critical_threshold: Option<f32>,  // ← can't derive Eq
    pub sensor_names: Vec<String>,
}
// Cannot be used as HashMap key. Cannot derive Eq.
// Because: f32::NAN == f32::NAN is false, violating reflexivity.
}

PartialOrd vs Ord

PartialOrdOrd
What it gives you<, >, <=, >=.sort(), BTreeMap keys
Total ordering?No (some pairs may be incomparable)Yes (every pair is comparable)
f32/f64?PartialOrd only (NaN breaks ordering)Cannot derive Ord

Real example: Ord — severity ranking

#![allow(unused)]
fn main() {
// From hms_trap/src/fault.rs — variant order defines severity
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum FaultSeverity {
    Info,      // lowest  (discriminant 0)
    Warning,   //         (discriminant 1)
    Error,     //         (discriminant 2)
    Critical,  // highest (discriminant 3)
}
// FaultSeverity::Info < FaultSeverity::Critical → true
// Enables: if severity >= FaultSeverity::Error { escalate(); }
}

Real example: Ord — diagnostic levels for comparison

#![allow(unused)]
fn main() {
// Example: orchestration.rs
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default)]
pub enum GpuDiagLevel {
    #[default]
    Quick,     // lowest
    Standard,
    Extended,
    Full,      // highest
}
// Enables: if requested_level >= GpuDiagLevel::Extended { run_extended_tests(); }
}

Derive decision tree

                        Your new type
                            │
                   Contains String/Vec/Box?
                      /              \
                    YES                NO
                     │                  │
              Clone only          Clone + Copy
                     │                  │
              Contains f32/f64?    Contains f32/f64?
                /          \         /          \
              YES           NO     YES           NO
               │             │      │             │
         PartialEq       PartialEq  PartialEq  PartialEq
         only            + Eq       only       + Eq
                          │                      │
                    Need sorting?           Need sorting?
                      /       \               /       \
                    YES        NO            YES        NO
                     │          │              │          │
               PartialOrd    Done        PartialOrd    Done
               + Ord                     + Ord
                     │                        │
               Need as                  Need as
               map key?                 map key?
                  │                        │
                + Hash                   + Hash

Quick reference: common derive combos from production Rust code

Type categoryTypical deriveExample
Simple status enumCopy, Clone, PartialEq, Eq, DefaultFanStatus
Enum used as HashMap keyCopy, Clone, PartialEq, Eq, HashCpuFaultType, SelComponent
Sortable severity enumCopy, Clone, PartialEq, Eq, PartialOrd, OrdFaultSeverity, GpuDiagLevel
Data struct with StringsClone, Debug, Serialize, DeserializeFruData, OverallSummary
Serializable configClone, Debug, Default, Serialize, DeserializeDiagConfig

Avoiding unchecked indexing

What you’ll learn: Why vec[i] is dangerous in Rust (panics on out-of-bounds), and safe alternatives like .get(), iterators, and entry() API for HashMap. Replaces C++’s undefined behavior with explicit handling.

  • In C++, vec[i] and map[key] have undefined behavior / auto-insert on missing keys. Rust’s [] panics on out-of-bounds.
  • Rule: Use .get() instead of [] unless you can prove the index is valid.

C++ → Rust comparison

// C++ — silent UB or insertion
std::vector<int> v = {1, 2, 3};
int x = v[10];        // UB! No bounds check with operator[]

std::map<std::string, int> m;
int y = m["missing"]; // Silently inserts key with value 0!
#![allow(unused)]
fn main() {
// Rust — safe alternatives
let v = vec![1, 2, 3];

// Bad: panics if index out of bounds
// let x = v[10];

// Good: returns Option<&i32>
let x = v.get(10);              // None — no panic
let x = v.get(1).copied().unwrap_or(0);  // 2, or 0 if missing
}

Real example: safe byte parsing from production Rust code

#![allow(unused)]
fn main() {
// Example: diagnostics.rs
// Parsing a binary SEL record — buffer might be shorter than expected
let sensor_num = bytes.get(7).copied().unwrap_or(0);
let ppin = cpu_ppin.get(i).map(|s| s.as_str()).unwrap_or("");
}

Real example: chained safe lookups with .and_then()

#![allow(unused)]
fn main() {
// Example: profile.rs — double lookup: HashMap → Vec
pub fn get_processor(&self, location: &str) -> Option<&Processor> {
    self.processor_by_location
        .get(location)                              // HashMap → Option<&usize>
        .and_then(|&idx| self.processors.get(idx))   // Vec → Option<&Processor>
}
// Both lookups return Option — no panics, no UB
}

Real example: safe JSON navigation

#![allow(unused)]
fn main() {
// Example: framework.rs — every JSON key returns Option
let manufacturer = product_fru
    .get("Manufacturer")            // Option<&Value>
    .and_then(|v| v.as_str())       // Option<&str>
    .unwrap_or(UNKNOWN_VALUE)       // &str (safe fallback)
    .to_string();
}

Compare to the C++ pattern: json["SystemInfo"]["ProductFru"]["Manufacturer"] — any missing key throws nlohmann::json::out_of_range.

When [] is acceptable

  • After a bounds check: if i < v.len() { v[i] }
  • In tests: Where panicking is the desired behavior
  • With constants: let first = v[0]; right after assert!(!v.is_empty());

Safe value extraction with unwrap_or

  • unwrap() panics on None / Err. In production code, prefer the safe alternatives.

The unwrap family

MethodBehavior on None/ErrUse When
.unwrap()PanicsTests only, or provably infallible
.expect("msg")Panics with messageWhen panic is justified, explain why
.unwrap_or(default)Returns defaultYou have a cheap constant fallback
.unwrap_or_else(|| expr)Calls closureFallback is expensive to compute
.unwrap_or_default()Returns Default::default()Type implements Default

Real example: parsing with safe defaults

#![allow(unused)]
fn main() {
// Example: peripherals.rs
// Regex capture groups might not match — provide safe fallbacks
let bus_hex = caps.get(1).map(|m| m.as_str()).unwrap_or("00");
let fw_status = caps.get(5).map(|m| m.as_str()).unwrap_or("0x0");
let bus = u8::from_str_radix(bus_hex, 16).unwrap_or(0);
}

Real example: unwrap_or_else with fallback struct

#![allow(unused)]
fn main() {
// Example: framework.rs
// Full function wraps logic in an Option-returning closure;
// if anything fails, return a default struct:
(|| -> Option<BaseboardFru> {
    let content = std::fs::read_to_string(path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
    // ... extract fields with .get()? chains
    Some(baseboard_fru)
})()
.unwrap_or_else(|| BaseboardFru {
    manufacturer: String::new(),
    model: String::new(),
    product_part_number: String::new(),
    serial_number: String::new(),
    asset_tag: String::new(),
})
}

Real example: unwrap_or_default on config deserialization

#![allow(unused)]
fn main() {
// Example: framework.rs
// If JSON config parsing fails, fall back to Default — no crash
Ok(json) => serde_json::from_str(&json).unwrap_or_default(),
}

The C++ equivalent would be a try/catch around nlohmann::json::parse() with manual default construction in the catch block.


Functional transforms: map, map_err, find_map

  • These methods on Option and Result let you transform the contained value without unwrapping, replacing nested if/else with linear chains.

Quick reference

MethodOnDoesC++ Equivalent
.map(|v| ...)Option / ResultTransform the Some/Ok valueif (opt) { *opt = transform(*opt); }
.map_err(|e| ...)ResultTransform the Err valueAdding context to catch block
.and_then(|v| ...)Option / ResultChain operations that return Option/ResultNested if-checks
.find_map(|v| ...)Iteratorfind + map in one passLoop with if + break
.filter(|v| ...)Option / IteratorKeep only values matching predicateif (!predicate) return nullopt;
.ok()?ResultConvert Result → Option and propagate Noneif (result.has_error()) return nullopt;

Real example: .and_then() chain for JSON field extraction

#![allow(unused)]
fn main() {
// Example: framework.rs — finding serial number with fallbacks
let sys_info = json.get("SystemInfo")?;

// Try BaseboardFru.BoardSerialNumber first
if let Some(serial) = sys_info
    .get("BaseboardFru")
    .and_then(|b| b.get("BoardSerialNumber"))
    .and_then(|v| v.as_str())
    .filter(valid_serial)     // Only accept non-empty, valid serials
{
    return Some(serial.to_string());
}

// Fallback to BoardFru.SerialNumber
sys_info
    .get("BoardFru")
    .and_then(|b| b.get("SerialNumber"))
    .and_then(|v| v.as_str())
    .filter(valid_serial)
    .map(|s| s.to_string())   // Convert &str → String only if Some
}

In C++ this would be a pyramid of if (json.contains("BaseboardFru")) { if (json["BaseboardFru"].contains("BoardSerialNumber")) { ... } }.

Real example: find_map — search + transform in one pass

#![allow(unused)]
fn main() {
// Example: context.rs — find SDR record matching sensor + owner
pub fn find_for_event(&self, sensor_number: u8, owner_id: u8) -> Option<&SdrRecord> {
    self.by_sensor.get(&sensor_number).and_then(|indices| {
        indices.iter().find_map(|&i| {
            let record = &self.records[i];
            if record.sensor_owner_id() == Some(owner_id) {
                Some(record)
            } else {
                None
            }
        })
    })
}
}

find_map is find + map fused: it stops at the first match and transforms it. The C++ equivalent is a for loop with an if + break.

Real example: map_err for error context

#![allow(unused)]
fn main() {
// Example: main.rs — add context to errors before propagating
let json_str = serde_json::to_string_pretty(&config)
    .map_err(|e| format!("Failed to serialize config: {}", e))?;
}

Transforms a serde_json::Error into a descriptive String error that includes context about what failed.


JSON handling: nlohmann::json → serde

  • C++ teams typically use nlohmann::json for JSON parsing. Rust uses serde + serde_json — which is more powerful because the JSON schema is encoded in the type system.

C++ (nlohmann) vs Rust (serde) comparison

// C++ with nlohmann::json — runtime field access
#include <nlohmann/json.hpp>
using json = nlohmann::json;

struct Fan {
    std::string logical_id;
    std::vector<std::string> sensor_ids;
};

Fan parse_fan(const json& j) {
    Fan f;
    f.logical_id = j.at("LogicalID").get<std::string>();    // throws if missing
    if (j.contains("SDRSensorIdHexes")) {                   // manual default handling
        f.sensor_ids = j["SDRSensorIdHexes"].get<std::vector<std::string>>();
    }
    return f;
}
#![allow(unused)]
fn main() {
// Rust with serde — compile-time schema, automatic field mapping
use serde::{Serialize, Deserialize};

#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Fan {
    pub logical_id: String,
    #[serde(rename = "SDRSensorIdHexes", default)]  // JSON key → Rust field
    pub sensor_ids: Vec<String>,                     // Missing → empty Vec
    #[serde(default)]
    pub sensor_names: Vec<String>,                   // Missing → empty Vec
}

// One line replaces the entire parse function:
let fan: Fan = serde_json::from_str(json_str)?;
}

Key serde attributes (real examples from production Rust code)

AttributePurposeC++ Equivalent
#[serde(default)]Use Default::default() for missing fieldsif (j.contains(key)) { ... } else { default; }
#[serde(rename = "Key")]Map JSON key name to Rust field nameManual j.at("Key") access
#[serde(flatten)]Absorb unknown keys into HashMapfor (auto& [k,v] : j.items()) { ... }
#[serde(skip)]Don’t serialize/deserialize this fieldNot storing in JSON
#[serde(tag = "type")]Internally tagged enum (discriminator field)if (j["type"] == "gpu") { ... }

Real example: full config struct

#![allow(unused)]
fn main() {
// Example: diag.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DiagConfig {
    pub sku: SkuConfig,
    #[serde(default)]
    pub level: DiagLevel,            // Missing → DiagLevel::default()
    #[serde(default)]
    pub modules: ModuleConfig,       // Missing → ModuleConfig::default()
    #[serde(default)]
    pub output_dir: String,          // Missing → ""
    #[serde(default, flatten)]
    pub options: HashMap<String, serde_json::Value>,  // Absorbs unknown keys
}

// Loading is 3 lines (vs ~20+ in C++ with nlohmann):
let content = std::fs::read_to_string(path)?;
let config: DiagConfig = serde_json::from_str(&content)?;
Ok(config)
}

Enum deserialization with #[serde(tag = "type")]

#![allow(unused)]
fn main() {
// Example: components.rs
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]                   // JSON: {"type": "Gpu", "product": ...}
pub enum PcieDeviceKind {
    Gpu { product: GpuProduct, manufacturer: GpuManufacturer },
    Nic { product: NicProduct, manufacturer: NicManufacturer },
    NvmeDrive { drive_type: StorageDriveType, capacity_gb: u32 },
    // ... 9 more variants
}
// serde automatically dispatches on the "type" field — no manual if/else chain
}

The C++ equivalent would be: if (j["type"] == "Gpu") { parse_gpu(j); } else if (j["type"] == "Nic") { parse_nic(j); } ...

Exercise: JSON deserialization with serde

  • Define a ServerConfig struct that can be deserialized from the following JSON:
{
    "hostname": "diag-node-01",
    "port": 8080,
    "debug": true,
    "modules": ["accel_diag", "nic_diag", "cpu_diag"]
}
  • Use #[derive(Deserialize)] and serde_json::from_str() to parse it
  • Add #[serde(default)] to debug so it defaults to false if missing
  • Bonus: Add an enum DiagLevel { Quick, Full, Extended } field with #[serde(default)] that defaults to Quick

Starter code (requires cargo add serde --features derive and cargo add serde_json):

use serde::Deserialize;

// TODO: Define DiagLevel enum with Default impl

// TODO: Define ServerConfig struct with serde attributes

fn main() {
    let json_input = r#"{
        "hostname": "diag-node-01",
        "port": 8080,
        "debug": true,
        "modules": ["accel_diag", "nic_diag", "cpu_diag"]
    }"#;

    // TODO: Deserialize and print the config
    // TODO: Try parsing JSON with "debug" field missing — verify it defaults to false
}
Solution (click to expand)
use serde::Deserialize;

#[derive(Debug, Deserialize, Default)]
enum DiagLevel {
    #[default]
    Quick,
    Full,
    Extended,
}

#[derive(Debug, Deserialize)]
struct ServerConfig {
    hostname: String,
    port: u16,
    #[serde(default)]       // defaults to false if missing
    debug: bool,
    modules: Vec<String>,
    #[serde(default)]       // defaults to DiagLevel::Quick if missing
    level: DiagLevel,
}

fn main() {
    let json_input = r#"{
        "hostname": "diag-node-01",
        "port": 8080,
        "debug": true,
        "modules": ["accel_diag", "nic_diag", "cpu_diag"]
    }"#;

    let config: ServerConfig = serde_json::from_str(json_input)
        .expect("Failed to parse JSON");
    println!("{config:#?}");

    // Test with missing optional fields
    let minimal = r#"{
        "hostname": "node-02",
        "port": 9090,
        "modules": []
    }"#;
    let config2: ServerConfig = serde_json::from_str(minimal)
        .expect("Failed to parse minimal JSON");
    println!("debug (default): {}", config2.debug);    // false
    println!("level (default): {:?}", config2.level);  // Quick
}
// Output:
// ServerConfig {
//     hostname: "diag-node-01",
//     port: 8080,
//     debug: true,
//     modules: ["accel_diag", "nic_diag", "cpu_diag"],
//     level: Quick,
// }
// debug (default): false
// level (default): Quick

Collapsing assignment pyramids with closures

What you’ll learn: How Rust’s expression-based syntax and closures flatten deeply-nested C++ if/else validation chains into clean, linear code.

  • C++ often requires multi-block if/else chains to assign variables, especially when validation or fallback logic is involved. Rust’s expression-based syntax and closures collapse these into flat, linear code.

Pattern 1: Tuple assignment with if expression

// C++ — three variables set across a multi-block if/else chain
uint32_t fault_code;
const char* der_marker;
const char* action;
if (is_c44ad) {
    fault_code = 32709; der_marker = "CSI_WARN"; action = "No action";
} else if (error.is_hardware_error()) {
    fault_code = 67956; der_marker = "CSI_ERR"; action = "Replace GPU";
} else {
    fault_code = 32709; der_marker = "CSI_WARN"; action = "No action";
}
#![allow(unused)]
fn main() {
// Rust equivalent:accel_fieldiag.rs
// Single expression assigns all three at once:
let (fault_code, der_marker, recommended_action) = if is_c44ad {
    (32709u32, "CSI_WARN", "No action")
} else if error.is_hardware_error() {
    (67956u32, "CSI_ERR", "Replace GPU")
} else {
    (32709u32, "CSI_WARN", "No action")
};
}

Pattern 2: IIFE (Immediately Invoked Function Expression) for fallible chains

// C++ — pyramid of doom for JSON navigation
std::string get_part_number(const nlohmann::json& root) {
    if (root.contains("SystemInfo")) {
        auto& sys = root["SystemInfo"];
        if (sys.contains("BaseboardFru")) {
            auto& bb = sys["BaseboardFru"];
            if (bb.contains("ProductPartNumber")) {
                return bb["ProductPartNumber"].get<std::string>();
            }
        }
    }
    return "UNKNOWN";
}
#![allow(unused)]
fn main() {
// Rust equivalent:framework.rs
// Closure + ? operator collapses the pyramid into linear code:
let part_number = (|| -> Option<String> {
    let path = self.args.sysinfo.as_ref()?;
    let content = std::fs::read_to_string(path).ok()?;
    let json: serde_json::Value = serde_json::from_str(&content).ok()?;
    let ppn = json
        .get("SystemInfo")?
        .get("BaseboardFru")?
        .get("ProductPartNumber")?
        .as_str()?;
    Some(ppn.to_string())
})()
.unwrap_or_else(|| "UNKNOWN".to_string());
}

The closure creates an Option<String> scope where ? bails early at any step. The .unwrap_or_else() provides the fallback once, at the end.

Pattern 3: Iterator chain replacing manual loop + push_back

// C++ — manual loop with intermediate variables
std::vector<std::tuple<std::vector<std::string>, std::string, std::string>> gpu_info;
for (const auto& [key, info] : gpu_pcie_map) {
    std::vector<std::string> bdfs;
    // ... parse bdf_path into bdfs
    std::string serial = info.serial_number.value_or("UNKNOWN");
    std::string model = info.model_number.value_or(model_name);
    gpu_info.push_back({bdfs, serial, model});
}
#![allow(unused)]
fn main() {
// Rust equivalent:peripherals.rs
// Single chain: values() → map → collect
let gpu_info: Vec<(Vec<String>, String, String, String)> = self
    .gpu_pcie_map
    .values()
    .map(|info| {
        let bdfs: Vec<String> = info.bdf_path
            .split(')')
            .filter(|s| !s.is_empty())
            .map(|s| s.trim_start_matches('(').to_string())
            .collect();
        let serial = info.serial_number.clone()
            .unwrap_or_else(|| "UNKNOWN".to_string());
        let model = info.model_number.clone()
            .unwrap_or_else(|| model_name.to_string());
        let gpu_bdf = format!("{}:{}:{}.{}",
            info.bdf.segment, info.bdf.bus, info.bdf.device, info.bdf.function);
        (bdfs, serial, model, gpu_bdf)
    })
    .collect();
}

Pattern 4: .filter().collect() replacing loop + if (condition) continue

// C++
std::vector<TestResult*> failures;
for (auto& t : test_results) {
    if (!t.is_pass()) {
        failures.push_back(&t);
    }
}
#![allow(unused)]
fn main() {
// Rust — from accel_diag/src/healthcheck.rs
pub fn failed_tests(&self) -> Vec<&TestResult> {
    self.test_results.iter().filter(|t| !t.is_pass()).collect()
}
}

Summary: When to use each pattern

C++ PatternRust ReplacementKey Benefit
Multi-block variable assignmentlet (a, b) = if ... { } else { };All variables bound atomically
Nested if (contains) pyramidIIFE closure with ? operatorLinear, flat, early-exit
for loop + push_back.iter().map(||).collect()No intermediate mut Vec
for + if (cond) continue.iter().filter(||).collect()Declarative intent
for + if + break (find first).iter().find_map(||)Search + transform in one pass

Capstone Exercise: Diagnostic Event Pipeline

🔴 Challenge — integrative exercise combining enums, traits, iterators, error handling, and generics

This integrative exercise brings together enums, traits, iterators, error handling, and generics. You’ll build a simplified diagnostic event processing pipeline similar to patterns used in production Rust code.

Requirements:

  1. Define an enum Severity { Info, Warning, Critical } with Display, and a struct DiagEvent containing source: String, severity: Severity, message: String, and fault_code: u32
  2. Define a trait EventFilter with a method fn should_include(&self, event: &DiagEvent) -> bool
  3. Implement two filters: SeverityFilter (only events >= a given severity) and SourceFilter (only events from a specific source string)
  4. Write a function fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> that returns formatted report lines for events that pass all filters
  5. Write a fn parse_event(line: &str) -> Result<DiagEvent, String> that parses lines of the form "source:severity:fault_code:message" (return Err for bad input)

Starter code:

use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
    Info,
    Warning,
    Critical,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        todo!()
    }
}

#[derive(Debug, Clone)]
struct DiagEvent {
    source: String,
    severity: Severity,
    message: String,
    fault_code: u32,
}

trait EventFilter {
    fn should_include(&self, event: &DiagEvent) -> bool;
}

struct SeverityFilter {
    min_severity: Severity,
}
// TODO: impl EventFilter for SeverityFilter

struct SourceFilter {
    source: String,
}
// TODO: impl EventFilter for SourceFilter

fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> {
    // TODO: Filter events that pass ALL filters, format as
    // "[SEVERITY] source (FC:fault_code): message"
    todo!()
}

fn parse_event(line: &str) -> Result<DiagEvent, String> {
    // Parse "source:severity:fault_code:message"
    // Return Err for invalid input
    todo!()
}

fn main() {
    let raw_lines = vec![
        "accel_diag:Critical:67956:ECC uncorrectable error detected",
        "nic_diag:Warning:32709:Link speed degraded",
        "accel_diag:Info:10001:Self-test passed",
        "cpu_diag:Critical:55012:Thermal throttling active",
        "accel_diag:Warning:32710:PCIe link width reduced",
    ];

    // Parse all lines, collect successes and report errors
    let events: Vec<DiagEvent> = raw_lines.iter()
        .filter_map(|line| match parse_event(line) {
            Ok(e) => Some(e),
            Err(e) => { eprintln!("Parse error: {e}"); None }
        })
        .collect();

    // Apply filters: only Critical+Warning events from accel_diag
    let sev_filter = SeverityFilter { min_severity: Severity::Warning };
    let src_filter = SourceFilter { source: "accel_diag".to_string() };
    let filters: Vec<&dyn EventFilter> = vec![&sev_filter, &src_filter];

    let report = process_events(&events, &filters);
    for line in &report {
        println!("{line}");
    }
    println!("--- {} event(s) matched ---", report.len());
}
Solution (click to expand)
use std::fmt;

#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
enum Severity {
    Info,
    Warning,
    Critical,
}

impl fmt::Display for Severity {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Severity::Info => write!(f, "INFO"),
            Severity::Warning => write!(f, "WARNING"),
            Severity::Critical => write!(f, "CRITICAL"),
        }
    }
}

impl Severity {
    fn from_str(s: &str) -> Result<Self, String> {
        match s {
            "Info" => Ok(Severity::Info),
            "Warning" => Ok(Severity::Warning),
            "Critical" => Ok(Severity::Critical),
            other => Err(format!("Unknown severity: {other}")),
        }
    }
}

#[derive(Debug, Clone)]
struct DiagEvent {
    source: String,
    severity: Severity,
    message: String,
    fault_code: u32,
}

trait EventFilter {
    fn should_include(&self, event: &DiagEvent) -> bool;
}

struct SeverityFilter {
    min_severity: Severity,
}

impl EventFilter for SeverityFilter {
    fn should_include(&self, event: &DiagEvent) -> bool {
        event.severity >= self.min_severity
    }
}

struct SourceFilter {
    source: String,
}

impl EventFilter for SourceFilter {
    fn should_include(&self, event: &DiagEvent) -> bool {
        event.source == self.source
    }
}

fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String> {
    events.iter()
        .filter(|e| filters.iter().all(|f| f.should_include(e)))
        .map(|e| format!("[{}] {} (FC:{}): {}", e.severity, e.source, e.fault_code, e.message))
        .collect()
}

fn parse_event(line: &str) -> Result<DiagEvent, String> {
    let parts: Vec<&str> = line.splitn(4, ':').collect();
    if parts.len() != 4 {
        return Err(format!("Expected 4 colon-separated fields, got {}", parts.len()));
    }
    let fault_code = parts[2].parse::<u32>()
        .map_err(|e| format!("Invalid fault code '{}': {e}", parts[2]))?;
    Ok(DiagEvent {
        source: parts[0].to_string(),
        severity: Severity::from_str(parts[1])?,
        fault_code,
        message: parts[3].to_string(),
    })
}

fn main() {
    let raw_lines = vec![
        "accel_diag:Critical:67956:ECC uncorrectable error detected",
        "nic_diag:Warning:32709:Link speed degraded",
        "accel_diag:Info:10001:Self-test passed",
        "cpu_diag:Critical:55012:Thermal throttling active",
        "accel_diag:Warning:32710:PCIe link width reduced",
    ];

    let events: Vec<DiagEvent> = raw_lines.iter()
        .filter_map(|line| match parse_event(line) {
            Ok(e) => Some(e),
            Err(e) => { eprintln!("Parse error: {e}"); None }
        })
        .collect();

    let sev_filter = SeverityFilter { min_severity: Severity::Warning };
    let src_filter = SourceFilter { source: "accel_diag".to_string() };
    let filters: Vec<&dyn EventFilter> = vec![&sev_filter, &src_filter];

    let report = process_events(&events, &filters);
    for line in &report {
        println!("{line}");
    }
    println!("--- {} event(s) matched ---", report.len());
}
// Output:
// [CRITICAL] accel_diag (FC:67956): ECC uncorrectable error detected
// [WARNING] accel_diag (FC:32710): PCIe link width reduced
// --- 2 event(s) matched ---

Logging and Tracing: syslog/printf → log + tracing

What you’ll learn: Rust’s two-layer logging architecture (facade + backend), the log and tracing crates, structured logging with spans, and how this replaces printf/syslog debugging.

C++ diagnostic code typically uses printf, syslog, or custom logging frameworks. Rust has a standardized two-layer logging architecture: a facade crate (log or tracing) and a backend (the actual logger implementation).

The log facade — Rust’s universal logging API

The log crate provides macros that mirror syslog severity levels. Libraries use log macros; binaries choose a backend:

// Cargo.toml
// [dependencies]
// log = "0.4"
// env_logger = "0.11"    # One of many backends

use log::{info, warn, error, debug, trace};

fn check_sensor(id: u32, temp: f64) {
    trace!("Reading sensor {id}");           // Finest granularity
    debug!("Sensor {id} raw value: {temp}"); // Development-time detail

    if temp > 85.0 {
        warn!("Sensor {id} high temperature: {temp}°C");
    }
    if temp > 95.0 {
        error!("Sensor {id} CRITICAL: {temp}°C — initiating shutdown");
    }
    info!("Sensor {id} check complete");     // Normal operation
}

fn main() {
    // Initialize the backend — typically done once in main()
    env_logger::init();  // Controlled by RUST_LOG env var

    check_sensor(0, 72.5);
    check_sensor(1, 91.0);
}
# Control log level via environment variable
RUST_LOG=debug cargo run          # Show debug and above
RUST_LOG=warn cargo run           # Show only warn and error
RUST_LOG=my_crate=trace cargo run # Per-module filtering
RUST_LOG=my_crate::gpu=debug,warn cargo run  # Mix levels

C++ comparison

C++Rust (log)Notes
printf("DEBUG: %s\n", msg)debug!("{msg}")Format checked at compile time
syslog(LOG_ERR, "...")error!("...")Backend decides where output goes
#ifdef DEBUG around log callstrace! / debug! compiled out at max_levelZero-cost when disabled
Custom Logger::log(level, msg)log::info!("...") — all crates use same APIUniversal facade, swappable backend
Per-file log verbosityRUST_LOG=crate::module=levelEnvironment-based, no recompile

The tracing crate — structured logging with spans

tracing extends log with structured fields and spans (timed scopes). This is especially useful for diagnostics code where you want to track context:

// Cargo.toml
// [dependencies]
// tracing = "0.1"
// tracing-subscriber = { version = "0.3", features = ["env-filter"] }

use tracing::{info, warn, error, instrument, info_span};

#[instrument(skip(data), fields(gpu_id = gpu_id, data_len = data.len()))]
fn run_gpu_test(gpu_id: u32, data: &[u8]) -> Result<(), String> {
    info!("Starting GPU test");

    let span = info_span!("ecc_check", gpu_id);
    let _guard = span.enter();  // All logs inside this scope include gpu_id

    if data.is_empty() {
        error!(gpu_id, "No test data provided");
        return Err("empty data".to_string());
    }

    // Structured fields — machine-parseable, not just string interpolation
    info!(
        gpu_id,
        temp_celsius = 72.5,
        ecc_errors = 0,
        "ECC check passed"
    );

    Ok(())
}

fn main() {
    // Initialize tracing subscriber
    tracing_subscriber::fmt()
        .with_env_filter("debug")  // Or use RUST_LOG env var
        .with_target(true)          // Show module path
        .with_thread_ids(true)      // Show thread IDs
        .init();

    let _ = run_gpu_test(0, &[1, 2, 3]);
}

Output with tracing-subscriber:

#![allow(unused)]
fn main() {
2026-02-15T10:30:00.123Z DEBUG ThreadId(01) run_gpu_test{gpu_id=0 data_len=3}: my_crate: Starting GPU test
2026-02-15T10:30:00.124Z  INFO ThreadId(01) run_gpu_test{gpu_id=0 data_len=3}:ecc_check{gpu_id=0}: my_crate: ECC check passed gpu_id=0 temp_celsius=72.5 ecc_errors=0
}

#[instrument] — automatic span creation

The #[instrument] attribute automatically creates a span with the function name and its arguments:

#![allow(unused)]
fn main() {
use tracing::instrument;

#[instrument]
fn parse_sel_record(record_id: u16, sensor_type: u8, data: &[u8]) -> Result<(), String> {
    // Every log inside this function automatically includes:
    // record_id, sensor_type, and data (if Debug)
    tracing::debug!("Parsing SEL record");
    Ok(())
}

// skip: exclude large/sensitive args from the span
// fields: add computed fields
#[instrument(skip(raw_buffer), fields(buf_len = raw_buffer.len()))]
fn decode_ipmi_response(raw_buffer: &[u8]) -> Result<Vec<u8>, String> {
    tracing::trace!("Decoding {} bytes", raw_buffer.len());
    Ok(raw_buffer.to_vec())
}
}

log vs tracing — which to use

Aspectlogtracing
ComplexitySimple — 5 macrosRicher — spans, fields, instruments
Structured dataString interpolation onlyKey-value fields: info!(gpu_id = 0, "msg")
Timing / spansNoYes — #[instrument], span.enter()
Async supportBasicFirst-class — spans propagate across .await
CompatibilityUniversal facadeCompatible with log (has a log bridge)
When to useSimple applications, librariesDiagnostic tools, async code, observability

Recommendation: Use tracing for production diagnostic-style projects (diagnostic tools with structured output). Use log for simple libraries where you want minimal dependencies. tracing includes a compatibility layer so libraries using log macros still work with a tracing subscriber.

Backend options

Backend CrateOutputUse Case
env_loggerstderr, coloredDevelopment, simple CLI tools
tracing-subscriberstderr, formattedProduction with tracing
syslogSystem syslogLinux system services
tracing-journaldsystemd journalsystemd-managed services
tracing-appenderRotating log filesLong-running daemons
tracing-opentelemetryOpenTelemetry collectorDistributed tracing

Rust Best Practices Summary

What you’ll learn: Practical guidelines for writing idiomatic Rust — code organization, naming conventions, error handling patterns, and documentation. A quick-reference chapter you’ll return to often.

Code Organization

  • Prefer small functions: Easy to test and reason about
  • Use descriptive names: calculate_total_price() vs calc()
  • Group related functionality: Use modules and separate files
  • Write documentation: Use /// for public APIs

Error Handling

  • Avoid unwrap() unless infallible: Only use when you’re 100% certain it won’t panic
#![allow(unused)]
fn main() {
// Bad: Can panic
let value = some_option.unwrap();

// Good: Handle the None case
let value = some_option.unwrap_or(default_value);
let value = some_option.unwrap_or_else(|| expensive_computation());
let value = some_option.unwrap_or_default(); // Uses Default trait

// For Result<T, E>
let value = some_result.unwrap_or(fallback_value);
let value = some_result.unwrap_or_else(|err| {
    eprintln!("Error occurred: {err}");
    default_value
});
}
  • Use expect() with descriptive messages: When unwrap is justified, explain why
#![allow(unused)]
fn main() {
let config = std::env::var("CONFIG_PATH")
    .expect("CONFIG_PATH environment variable must be set");
}
  • Return Result<T, E> for fallible operations: Let callers decide how to handle errors
  • Use thiserror for custom error types: More ergonomic than manual implementations
#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
pub enum MyError {
    #[error("IO error: {0}")]
    Io(#[from] std::io::Error),
    
    #[error("Parse error: {message}")]
    Parse { message: String },
    
    #[error("Value {value} is out of range")]
    OutOfRange { value: i32 },
}
}
  • Chain errors with ? operator: Propagate errors up the call stack
  • Prefer thiserror over anyhow: Our team convention is to define explicit error enums with #[derive(thiserror::Error)] so callers can match on specific variants. anyhow::Error is convenient for quick prototyping but erases the error type, making it harder for callers to handle specific failures. Use thiserror for library and production code; reserve anyhow for throwaway scripts or top-level binaries where you only need to print the error.
  • When unwrap() is acceptable:
    • Unit tests: assert_eq!(result.unwrap(), expected)
    • Prototyping: Quick and dirty code that you’ll replace
    • Infallible operations: When you can prove it won’t fail
#![allow(unused)]
fn main() {
let numbers = vec![1, 2, 3];
let first = numbers.get(0).unwrap(); // Safe: we just created the vec with elements

// Better: Use expect() with explanation
let first = numbers.get(0).expect("numbers vec is non-empty by construction");
}
  • Fail fast: Check preconditions early and return errors immediately

Memory Management

  • Prefer borrowing over cloning: Use &T instead of cloning when possible
  • Use Rc<T> sparingly: Only when you need shared ownership
  • Limit lifetimes: Use scopes {} to control when values are dropped
  • Avoid RefCell<T> in public APIs: Keep interior mutability internal

Performance

  • Profile before optimizing: Use cargo bench and profiling tools
  • Prefer iterators over loops: More readable and often faster
  • Use &str over String: When you don’t need ownership
  • Consider Box<T> for large stack objects: Move them to heap if needed

Essential Traits to Implement

Core Traits Every Type Should Consider

When creating custom types, consider implementing these fundamental traits to make your types feel native to Rust:

Debug and Display

#![allow(unused)]
fn main() {
use std::fmt;

#[derive(Debug)]  // Automatic implementation for debugging
struct Person {
    name: String,
    age: u32,
}

// Manual Display implementation for user-facing output
impl fmt::Display for Person {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        write!(f, "{} (age {})", self.name, self.age)
    }
}

// Usage:
let person = Person { name: "Alice".to_string(), age: 30 };
println!("{:?}", person);  // Debug: Person { name: "Alice", age: 30 }
println!("{}", person);    // Display: Alice (age 30)
}

Clone and Copy

#![allow(unused)]
fn main() {
// Copy: Implicit duplication for small, simple types
#[derive(Debug, Clone, Copy)]
struct Point {
    x: i32,
    y: i32,
}

// Clone: Explicit duplication for complex types
#[derive(Debug, Clone)]
struct Person {
    name: String,  // String doesn't implement Copy
    age: u32,
}

let p1 = Point { x: 1, y: 2 };
let p2 = p1;  // Copy (implicit)

let person1 = Person { name: "Bob".to_string(), age: 25 };
let person2 = person1.clone();  // Clone (explicit)
}

PartialEq and Eq

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq)]
struct UserId(u64);

#[derive(Debug, PartialEq)]
struct Temperature {
    celsius: f64,  // f64 doesn't implement Eq (due to NaN)
}

let id1 = UserId(123);
let id2 = UserId(123);
assert_eq!(id1, id2);  // Works because of PartialEq

let temp1 = Temperature { celsius: 20.0 };
let temp2 = Temperature { celsius: 20.0 };
assert_eq!(temp1, temp2);  // Works with PartialEq
}

PartialOrd and Ord

#![allow(unused)]
fn main() {
#[derive(Debug, PartialEq, Eq, PartialOrd, Ord)]
struct Priority(u8);

let high = Priority(1);
let low = Priority(10);
assert!(high < low);  // Lower numbers = higher priority

// Use in collections
let mut priorities = vec![Priority(5), Priority(1), Priority(8)];
priorities.sort();  // Works because Priority implements Ord
}

Default

#![allow(unused)]
fn main() {
#[derive(Debug, Default)]
struct Config {
    debug: bool,           // false (default)
    max_connections: u32,  // 0 (default)
    timeout: Option<u64>,  // None (default)
}

// Custom Default implementation
impl Default for Config {
    fn default() -> Self {
        Config {
            debug: false,
            max_connections: 100,  // Custom default
            timeout: Some(30),     // Custom default
        }
    }
}

let config = Config::default();
let config = Config { debug: true, ..Default::default() };  // Partial override
}

From and Into

#![allow(unused)]
fn main() {
struct UserId(u64);
struct UserName(String);

// Implement From, and Into comes for free
impl From<u64> for UserId {
    fn from(id: u64) -> Self {
        UserId(id)
    }
}

impl From<String> for UserName {
    fn from(name: String) -> Self {
        UserName(name)
    }
}

impl From<&str> for UserName {
    fn from(name: &str) -> Self {
        UserName(name.to_string())
    }
}

// Usage:
let user_id: UserId = 123u64.into();         // Using Into
let user_id = UserId::from(123u64);          // Using From
let username = UserName::from("alice");      // &str -> UserName
let username: UserName = "bob".into();       // Using Into
}

TryFrom and TryInto

#![allow(unused)]
fn main() {
use std::convert::TryFrom;

struct PositiveNumber(u32);

#[derive(Debug)]
struct NegativeNumberError;

impl TryFrom<i32> for PositiveNumber {
    type Error = NegativeNumberError;
    
    fn try_from(value: i32) -> Result<Self, Self::Error> {
        if value >= 0 {
            Ok(PositiveNumber(value as u32))
        } else {
            Err(NegativeNumberError)
        }
    }
}

// Usage:
let positive = PositiveNumber::try_from(42)?;     // Ok(PositiveNumber(42))
let error = PositiveNumber::try_from(-5);         // Err(NegativeNumberError)
}

Serde (for serialization)

#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};

#[derive(Debug, Serialize, Deserialize)]
struct User {
    id: u64,
    name: String,
    email: String,
}

// Automatic JSON serialization/deserialization
let user = User {
    id: 1,
    name: "Alice".to_string(),
    email: "[email protected]".to_string(),
};

let json = serde_json::to_string(&user)?;
let deserialized: User = serde_json::from_str(&json)?;
}

Trait Implementation Checklist

For any new type, consider this checklist:

#![allow(unused)]
fn main() {
#[derive(
    Debug,          // [OK] Always implement for debugging
    Clone,          // [OK] If the type should be duplicatable
    PartialEq,      // [OK] If the type should be comparable
    Eq,             // [OK] If comparison is reflexive/transitive
    PartialOrd,     // [OK] If the type has ordering
    Ord,            // [OK] If ordering is total
    Hash,           // [OK] If type will be used as HashMap key
    Default,        // [OK] If there's a sensible default value
)]
struct MyType {
    // fields...
}

// Manual implementations to consider:
impl Display for MyType { /* user-facing representation */ }
impl From<OtherType> for MyType { /* convenient conversion */ }
impl TryFrom<FallibleType> for MyType { /* fallible conversion */ }
}

When NOT to Implement Traits

  • Don’t implement Copy for types with heap data: String, Vec, HashMap etc.
  • Don’t implement Eq if values can be NaN: Types containing f32/f64
  • Don’t implement Default if there’s no sensible default: File handles, network connections
  • Don’t implement Clone if cloning is expensive: Large data structures (consider Rc<T> instead)

Summary: Trait Benefits

TraitBenefitWhen to Use
Debugprintln!("{:?}", value)Always (except rare cases)
Displayprintln!("{}", value)User-facing types
Clonevalue.clone()When explicit duplication makes sense
CopyImplicit duplicationSmall, simple types
PartialEq== and != operatorsMost types
EqReflexive equalityWhen equality is mathematically sound
PartialOrd<, >, <=, >=Types with natural ordering
Ordsort(), BinaryHeapWhen ordering is total
HashHashMap keysTypes used as map keys
DefaultDefault::default()Types with obvious defaults
From/IntoConvenient conversionsCommon type conversions
TryFrom/TryIntoFallible conversionsConversions that can fail


C++ → Rust Semantic Deep Dives

What you’ll learn: Detailed mappings for C++ concepts that don’t have obvious Rust equivalents — the four named casts, SFINAE vs trait bounds, CRTP vs associated types, and other common friction points during translation.

The sections below map C++ concepts that don’t have an obvious 1:1 Rust equivalent. These differences frequently trip up C++ programmers during translation work.

Casting Hierarchy: Four C++ Casts → Rust Equivalents

C++ has four named casts. Rust replaces them with different, more explicit mechanisms:

// C++ casting hierarchy
int i = static_cast<int>(3.14);            // 1. Numeric / up-cast
Derived* d = dynamic_cast<Derived*>(base); // 2. Runtime downcasting
int* p = const_cast<int*>(cp);              // 3. Cast away const
auto* raw = reinterpret_cast<char*>(&obj); // 4. Bit-level reinterpretation
C++ CastRust EquivalentSafetyNotes
static_cast (numeric)as keywordSafe but can truncate/wraplet i = 3.14_f64 as i32; — truncates to 3
static_cast (numeric, checked)From/IntoSafe, compile-time verifiedlet i: i32 = 42_u8.into(); — only widens
static_cast (numeric, fallible)TryFrom/TryIntoSafe, returns Resultlet i: u8 = 300_u16.try_into()?; — returns Err
dynamic_cast (downcast)match on enum / Any::downcast_refSafePattern matching for enums; Any for trait objects
const_castNo equivalentRust has no way to cast away & → &mut in safe code. Use Cell/RefCell for interior mutability
reinterpret_caststd::mem::transmuteunsafeReinterprets bit pattern. Almost always wrong — prefer from_le_bytes() etc.
#![allow(unused)]
fn main() {
// Rust equivalents:

// 1. Numeric casts — prefer From/Into over `as`
let widened: u32 = 42_u8.into();             // Infallible widening — always prefer
let truncated = 300_u16 as u8;                // ⚠ Wraps to 44! Silent data loss
let checked: Result<u8, _> = 300_u16.try_into(); // Err — safe fallible conversion

// 2. Downcast: enum (preferred) or Any (when needed for type erasure)
use std::any::Any;

fn handle_any(val: &dyn Any) {
    if let Some(s) = val.downcast_ref::<String>() {
        println!("Got string: {s}");
    } else if let Some(n) = val.downcast_ref::<i32>() {
        println!("Got int: {n}");
    }
}

// 3. "const_cast" → interior mutability (no unsafe needed)
use std::cell::Cell;
struct Sensor {
    read_count: Cell<u32>,  // Mutate through &self
}
impl Sensor {
    fn read(&self) -> f64 {
        self.read_count.set(self.read_count.get() + 1); // &self, not &mut self
        42.0
    }
}

// 4. reinterpret_cast → transmute (almost never needed)
// Prefer safe alternatives:
let bytes: [u8; 4] = 0x12345678_u32.to_ne_bytes();  // ✅ Safe
let val = u32::from_ne_bytes(bytes);                   // ✅ Safe
// unsafe { std::mem::transmute::<u32, [u8; 4]>(val) } // ❌ Avoid
}

Guideline: In idiomatic Rust, as should be rare (use From/Into for widening, TryFrom/TryInto for narrowing), transmute should be exceptional, and const_cast has no equivalent because interior mutability types make it unnecessary.


Preprocessor → cfg, Feature Flags, and macro_rules!

C++ relies heavily on the preprocessor for conditional compilation, constants, and code generation. Rust replaces all of these with first-class language features.

#define constants → const or const fn

// C++
#define MAX_RETRIES 5
#define BUFFER_SIZE (1024 * 64)
#define SQUARE(x) ((x) * (x))  // Macro — textual substitution, no type safety
#![allow(unused)]
fn main() {
// Rust — type-safe, scoped, no textual substitution
const MAX_RETRIES: u32 = 5;
const BUFFER_SIZE: usize = 1024 * 64;
const fn square(x: u32) -> u32 { x * x }  // Evaluated at compile time

// Can be used in const contexts:
const AREA: u32 = square(12);  // Computed at compile time
static BUFFER: [u8; BUFFER_SIZE] = [0; BUFFER_SIZE];
}

#ifdef / #if → #[cfg()] and cfg!()

// C++
#ifdef DEBUG
    log_verbose("Step 1 complete");
#endif

#if defined(LINUX) && !defined(ARM)
    use_x86_path();
#else
    use_generic_path();
#endif
#![allow(unused)]
fn main() {
// Rust — attribute-based conditional compilation
#[cfg(debug_assertions)]
fn log_verbose(msg: &str) { eprintln!("[VERBOSE] {msg}"); }

#[cfg(not(debug_assertions))]
fn log_verbose(_msg: &str) { /* compiled away in release */ }

// Combine conditions:
#[cfg(all(target_os = "linux", target_arch = "x86_64"))]
fn use_x86_path() { /* ... */ }

#[cfg(not(all(target_os = "linux", target_arch = "x86_64")))]
fn use_generic_path() { /* ... */ }

// Runtime check (condition is still compile-time, but usable in expressions):
if cfg!(target_os = "windows") {
    println!("Running on Windows");
}
}

Feature flags in Cargo.toml

# Cargo.toml — replace #ifdef FEATURE_FOO
[features]
default = ["json"]
json = ["dep:serde_json"]       # Optional dependency
verbose-logging = []            # Flag with no extra dependency
gpu-support = ["dep:cuda-sys"]  # Optional GPU support
#![allow(unused)]
fn main() {
// Conditional code based on feature flags:
#[cfg(feature = "json")]
pub fn parse_config(data: &str) -> Result<Config, Error> {
    serde_json::from_str(data).map_err(Error::from)
}

#[cfg(feature = "verbose-logging")]
macro_rules! verbose {
    ($($arg:tt)*) => { eprintln!("[VERBOSE] {}", format!($($arg)*)); }
}
#[cfg(not(feature = "verbose-logging"))]
macro_rules! verbose {
    ($($arg:tt)*) => { }; // Compiles to nothing
}
}

#define MACRO(x) → macro_rules!

// C++ — textual substitution, notoriously error-prone
#define DIAG_CHECK(cond, msg) \
    do { if (!(cond)) { log_error(msg); return false; } } while(0)
#![allow(unused)]
fn main() {
// Rust — hygienic, type-checked, operates on syntax tree
macro_rules! diag_check {
    ($cond:expr, $msg:expr) => {
        if !($cond) {
            log_error($msg);
            return Err(DiagError::CheckFailed($msg.to_string()));
        }
    };
}

fn run_test() -> Result<(), DiagError> {
    diag_check!(temperature < 85.0, "GPU too hot");
    diag_check!(voltage > 0.8, "Rail voltage too low");
    Ok(())
}
}
C++ PreprocessorRust EquivalentAdvantage
#define PI 3.14const PI: f64 = 3.14;Typed, scoped, visible to debugger
#define MAX(a,b) ((a)>(b)?(a):(b))macro_rules! or generic fn max<T: Ord>No double-evaluation bugs
#ifdef DEBUG#[cfg(debug_assertions)]Checked by compiler, no typo risk
#ifdef FEATURE_X#[cfg(feature = "x")]Cargo manages features; dependency-aware
#include "header.h"mod module; + use module::Item;No include guards, no circular includes
#pragma onceNot neededEach .rs file is a module — included exactly once

Header Files and #include → Modules and use

In C++, the compilation model revolves around textual inclusion:

// widget.h — every translation unit that uses Widget includes this
#pragma once
#include <string>
#include <vector>

class Widget {
public:
    Widget(std::string name);
    void activate();
private:
    std::string name_;
    std::vector<int> data_;
};
// widget.cpp — separate definition
#include "widget.h"
Widget::Widget(std::string name) : name_(std::move(name)) {}
void Widget::activate() { /* ... */ }

In Rust, there are no header files, no forward declarations, no include guards:

#![allow(unused)]
fn main() {
// src/widget.rs — declaration AND definition in one file
pub struct Widget {
    name: String,         // Private by default
    data: Vec<i32>,
}

impl Widget {
    pub fn new(name: String) -> Self {
        Widget { name, data: Vec::new() }
    }
    pub fn activate(&self) { /* ... */ }
}
}
// src/main.rs — import by module path
mod widget;  // Tells compiler to include src/widget.rs
use widget::Widget;

fn main() {
    let w = Widget::new("sensor".to_string());
    w.activate();
}
C++RustWhy it’s better
#include "foo.h"mod foo; in parent + use foo::Item;No textual inclusion, no ODR violations
#pragma once / include guardsNot neededEach .rs file is a module — compiled once
Forward declarationsNot neededCompiler sees entire crate; order doesn’t matter
class Foo; (incomplete type)Not neededNo separate declaration/definition split
.h + .cpp for each classSingle .rs fileNo declaration/definition mismatch bugs
using namespace std;use std::collections::HashMap;Always explicit — no global namespace pollution
Nested namespace a::bNested mod a { mod b { } } or a/b.rsFile system mirrors module tree

friend and Access Control → Module Visibility

C++ uses friend to grant specific classes or functions access to private members. Rust has no friend keyword — instead, privacy is module-scoped:

// C++
class Engine {
    friend class Car;   // Car can access private members
    int rpm_;
    void set_rpm(int r) { rpm_ = r; }
public:
    int rpm() const { return rpm_; }
};
// Rust — items in the same module can access all fields, no `friend` needed
mod vehicle {
    pub struct Engine {
        rpm: u32,  // Private to the module (not to the struct!)
    }

    impl Engine {
        pub fn new() -> Self { Engine { rpm: 0 } }
        pub fn rpm(&self) -> u32 { self.rpm }
    }

    pub struct Car {
        engine: Engine,
    }

    impl Car {
        pub fn new() -> Self { Car { engine: Engine::new() } }
        pub fn accelerate(&mut self) {
            self.engine.rpm = 3000; // ✅ Same module — direct field access
        }
        pub fn rpm(&self) -> u32 {
            self.engine.rpm  // ✅ Same module — can read private field
        }
    }
}

fn main() {
    let mut car = vehicle::Car::new();
    car.accelerate();
    // car.engine.rpm = 9000;  // ❌ Compile error: `engine` is private
    println!("RPM: {}", car.rpm()); // ✅ Public method on Car
}
C++ AccessRust EquivalentScope
private(default, no keyword)Accessible within the same module only
protectedNo direct equivalentUse pub(super) for parent module access
publicpubAccessible everywhere
friend class FooPut Foo in the same moduleModule-level privacy replaces friend
—pub(crate)Visible within the crate but not to external dependents
—pub(super)Visible to the parent module only
—pub(in crate::path)Visible within a specific module subtree

Key insight: C++ privacy is per-class. Rust privacy is per-module. This means you control access by choosing which types live in the same module — colocated types have full access to each other’s private fields.


volatile → Atomics and read_volatile/write_volatile

In C++, volatile tells the compiler not to optimize away reads/writes — typically used for memory-mapped hardware registers. Rust has no volatile keyword.

// C++: volatile for hardware registers
volatile uint32_t* const GPIO_REG = reinterpret_cast<volatile uint32_t*>(0x4002'0000);
*GPIO_REG = 0x01;              // Write not optimized away
uint32_t val = *GPIO_REG;     // Read not optimized away
#![allow(unused)]
fn main() {
// Rust: explicit volatile operations — only in unsafe code
use std::ptr;

const GPIO_REG: *mut u32 = 0x4002_0000 as *mut u32;

// SAFETY: GPIO_REG is a valid memory-mapped I/O address.
unsafe {
    ptr::write_volatile(GPIO_REG, 0x01);   // Write not optimized away
    let val = ptr::read_volatile(GPIO_REG); // Read not optimized away
}
}

For concurrent shared state (the other common C++ volatile use), Rust uses atomics:

// C++: volatile is NOT sufficient for thread safety (common mistake!)
volatile bool stop_flag = false;  // ❌ Data race — UB in C++11+

// Correct C++:
std::atomic<bool> stop_flag{false};
#![allow(unused)]
fn main() {
// Rust: atomics are the only way to share mutable state across threads
use std::sync::atomic::{AtomicBool, Ordering};

static STOP_FLAG: AtomicBool = AtomicBool::new(false);

// From another thread:
STOP_FLAG.store(true, Ordering::Release);

// Check:
if STOP_FLAG.load(Ordering::Acquire) {
    println!("Stopping");
}
}
C++ UsageRust EquivalentNotes
volatile for hardware registersptr::read_volatile / ptr::write_volatileRequires unsafe — correct for MMIO
volatile for thread signalingAtomicBool / AtomicU32 etc.C++ volatile is wrong for this too!
std::atomic<T>std::sync::atomic::AtomicTSame semantics, same orderings
std::atomic<T>::load(memory_order_acquire)AtomicT::load(Ordering::Acquire)1:1 mapping

static Variables → static, const, LazyLock, OnceLock

Basic static and const

// C++
const int MAX_RETRIES = 5;                    // Compile-time constant
static std::string CONFIG_PATH = "/etc/app";  // Static init — order undefined!
#![allow(unused)]
fn main() {
// Rust
const MAX_RETRIES: u32 = 5;                   // Compile-time constant, inlined
static CONFIG_PATH: &str = "/etc/app";         // 'static lifetime, fixed address
}

The static initialization order fiasco

C++ has a well-known problem: global constructors in different translation units execute in unspecified order. Rust avoids this entirely — static values must be compile-time constants (no constructors).

For runtime-initialized globals, use LazyLock (Rust 1.80+) or OnceLock:

#![allow(unused)]
fn main() {
use std::sync::LazyLock;

// Equivalent to C++ `static std::regex` — initialized on first access, thread-safe
static CONFIG_REGEX: LazyLock<regex::Regex> = LazyLock::new(|| {
    regex::Regex::new(r"^[a-z]+_diag$").expect("invalid regex")
});

fn is_valid_diag(name: &str) -> bool {
    CONFIG_REGEX.is_match(name)  // First call initializes; subsequent calls are fast
}
}
#![allow(unused)]
fn main() {
use std::sync::OnceLock;

// OnceLock: initialized once, can be set from runtime data
static DB_CONN: OnceLock<String> = OnceLock::new();

fn init_db(connection_string: &str) {
    DB_CONN.set(connection_string.to_string())
        .expect("DB_CONN already initialized");
}

fn get_db() -> &'static str {
    DB_CONN.get().expect("DB not initialized")
}
}
C++RustNotes
const int X = 5;const X: i32 = 5;Both compile-time. Rust requires type annotation
constexpr int X = 5;const X: i32 = 5;Rust const is always constexpr
static int count = 0; (file scope)static COUNT: AtomicI32 = AtomicI32::new(0);Mutable statics require unsafe or atomics
static std::string s = "hi";static S: &str = "hi"; or LazyLock<String>No runtime constructor for simple cases
static MyObj obj; (complex init)static OBJ: LazyLock<MyObj> = LazyLock::new(|| { ... });Thread-safe, lazy, no init order issues
thread_localthread_local! { static X: Cell<u32> = Cell::new(0); }Same semantics

constexpr → const fn

C++ constexpr marks functions and variables for compile-time evaluation. Rust uses const fn and const for the same purpose:

// C++
constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}
constexpr int val = factorial(5);  // Computed at compile time → 120
#![allow(unused)]
fn main() {
// Rust
const fn factorial(n: u32) -> u32 {
    if n <= 1 { 1 } else { n * factorial(n - 1) }
}
const VAL: u32 = factorial(5);  // Computed at compile time → 120

// Also works in array sizes and match patterns:
const LOOKUP: [u32; 5] = [factorial(1), factorial(2), factorial(3),
                           factorial(4), factorial(5)];
}
C++RustNotes
constexpr int f()const fn f() -> i32Same intent — compile-time evaluable
constexpr variableconst variableRust const is always compile-time
consteval (C++20)No equivalentconst fn can also run at runtime
if constexpr (C++17)No equivalent (use cfg! or generics)Trait specialization fills some use cases
constinit (C++20)static with const initializerRust static must be const-initialized by default

Current limitations of const fn (stabilized as of Rust 1.82):

  • No trait methods (can’t call .len() on a Vec in const context)
  • No heap allocation (Box::new, Vec::new not const)
  • No floating-point arithmetic — stabilized in Rust 1.82
  • Can’t use for loops (use recursion or while with manual index)

SFINAE and enable_if → Trait Bounds and where Clauses

In C++, SFINAE (Substitution Failure Is Not An Error) is the mechanism behind conditional generic programming. It is powerful but notoriously unreadable. Rust replaces it entirely with trait bounds:

// C++: SFINAE-based conditional function (pre-C++20)
template<typename T,
         std::enable_if_t<std::is_integral_v<T>, int> = 0>
T double_it(T val) { return val * 2; }

template<typename T,
         std::enable_if_t<std::is_floating_point_v<T>, int> = 0>
T double_it(T val) { return val * 2.0; }

// C++20 concepts — cleaner but still verbose:
template<std::integral T>
T double_it(T val) { return val * 2; }
#![allow(unused)]
fn main() {
// Rust: trait bounds — readable, composable, excellent error messages
use std::ops::Mul;

fn double_it<T: Mul<Output = T> + From<u8>>(val: T) -> T {
    val * T::from(2)
}

// Or with where clause for complex bounds:
fn process<T>(val: T) -> String
where
    T: std::fmt::Display + Clone + Send,
{
    format!("Processing: {}", val)
}

// Conditional behavior via separate impls (replaces SFINAE overloads):
trait Describable {
    fn describe(&self) -> String;
}

impl Describable for u32 {
    fn describe(&self) -> String { format!("integer: {self}") }
}

impl Describable for f64 {
    fn describe(&self) -> String { format!("float: {self:.2}") }
}
}
C++ Template MetaprogrammingRust EquivalentReadability
std::enable_if_t<cond>where T: Trait🟢 Clear English
std::is_integral_v<T>Bound on a numeric trait or specific types🟢 No _v / _t suffixes
SFINAE overload setsSeparate impl Trait for ConcreteType blocks🟢 Each impl stands alone
if constexpr (std::is_same_v<T, int>)Specialization via trait impls🟢 Compile-time dispatched
C++20 concepttrait🟢 Nearly identical intent
requires clausewhere clause🟢 Same position, similar syntax
Compilation fails deep inside templateCompilation fails at the call site with trait mismatch🟢 No 200-line error cascades

Key insight: C++ concepts (C++20) are the closest thing to Rust traits. If you’re familiar with C++20 concepts, think of Rust traits as concepts that have been a first-class language feature since 1.0, with a coherent implementation model (trait impls) instead of duck typing.


std::function → Function Pointers, impl Fn, and Box<dyn Fn>

C++ std::function<R(Args...)> is a type-erased callable. Rust has three options, each with different trade-offs:

// C++: one-size-fits-all (heap-allocated, type-erased)
#include <functional>
std::function<int(int)> make_adder(int n) {
    return [n](int x) { return x + n; };
}
#![allow(unused)]
fn main() {
// Rust Option 1: fn pointer — simple, no captures, no allocation
fn add_one(x: i32) -> i32 { x + 1 }
let f: fn(i32) -> i32 = add_one;
println!("{}", f(5)); // 6

// Rust Option 2: impl Fn — monomorphized, zero overhead, can capture
fn apply(val: i32, f: impl Fn(i32) -> i32) -> i32 { f(val) }
let n = 10;
let result = apply(5, |x| x + n);  // Closure captures `n`

// Rust Option 3: Box<dyn Fn> — type-erased, heap-allocated (like std::function)
fn make_adder(n: i32) -> Box<dyn Fn(i32) -> i32> {
    Box::new(move |x| x + n)
}
let adder = make_adder(10);
println!("{}", adder(5));  // 15

// Storing heterogeneous callables (like vector<function<int(int)>>):
let callbacks: Vec<Box<dyn Fn(i32) -> i32>> = vec![
    Box::new(|x| x + 1),
    Box::new(|x| x * 2),
    Box::new(make_adder(100)),
];
for cb in &callbacks {
    println!("{}", cb(5));  // 6, 10, 105
}
}
When to useC++ EquivalentRust Choice
Top-level function, no capturesFunction pointerfn(Args) -> Ret
Generic function accepting callablesTemplate parameterimpl Fn(Args) -> Ret (static dispatch)
Trait bound in genericstemplate<typename F>F: Fn(Args) -> Ret
Stored callable, type-erasedstd::function<R(Args)>Box<dyn Fn(Args) -> Ret>
Callback that mutates statestd::function with mutable lambdaBox<dyn FnMut(Args) -> Ret>
One-shot callback (consumed)std::function (moved)Box<dyn FnOnce(Args) -> Ret>

Performance note: impl Fn has zero overhead (monomorphized, like a C++ template). Box<dyn Fn> has the same overhead as std::function (vtable + heap allocation). Prefer impl Fn unless you need to store heterogeneous callables.


Container Mapping: C++ STL → Rust std::collections

C++ STL ContainerRust EquivalentNotes
std::vector<T>Vec<T>Nearly identical API. Rust checks bounds by default
std::array<T, N>[T; N]Stack-allocated fixed-size array
std::deque<T>std::collections::VecDeque<T>Ring buffer. Efficient push/pop at both ends
std::list<T>std::collections::LinkedList<T>Rarely used in Rust — Vec is almost always faster
std::forward_list<T>No equivalentUse Vec or VecDeque
std::unordered_map<K, V>std::collections::HashMap<K, V>Uses SipHash by default (DoS-resistant)
std::map<K, V>std::collections::BTreeMap<K, V>B-tree; keys sorted; K: Ord required
std::unordered_set<T>std::collections::HashSet<T>T: Hash + Eq required
std::set<T>std::collections::BTreeSet<T>Sorted set; T: Ord required
std::priority_queue<T>std::collections::BinaryHeap<T>Max-heap by default (same as C++)
std::stack<T>Vec<T> with .push() / .pop()No separate stack type needed
std::queue<T>VecDeque<T> with .push_back() / .pop_front()No separate queue type needed
std::stringStringUTF-8 guaranteed, not null-terminated
std::string_view&strBorrowed UTF-8 slice
std::span<T> (C++20)&[T] / &mut [T]Rust slices have been a first-class type since 1.0
std::tuple<A, B, C>(A, B, C)First-class syntax, destructurable
std::pair<A, B>(A, B)Just a 2-element tuple
std::bitset<N>No std equivalentUse the bitvec crate or [u8; N/8]

Key differences:

  • Rust’s HashMap/HashSet require K: Hash + Eq — the compiler enforces this at the type level, unlike C++ where using an unhashable key gives a template error deep in the STL
  • Vec indexing (v[i]) panics on out-of-bounds by default. Use .get(i) for Option<&T> or iterators to avoid bounds checks entirely
  • No std::multimap or std::multiset — use HashMap<K, Vec<V>> or BTreeMap<K, Vec<V>>

Exception Safety → Panic Safety

C++ defines three levels of exception safety (Abrahams guarantees):

C++ LevelMeaningRust Equivalent
No-throwFunction never throwsFunction never panics (returns Result)
Strong (commit-or-rollback)If it throws, state is unchangedOwnership model makes this natural — if ? returns early, partially built values are dropped
BasicIf it throws, invariants are preservedRust’s default — Drop runs, no leaks

How Rust’s ownership model helps

#![allow(unused)]
fn main() {
// Strong guarantee for free — if file.write() fails, config is unchanged
fn update_config(config: &mut Config, path: &str) -> Result<(), Error> {
    let new_data = fetch_from_network()?; // Err → early return, config untouched
    let validated = validate(new_data)?;   // Err → early return, config untouched
    *config = validated;                   // Only reached on success (commit)
    Ok(())
}
}

In C++, achieving the strong guarantee requires manual rollback or the copy-and-swap idiom. In Rust, ? propagation gives you the strong guarantee by default for most code.

catch_unwind — Rust’s equivalent of catch(...)

#![allow(unused)]
fn main() {
use std::panic;

// Catch a panic (like catch(...) in C++) — rarely needed
let result = panic::catch_unwind(|| {
    // Code that might panic
    let v = vec![1, 2, 3];
    v[10]  // Panics! (index out of bounds)
});

match result {
    Ok(val) => println!("Got: {val}"),
    Err(_) => eprintln!("Caught a panic — cleaned up"),
}
}

UnwindSafe — marking types as panic-safe

#![allow(unused)]
fn main() {
use std::panic::UnwindSafe;

// Types behind &mut are NOT UnwindSafe by default — the panic may have
// left them in a partially-modified state
fn safe_execute<F: FnOnce() + UnwindSafe>(f: F) {
    let _ = std::panic::catch_unwind(f);
}

// Use AssertUnwindSafe to override when you've audited the code:
use std::panic::AssertUnwindSafe;
let mut data = vec![1, 2, 3];
let _ = std::panic::catch_unwind(AssertUnwindSafe(|| {
    data.push(4);
}));
}
C++ Exception PatternRust Equivalent
throw MyException()return Err(MyError::...) (preferred) or panic!("...")
try { } catch (const E& e)match result { Ok(v) => ..., Err(e) => ... } or ?
catch (...)std::panic::catch_unwind(...)
noexcept-> Result<T, E> (errors are values, not exceptions)
RAII cleanup in stack unwindingDrop::drop() runs during panic unwinding
std::uncaught_exceptions()std::thread::panicking()
-fno-exceptions compile flagpanic = "abort" in Cargo.toml [profile]

Bottom line: In Rust, most code uses Result<T, E> instead of exceptions, making error paths explicit and composable. panic! is reserved for bugs (like assert! failures), not routine errors. This means “exception safety” is largely a non-issue — the ownership system handles cleanup automatically.


C++ to Rust Migration Patterns

Quick Reference: C++ → Rust Idiom Map

C++ PatternRust IdiomNotes
class Derived : public Baseenum Variant { A {...}, B {...} }Prefer enums for closed sets
virtual void method() = 0trait MyTrait { fn method(&self); }Use for open/extensible interfaces
dynamic_cast<Derived*>(ptr)match value { Variant::A(data) => ..., }Exhaustive, no runtime failure
vector<unique_ptr<Base>>Vec<Box<dyn Trait>>Only when genuinely polymorphic
shared_ptr<T>Rc<T> or Arc<T>Prefer Box<T> or owned values first
enable_shared_from_this<T>Arena pattern (Vec<T> + indices)Eliminates reference cycles entirely
Base* m_pFramework in every classfn execute(&mut self, ctx: &mut Context)Pass context, don’t store pointers
try { } catch (...) { }match result { Ok(v) => ..., Err(e) => ... }Or use ? for propagation
std::optional<T>Option<T>match required, can’t forget None
const std::string& parameter&str parameterAccepts both String and &str
enum class Foo { A, B, C }enum Foo { A, B, C }Rust enums can also carry data
auto x = std::move(obj)let x = obj;Move is the default, no std::move needed
CMake + make + lintcargo build / test / clippy / fmtOne tool for everything

Migration Strategy

  1. Start with data types: Translate structs and enums first — this forces you to think about ownership
  2. Convert factories to enums: If a factory creates different derived types, it should probably be enum + match
  3. Convert god objects to composed structs: Group related fields into focused structs
  4. Replace pointers with borrows: Convert Base* stored pointers to &'a T lifetime-bounded borrows
  5. Use Box<dyn Trait> sparingly: Only for plugin systems and test mocking
  6. Let the compiler guide you: Rust’s error messages are excellent — read them carefully

Rust Macros: From Preprocessor to Metaprogramming

What you’ll learn: How Rust macros work, when to use them instead of functions or generics, and how they replace the C/C++ preprocessor. By the end of this chapter you can write your own macro_rules! macros and understand what #[derive(Debug)] does under the hood.

Macros are one of the first things you encounter in Rust (println!("hello") on line one) but one of the last things most courses explain. This chapter fixes that.

Why Macros Exist

Functions and generics handle most code reuse in Rust. Macros fill the gaps where the type system can’t reach:

NeedFunction/Generic?Macro?Why
Compute a value✅ fn max<T: Ord>(a: T, b: T) -> T—Type system handles it
Accept variable number of arguments❌ Rust has no variadic functions✅ println!("{} {}", a, b)Macros accept any number of tokens
Generate repetitive impl blocks❌ No way with generics alone✅ macro_rules!Macros generate code at compile time
Run code at compile time❌ const fn is limited✅ Procedural macrosFull Rust code runs at compile time
Conditionally include code❌✅ #[cfg(...)]Attribute macros control compilation

If you’re coming from C/C++, think of macros as the only correct replacement for the preprocessor — except they operate on the syntax tree instead of raw text, so they’re hygienic (no accidental name collisions) and type-aware.

For C developers: Rust macros replace #define entirely. There is no textual preprocessor. See ch18 for the full preprocessor → Rust mapping.


Declarative Macros with macro_rules!

Declarative macros (also called “macros by example”) are Rust’s most common macro form. They use pattern matching on syntax, similar to match on values.

Basic syntax

macro_rules! say_hello {
    () => {
        println!("Hello!");
    };
}

fn main() {
    say_hello!();  // Expands to: println!("Hello!");
}

The ! after the name is what tells you (and the compiler) this is a macro invocation.

Pattern matching with arguments

Macros match on token trees using fragment specifiers:

macro_rules! greet {
    // Pattern 1: no arguments
    () => {
        println!("Hello, world!");
    };
    // Pattern 2: one expression argument
    ($name:expr) => {
        println!("Hello, {}!", $name);
    };
}

fn main() {
    greet!();           // "Hello, world!"
    greet!("Rust");     // "Hello, Rust!"
}

Fragment specifiers reference

SpecifierMatchesExample
$x:exprAny expression42, a + b, foo()
$x:tyA typei32, Vec<String>, &str
$x:identAn identifierfoo, my_var
$x:patA patternSome(x), _, (a, b)
$x:stmtA statementlet x = 5;
$x:blockA block{ println!("hi"); 42 }
$x:literalA literal42, "hello", true
$x:ttA single token treeAnything — the wildcard
$x:itemAn item (fn, struct, impl, etc.)fn foo() {}

Repetition — the killer feature

C/C++ macros can’t loop. Rust macros can repeat patterns:

macro_rules! make_vec {
    // Match zero or more comma-separated expressions
    ( $( $element:expr ),* ) => {
        {
            let mut v = Vec::new();
            $( v.push($element); )*  // Repeat for each matched element
            v
        }
    };
}

fn main() {
    let v = make_vec![1, 2, 3, 4, 5];
    println!("{v:?}");  // [1, 2, 3, 4, 5]
}

The $( ... ),* syntax means “match zero or more of this pattern, separated by commas.” The $( ... )* in the expansion repeats the body once for each match.

This is exactly how vec![] is implemented in the standard library. The actual source is:

#![allow(unused)]
fn main() {
macro_rules! vec {
    () => { Vec::new() };
    ($elem:expr; $n:expr) => { vec::from_elem($elem, $n) };
    ($($x:expr),+ $(,)?) => { <[_]>::into_vec(Box::new([$($x),+])) };
}
}

The $(,)? at the end allows an optional trailing comma.

Repetition operators

OperatorMeaningExample
$( ... )*Zero or morevec![], vec![1], vec![1, 2, 3]
$( ... )+One or moreAt least one element required
$( ... )?Zero or oneOptional element

Practical example: a hashmap! constructor

The standard library has vec![] but no hashmap!{}. Let’s build one:

macro_rules! hashmap {
    ( $( $key:expr => $value:expr ),* $(,)? ) => {
        {
            let mut map = std::collections::HashMap::new();
            $( map.insert($key, $value); )*
            map
        }
    };
}

fn main() {
    let scores = hashmap! {
        "Alice" => 95,
        "Bob" => 87,
        "Carol" => 92,  // trailing comma OK thanks to $(,)?
    };
    println!("{scores:?}");
}

Practical example: diagnostic check macro

A pattern common in embedded/diagnostic code — check a condition and return an error:

#![allow(unused)]
fn main() {
use thiserror::Error;

#[derive(Error, Debug)]
enum DiagError {
    #[error("Check failed: {0}")]
    CheckFailed(String),
}

macro_rules! diag_check {
    ($cond:expr, $msg:expr) => {
        if !($cond) {
            return Err(DiagError::CheckFailed($msg.to_string()));
        }
    };
}

fn run_diagnostics(temp: f64, voltage: f64) -> Result<(), DiagError> {
    diag_check!(temp < 85.0, "GPU too hot");
    diag_check!(voltage > 0.8, "Rail voltage too low");
    diag_check!(voltage < 1.5, "Rail voltage too high");
    println!("All checks passed");
    Ok(())
}
}

C/C++ comparison:

// C preprocessor — textual substitution, no type safety, no hygiene
#define DIAG_CHECK(cond, msg) \
    do { if (!(cond)) { log_error(msg); return -1; } } while(0)

The Rust version returns a proper Result type, has no double-evaluation risk, and the compiler checks that $cond is actually a bool expression.

Hygiene: why Rust macros are safe

C/C++ macro bugs often come from name collisions:

// C: dangerous — `x` could shadow the caller's `x`
#define SQUARE(x) ((x) * (x))
int x = 5;
int result = SQUARE(x++);  // UB: x incremented twice!

Rust macros are hygienic — variables created inside a macro don’t leak out:

macro_rules! make_x {
    () => {
        let x = 42;  // This `x` is scoped to the macro expansion
    };
}

fn main() {
    let x = 10;
    make_x!();
    println!("{x}");  // Prints 10, not 42 — hygiene prevents collision
}

The macro’s x and the caller’s x are treated as different variables by the compiler, even though they have the same name. This is impossible with the C preprocessor.


Common Standard Library Macros

You’ve been using these since chapter 1 — here’s what they actually do:

MacroWhat it doesExpands to (simplified)
println!("{}", x)Format and print to stdout + newlinestd::io::_print(format_args!(...))
eprintln!("{}", x)Print to stderr + newlineSame but to stderr
format!("{}", x)Format into a StringAllocates and returns a String
vec![1, 2, 3]Create a Vec with elementsVec::from([1, 2, 3]) (approximately)
todo!()Mark unfinished codepanic!("not yet implemented")
unimplemented!()Mark deliberately unimplemented codepanic!("not implemented")
unreachable!()Mark code the compiler can’t prove unreachablepanic!("unreachable")
assert!(cond)Panic if condition is falseif !cond { panic!(...) }
assert_eq!(a, b)Panic if values aren’t equalShows both values on failure
dbg!(expr)Print expression + value to stderr, return valueeprintln!("[file:line] expr = {:#?}", &expr); expr
include_str!("file.txt")Embed file contents as &str at compile timeReads file during compilation
include_bytes!("data.bin")Embed file contents as &[u8] at compile timeReads file during compilation
cfg!(condition)Compile-time condition as a booltrue or false based on target
env!("VAR")Read environment variable at compile timeFails compilation if not set
concat!("a", "b")Concatenate literals at compile time"ab"

dbg! — the debugging macro you’ll use daily

fn factorial(n: u32) -> u32 {
    if dbg!(n <= 1) {     // Prints: [src/main.rs:2] n <= 1 = false
        dbg!(1)           // Prints: [src/main.rs:3] 1 = 1
    } else {
        dbg!(n * factorial(n - 1))  // Prints intermediate values
    }
}

fn main() {
    dbg!(factorial(4));   // Prints all recursive calls with file:line
}

dbg! returns the value it wraps, so you can insert it anywhere without changing program behavior. It prints to stderr (not stdout), so it doesn’t interfere with program output. Remove all dbg! calls before committing code.

Format string syntax

Since println!, format!, eprintln!, and write! all use the same format machinery, here’s the quick reference:

#![allow(unused)]
fn main() {
let name = "sensor";
let value = 3.14159;
let count = 42;

println!("{name}");                    // Variable by name (Rust 1.58+)
println!("{}", name);                  // Positional
println!("{value:.2}");                // 2 decimal places: "3.14"
println!("{count:>10}");               // Right-aligned, width 10: "        42"
println!("{count:0>10}");              // Zero-padded: "0000000042"
println!("{count:#06x}");              // Hex with prefix: "0x002a"
println!("{count:#010b}");             // Binary with prefix: "0b00101010"
println!("{value:?}");                 // Debug format
println!("{value:#?}");                // Pretty-printed Debug format
}

For C developers: Think of this as a type-safe printf — the compiler checks that {:.2} is applied to a float, not a string. No %s/%d format mismatch bugs.

For C++ developers: This replaces std::cout << std::fixed << std::setprecision(2) << value with a single readable format string.


Derive Macros

You’ve seen #[derive(...)] on nearly every struct in this book:

#![allow(unused)]
fn main() {
#[derive(Debug, Clone, PartialEq)]
struct Point {
    x: f64,
    y: f64,
}
}

#[derive(Debug)] is a derive macro — a special kind of procedural macro that generates trait implementations automatically. Here’s what it produces (simplified):

#![allow(unused)]
fn main() {
// What #[derive(Debug)] generates for Point:
impl std::fmt::Debug for Point {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.debug_struct("Point")
            .field("x", &self.x)
            .field("y", &self.y)
            .finish()
    }
}
}

Without #[derive(Debug)], you’d have to write that impl block by hand for every struct.

Commonly derived traits

DeriveWhat it generatesWhen to use
Debug{:?} formattingAlmost always — enables printing for debugging
Clone.clone() methodWhen you need to duplicate values
CopyImplicit copy on assignmentSmall, stack-only types (integers, [f64; 3])
PartialEq / Eq== and != operatorsWhen you need equality comparison
PartialOrd / Ord<, >, <=, >= operatorsWhen you need ordering
HashHashing for HashMap/HashSet keysTypes used as map keys
DefaultType::default() constructorTypes with sensible zero/empty values
serde::Serialize / DeserializeJSON/TOML/etc. serializationData types that cross API boundaries

The derive decision tree

Should I derive it?
  │
  ├── Does my type contain only types that implement the trait?
  │     ├── Yes → #[derive] will work
  │     └── No  → Write a manual impl (or skip it)
  │
  └── Will users of my type reasonably expect this behavior?
        ├── Yes → Derive it (Debug, Clone, PartialEq are almost always reasonable)
        └── No  → Don't derive (e.g., don't derive Copy for a type with a file handle)

C++ comparison: #[derive(Clone)] is like auto-generating a correct copy constructor. #[derive(PartialEq)] is like auto-generating operator== that compares each field — something C++20’s = default spaceship operator finally provides.


Attribute Macros

Attribute macros transform the item they’re attached to. You’ve already used several:

#![allow(unused)]
fn main() {
#[test]                    // Marks a function as a test
fn test_addition() {
    assert_eq!(2 + 2, 4);
}

#[cfg(target_os = "linux")] // Conditionally includes this function
fn linux_only() { /* ... */ }

#[derive(Debug)]            // Generates Debug implementation
struct MyType { /* ... */ }

#[allow(dead_code)]         // Suppresses a compiler warning
fn unused_helper() { /* ... */ }

#[must_use]                 // Warn if return value is discarded
fn compute_checksum(data: &[u8]) -> u32 { /* ... */ }
}

Common built-in attributes:

AttributePurpose
#[test]Mark as test function
#[cfg(...)]Conditional compilation
#[derive(...)]Auto-generate trait impls
#[allow(...)] / #[deny(...)] / #[warn(...)]Control lint levels
#[must_use]Warn on unused return values
#[inline] / #[inline(always)]Hint to inline the function
#[repr(C)]Use C-compatible memory layout (for FFI)
#[no_mangle]Don’t mangle the symbol name (for FFI)
#[deprecated]Mark as deprecated with optional message

For C/C++ developers: Attributes replace a mix of preprocessor directives (#pragma, __attribute__((...))), and compiler-specific extensions. They’re part of the language grammar, not bolted-on extensions.


Procedural Macros (Conceptual Overview)

Procedural macros (“proc macros”) are macros written as separate Rust programs that run at compile time and generate code. They’re more powerful than macro_rules! but also more complex.

There are three kinds:

KindSyntaxExampleWhat it does
Function-likemy_macro!(...)sql!(SELECT * FROM users)Parses custom syntax, generates Rust code
Derive#[derive(MyTrait)]#[derive(Serialize)]Generates trait impl from struct definition
Attribute#[my_attr]#[tokio::main], #[instrument]Transforms the annotated item

You’ve already used proc macros

  • #[derive(Error)] from thiserror — generates Display and From impls for error enums
  • #[derive(Serialize, Deserialize)] from serde — generates serialization code
  • #[tokio::main] — transforms async fn main() into a runtime setup + block_on
  • #[test] — registered by the test harness (built-in proc macro)

When to write your own proc macro

You likely won’t need to write proc macros during this course. They’re useful when:

  • You need to inspect struct fields/enum variants at compile time (derive macros)
  • You’re building a domain-specific language (function-like macros)
  • You need to transform function signatures (attribute macros)

For most code, macro_rules! or plain functions are sufficient.

C++ comparison: Procedural macros fill the role that code generators, template metaprogramming, and external tools like protoc fill in C++. The difference is that proc macros are part of the cargo build pipeline — no external build steps, no CMake custom commands.


When to Use What: Macros vs Functions vs Generics

Need to generate code?
  │
  ├── No → Use a function or generic function
  │         (simpler, better error messages, IDE support)
  │
  └── Yes ─┬── Variable number of arguments?
            │     └── Yes → macro_rules! (e.g., println!, vec!)
            │
            ├── Repetitive impl blocks for many types?
            │     └── Yes → macro_rules! with repetition
            │
            ├── Need to inspect struct fields?
            │     └── Yes → Derive macro (proc macro)
            │
            ├── Need custom syntax (DSL)?
            │     └── Yes → Function-like proc macro
            │
            └── Need to transform a function/struct?
                  └── Yes → Attribute proc macro

General guideline: If a function or generic can do it, don’t use a macro. Macros have worse error messages, no IDE auto-complete inside the macro body, and are harder to debug.


Exercises

🟢 Exercise 1: min! macro

Write a min! macro that:

  • min!(a, b) returns the smaller of two values
  • min!(a, b, c) returns the smallest of three values
  • Works with any type that implements PartialOrd

Hint: You’ll need two match arms in your macro_rules!.

Solution (click to expand)
macro_rules! min {
    ($a:expr, $b:expr) => {
        if $a < $b { $a } else { $b }
    };
    ($a:expr, $b:expr, $c:expr) => {
        min!(min!($a, $b), $c)
    };
}

fn main() {
    println!("{}", min!(3, 7));        // 3
    println!("{}", min!(9, 2, 5));     // 2
    println!("{}", min!(1.5, 0.3));    // 0.3
}

Note: For production code, prefer std::cmp::min or a.min(b). This exercise demonstrates the mechanics of multi-arm macros.

🟡 Exercise 2: hashmap! from scratch

Without looking at the example above, write a hashmap! macro that:

  • Creates a HashMap from key => value pairs
  • Supports trailing commas
  • Works with any hashable key type

Test with:

#![allow(unused)]
fn main() {
let m = hashmap! {
    "name" => "Alice",
    "role" => "Engineer",
};
assert_eq!(m["name"], "Alice");
assert_eq!(m.len(), 2);
}
Solution (click to expand)
use std::collections::HashMap;

macro_rules! hashmap {
    ( $( $key:expr => $val:expr ),* $(,)? ) => {{
        let mut map = HashMap::new();
        $( map.insert($key, $val); )*
        map
    }};
}

fn main() {
    let m = hashmap! {
        "name" => "Alice",
        "role" => "Engineer",
    };
    assert_eq!(m["name"], "Alice");
    assert_eq!(m.len(), 2);
    println!("Tests passed!");
}

🟡 Exercise 3: assert_approx_eq! for floating-point comparison

Write a macro assert_approx_eq!(a, b, epsilon) that panics if |a - b| > epsilon. This is useful for testing floating-point calculations where exact equality fails.

Test with:

#![allow(unused)]
fn main() {
assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);        // Should pass
assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4); // Should pass
// assert_approx_eq!(1.0, 2.0, 0.5);              // Should panic
}
Solution (click to expand)
macro_rules! assert_approx_eq {
    ($a:expr, $b:expr, $eps:expr) => {
        let (a, b, eps) = ($a as f64, $b as f64, $eps as f64);
        let diff = (a - b).abs();
        if diff > eps {
            panic!(
                "assertion failed: |{} - {}| = {} > {} (epsilon)",
                a, b, diff, eps
            );
        }
    };
}

fn main() {
    assert_approx_eq!(0.1 + 0.2, 0.3, 1e-10);
    assert_approx_eq!(3.14159, std::f64::consts::PI, 1e-4);
    println!("All float comparisons passed!");
}

🔴 Exercise 4: impl_display_for_enum!

Write a macro that generates a Display implementation for simple C-like enums. Given:

#![allow(unused)]
fn main() {
impl_display_for_enum! {
    enum Color {
        Red => "red",
        Green => "green",
        Blue => "blue",
    }
}
}

It should generate both the enum Color { Red, Green, Blue } definition AND the impl Display for Color that maps each variant to its string.

Hint: You’ll need both $( ... ),* repetition and multiple fragment specifiers.

Solution (click to expand)
use std::fmt;

macro_rules! impl_display_for_enum {
    (enum $name:ident { $( $variant:ident => $display:expr ),* $(,)? }) => {
        #[derive(Debug, Clone, Copy, PartialEq)]
        enum $name {
            $( $variant ),*
        }

        impl fmt::Display for $name {
            fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
                match self {
                    $( $name::$variant => write!(f, "{}", $display), )*
                }
            }
        }
    };
}

impl_display_for_enum! {
    enum Color {
        Red => "red",
        Green => "green",
        Blue => "blue",
    }
}

fn main() {
    let c = Color::Green;
    println!("Color: {c}");          // "Color: green"
    println!("Debug: {c:?}");        // "Debug: Green"
    assert_eq!(format!("{}", Color::Red), "red");
    println!("All tests passed!");
}