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_stdand embedded Rust essentials for firmware teams- Case studies: real-world C++ to Rust translation patterns
- We’ll not cover
asyncRust 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:
| Chapters | Topic | Suggested Time | Checkpoint |
|---|---|---|---|
| 1–4 | Setup, types, control flow | 1 day | You can write a CLI temperature converter |
| 5–7 | Data structures, ownership | 1–2 days | You can explain why let s2 = s1 invalidates s1 |
| 8–9 | Modules, error handling | 1 day | You can create a multi-file project that propagates errors with ? |
| 10–12 | Traits, generics, closures | 1–2 days | You can write a generic function with trait bounds |
| 13–14 | Concurrency, unsafe/FFI | 1 day | You can write a thread-safe counter with Arc<Mutex<T>> |
| 15–16 | Deep dives | At your own pace | Reference material — read when relevant |
| 17–19 | Best practices & reference | At your own pace | Consult 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
- Speaker intro and general approach
- The case for Rust
- How does Rust address these issues?
- Other Rust USPs and features
- Quick Reference: Rust vs C/C++
- Why C/C++ Developers Need Rust
2. Getting Started
- Enough talk already: Show me some code
- Rust Local installation
- Rust packages (crates)
- Example: cargo and crates
3. Basic Types and Variables
- Built-in Rust types
- Rust type specification and assignment
- Rust type specification and inference
- Rust variables and mutability
4. Control Flow
5. Data Structures and Collections
- Rust array type
- Rust tuples
- Rust references
- C++ References vs Rust References — Key Differences
- Rust slices
- Rust constants and statics
- Rust strings: String vs &str
- Rust structs
- Rust Vec<T>
- Rust HashMap
- Exercise: Vec and HashMap
6. Pattern Matching and Enums
7. Ownership and Memory Management
- Rust memory management
- Rust ownership, borrowing and lifetimes
- Rust move semantics
- Rust Clone
- Rust Copy trait
- Rust Drop trait
- Exercise: Move, Copy and Drop
- Rust lifetime and borrowing
- Rust lifetime annotations
- Exercise: Slice storage with lifetimes
- Lifetime Elision Rules Deep Dive
- Rust Box<T>
- Interior Mutability: Cell<T> and RefCell<T>
- Shared Ownership: Rc<T>
- Exercise: Shared ownership and interior mutability
8. Modules and Crates
- Rust crates and modules
- Exercise: Modules and functions
- Workspaces and crates (packages)
- Exercise: Using workspaces and package dependencies
- Using community crates from crates.io
- Crates dependencies and SemVer
- Exercise: Using the rand crate
- Cargo.toml and Cargo.lock
- Cargo test feature
- Other Cargo features
- Testing Patterns
9. Error Handling
- Connecting enums to Option and Result
- Rust Option type
- Rust Result type
- Exercise: log() function implementation with Option
- Rust error handling
- Exercise: error handling
- Error Handling Best Practices
10. Traits and Generics
- Rust traits
- C++ Operator Overloading → Rust std::ops Traits
- Exercise: Logger trait implementation
- When to use enum vs dyn Trait
- Exercise: Think Before You Translate
- Rust generics
- Exercise: Generics
- Combining Rust traits and generics
- Rust traits constraints in data types
- Exercise: Trait constraints and generics
- Rust type state pattern and generics
- Rust builder pattern
11. Type System Advanced Features
12. Functional Programming
- Rust closures
- Exercise: Closures and capturing
- Rust iterators
- Exercise: Rust iterators
- Iterator Power Tools Reference
13. Concurrency
14. Unsafe Rust and FFI
- Unsafe Rust
- Simple FFI example
- Complex FFI example
- Ensuring correctness of unsafe code
- Exercise: Writing a safe FFI wrapper
Part II — Deep Dives
15. no_std — Rust for Bare Metal
16. Case Studies: Real-World C++ to Rust Translation
- Case Study 1: Inheritance hierarchy → Enum dispatch
- Case Study 2: shared_ptr tree → Arena/index pattern
- Case Study 3: Framework communication → Lifetime borrowing
- Case Study 4: God object → Composable state
- Case Study 5: Trait objects — when they ARE right
Part III — Best Practices & Reference
17. Best Practices
- Rust Best Practices Summary
- Avoiding excessive clone()
- Avoiding unchecked indexing
- Collapsing assignment pyramids
- Capstone Exercise: Diagnostic Event Pipeline
- Logging and Tracing Ecosystem
18. C++ → Rust Semantic Deep Dives
19. Rust Macros
- Declarative macros (
macro_rules!) - Common standard library macros
- Derive macros
- Attribute macros
- Procedural macros
- When to use what: macros vs functions vs generics
- Exercises
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
Droptrait 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
SendandSynctraits
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/Syncchecking) - No use-after-move (unlike C++
std::movewhich leaves zombie objects) - No uninitialized variables
- All variables must be initialized before use
- No trivial memory leaks
Droptrait = 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)
- Lock guards are the only way to access the data (
- No exception handling complexity
- Errors are values (
Result<T, E>), visible in function signatures, propagated with?
- Errors are values (
- Excellent support for type inference, enums, pattern matching, zero cost abstractions
- Built-in support for dependency management, building, testing, formatting, linting
cargoreplaces make/CMake + lint + test frameworks
Quick Reference: Rust vs C/C++
| Concept | C | C++ | Rust | Key Difference |
|---|---|---|---|---|
| Memory management | malloc()/free() | unique_ptr, shared_ptr | Box<T>, Rc<T>, Arc<T> | Automatic, no cycles |
| Arrays | int arr[10] | std::vector<T>, std::array<T> | Vec<T>, [T; N] | Bounds checking by default |
| Strings | char* with \0 | std::string, string_view | String, &str | UTF-8 guaranteed, lifetime-checked |
| References | int* ptr | T&, T&& (move) | &T, &mut T | Borrow checking, lifetimes |
| Polymorphism | Function pointers | Virtual functions, inheritance | Traits, trait objects | Composition over inheritance |
| Generic programming | Macros (void*) | Templates | Generics + trait bounds | Better error messages |
| Error handling | Return codes, errno | Exceptions, std::optional | Result<T, E>, Option<T> | No hidden control flow |
| NULL/null safety | ptr == NULL | nullptr, std::optional<T> | Option<T> | Forced null checking |
| Thread safety | Manual (pthreads) | Manual synchronization | Compile-time guarantees | Data races impossible |
| Build system | Make, CMake | CMake, Make, etc. | Cargo | Integrated toolchain |
| Undefined behavior | Runtime crashes | Subtle UB (signed overflow, aliasing) | Compile-time errors | Safety guaranteed |
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 Issue | C | C++ | 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++ Mitigation | What It Fixes | What It Doesn’t Fix |
|---|---|---|
std::unique_ptr | Prevents leaks via RAII | Use-after-move still compiles; leaves a zombie nullptr |
std::shared_ptr | Shared ownership | Reference cycles leak silently; weak_ptr discipline is manual |
std::optional | Replaces some null use | .value() throws if empty — hidden control flow |
std::string_view | Avoids copies | Dangling if the source string is freed — no lifetime checking |
| Move semantics | Efficient transfers | Moved-from objects are in a “valid but unspecified state” — UB waiting to happen |
| RAII | Automatic cleanup | Requires 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:
| Problem | Rust’s Solution |
|---|---|
| Buffer overflows | Slices carry length; indexing is bounds-checked |
| Dangling pointers / use-after-free | Lifetime system proves references are valid at compile time |
| Use-after-move | Moves are destructive — compiler refuses to let you touch the original |
| Memory leaks | Drop trait = RAII without the Rule of Five; automatic, correct cleanup |
| Reference cycles | Ownership is tree-shaped; Rc + Weak makes cycles explicit |
| Iterator invalidation | Borrow checker forbids mutating a collection while borrowing it |
| NULL pointers | No null. Option<T> forces explicit handling via pattern matching |
| Data races | Send/Sync traits make data races a compile error |
| Uninitialized variables | All variables must be initialized; compiler enforces it |
| Integer UB | Debug panics on overflow; release wraps (both defined behavior) |
| Exceptions | No exceptions; Result<T, E> is visible in type signatures, propagated with ? |
| Inheritance complexity | Traits + composition; no Diamond Problem, no vtable fragility |
| Forgotten mutex unlocks | Mutex<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<T>)<br/>No exceptions (Result<T,E>)<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
| Concept | C | C++ | Rust | Key Difference |
|---|---|---|---|---|
| Memory management | malloc()/free() | unique_ptr, shared_ptr | Box<T>, Rc<T>, Arc<T> | Automatic, no cycles, no zombies |
| Arrays | int arr[10] | std::vector<T>, std::array<T> | Vec<T>, [T; N] | Bounds checking by default |
| Strings | char* with \0 | std::string, string_view | String, &str | UTF-8 guaranteed, lifetime-checked |
| References | int* (raw) | T&, T&& (move) | &T, &mut T | Lifetime + borrow checking |
| Polymorphism | Function pointers | Virtual functions, inheritance | Traits, trait objects | Composition over inheritance |
| Generics | Macros / void* | Templates | Generics + trait bounds | Clear error messages |
| Error handling | Return codes, errno | Exceptions, std::optional | Result<T, E>, Option<T> | No hidden control flow |
| NULL safety | ptr == NULL | nullptr, std::optional<T> | Option<T> | Forced null checking |
| Thread safety | Manual (pthreads) | Manual (std::mutex, etc.) | Compile-time Send/Sync | Data races impossible |
| Build system | Make, CMake | CMake, Make, etc. | Cargo | Integrated toolchain |
| Undefined behavior | Rampant | Subtle (signed overflow, aliasing) | Zero in safe code | Safety 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
fnkeyword - 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
- All functions in Rust begin with the
- 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_replfor 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
rustcis the standalone compiler, but it’s seldom used directly- The preferred tool,
cargois the Swiss Army knife and is used for dependency management, building, testing, formatting, linting, etc. - The Rust toolchain comes in the
stable,betaandnightly(experimental) channels, but we’ll stick withstable. Use therustup updatecommand to upgrade thestableinstallation that’s released every six weeks
- We’ll also install the
rust-analyzerplug-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
cargotool 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
- 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
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 runwill compile and run thedebug(unoptimized) version of the crate. To execute thereleaseversion, usecargo run --release - Note that actual binary file resides under the
targetfolder under thedebugorreleasefolder - We might have also noticed a file called
Cargo.lockin 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.locklater
- We will revisit the specific purpose of
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
| Description | Type | Example |
|---|---|---|
| Signed integers | i8, i16, i32, i64, i128, isize | -1, 42, 1_00_000, 1_00_000i64 |
| Unsigned integers | u8, u16, u32, u64, u128, usize | 0, 42, 42u32, 42u64 |
| Floating point | f32, f64 | 0.0, 0.42 |
| Unicode | char | ‘a’, ‘$’ |
| Boolean | bool | true, false |
- Rust permits arbitrarily use of
_between numbers for ease of reading
Rust type specification and assignment
- Rust uses the
letkeyword 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 an 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
- Rust can automatically infer the type of the variable based on the context.
- ▶ Try it in the Rust Playground
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
mutkeyword is used to denote that a variable is mutable. For example, the following code will not compile unless thelet a = 42is changed tolet 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/elseas expressions,loop/while/for,match, and how they differ from C/C++ counterparts. The key insight: most Rust control flow returns values.
- In Rust,
ifis 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
whilekeyword can be used to loop while an expression is true
fn main() {
let mut x = 40;
while x != 42 {
x += 1;
}
}
- The
forkeyword 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
loopkeyword creates an infinite loop until abreakis 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
breakstatement can include an optional expression that can be used to assign the value of aloopexpression - The
continuekeyword can be used to return to the top of theloop - Loop labels can be used with
breakorcontinueand 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
returnkeyword 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));
}
5. Data Structures
Rust array type
What you’ll learn: Rust’s core data structures — arrays, tuples, slices, strings, structs,
Vec, andHashMap. This is a dense chapter; focus on understandingStringvs&strand 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 10 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 thedebugprint formatter. The:#?formatter can be used forpretty print. These formatters can be customized per type (more on this later)
- Rust has several built-in formatters for printing. In the below, the
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 no 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
// Illegal because b and still are still in scope
// let d = &mut a;
}
let d = &mut a; // Ok: b and c are not in scope
*d = 43;
}
Rust slices
- Rust references can be used 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
constkeyword can be used to define a constant value. Constant values are evaluated at compile time and are inlined into the program - The
statickeyword 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’smalloc’d buffer, or C++’sstd::string)&str— borrowed, lightweight reference (like C’sconst char*with length, or C++’sstd::string_view— but&stris 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. Unlikestd::string_view, a&stris 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.
| Aspect | C char* | C++ std::string | Rust String | Rust &str |
|---|---|---|---|---|
| Memory | Manual (malloc/free) | Heap-allocated, owns buffer | Heap-allocated, auto-freed | Borrowed reference (lifetime-checked) |
| Mutability | Always mutable via pointer | Mutable | Mutable with mut | Always immutable |
| Size info | None (relies on '\0') | Tracks length and capacity | Tracks length and capacity | Tracks length (fat pointer) |
| Encoding | Unspecified (usually ASCII) | Unspecified (usually ASCII) | Guaranteed valid UTF-8 | Guaranteed valid UTF-8 |
| Null terminator | Required | Required (c_str()) | Not used | Not 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) -> usizethat counts the number of whitespace-separated words in a string - Write a function
fn longest_word(text: &str) -> &strthat returns the longest word (hint: you’ll need to think about lifetimes – why does the return type need to be&strand notString?)
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
structkeyword declares a user-defined struct typestructmembers 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 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 managedmalloc/reallocarrays in C, or C++’sstd::vector)- Unlike arrays with fixed size,
Veccan grow and shrink at runtime Vecowns its data and automatically manages memory allocation/deallocation
- Unlike arrays with fixed size,
- 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
HashMapimplements generickey->valuelookups (a.k.a.dictionaryormap)
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 aretrueand others arefalse). Loop over all elements in the hashmap and put the keys into oneVecand 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
&Tworks 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++ Concept | Rust Equivalent | Notes |
|---|---|---|
T& (lvalue ref) | &T or &mut T | Rust splits into shared vs exclusive |
T&& (rvalue ref) | Just T | Take 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 needed | No 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
noexceptconsiderations 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),
matchfor 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::variantbut with exhaustive pattern matching, nostd::getexceptions, and nostd::visitboilerplate - The size of the
enumis that of the largest possible type. The individual variants are not related to one another and can have completely different types enumtypes 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
matchis the equivalent of the C “switch” on steroidsmatchcan be used for pattern matching on simple data types,struct,enum- The
matchstatement must be exhaustive, i.e., they must cover all possible cases for a giventype. The_can be used a wildcard for the “all else” case matchcan yield a value, but all arms (=>) of 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
matchsupports ranges, boolean filters, andifguard 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
matchandenumsare 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
- The match statement can “bind” the contained value to a variable. Use
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
matchcan 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
implcan define methods associated for types likestruct,enum, etc- The methods may optionally take
selfas a parameter.selfis conceptually similar to passing a pointer to the struct as the first parameter in C, orthisin C++ - The reference to
selfcan be immutable (default:&self), mutable (&mut self), orself(transferring ownership) - The
Selfkeyword can be used a shortcut to imply the type
- The methods may optionally take
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
Pointadd()will take anotherPointand will increment the x and y values in place (hint: use&mut self)transform()will consume an existingPoint(hint: useself) and return a newPointby 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 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
Droptrait. 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 withfree(). 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
- In C: memory is allocated with
- 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++ Rust Safety 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 unsafeblocksFor C developers:
Box<T>replacesmalloc/freepairs.Rc<T>replaces manual reference counting. Raw pointers exist but are confined tounsafeblocks.
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
borrowfrom the original owner. The rule is that the scope of the borrow can never exceed the owning scope. In other words, thelifetimeof a borrow cannot exceed the owning lifetime
- The initial declaration of the variable establishes
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
dropa 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
Copytrait- Examples include u8, u32, i8, i32, etc. Copy semantics use “pass by value”
- User defined data types can optionally opt into
copysemantics using thederivemacro with to automatically implement theCopytrait - 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 scopedropis part of a generic trait calledDrop. The compiler provides a blanket NOP implementation for all types, but types can override it. For example, theStringtype 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, usedrop(obj)which moves the value into the function, runs its destructor, and prevents any further use — eliminating double-free bugs
For C++ developers:
Dropmaps directly to C++ destructors (~ClassName()):
C++ destructor Rust DropSyntax ~MyClass() { ... }impl Drop for MyType { fn drop(&mut self) { ... } }When called End of scope (RAII) End of scope (same) Called on move Source left in “valid but unspecified” state — destructor still runs on the moved-from object Source is gone — no destructor call on moved-from value Manual call obj.~MyClass()(dangerous, rarely used)drop(obj)(safe — takes ownership, callsdrop, prevents further use)Order Reverse declaration order Reverse declaration order (same) Rule of Five Must manage copy ctor, move ctor, copy assign, move assign, destructor Only Drop— compiler handles move semantics, andCloneis opt-inVirtual dtor needed? Yes, if deleting through base pointer No — 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
Pointwith and withoutCopyin#[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
DropforPointthat sets x and y to 0 indrop. 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)
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
- Lifetimes are denoted with
- 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
&strand store references slices from it inside the structure - Write a function that accepts the structure and returns the contained slice
- Create a long
// 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 with references"] --> R1
R1["Rule 1: Each input reference<br/>gets its own lifetime<br/><br/>fn f(&str, &str)<br/>→ fn f<'a,'b>(&'a str, &'b str)"]
R1 --> R2
R2["Rule 2: If exactly ONE input<br/>lifetime, assign it to ALL outputs<br/><br/>fn f(&str) → &str<br/>→ fn f<'a>(&'a str) → &'a str"]
R2 --> R3
R3["Rule 3: If one input is &self<br/>or &mut self, assign its lifetime<br/>to ALL outputs<br/><br/>fn f(&self, &str) → &str<br/>→ fn f<'a>(&'a self, &str) → &'a str"]
R3 --> CHECK{{"All output lifetimes<br/>determined?"}}
CHECK -->|"Yes"| OK["✅ No annotations needed"]
CHECK -->|"No"| ERR["❌ Compile error:<br/>must annotate 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:
| C | Rust | What happens |
|---|---|---|
char* get_name(struct User* u) | fn get_name(&self) -> &str | Rule 3 elides: output borrows from self |
char* concat(char* a, char* b) | fn concat<'a>(a: &'a str, b: &'a str) -> &'a str | Must 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 wrong | Compiler 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, andCell<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 toWeak<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<i32>"]
G["g: Box<i32>"]
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_vacationfield to be updated, while ensuringemployee_idcannot 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: Copyfor.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
| Criterion | Cell<T> | RefCell<T> |
|---|---|---|
| Works with | Copy types (integers, bools, floats) | Any type (String, Vec, structs) |
| Access pattern | Copies values in/out (.get(), .set()) | Borrows in place (.borrow(), .borrow_mut()) |
| Failure mode | Cannot fail — no runtime checks | Panics if you borrow mutably while another borrow is active |
| Overhead | Zero — just copies bytes | Small — tracks borrow state at runtime |
| Use when | You need a mutable flag, counter, or small value inside an immutable struct | You 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 Pointer Rust Equivalent Key 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; useArconly when sharing across threadsstd::weak_ptr<T>Weak<T>(fromRc::downgrade()orArc::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 forBox/Rc/Arconly 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: useWeakfor “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:
| Pattern | Use 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
Employeestruct withemployee_id: u64andname: String. Place it in anRc<Employee>and clone it into two separateVecs (us_employeesandglobal_employees). Print from both vectors to show they share the same data. - Part 2 (Cell): Add an
on_vacation: Cell<bool>field toEmployee. Pass an immutable&Employeereference to a function and toggleon_vacationfrom inside that function — without making the reference mutable. - Part 3 (RefCell): Replace
name: Stringwithname: 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 crates and modules
What you’ll learn: How Rust organizes code into modules and crates — privacy-by-default visibility,
pubmodifiers, workspaces, and thecrates.ioecosystem. 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
modkeyword. - 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 ofpubcan be further restricted topub(crate), etc - Even if an type is public, it doesn’t automatically become visible within the scope of another module unless it’s imported using the
usekeyword. Child submodules can reference types in the parent scope using theuse super:: - Source files (.rs) aren’t automatically included in the crate unless they are explicitly listed in
main.rs(executable) orlib.rs
- Each source file (.rs) is its own module, and can create nested modules using the
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
fnkeyword. The->keyword declares that the function returns a value (the default is void) with the typeu32(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 fooinmod a { struct foo; }is a distinct type (a::foo) frommod b { struct foo; }(b::foo))
- The module scoping extends to all types (for example, a
- As previously mentioned, function are defined with the
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
- 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.tomlat the workspace root should have a pointer to the constituent packages (crates)
- A workspace is simply a collection of local crates that will be used to build the target binaries. The
[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 worldprogram` - 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 --libspecifies 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
helloandhellolib. Notice that both of them have been to the upper levelCargo.toml - The presence of
lib.rsinhellolibimplies a library package (see https://doc.rust-lang.org/cargo/reference/cargo-targets.html for customization options) - Adding a dependency on
hellolibinCargo.tomlforhello
[dependencies]
hellolib = {path = "../hellolib"}
- Using
add()fromhellolib
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 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.iohas a major and minor version- Crates are expected to observe the major and minor
SemVerguidelines 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 are expected to observe the major and minor
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.tomlentries for declaring a dependency on therandcrate - At least
0.10.0, but anything< 0.11.0is fine
[dependencies]
rand = { version = "0.10.0"}
- Only
0.10.0, and nothing else
[dependencies]
rand = { version = "=0.10.0"}
- Don’t care;
cargowill 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
helloworldexample to print a random number - Use
cargo add randto 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.tomlhad specified a version of0.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.lockin the git repo to ensure reproducible builds
- The main idea behind Cargo.lock is to ensure reproducible builds. For example, if
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 (Linuxvs.Windows) for example - Tests can be executed with
cargo test. Reference: https://doc.rust-lang.org/reference/conditional-compilation.html
- The test code is never included in the actual binary. This is made possible by the
#![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
cargohas several other useful features including:cargo clippyis a great way of linting Rust code. In general, warnings should be fixed (or rarely suppressed if really warranted)cargo formatexecutes therustfmttool to format source code. Using the tool ensures standard formatting of checked-in code and puts an end to debates about stylecargo doccan be used to generate documentation from the///style comments. The documentation for all crates oncrates.iowas 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 Flag | Cargo.toml Key | Values |
|---|---|---|
-O0 / -O2 / -O3 | opt-level | 0, 1, 2, 3, "s", "z" |
-flto | lto | false, "thin", "fat" |
-g / no -g | debug | true, false, "line-tables-only" |
strip command | strip | "none", "debuginfo", "symbols", true/false |
| — | codegen-units | 1 = 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 / CMake | Rust build.rs |
|---|---|
-lfoo | println!("cargo::rustc-link-lib=foo") |
-L/path | println!("cargo::rustc-link-search=/path") |
| Compile C source | cc::Build::new().file("foo.c").compile("foo") |
| Generate code | Write 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-Compile | Rust Equivalent |
|---|---|
apt install gcc-aarch64-linux-gnu | rustup 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 targets | cargo 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 Preprocessor | Rust Feature Flags |
|---|---|
gcc -DDEBUG | cargo build --features verbose |
#ifdef DEBUG | #[cfg(feature = "verbose")] |
#define MAX 100 | const 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);
}
}
| Aspect | Unit Tests (#[cfg(test)]) | Integration Tests (tests/) |
|---|---|---|
| Location | Same file as code | Separate tests/ directory |
| Access | Private + public items | Public API only |
| Run command | cargo test | cargo 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 Testing | Rust Equivalent |
|---|---|
CUnit, CMocka, custom framework | Built-in #[test] + cargo test |
setUp() / tearDown() | Builder function + Drop trait |
#ifdef TEST mock functions | Trait-based dependency injection |
assert(x == y) | assert_eq!(x, y) with auto diff output |
| Separate test executable | Same binary, conditional compilation with #[cfg(test)] |
valgrind --leak-check=full ./test | cargo test (memory safe by default) + cargo miri test |
Code coverage: gcov / lcov | cargo tarpaulin or cargo llvm-cov |
| Test discovery: manual registration | Automatic — any #[test] fn is discovered |
Testing Patterns
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 withproptest, snapshot testing withinsta, 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) | Rust | Notes |
|---|---|---|
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::Test | Builder functions + Default | No inheritance needed |
Google Mock MOCK_METHOD | Trait + test impl | More explicit, no macro magic |
INSTANTIATE_TEST_SUITE_P (parameterized) | proptest! or macro-generated tests | |
SetUp() / TearDown() | RAII via Drop — cleanup is automatic | Variables dropped at end of test |
| Separate test binary + CMake | cargo test — zero config | |
ctest --output-on-failure | cargo 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 nofriendaccess.
9. Error Handling
Connecting enums to Option and Result
What you’ll learn: How Rust replaces null pointers with
Option<T>and exceptions withResult<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
enumtype we learned earlier? Rust’sOptionandResultare 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
matchworks directly withOptionandResult - There is no null pointer in Rust –
Option<T>is the replacement, and the compiler forces you to handle theNonecase
C++ Comparison: Exceptions vs Result
| C++ Pattern | Rust Equivalent | Advantage |
|---|---|---|
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 annotation | Default — all Rust functions are “noexcept” | Exceptions don’t exist |
errno / return codes | Result<T, E> | Type-safe, can’t ignore |
Rust Option type
- The Rust
Optiontype is anenumwith only two variants:Some<T>andNone- The idea is that this represents a
nullabletype, i.e., it either contains a valid value of that type (Some<T>), or has no valid value (None) - The
Optiontype is used in APIs 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
- The idea is that this represents a
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
Optioncan be processed in various waysunwrap()panics if theOption<T>isNoneand returnsTotherwise and it is the least preferred approachor()can be used to return an alternative valueif letlets us test forSome<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
enumtype similar toOptionwith two variants:Ok<T>orErr<E>Resultis used extensively in Rust APIs that can fail. The idea is that on success, functions will return aOk<T>, or they will return a specific errorErr<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 |
None | Err(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
Optionwhen absence is normal (e.g., looking up a key). UseResultwhen failure needs explanation (e.g., file I/O, parsing).
Exercise: log() function implementation with Option
🟢 Starter
- Implement a
log()function that accepts anOption<&str>parameter. If the parameter isNone, it should print a default string - The function should return a
Resultwith()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
panicsshould be avoided.panicsare caused by bugs in the program, including exceeding index bounds, callingunwrap()on anOption<None>, etc. - It is OK to have explicit
panicsfor conditions that should be impossible. Thepanic!orassert!macros can be used for sanity checks
- In general, situation that result in
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 andErr<E>contains the error
- The
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 thematchOk/Errpattern- 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 bystr::parse()
- Note the method must return
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. TheResult<>for success and error type is() - Invoke
log()function that exits with the sameResult<>type iflog()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 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 useanyhowvsthiserrorin production code.
OptionandResultare 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 valueOk(T)->Ok(U)orSome(T)->Some(U)map_err(): Transform the error typeErr(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 checkto get better error messages thancargo run
- Fix: Add
- Integer overflow in debug mode: Rust panics on overflow
- Fix: Use
wrapping_add(),saturating_add(), orchecked_add()for explicit behavior
- Fix: Use
- String vs &str confusion: Different types for different use cases
- Use
&strfor string slices (borrowed),Stringfor owned strings - Fix: Use
.to_string()orString::from()to convert&strtoString
- Use
- 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 variant | Holds | Created by |
|---|---|---|
FileRead(io::Error) | The original I/O error | #[from] auto-converts via ? |
Invalid { message } | A human-readable explanation | Your 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:
- Why does
?on theread_to_stringcall work? (Because#[from]generatesimpl From<io::Error> for ConfigError)- 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
| Concept | C++ | Rust |
|---|---|---|
| Error hierarchy | class AppError : public std::runtime_error | #[derive(thiserror::Error)] enum Error { ... } |
| Return error | std::expected<T, Error> or throw | fn foo() -> Result<T> |
| Convert error | Manual try/catch + rethrow | #[from] + ? — zero boilerplate |
| Result alias | template<class T> using Result = std::expected<T, Error>; | pub type Result<T> = core::result::Result<T, Error>; |
| Error message | Override what() | #[error("...")] — compiled into Display impl |
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++
| Aspect | C++ | Rust |
|---|---|---|
| Mechanism | Magic function names (operator+) | Implement a trait (impl Add for T) |
| Discovery | Grep for operator+ or read the header | Look at trait impls — IDE support excellent |
| Return type | Free choice | Fixed by the Output associated type |
| Receiver | Usually takes const T& (borrows) | Takes self by value (moves!) by default |
| Symmetry | Can write impl operator+(int, Vec2) | Must add impl Add<Vec2> for i32 (foreign trait rules apply) |
<< for printing | operator<<(ostream&, T) — overload for any stream | impl 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++ Operator | Rust Trait | Notes |
|---|---|---|
operator+ | std::ops::Add | Output associated type |
operator- | std::ops::Sub | |
operator* | std::ops::Mul | Not pointer deref — that’s Deref |
operator/ | std::ops::Div | |
operator% | std::ops::Rem | |
operator- (unary) | std::ops::Neg | |
operator! / operator~ | std::ops::Not | Rust uses ! for both logical and bitwise NOT (no ~ operator) |
operator&, |, ^ | BitAnd, BitOr, BitXor | |
operator<<, >> (shift) | Shl, Shr | NOT stream I/O! |
operator+= | std::ops::AddAssign | Takes &mut self (not self) |
operator[] | std::ops::Index / IndexMut | Returns &Output / &mut Output |
operator() | Fn / FnMut / FnOnce | Closures 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::Display | println!("{}", x) |
operator<< (debug) | fmt::Debug | println!("{:?}", x) |
operator bool | No direct equivalent | Use impl From<T> for bool or a named method like .is_empty() |
operator T() (implicit conversion) | No implicit conversions | Use From/Into traits (explicit) |
Guardrails: what Rust prevents
- No implicit conversions: C++
operator int()can cause silent, surprising casts. Rust has no implicit conversion operators — useFrom/Intoand call.into()explicitly. - No overloading
&&/||: C++ allows it (breaking short-circuit semantics!). Rust does not. - No overloading
=: Assignment is always a move or copy, never user-defined. Compound assignment (+=) IS overloadable viaAddAssign, etc. - No overloading
,: C++ allowsoperator,()— one of the most infamous C++ footguns. Rust does not. - No overloading
&(address-of): Another C++ footgun (std::addressofexists to work around it). Rust’s&always means “borrow.” - Coherence rules: You can only implement
Add<Foreign>for your own type, orAdd<YourType>for a foreign type — neverAdd<Foreign>forForeign. 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 traitwith a single method called log() that accepts a u64- Implement two different loggers
SimpleLoggerandComplexLoggerthat implement theLog trait. One should output “Simple logger” with theu64and the other should output “Complex logger” with theu64
- Implement two different loggers
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
implcan 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
implcan 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:
| Approach | Dispatch | Performance | Heterogeneous collections? | When to use |
|---|---|---|---|---|
impl Trait / generics | Static (monomorphized) | Zero-cost — inlined at compile time | No — each slot has one concrete type | Default choice. Function arguments, return types |
dyn Trait | Dynamic (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 |
enum | Match | Zero-cost — known variants at compile time | Yes — but only known variants | When 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 Traitis like C++ templates (monomorphized, zero-cost).dyn Traitis like C++ virtual functions (vtable dispatch). Rust enums withmatchare likestd::variantwithstd::visit— but exhaustive matching is enforced by the compiler.
Rule of thumb: Start with
impl Trait(static dispatch). Reach fordyn Traitonly when you need heterogeneous collections or can’t know the concrete type at compile time. Useenumwhen you own all the variants.
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
Tthat is encountered
- The generic parameter appears as an identifier within
// 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:f32vs.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
Pointtype to use two different types (TandU) 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 usingwhere. The following defines a generic functionget_areathat takes any typeTas long as it implements theComputeAreatrait
#![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
PrintDescriptiontraitand a genericstructShapewith 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
structwith a generic membercipherthat implementsCipherText
#![allow(unused)]
fn main() {
trait CipherText {
fn encrypt(&self);
}
// TO DO
//struct Cipher<>
}
- Next, implement a method called
encrypton thestructimplthat invokesencryptoncipher
#![allow(unused)]
fn main() {
// TO DO
impl for Cipher<> {}
}
- Next, implement
CipherTexton two structs calledCipherOneandCipherTwo(justprintln()is fine). CreateCipherOneandCipherTwo, and useCipherto 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
Dronewith say two states:IdleandFlying. In theIdlestate, the only permitted method istakeoff(). In theFlyingstate, we permitland()
- Consider a
-
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 azero-sizedmarker data type. In this case, we use it to represent theIdleandFlyingstates, but it haszeroruntime size - Notice that the
takeoffandlandmethods takeselfas a parameter. This is referred to asconsuming(contrast with&selfwhich uses borrowing). Basically, once we call thetakeoff()onDrone<Idle>, we can only get back aDrone<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
TwithPhantomData<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
selfto transition from one state to another - This gives us
zero costabstractions. 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
selfcan 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 From and Into traits
What you’ll learn: Rust’s type conversion traits —
From<T>andInto<T>for infallible conversions,TryFromandTryIntofor fallible ones. ImplementFromand getIntofor free. Replaces C++ conversion operators and constructors.
FromandIntoare complementary traits to facilitate type conversion- Types normally implement on the
Fromtrait. theString::from()converts from “&str” toString, and 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
Fromtrait forPointto convert into a type calledTransposePoint.TransposePointswaps thexandyelements ofPoint
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
Defaultcan be used to implement default values for a type- Types can use the
Derivemacro withDefaultor provide a custom implementation
- Types can use the
#[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
Defaulttrait has several use cases including- Performing a partial copy and using default initialization for rest
- Default alternative for
Optiontypes in methods likeunwrap_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
ascan be used forexplicitconversions asshould be sparingly used because it’s subject to loss of data by narrowing and so forth. In general, it’s preferable to useinto()orfrom()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 = f.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}");
}
}
12. Closures
Rust closures
What you’ll learn: Closures as anonymous functions, the three capture traits (
Fn,FnMut,FnOnce),moveclosures, 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
- C++ equivalent: lambdas (
- 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
Stringfrom the enclosing scope and appends to it (hint: usemove) - 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 perform 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 >= 42is 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 thefor_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
_inVec<_>is the equivalent of a wildcard character for the type returned by themap. For example, we can even return aStringfrommap
- In the below the
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++ Pattern | Rust Iterator | Example |
|---|---|---|
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:
- Filters sensors with temp > 80.0
- Sorts them by temperature (descending)
- Formats each as
"{name}: {temp}°C [ALARM]" - 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
Iteratortrait 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 typein theIterator(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 theFibonaccistructure - We could have implemented another trait called
IntoIteratorto implement theinto_iter()method for more specialized iterators - ▶ Try it in the Rust Playground
Iterator Power Tools
Iterator Power Tools Reference
What you’ll learn: Advanced iterator combinators beyond
filter/map/collect—enumerate,zip,chain,flat_map,scan,windows, andchunks. Essential for replacing C-style indexedforloops 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
| Method | C Equivalent | What it does | Returns |
|---|---|---|---|
enumerate() | for (int i=0; ...) | Pairs each element with its index | (usize, T) |
zip(other) | Parallel arrays with same index | Pairs elements from two iterators | (A, B) |
chain(other) | Process array1 then array2 | Concatenates two iterators | T |
flat_map(f) | Nested loops | Maps then flattens one level | U |
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 time | Non-overlapping slices of size n | &[T] |
fold(init, f) | int acc = init; for (...) acc = f(acc, x); | Reduce to single value | Acc |
scan(init, f) | Running accumulator with output | Like fold but yields intermediate results | Option<B> |
take(n) / skip(n) | Start loop at offset / limit | First n / skip first n elements | T |
take_while(f) / skip_while(f) | while (pred) {...} | Take/skip while predicate holds | T |
peekable() | Lookahead with arr[i+1] | Allows .peek() without consuming | T |
step_by(n) | for (i=0; i<len; i+=n) | Take every nth element | T |
unzip() | Split parallel arrays | Collect pairs into two collections | (A, B) |
sum() / product() | Accumulate sum/product | Reduce with + or * | T |
min() / max() | Find extremes | Return Option<T> | Option<T> |
any(f) / all(f) | bool found = false; for (...) ... | Short-circuit boolean search | bool |
position(f) | for (i=0; ...) if (pred) return i; | Index of first match | Option<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:
- Parses each line into
(name, f64, unit) - Filters out readings below a threshold
- Groups by sensor name using
foldinto aHashMap - 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
Iteratortrait 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 typein theIterator(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 theFibonaccistructure - We could have implemented another trait called
IntoIteratorto implement theinto_iter()method for more specialized iterators - https://play.rust-lang.org/?version=stable&mode=debug&edition=2021&gist=ab367dc2611e1b5a0bf98f1185b38f3f
Rust concurrency
What you’ll learn: Rust’s concurrency model — threads,
Send/Syncmarker 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::threadin C++- Key difference: Rust prevents data races at compile time through
SendandSyncmarker traits - In C++, sharing a
std::vectoracross 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
- Key difference: Rust prevents data races at compile time through
- 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 becausethread::scopewaits until the internal thread returns- Try executing this exercise without
thread::scopeto 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
moveto transfer ownership to the thread. ForCopytypes like[i32; 3], themovekeyword 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 threadsArcstands for Atomic Reference Counted. The reference isn’t released until the reference count reaches 0Arc::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 withMutex<T>to provide mutable references.Mutexguards the protected data and ensures that only the thread holding the lock has access.- The
MutexGuardis automatically released when it goes out of scope (RAII). Note:std::mem::forgetcan 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
RwLockwhen reads far outnumber writes (e.g., configuration, caches) - Use
Mutexwhen read/write frequency is similar or critical sections are short
- Use
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
MutexorRwLock, the lock becomes poisoned- Subsequent calls to
.lock()returnErr(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::mutexhas no poisoning concept; a panicking thread just leaves the lock held
- Subsequent calls to
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::atomictypes avoid the overhead of aMutexAtomicBool,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
}
| Primitive | When to use | C++ equivalent |
|---|---|---|
Mutex<T> | General mutable shared state | std::mutex + manual data association |
RwLock<T> | Read-heavy workloads | std::shared_mutex |
Atomic* | Simple counters, flags, lock-free patterns | std::atomic<T> |
Condvar | Wait for a condition to become true | std::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)
- Always paired with a
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
Condvarwhen 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
SenderandReceiver- This uses a paradigm called
mpscorMulti-producer, Single-Consumer - Both
send()andrecv()can block the thread
- This uses a paradigm called
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 isSendif it can be safely transferred to another threadSync: A type isSyncif it can be safely shared (via&T) between threads
- Most types are automatically
Send + Sync. Notable exceptions:Rc<T>is neither Send nor Sync (useArc<T>for threads)Cell<T>andRefCell<T>are not Sync (useMutex<T>orRwLock<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 implementSend Arc<Mutex<T>>is the thread-safe equivalent ofRc<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. AnRc<T>has a fragile (non-atomic) reference counter; handing it off or sharing it would corrupt the count, so it is neitherSendnorSync.
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
14. Unsafe Rust and FFI
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/CStrfor string interop, and how to write safe wrappers around unsafe code.
unsafeunlocks 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
unsafetells 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
unsafeshould be limited to the smallest possible scope - All code using
unsafeshould 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:
| Type | Analogous to | Use when |
|---|---|---|
CString | String (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 theResult. You’ll seeCStrused extensively in the FFI examples below.
FFImethods 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
cbindgento generate header files forC - We will see how
unsafewrappers 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
- Test methods are decorated with
#![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
SimpleLoggeris 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:
- We’ll assume the common interface pattern passing in a
#![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
unsaferequires 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_unwindat FFI entry points, or configurepanic = "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:
| Miri | Valgrind | C++ sanitizers (ASan/MSan/UBSan) | |
|---|---|---|---|
| What it catches | Rust-specific UB: stacked borrows, invalid enum discriminants, uninitialized reads, aliasing violations | Memory leaks, use-after-free, invalid reads/writes, uninitialized memory | Buffer overflow, use-after-free, data races, UB |
| How it works | Interprets MIR (Rust’s mid-level IR) — no native execution | Instruments compiled binary at runtime | Compile-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 use | Pure Rust unsafe code, data structure invariants | FFI code, full binary integration tests | C/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 ValgrindCatches leaks in
Box::leak/Box::from_rawpatterns 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
cbindgenis a great tool for (C) FFI to Rust- Use
bindgenfor FFI-interfaces in the other direction (consult the extensive documentation)
- Use
- 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
unsafecode has comments with an explicit documentation about assumptions and why it’s correct- Callers of
unsafecode should have corresponding comments on safety as well, and observe restrictions
- Callers of
Exercise: Writing a safe FFI wrapper
🔴 Challenge — requires understanding unsafe blocks, raw pointers, and safe API design
- Write a safe Rust wrapper around an
unsafeFFI-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_greetthat writes a greeting into a raw*mut u8buffer - Step 2: Write a safe wrapper
safe_greetthat allocates aVec<u8>, calls the unsafe function, and returns aString - 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!
no_std — Rust Without the Standard Library
What you’ll learn: How to write Rust for bare-metal and embedded targets using
#![no_std]— thecoreandalloccrate split, panic handlers, and how this compares to embedded C withoutlibc.
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).
| Layer | What it provides | Requires OS / heap? |
|---|---|---|
core | Primitive types, Option, Result, Iterator, math, slice, str, atomics, fmt | No — runs on bare metal |
alloc | Vec, String, Box, Rc, Arc, BTreeMap | Needs a global allocator, but no OS |
std | HashMap, fs, net, thread, io, env, process | Yes — needs an OS |
Rule of thumb for embedded devs: if your C project links against
-lcand usesmalloc, you can probably usecore+alloc. If it runs on bare metal withoutmalloc, stick withcoreonly.
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 feature | no_std alternative |
|---|---|
println! | core::write! to a UART / defmt |
HashMap | heapless::FnvIndexMap (fixed capacity) or BTreeMap (with alloc) |
Vec | heapless::Vec (stack-allocated, fixed capacity) |
String | heapless::String or &str |
std::io::Read/Write | embedded_io::Read/Write |
thread::spawn | Interrupt handlers, RTIC tasks |
std::time | Hardware timer peripherals |
std::fs | Flash / EEPROM drivers |
Notable no_std crates for embedded
| Crate | Purpose | Notes |
|---|---|---|
heapless | Fixed-capacity Vec, String, Queue, Map | No allocator needed — all on the stack |
defmt | Efficient logging over probe/ITM | Like printf but deferred formatting on the host |
embedded-hal | Hardware abstraction traits (SPI, I²C, GPIO, UART) | Implement once, run on any MCU |
cortex-m | ARM Cortex-M intrinsics & register access | Low-level, like CMSIS |
cortex-m-rt | Runtime / startup code for Cortex-M | Replaces your startup.s |
rtic | Real-Time Interrupt-driven Concurrency | Compile-time task scheduling, zero overhead |
embassy | Async executor for embedded | async/await on bare metal |
postcard | no_std serde serialization (binary) | Replaces serde_json when you can’t afford strings |
thiserror | Derive macro for Error trait | Works in no_std since v2; prefer over anyhow |
smoltcp | no_std TCP/IP stack | When 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()returnsOption— 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
mainand 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 fullpop(&mut self) -> Option<T>— returns oldest elementlen(&self) -> usizeis_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:
MaybeUninitis Rust’s equivalent of uninitialized memory — the compiler won’t insert zero-fills, just likechar buf[N];in C- The
unsafeblocks are minimal (2 lines) and each has a// SAFETY:comment - The
const fn new()means you can create ring buffers instaticvariables without a runtime constructor - The tests run on your host with
cargo testeven though the code isno_std
Embedded Deep Dive
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
volatilekeyword 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 access | Rust 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 UB | Compile error — field doesn’t exist |
| Wrong register width → silent UB | Type-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 script | Rust 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<I2C: I2c>"]
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 setup | Rust equivalent |
|---|---|
_sbrk() / custom malloc() | #[global_allocator] + Heap::init() |
configTOTAL_HEAP_SIZE (FreeRTOS) | HEAP_SIZE constant |
pvPortMalloc() | alloc::vec::Vec::new() — automatic |
| Heap exhaustion → undefined behavior | alloc_error_handler → controlled panic |
Mixed no_std + std Workspaces
Real projects (like a large Rust workspace) often have:
no_stdlibrary crates for hardware-portable logicstdbinary 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:
- Define a
LedController<SPI>struct - Implement
new(),set_brightness(led: u8, brightness: u8), andall_off() - SPI protocol: send
[led_index, brightness_value]as 2-byte transaction - 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:
| Aspect | OpenOCD + GDB | probe-rs |
|---|---|---|
| Install | 2 separate packages + scripts | cargo install probe-rs-tools |
| Config | .cfg files per board/probe | --chip flag or Embed.toml |
| Console output | Semihosting (very slow) | RTT (~10× faster) |
| Log framework | printf | defmt (structured, zero-cost) |
| Flash algorithm | XML pack files | Built-in for 1000+ chips |
| GDB support | Native | probe-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:
| Feature | C printf (semihosting) | Rust log crate | defmt |
|---|---|---|---|
| Speed | ~100ms per call | N/A (needs std) | ~1μs per call |
| Flash usage | Full format strings | Full format strings | Index only (bytes) |
| Transport | Semihosting (halts CPU) | Serial/UART | RTT (non-blocking) |
| Structured output | No | Text only | Typed, binary-encoded |
no_std | Via semihosting | Facade only (backends need std) | ✅ Native |
| Filter levels | Manual #ifdef | RUST_LOG=debug | defmt::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 Action | Rust Equivalent |
|---|---|
openocd -f board/st_nucleo_f4.cfg | probe-rs info (auto-detects probe + chip) |
arm-none-eabi-gdb -x .gdbinit | probe-rs gdb --chip STM32F401RE |
target remote :3333 | GDB connects to localhost:1337 |
monitor reset halt | probe-rs reset --chip ... |
load firmware.elf | cargo flash --chip ... |
printf("debug: %d\n", val) (semihosting) | defmt::info!("debug: {}", val) (RTT) |
| Keil/IAR GUI debugger | VS Code + probe-rs-debugger extension |
| Segger SystemView | defmt + 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.”
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++ Pattern | Rust Pattern | Impact |
|---|---|---|---|
| 1 | Class hierarchy + dynamic_cast | Enum dispatch + match | ~400 → 0 dynamic_casts |
| 2 | shared_ptr / enable_shared_from_this tree | Arena + index linkage | No reference cycles |
| 3 | Framework* raw pointer in every module | DiagContext<'a> with lifetime borrowing | Compile-time validity |
| 4 | God object | Composable state structs | Testable, modular |
| 5 | vector<unique_ptr<Base>> everywhere | Trait objects only where needed (~25 uses) | Static dispatch default |
Before and After Metrics
| Metric | C++ (Original) | Rust (Rewrite) |
|---|---|---|
dynamic_cast / type downcasts | ~400 | 0 |
virtual / override methods | ~900 | ~25 (Box<dyn Trait>) |
Raw new allocations | ~200 | 0 (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 expressions | N/A | ~750 match |
| God objects (>5K lines) | 2 | 0 |
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, noweak_ptr, noenable_shared_from_this - No reference cycles possible — indices are just
usizevalues - 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
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_triggersvsm_alertTriggers— clear ownership - Fearless refactoring: Changing
GpuDiagStatecan’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 Case | Pattern | Why |
|---|---|---|
| Fixed set of variants known at compile time | enum + match | Exhaustive checking, no vtable |
| Hardware event types (Degrade, Fatal, Boot, …) | enum GpuEventKind | All variants known, performance matters |
| PCIe device types (GPU, NIC, Switch, …) | enum PcieDeviceKind | Fixed set, each variant has different data |
| Plugin/module system (open for extension) | Box<dyn Trait> | New modules added without modifying framework |
| Test mocking | Box<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
- 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 - Arena pattern eliminates reference cycles —
shared_ptrandenable_shared_from_thisare symptoms of unclear ownership. Think about who owns the data first - Pass context, don’t store pointers — Lifetime-bounded
DiagContext<'a>is safer and clearer than storingFramework*in every module - Decompose god objects — If a struct has 30+ fields, it’s probably 3-4 structs wearing a trenchcoat
- The compiler is your pair programmer — ~400
dynamic_castcalls meant ~400 potential runtime failures. Zerodynamic_castequivalents 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 selfin 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
- Start with a small, self-contained module (not the god object)
- Translate data structures first, then behavior
- Let the compiler guide you — its error messages are excellent
- Reach for
enumbeforedyn Trait - Use the Rust playground to prototype patterns before integrating
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()vscalc() - 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
thiserrorfor 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
thiserroroveranyhow: Our team convention is to define explicit error enums with#[derive(thiserror::Error)]so callers can match on specific variants.anyhow::Erroris convenient for quick prototyping but erases the error type, making it harder for callers to handle specific failures. Usethiserrorfor library and production code; reserveanyhowfor 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
- Unit tests:
#![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
&Tinstead 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 benchand profiling tools - Prefer iterators over loops: More readable and often faster
- Use
&stroverString: 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: "alice@example.com".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,HashMapetc. - 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
| Trait | Benefit | When to Use |
|---|---|---|
Debug | println!("{:?}", value) | Always (except rare cases) |
Display | println!("{}", value) | User-facing types |
Clone | value.clone() | When explicit duplication makes sense |
Copy | Implicit duplication | Small, simple types |
PartialEq | == and != operators | Most types |
Eq | Reflexive equality | When equality is mathematically sound |
PartialOrd | <, >, <=, >= | Types with natural ordering |
Ord | sort(), BinaryHeap | When ordering is total |
Hash | HashMap keys | Types used as map keys |
Default | Default::default() | Types with obvious defaults |
From/Into | Convenient conversions | Common type conversions |
TryFrom/TryInto | Fallible conversions | Conversions that can fail |
Avoiding Excessive clone()
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
| Situation | Why clone is OK | Example |
|---|---|---|
Arc::clone() for threading | Bumps ref count (~1 ns), doesn’t copy data | let flag = stop_flag.clone(); |
| Moving data into a spawned thread | Thread needs its own copy | let ctx = ctx.clone(); thread::spawn(move || { ... }) |
Extracting from &self fields | Can’t move out of a borrow | self.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?
- Can I accept
&str/&Tinstead ofString/T? → Borrow, don’t clone - Can I restructure to avoid needing two owners? → Pass by reference or use scopes
- Is this
Arc::clone()? → That’s fine, it’s O(1) - Am I moving data into a thread/closure? → Clone is necessary
- 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
| Situation | Use 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 returnsstd::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++ | Rust | Notes |
|---|---|---|
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 null | Weak::upgrade() → Option<Rc<T>> | None if dropped |
shared_ptr::use_count() | Rc::strong_count() | Same meaning |
When to use Weak
| Situation | Pattern |
|---|---|
| Parent ↔ child tree relationships | Parent holds Rc<Child>, child holds Weak<Parent> |
| Observer pattern / event listeners | Event source holds Weak<Observer>, observer holds Rc<Source> |
| Cache that doesn’t prevent deallocation | HashMap<Key, Weak<Value>> — entries go stale naturally |
| Breaking cycles in graph structures | Cross-links use Weak, tree edges use Rc/Arc |
Prefer the arena pattern (Case Study 2) over
Rc/Weakfor tree structures in new code.Vec<T>+ indices is simpler, faster, and has zero reference-counting overhead. UseRc/Weakwhen 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 bitwisememcpyautomatically. In Rust,Copyis the same idea: assignmentlet 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 astd::vectormember), the equivalent in Rust is implementingClone. 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:Copytypes copy silently (cheap), non-Copytypes move by default, and you must opt in to an expensive duplicate with.clone(). - Similarly, C++
operator==doesn’t distinguish between types wherea == aalways holds (like integers) and types where it doesn’t (likefloatwith NaN). Rust encodes this inPartialEqvsEq.
Copy vs Clone
| Copy | Clone | |
|---|---|---|
| How it works | Bitwise memcpy (implicit) | Custom logic (explicit .clone()) |
| When it happens | On assignment: let b = a; | Only when you call .clone() |
| After copy/clone | Both a and b are valid | Both a and b are valid |
| Without either | let b = a; moves a (a is gone) | let b = a; moves a (a is gone) |
| Allowed for | Types with no heap data | Any type |
| C++ analogy | Trivially 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
| PartialEq | Eq | |
|---|---|---|
| What it gives you | == and != operators | Marker: “equality is reflexive” |
| Reflexive? (a == a) | Not guaranteed | Guaranteed |
| Why it matters | f32::NAN != f32::NAN | HashMap keys require Eq |
| When to derive | Almost always | When the type has no f32/f64 fields |
| C++ analogy | operator== | 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
| PartialOrd | Ord | |
|---|---|---|
| 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 category | Typical derive | Example |
|---|---|---|
| Simple status enum | Copy, Clone, PartialEq, Eq, Default | FanStatus |
| Enum used as HashMap key | Copy, Clone, PartialEq, Eq, Hash | CpuFaultType, SelComponent |
| Sortable severity enum | Copy, Clone, PartialEq, Eq, PartialOrd, Ord | FaultSeverity, GpuDiagLevel |
| Data struct with Strings | Clone, Debug, Serialize, Deserialize | FruData, OverallSummary |
| Serializable config | Clone, Debug, Default, Serialize, Deserialize | DiagConfig |
Avoiding Unchecked Indexing
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, andentry()API forHashMap. Replaces C++’s undefined behavior with explicit handling.
- In C++,
vec[i]andmap[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 afterassert!(!v.is_empty());
Safe value extraction with unwrap_or
unwrap()panics onNone/Err. In production code, prefer the safe alternatives.
The unwrap family
| Method | Behavior on None/Err | Use When |
|---|---|---|
.unwrap() | Panics | Tests only, or provably infallible |
.expect("msg") | Panics with message | When panic is justified, explain why |
.unwrap_or(default) | Returns default | You have a cheap constant fallback |
.unwrap_or_else(|| expr) | Calls closure | Fallback 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
OptionandResultlet you transform the contained value without unwrapping, replacing nestedif/elsewith linear chains.
Quick reference
| Method | On | Does | C++ Equivalent |
|---|---|---|---|
.map(|v| ...) | Option / Result | Transform the Some/Ok value | if (opt) { *opt = transform(*opt); } |
.map_err(|e| ...) | Result | Transform the Err value | Adding context to catch block |
.and_then(|v| ...) | Option / Result | Chain operations that return Option/Result | Nested if-checks |
.find_map(|v| ...) | Iterator | find + map in one pass | Loop with if + break |
.filter(|v| ...) | Option / Iterator | Keep only values matching predicate | if (!predicate) return nullopt; |
.ok()? | Result | Convert Result → Option and propagate None | if (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::jsonfor 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)
| Attribute | Purpose | C++ Equivalent |
|---|---|---|
#[serde(default)] | Use Default::default() for missing fields | if (j.contains(key)) { ... } else { default; } |
#[serde(rename = "Key")] | Map JSON key name to Rust field name | Manual j.at("Key") access |
#[serde(flatten)] | Absorb unknown keys into HashMap | for (auto& [k,v] : j.items()) { ... } |
#[serde(skip)] | Don’t serialize/deserialize this field | Not 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
ServerConfigstruct 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)]andserde_json::from_str()to parse it - Add
#[serde(default)]todebugso it defaults tofalseif missing - Bonus: Add an
enum DiagLevel { Quick, Full, Extended }field with#[serde(default)]that defaults toQuick
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
Collapsing assignment pyramids with closures
What you’ll learn: How Rust’s expression-based syntax and closures flatten deeply-nested C++
if/elsevalidation chains into clean, linear code.
- C++ often requires multi-block
if/elsechains 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++ Pattern | Rust Replacement | Key Benefit |
|---|---|---|
| Multi-block variable assignment | let (a, b) = if ... { } else { }; | All variables bound atomically |
Nested if (contains) pyramid | IIFE closure with ? operator | Linear, 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:
- Define an
enum Severity { Info, Warning, Critical }withDisplay, and astruct DiagEventcontainingsource: String,severity: Severity,message: String, andfault_code: u32 - Define a
trait EventFilterwith a methodfn should_include(&self, event: &DiagEvent) -> bool - Implement two filters:
SeverityFilter(only events >= a given severity) andSourceFilter(only events from a specific source string) - Write a function
fn process_events(events: &[DiagEvent], filters: &[&dyn EventFilter]) -> Vec<String>that returns formatted report lines for events that pass all filters - Write a
fn parse_event(line: &str) -> Result<DiagEvent, String>that parses lines of the form"source:severity:fault_code:message"(returnErrfor 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 Ecosystem
Logging and Tracing: syslog/printf → log + tracing
What you’ll learn: Rust’s two-layer logging architecture (facade + backend), the
logandtracingcrates, structured logging with spans, and how this replacesprintf/syslogdebugging.
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 calls | trace! / debug! compiled out at max_level | Zero-cost when disabled |
Custom Logger::log(level, msg) | log::info!("...") — all crates use same API | Universal facade, swappable backend |
| Per-file log verbosity | RUST_LOG=crate::module=level | Environment-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
| Aspect | log | tracing |
|---|---|---|
| Complexity | Simple — 5 macros | Richer — spans, fields, instruments |
| Structured data | String interpolation only | Key-value fields: info!(gpu_id = 0, "msg") |
| Timing / spans | No | Yes — #[instrument], span.enter() |
| Async support | Basic | First-class — spans propagate across .await |
| Compatibility | Universal facade | Compatible with log (has a log bridge) |
| When to use | Simple applications, libraries | Diagnostic tools, async code, observability |
Recommendation: Use
tracingfor production diagnostic-style projects (diagnostic tools with structured output). Uselogfor simple libraries where you want minimal dependencies.tracingincludes a compatibility layer so libraries usinglogmacros still work with atracingsubscriber.
Backend options
| Backend Crate | Output | Use Case |
|---|---|---|
env_logger | stderr, colored | Development, simple CLI tools |
tracing-subscriber | stderr, formatted | Production with tracing |
syslog | System syslog | Linux system services |
tracing-journald | systemd journal | systemd-managed services |
tracing-appender | Rotating log files | Long-running daemons |
tracing-opentelemetry | OpenTelemetry collector | Distributed tracing |
18. C++ → Rust Semantic Deep Dives
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++ Cast | Rust Equivalent | Safety | Notes |
|---|---|---|---|
static_cast (numeric) | as keyword | Safe but can truncate/wrap | let i = 3.14_f64 as i32; — truncates to 3 |
static_cast (numeric, checked) | From/Into | Safe, compile-time verified | let i: i32 = 42_u8.into(); — only widens |
static_cast (numeric, fallible) | TryFrom/TryInto | Safe, returns Result | let i: u8 = 300_u16.try_into()?; — returns Err |
dynamic_cast (downcast) | match on enum / Any::downcast_ref | Safe | Pattern matching for enums; Any for trait objects |
const_cast | No equivalent | Rust has no way to cast away & → &mut in safe code. Use Cell/RefCell for interior mutability | |
reinterpret_cast | std::mem::transmute | unsafe | Reinterprets 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,
asshould be rare (useFrom/Intofor widening,TryFrom/TryIntofor narrowing),transmuteshould be exceptional, andconst_casthas 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++ Preprocessor | Rust Equivalent | Advantage |
|---|---|---|
#define PI 3.14 | const 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 once | Not needed | Each .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++ | Rust | Why it’s better |
|---|---|---|
#include "foo.h" | mod foo; in parent + use foo::Item; | No textual inclusion, no ODR violations |
#pragma once / include guards | Not needed | Each .rs file is a module — compiled once |
| Forward declarations | Not needed | Compiler sees entire crate; order doesn’t matter |
class Foo; (incomplete type) | Not needed | No separate declaration/definition split |
.h + .cpp for each class | Single .rs file | No declaration/definition mismatch bugs |
using namespace std; | use std::collections::HashMap; | Always explicit — no global namespace pollution |
Nested namespace a::b | Nested mod a { mod b { } } or a/b.rs | File 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++ Access | Rust Equivalent | Scope |
|---|---|---|
private | (default, no keyword) | Accessible within the same module only |
protected | No direct equivalent | Use pub(super) for parent module access |
public | pub | Accessible everywhere |
friend class Foo | Put Foo in the same module | Module-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++ Usage | Rust Equivalent | Notes |
|---|---|---|
volatile for hardware registers | ptr::read_volatile / ptr::write_volatile | Requires unsafe — correct for MMIO |
volatile for thread signaling | AtomicBool / AtomicU32 etc. | C++ volatile is wrong for this too! |
std::atomic<T> | std::sync::atomic::AtomicT | Same 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++ | Rust | Notes |
|---|---|---|
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_local | thread_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++ | Rust | Notes |
|---|---|---|
constexpr int f() | const fn f() -> i32 | Same intent — compile-time evaluable |
constexpr variable | const variable | Rust const is always compile-time |
consteval (C++20) | No equivalent | const 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 initializer | Rust 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 aVecin const context)- No heap allocation (
Box::new,Vec::newnot const)No floating-point arithmetic— stabilized in Rust 1.82- Can’t use
forloops (use recursion orwhilewith 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 Metaprogramming | Rust Equivalent | Readability |
|---|---|---|
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 sets | Separate 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 concept | trait | 🟢 Nearly identical intent |
requires clause | where clause | 🟢 Same position, similar syntax |
| Compilation fails deep inside template | Compilation 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 use | C++ Equivalent | Rust Choice |
|---|---|---|
| Top-level function, no captures | Function pointer | fn(Args) -> Ret |
| Generic function accepting callables | Template parameter | impl Fn(Args) -> Ret (static dispatch) |
| Trait bound in generics | template<typename F> | F: Fn(Args) -> Ret |
| Stored callable, type-erased | std::function<R(Args)> | Box<dyn Fn(Args) -> Ret> |
| Callback that mutates state | std::function with mutable lambda | Box<dyn FnMut(Args) -> Ret> |
| One-shot callback (consumed) | std::function (moved) | Box<dyn FnOnce(Args) -> Ret> |
Performance note:
impl Fnhas zero overhead (monomorphized, like a C++ template).Box<dyn Fn>has the same overhead asstd::function(vtable + heap allocation). Preferimpl Fnunless you need to store heterogeneous callables.
Container Mapping: C++ STL → Rust std::collections
| C++ STL Container | Rust Equivalent | Notes |
|---|---|---|
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 equivalent | Use 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::string | String | UTF-8 guaranteed, not null-terminated |
std::string_view | &str | Borrowed 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 equivalent | Use the bitvec crate or [u8; N/8] |
Key differences:
- Rust’s
HashMap/HashSetrequireK: 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 Vecindexing (v[i]) panics on out-of-bounds by default. Use.get(i)forOption<&T>or iterators to avoid bounds checks entirely- No
std::multimaporstd::multiset— useHashMap<K, Vec<V>>orBTreeMap<K, Vec<V>>
Exception Safety → Panic Safety
C++ defines three levels of exception safety (Abrahams guarantees):
| C++ Level | Meaning | Rust Equivalent |
|---|---|---|
| No-throw | Function never throws | Function never panics (returns Result) |
| Strong (commit-or-rollback) | If it throws, state is unchanged | Ownership model makes this natural — if ? returns early, partially built values are dropped |
| Basic | If it throws, invariants are preserved | Rust’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 Pattern | Rust 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 unwinding | Drop::drop() runs during panic unwinding |
std::uncaught_exceptions() | std::thread::panicking() |
-fno-exceptions compile flag | panic = "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 (likeassert!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++ Pattern | Rust Idiom | Notes |
|---|---|---|
class Derived : public Base | enum Variant { A {...}, B {...} } | Prefer enums for closed sets |
virtual void method() = 0 | trait 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 class | fn 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 parameter | Accepts 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 + lint | cargo build / test / clippy / fmt | One tool for everything |
Migration Strategy
- Start with data types: Translate structs and enums first — this forces you to think about ownership
- Convert factories to enums: If a factory creates different derived types, it should probably be
enum+match - Convert god objects to composed structs: Group related fields into focused structs
- Replace pointers with borrows: Convert
Base*stored pointers to&'a Tlifetime-bounded borrows - Use
Box<dyn Trait>sparingly: Only for plugin systems and test mocking - Let the compiler guide you: Rust’s error messages are excellent — read them carefully
19. Rust Macros: From Preprocessor to Metaprogramming
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:
| Need | Function/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 macros | Full 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
#defineentirely. 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
| Specifier | Matches | Example |
|---|---|---|
$x:expr | Any expression | 42, a + b, foo() |
$x:ty | A type | i32, Vec<String>, &str |
$x:ident | An identifier | foo, my_var |
$x:pat | A pattern | Some(x), _, (a, b) |
$x:stmt | A statement | let x = 5; |
$x:block | A block | { println!("hi"); 42 } |
$x:literal | A literal | 42, "hello", true |
$x:tt | A single token tree | Anything — the wildcard |
$x:item | An 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
| Operator | Meaning | Example |
|---|---|---|
$( ... )* | Zero or more | vec![], vec![1], vec![1, 2, 3] |
$( ... )+ | One or more | At least one element required |
$( ... )? | Zero or one | Optional 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
Resulttype, has no double-evaluation risk, and the compiler checks that$condis actually aboolexpression.
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:
| Macro | What it does | Expands to (simplified) |
|---|---|---|
println!("{}", x) | Format and print to stdout + newline | std::io::_print(format_args!(...)) |
eprintln!("{}", x) | Print to stderr + newline | Same but to stderr |
format!("{}", x) | Format into a String | Allocates and returns a String |
vec![1, 2, 3] | Create a Vec with elements | Vec::from([1, 2, 3]) (approximately) |
todo!() | Mark unfinished code | panic!("not yet implemented") |
unimplemented!() | Mark deliberately unimplemented code | panic!("not implemented") |
unreachable!() | Mark code the compiler can’t prove unreachable | panic!("unreachable") |
assert!(cond) | Panic if condition is false | if !cond { panic!(...) } |
assert_eq!(a, b) | Panic if values aren’t equal | Shows both values on failure |
dbg!(expr) | Print expression + value to stderr, return value | eprintln!("[file:line] expr = {:#?}", &expr); expr |
include_str!("file.txt") | Embed file contents as &str at compile time | Reads file during compilation |
include_bytes!("data.bin") | Embed file contents as &[u8] at compile time | Reads file during compilation |
cfg!(condition) | Compile-time condition as a bool | true or false based on target |
env!("VAR") | Read environment variable at compile time | Fails 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/%dformat mismatch bugs.For C++ developers: This replaces
std::cout << std::fixed << std::setprecision(2) << valuewith 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
| Derive | What it generates | When to use |
|---|---|---|
Debug | {:?} formatting | Almost always — enables printing for debugging |
Clone | .clone() method | When you need to duplicate values |
Copy | Implicit copy on assignment | Small, stack-only types (integers, [f64; 3]) |
PartialEq / Eq | == and != operators | When you need equality comparison |
PartialOrd / Ord | <, >, <=, >= operators | When you need ordering |
Hash | Hashing for HashMap/HashSet keys | Types used as map keys |
Default | Type::default() constructor | Types with sensible zero/empty values |
serde::Serialize / Deserialize | JSON/TOML/etc. serialization | Data 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-generatingoperator==that compares each field — something C++20’s= defaultspaceship 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:
| Attribute | Purpose |
|---|---|
#[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:
| Kind | Syntax | Example | What it does |
|---|---|---|---|
| Function-like | my_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)]fromthiserror— generatesDisplayandFromimpls for error enums#[derive(Serialize, Deserialize)]fromserde— generates serialization code#[tokio::main]— transformsasync 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
protocfill 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 valuesmin!(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
HashMapfromkey => valuepairs - 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!");
}