Rust for C# Programmers: Complete Training Guide
A comprehensive guide to learning Rust for developers with C# experience. This guide covers everything from basic syntax to advanced patterns, focusing on the conceptual shifts and practical differences between the two languages.
Course Overview
- The case for Rust — Why Rust matters for C# developers: performance, safety, and correctness
- Getting started — Installation, tooling, and your first program
- Basic building blocks — Types, variables, control flow
- Data structures — Arrays, tuples, structs, collections
- Pattern matching and enums — Algebraic data types and exhaustive matching
- Ownership and borrowing — Rust’s memory management model
- Modules and crates — Code organization and dependencies
- Error handling — Result-based error propagation
- Traits and generics — Rust’s type system
- Closures and iterators — Functional programming patterns
- Concurrency — Fearless concurrency with type-system guarantees, async/await deep dive
- Unsafe Rust and FFI — When and how to go beyond safe Rust
- Migration patterns — Real-world C# to Rust patterns and incremental adoption
- Best practices — Idiomatic Rust for C# developers
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 in Rust |
| 5–6 | Data structures, enums, pattern matching | 1–2 days | You can define an enum with data and match exhaustively on it |
| 7 | Ownership and borrowing | 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, iterators | 1–2 days | You can translate a LINQ chain to Rust iterators |
| 13 | Concurrency and async | 1 day | You can write a thread-safe counter with Arc<Mutex<T>> |
| 14 | Unsafe Rust, FFI, testing | 1 day | You can call a Rust function from C# via P/Invoke |
| 15–16 | Migration, best practices, tooling | At your own pace | Reference material — consult as you write real code |
| 17 | Capstone project | 1–2 days | You have a working CLI tool that fetches weather data |
How to use the exercises:
- Chapters include hands-on exercises in collapsible
<details>blocks with solutions - 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
Difficulty indicators:
- 🟢 Beginner — Direct translation from C# concepts
- 🟡 Intermediate — Requires understanding ownership or traits
- 🔴 Advanced — Lifetimes, async internals, or unsafe code
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 deeper async patterns, see the companion Async Rust Training
Table of Contents
Part I — Foundations
1. Introduction and Motivation 🟢
- The Case for Rust for C# Developers
- Common C# Pain Points That Rust Addresses
- When to Choose Rust Over C#
- Language Philosophy Comparison
- Quick Reference: Rust vs C#
2. Getting Started 🟢
- Installation and Setup
- Your First Rust Program
- Cargo vs NuGet/MSBuild
- Reading Input and CLI Arguments
- Essential Rust Keywords (optional reference — consult as needed)
3. Built-in Types and Variables 🟢
- Variables and Mutability
- Primitive Types Comparison
- String Types: String vs &str
- Printing and String Formatting
- Type Casting and Conversions
- True Immutability vs Record Illusions
4. Control Flow 🟢
- Functions vs Methods
- Expression vs Statement (Important!)
- Conditional Statements
- Loops and Iteration
5. Data Structures and Collections 🟢
- Tuples and Destructuring
- Arrays and Slices
- Structs vs Classes
- Constructor Patterns
Vec<T>vsList<T>- HashMap vs Dictionary
6. Enums and Pattern Matching 🟡
- Algebraic Data Types vs C# Unions
- Exhaustive Pattern Matching
Option<T>for Null Safety- Guards and Advanced Patterns
7. Ownership and Borrowing 🟡
- Understanding Ownership
- Move Semantics vs Reference Semantics
- Borrowing and References
- Memory Safety Deep Dive
- Lifetimes Deep Dive 🔴
- Smart Pointers, Drop, and Deref 🔴
8. Crates and Modules 🟢
9. Error Handling 🟡
- Exceptions vs
Result<T, E> - The ? Operator
- Custom Error Types
- Crate-Level Error Types and Result Aliases
- Error Recovery Patterns
10. Traits and Generics 🟡
- Traits vs Interfaces
- Inheritance vs Composition
- Generic Constraints: where vs trait bounds
- Common Standard Library Traits
11. From and Into Traits 🟡
12. Closures and Iterators 🟡
Part II — Concurrency & Systems
13. Concurrency 🔴
- Thread Safety: Convention vs Type System Guarantees
- async/await: C# Task vs Rust Future
- Cancellation Patterns
- Pin and tokio::spawn
14. Unsafe Rust, FFI, and Testing 🟡
- When and Why to Use Unsafe
- Interop with C# via FFI
- Testing in Rust vs C#
- Property Testing and Mocking
Part III — Migration & Best Practices
15. Migration Patterns and Case Studies 🟡
16. Best Practices and Reference 🟡
- Idiomatic Rust for C# Developers
- Performance Comparison: Managed vs Native
- Common Pitfalls and Solutions
- Learning Path and Resources
- Rust Tooling Ecosystem
Capstone
17. Capstone Project 🟡
- Build a CLI Weather Tool — combines structs, traits, error handling, async, modules, serde, and testing into a working application
1. Introduction and Motivation
Speaker Intro and General Approach
- 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# and .NET development
- Examples deliberately map C# concepts to Rust equivalents
- Please feel free to ask clarifying questions at any point of time
The Case for Rust for C# Developers
What you’ll learn: Why Rust matters for C# developers — the performance gap between managed and native code, how Rust eliminates null-reference exceptions and hidden control flow at compile time, and the key scenarios where Rust complements or replaces C#.
Difficulty: 🟢 Beginner
Performance Without the Runtime Tax
// C# - Great productivity, runtime overhead
public class DataProcessor
{
private List<int> data = new List<int>();
public void ProcessLargeDataset()
{
// Allocations trigger GC
for (int i = 0; i < 10_000_000; i++)
{
data.Add(i * 2); // GC pressure
}
// Unpredictable GC pauses during processing
}
}
// Runtime: Variable (50-200ms due to GC)
// Memory: ~80MB (including GC overhead)
// Predictability: Low (GC pauses)
#![allow(unused)]
fn main() {
// Rust - Same expressiveness, zero runtime overhead
struct DataProcessor {
data: Vec<i32>,
}
impl DataProcessor {
fn process_large_dataset(&mut self) {
// Zero-cost abstractions
for i in 0..10_000_000 {
self.data.push(i * 2); // No GC pressure
}
// Deterministic performance
}
}
// Runtime: Consistent (~30ms)
// Memory: ~40MB (exact allocation)
// Predictability: High (no GC)
}
Memory Safety Without Runtime Checks
// C# - Runtime safety with overhead
public class RuntimeCheckedOperations
{
public string? ProcessArray(int[] array)
{
// Runtime bounds checking on every access
if (array.Length > 0)
{
return array[0].ToString(); // Safe — int is a value type, never null
}
return null; // Nullable return (string? with C# 8+ nullable reference types)
}
public void ProcessConcurrently()
{
var list = new List<int>();
// Data races possible, requires careful locking
Parallel.For(0, 1000, i =>
{
lock (list) // Runtime overhead
{
list.Add(i);
}
});
}
}
#![allow(unused)]
fn main() {
// Rust - Compile-time safety with zero runtime cost
struct SafeOperations;
impl SafeOperations {
// Compile-time null safety, no runtime checks
fn process_array(array: &[i32]) -> Option<String> {
array.first().map(|x| x.to_string())
// No null references possible
// Bounds checking optimized away when provably safe
}
fn process_concurrently() {
use std::sync::{Arc, Mutex};
use std::thread;
let data = Arc::new(Mutex::new(Vec::new()));
// Data races prevented at compile time
let handles: Vec<_> = (0..1000).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
data.lock().unwrap().push(i);
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
}
}
}
Common C# Pain Points That Rust Addresses
1. The Billion Dollar Mistake: Null References
// C# - Null reference exceptions are runtime bombs
public class UserService
{
public string GetUserDisplayName(User user)
{
// Any of these could throw NullReferenceException
return user.Profile.DisplayName.ToUpper();
// ^^^^^ ^^^^^^^ ^^^^^^^^^^^ ^^^^^^^
// Could be null at runtime
}
// Even with nullable reference types (C# 8+)
public string GetDisplayName(User? user)
{
return user?.Profile?.DisplayName?.ToUpper() ?? "Unknown";
// Still possible to have null at runtime
}
}
#![allow(unused)]
fn main() {
// Rust - Null safety guaranteed at compile time
struct UserService;
impl UserService {
fn get_user_display_name(user: &User) -> Option<String> {
user.profile.as_ref()?
.display_name.as_ref()
.map(|name| name.to_uppercase())
// Compiler forces you to handle None case
// Impossible to have null pointer exceptions
}
fn get_display_name_safe(user: Option<&User>) -> String {
user.and_then(|u| u.profile.as_ref())
.and_then(|p| p.display_name.as_ref())
.map(|name| name.to_uppercase())
.unwrap_or_else(|| "Unknown".to_string())
// Explicit handling, no surprises
}
}
}
2. Hidden Exceptions and Control Flow
// C# - Exceptions can be thrown from anywhere
public async Task<UserData> GetUserDataAsync(int userId)
{
// Each of these might throw different exceptions
var user = await userRepository.GetAsync(userId); // SqlException
var permissions = await permissionService.GetAsync(user); // HttpRequestException
var preferences = await preferenceService.GetAsync(user); // TimeoutException
return new UserData(user, permissions, preferences);
// Caller has no idea what exceptions to expect
}
#![allow(unused)]
fn main() {
// Rust - All errors explicit in function signatures
#[derive(Debug)]
enum UserDataError {
DatabaseError(String),
NetworkError(String),
Timeout,
UserNotFound(i32),
}
async fn get_user_data(user_id: i32) -> Result<UserData, UserDataError> {
// All errors explicit and handled
let user = user_repository.get(user_id).await
.map_err(UserDataError::DatabaseError)?;
let permissions = permission_service.get(&user).await
.map_err(UserDataError::NetworkError)?;
let preferences = preference_service.get(&user).await
.map_err(|_| UserDataError::Timeout)?;
Ok(UserData::new(user, permissions, preferences))
// Caller knows exactly what errors are possible
}
}
3. Correctness: The Type System as a Proof Engine
Rust’s type system catches entire categories of logic bugs at compile time that C# can only catch at runtime — or not at all.
ADTs vs Sealed-Class Workarounds
// C# — Discriminated unions require sealed-class boilerplate.
// The compiler warns about missing cases (CS8524) ONLY when there's no _ catch-all.
// In practice, most C# code uses _ as a default, which silences the warning.
public abstract record Shape;
public sealed record Circle(double Radius) : Shape;
public sealed record Rectangle(double W, double H) : Shape;
public sealed record Triangle(double A, double B, double C) : Shape;
public static double Area(Shape shape) => shape switch
{
Circle c => Math.PI * c.Radius * c.Radius,
Rectangle r => r.W * r.H,
// Forgot Triangle? The _ catch-all silences any compiler warning.
_ => throw new ArgumentException("Unknown shape")
};
// Add a new variant six months later — the _ pattern hides the missing case.
// No compiler warning tells you about the 47 switch expressions you need to update.
#![allow(unused)]
fn main() {
// Rust — ADTs + exhaustive matching = compile-time proof
enum Shape {
Circle { radius: f64 },
Rectangle { w: f64, h: f64 },
Triangle { a: f64, b: f64, c: f64 },
}
fn area(shape: &Shape) -> f64 {
match shape {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { w, h } => w * h,
// Forget Triangle? ERROR: non-exhaustive pattern
Shape::Triangle { a, b, c } => {
let s = (a + b + c) / 2.0;
(s * (s - a) * (s - b) * (s - c)).sqrt()
}
}
}
// Add a new variant → compiler shows you EVERY match that needs updating.
}
Immutability by Default vs Opt-In Immutability
// C# — Everything is mutable by default
public class Config
{
public string Host { get; set; } // Mutable by default
public int Port { get; set; }
}
// "readonly" and "record" help, but don't prevent deep mutation:
public record ServerConfig(string Host, int Port, List<string> AllowedOrigins);
var config = new ServerConfig("localhost", 8080, new List<string> { "*.example.com" });
// Records are "immutable" but reference-type fields are NOT:
config.AllowedOrigins.Add("*.evil.com"); // Compiles and mutates! ← bug
// The compiler gives you no warning.
#![allow(unused)]
fn main() {
// Rust — Immutable by default, mutation is explicit and visible
struct Config {
host: String,
port: u16,
allowed_origins: Vec<String>,
}
let config = Config {
host: "localhost".into(),
port: 8080,
allowed_origins: vec!["*.example.com".into()],
};
// config.allowed_origins.push("*.evil.com".into()); // ERROR: cannot borrow as mutable
// Mutation requires explicit opt-in:
let mut config = config;
config.allowed_origins.push("*.safe.com".into()); // OK — visibly mutable
// "mut" in the signature tells every reader: "this function modifies data"
fn add_origin(config: &mut Config, origin: String) {
config.allowed_origins.push(origin);
}
}
Functional Programming: First-Class vs Afterthought
// C# — FP bolted on; LINQ is expressive but the language fights you
public IEnumerable<Order> GetHighValueOrders(IEnumerable<Order> orders)
{
return orders
.Where(o => o.Total > 1000) // Func<Order, bool> — heap-allocated delegate
.Select(o => new OrderSummary // Anonymous type or extra class
{
Id = o.Id,
Total = o.Total
})
.OrderByDescending(o => o.Total);
// No exhaustive matching on results
// Null can sneak in anywhere in the pipeline
// Can't enforce purity — any lambda might have side effects
}
#![allow(unused)]
fn main() {
// Rust — FP is a first-class citizen
fn get_high_value_orders(orders: &[Order]) -> Vec<OrderSummary> {
orders.iter()
.filter(|o| o.total > 1000) // Zero-cost closure, no heap allocation
.map(|o| OrderSummary { // Type-checked struct
id: o.id,
total: o.total,
})
.sorted_by(|a, b| b.total.cmp(&a.total)) // itertools
.collect()
// No nulls anywhere in the pipeline
// Closures are monomorphized — zero overhead vs hand-written loops
// Purity enforced: &[Order] means the function CAN'T modify orders
}
}
Inheritance: Elegant in Theory, Fragile in Practice
// C# — The fragile base class problem
public class Animal
{
public virtual string Speak() => "...";
public void Greet() => Console.WriteLine($"I say: {Speak()}");
}
public class Dog : Animal
{
public override string Speak() => "Woof!";
}
public class RobotDog : Dog
{
// Which Speak() does Greet() call? What if Dog changes?
// Diamond problem with interfaces + default methods
// Tight coupling: changing Animal can break RobotDog silently
}
// Common C# anti-patterns:
// - God base classes with 20 virtual methods
// - Deep hierarchies (5+ levels) nobody can reason about
// - "protected" fields creating hidden coupling
// - Base class changes silently altering derived behavior
#![allow(unused)]
fn main() {
// Rust — Composition over inheritance, enforced by the language
trait Speaker {
fn speak(&self) -> &str;
}
trait Greeter: Speaker {
fn greet(&self) {
println!("I say: {}", self.speak());
}
}
struct Dog;
impl Speaker for Dog {
fn speak(&self) -> &str { "Woof!" }
}
impl Greeter for Dog {} // Uses default greet()
struct RobotDog {
voice: String, // Composition: owns its own data
}
impl Speaker for RobotDog {
fn speak(&self) -> &str { &self.voice }
}
impl Greeter for RobotDog {} // Clear, explicit behavior
// No fragile base class problem — no base classes at all
// No hidden coupling — traits are explicit contracts
// No diamond problem — trait coherence rules prevent ambiguity
// Adding a method to Speaker? Compiler tells you everywhere to implement it.
}
Key insight: In C#, correctness is a discipline — you hope developers follow conventions, write tests, and catch edge cases in code review. In Rust, correctness is a property of the type system — entire categories of bugs (null derefs, forgotten variants, accidental mutation, data races) are structurally impossible.
4. Unpredictable Performance Due to GC
// C# - GC can pause at any time
public class HighFrequencyTrader
{
private List<Trade> trades = new List<Trade>();
public void ProcessMarketData(MarketTick tick)
{
// Allocations can trigger GC at worst possible moment
var analysis = new MarketAnalysis(tick);
trades.Add(new Trade(analysis.Signal, tick.Price));
// GC might pause here during critical market moment
// Pause duration: 1-100ms depending on heap size
}
}
#![allow(unused)]
fn main() {
// Rust - Predictable, deterministic performance
struct HighFrequencyTrader {
trades: Vec<Trade>,
}
impl HighFrequencyTrader {
fn process_market_data(&mut self, tick: MarketTick) {
// Zero allocations, predictable performance
let analysis = MarketAnalysis::from(tick);
self.trades.push(Trade::new(analysis.signal(), tick.price));
// No GC pauses, consistent sub-microsecond latency
// Performance guaranteed by type system
}
}
}
When to Choose Rust Over C#
✅ Choose Rust When:
- Correctness matters: State machines, protocol implementations, financial logic — where a missed case is a production incident, not a test failure
- Performance is critical: Real-time systems, high-frequency trading, game engines
- Memory usage matters: Embedded systems, cloud costs, mobile applications
- Predictability required: Medical devices, automotive, financial systems
- Security is paramount: Cryptography, network security, system-level code
- Long-running services: Where GC pauses cause issues
- Resource-constrained environments: IoT, edge computing
- System programming: CLI tools, databases, web servers, operating systems
✅ Stay with C# When:
- Rapid application development: Business applications, CRUD applications
- Large existing codebase: When migration cost is prohibitive
- Team expertise: When Rust learning curve doesn’t justify benefits
- Enterprise integrations: Heavy .NET Framework/Windows dependencies
- GUI applications: WPF, WinUI, Blazor ecosystems
- Time to market: When development speed trumps performance
🔄 Consider Both (Hybrid Approach):
- Performance-critical components in Rust: Called from C# via P/Invoke
- Business logic in C#: Familiar, productive development
- Gradual migration: Start with new services in Rust
Real-World Impact: Why Companies Choose Rust
Dropbox: Storage Infrastructure
- Before (Python): High CPU usage, memory overhead
- After (Rust): 10x performance improvement, 50% memory reduction
- Result: Millions saved in infrastructure costs
Discord: Voice/Video Backend
- Before (Go): GC pauses causing audio drops
- After (Rust): Consistent low-latency performance
- Result: Better user experience, reduced server costs
Microsoft: Windows Components
- Rust in Windows: File system, networking stack components
- Benefit: Memory safety without performance cost
- Impact: Fewer security vulnerabilities, same performance
Why This Matters for C# Developers:
- Complementary skills: Rust and C# solve different problems
- Career growth: Systems programming expertise increasingly valuable
- Performance understanding: Learn zero-cost abstractions
- Safety mindset: Apply ownership thinking to any language
- Cloud costs: Performance directly impacts infrastructure spend
Language Philosophy Comparison
C# Philosophy
- Productivity first: Rich tooling, extensive framework, “pit of success”
- Managed runtime: Garbage collection handles memory automatically
- Enterprise-focused: Strong typing with reflection, extensive standard library
- Object-oriented: Classes, inheritance, interfaces as primary abstractions
Rust Philosophy
- Performance without sacrifice: Zero-cost abstractions, no runtime overhead
- Memory safety: Compile-time guarantees prevent crashes and security vulnerabilities
- Systems programming: Direct hardware access with high-level abstractions
- Functional + systems: Immutability by default, ownership-based resource management
graph TD
subgraph "C# Development Model"
CS_CODE["C# Source Code<br/>Classes, Methods, Properties"]
CS_COMPILE["C# Compiler<br/>(csc.exe)"]
CS_IL["Intermediate Language<br/>(IL bytecode)"]
CS_RUNTIME[".NET Runtime<br/>(CLR)"]
CS_JIT["Just-In-Time Compiler"]
CS_NATIVE["Native Machine Code"]
CS_GC["Garbage Collector<br/>(Memory management)"]
CS_CODE --> CS_COMPILE
CS_COMPILE --> CS_IL
CS_IL --> CS_RUNTIME
CS_RUNTIME --> CS_JIT
CS_JIT --> CS_NATIVE
CS_RUNTIME --> CS_GC
CS_BENEFITS["[OK] Fast development<br/>[OK] Rich ecosystem<br/>[OK] Automatic memory management<br/>[ERROR] Runtime overhead<br/>[ERROR] GC pauses<br/>[ERROR] Platform dependency"]
end
subgraph "Rust Development Model"
RUST_CODE["Rust Source Code<br/>Structs, Enums, Functions"]
RUST_COMPILE["Rust Compiler<br/>(rustc)"]
RUST_NATIVE["Native Machine Code<br/>(Direct compilation)"]
RUST_ZERO["Zero Runtime<br/>(No VM, No GC)"]
RUST_CODE --> RUST_COMPILE
RUST_COMPILE --> RUST_NATIVE
RUST_NATIVE --> RUST_ZERO
RUST_BENEFITS["[OK] Maximum performance<br/>[OK] Memory safety<br/>[OK] No runtime dependencies<br/>[ERROR] Steeper learning curve<br/>[ERROR] Longer compile times<br/>[ERROR] More explicit code"]
end
style CS_BENEFITS fill:#e3f2fd,color:#000
style RUST_BENEFITS fill:#e8f5e8,color:#000
style CS_GC fill:#fff3e0,color:#000
style RUST_ZERO fill:#e8f5e8,color:#000
Quick Reference: Rust vs C#
| Concept | C# | Rust | Key Difference |
|---|---|---|---|
| Memory management | Garbage collector | Ownership system | Zero-cost, deterministic cleanup |
| Null references | null everywhere | Option<T> | Compile-time null safety |
| Error handling | Exceptions | Result<T, E> | Explicit, no hidden control flow |
| Mutability | Mutable by default | Immutable by default | Opt-in to mutation |
| Type system | Reference/value types | Ownership types | Move semantics, borrowing |
| Assemblies | GAC, app domains | Crates | Static linking, no runtime |
| Namespaces | using System.IO | use std::fs | Module system |
| Interfaces | interface IFoo | trait Foo | Default implementations |
| Generics | List<T> where T : class | Vec<T> where T: Clone | Zero-cost abstractions |
| Threading | locks, async/await | Ownership + Send/Sync | Data race prevention |
| Performance | JIT compilation | AOT compilation | Predictable, no GC pauses |
2. Getting Started
Installation and Setup
What you’ll learn: How to install Rust and set up your IDE, the Cargo build system vs MSBuild/NuGet, your first Rust program compared to C#, and how to read command-line input.
Difficulty: 🟢 Beginner
Installing Rust
# Install Rust (works on Windows, macOS, Linux)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
# On Windows, you can also download from: https://rustup.rs/
Rust Tools vs C# Tools
| C# Tool | Rust Equivalent | Purpose |
|---|---|---|
dotnet new | cargo new | Create new project |
dotnet build | cargo build | Compile project |
dotnet run | cargo run | Run project |
dotnet test | cargo test | Run tests |
| NuGet | Crates.io | Package repository |
| MSBuild | Cargo | Build system |
| Visual Studio | VS Code + rust-analyzer | IDE |
IDE Setup
-
VS Code (Recommended for beginners)
- Install “rust-analyzer” extension
- Install “CodeLLDB” for debugging
-
Visual Studio (Windows)
- Install Rust support extension
-
JetBrains RustRover (Full IDE)
- Similar to Rider for C#
Your First Rust Program
C# Hello World
// Program.cs
using System;
namespace HelloWorld
{
class Program
{
static void Main(string[] args)
{
Console.WriteLine("Hello, World!");
}
}
}
Rust Hello World
// main.rs
fn main() {
println!("Hello, World!");
}
Key Differences for C# Developers
- No classes required - Functions can exist at the top level
- No namespaces - Uses module system instead
println!is a macro - Notice the!- No semicolon after println! - Expression vs statement
- No explicit return type -
mainreturns()(unit type)
Creating Your First Project
# Create new project (like 'dotnet new console')
cargo new hello_rust
cd hello_rust
# Project structure created:
# hello_rust/
# ├── Cargo.toml (like .csproj file)
# └── src/
# └── main.rs (like Program.cs)
# Run the project (like 'dotnet run')
cargo run
Cargo vs NuGet/MSBuild
Project Configuration
C# (.csproj)
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="3.0.1" />
</Project>
Rust (Cargo.toml)
[package]
name = "hello_rust"
version = "0.1.0"
edition = "2021"
[dependencies]
serde_json = "1.0" # Like Newtonsoft.Json
log = "0.4" # Like Serilog
Common Cargo Commands
# Create new project
cargo new my_project
cargo new my_project --lib # Create library project
# Build and run
cargo build # Like 'dotnet build'
cargo run # Like 'dotnet run'
cargo test # Like 'dotnet test'
# Package management
cargo add serde # Add dependency (like 'dotnet add package')
cargo update # Update dependencies
# Release build
cargo build --release # Optimized build
cargo run --release # Run optimized version
# Documentation
cargo doc --open # Generate and open docs
Workspace vs Solution
C# Solution (.sln)
MySolution/
├── MySolution.sln
├── WebApi/
│ └── WebApi.csproj
├── Business/
│ └── Business.csproj
└── Tests/
└── Tests.csproj
Rust Workspace (Cargo.toml)
[workspace]
members = [
"web_api",
"business",
"tests"
]
Reading Input and CLI Arguments
Every C# developer knows Console.ReadLine(). Here’s how to handle user input, environment variables, and command-line arguments in Rust.
Console Input
// C# — reading user input
Console.Write("Enter your name: ");
string? name = Console.ReadLine(); // Returns string? in .NET 6+
Console.WriteLine($"Hello, {name}!");
// Parsing input
Console.Write("Enter a number: ");
if (int.TryParse(Console.ReadLine(), out int number))
{
Console.WriteLine($"You entered: {number}");
}
else
{
Console.WriteLine("That's not a valid number.");
}
use std::io::{self, Write};
fn main() {
// Reading a line of input
print!("Enter your name: ");
io::stdout().flush().unwrap(); // flush because print! doesn't auto-flush
let mut name = String::new();
io::stdin().read_line(&mut name).expect("Failed to read line");
let name = name.trim(); // remove trailing newline
println!("Hello, {name}!");
// Parsing input
print!("Enter a number: ");
io::stdout().flush().unwrap();
let mut input = String::new();
io::stdin().read_line(&mut input).expect("Failed to read");
match input.trim().parse::<i32>() {
Ok(number) => println!("You entered: {number}"),
Err(_) => println!("That's not a valid number."),
}
}
Command-Line Arguments
// C# — reading CLI args
static void Main(string[] args)
{
if (args.Length < 1)
{
Console.WriteLine("Usage: program <filename>");
return;
}
string filename = args[0];
Console.WriteLine($"Processing {filename}");
}
use std::env;
fn main() {
let args: Vec<String> = env::args().collect();
// args[0] = program name (like C#'s Assembly name)
// args[1..] = actual arguments
if args.len() < 2 {
eprintln!("Usage: {} <filename>", args[0]); // eprintln! → stderr
std::process::exit(1);
}
let filename = &args[1];
println!("Processing {filename}");
}
Environment Variables
// C#
string dbUrl = Environment.GetEnvironmentVariable("DATABASE_URL") ?? "localhost";
#![allow(unused)]
fn main() {
use std::env;
let db_url = env::var("DATABASE_URL").unwrap_or_else(|_| "localhost".to_string());
// env::var returns Result<String, VarError> — no nulls!
}
Production CLI Apps with clap
For anything beyond trivial argument parsing, use the clap crate — it’s the Rust equivalent of System.CommandLine or libraries like CommandLineParser.
# Cargo.toml
[dependencies]
clap = { version = "4", features = ["derive"] }
use clap::Parser;
/// A simple file processor — this doc comment becomes the help text
#[derive(Parser, Debug)]
#[command(name = "processor", version, about)]
struct Args {
/// Input file to process
#[arg(short, long)]
input: String,
/// Output file (defaults to stdout)
#[arg(short, long)]
output: Option<String>,
/// Enable verbose logging
#[arg(short, long, default_value_t = false)]
verbose: bool,
/// Number of worker threads
#[arg(short = 'j', long, default_value_t = 4)]
threads: usize,
}
fn main() {
let args = Args::parse(); // auto-parses, validates, generates --help
if args.verbose {
println!("Input: {}", args.input);
println!("Output: {:?}", args.output);
println!("Threads: {}", args.threads);
}
// Use args.input, args.output, etc.
}
# Auto-generated help:
$ processor --help
A simple file processor
Usage: processor [OPTIONS] --input <INPUT>
Options:
-i, --input <INPUT> Input file to process
-o, --output <OUTPUT> Output file (defaults to stdout)
-v, --verbose Enable verbose logging
-j, --threads <THREADS> Number of worker threads [default: 4]
-h, --help Print help
-V, --version Print version
// C# equivalent with System.CommandLine (more boilerplate):
var inputOption = new Option<string>("--input", "Input file") { IsRequired = true };
var verboseOption = new Option<bool>("--verbose", "Enable verbose logging");
var rootCommand = new RootCommand("A simple file processor");
rootCommand.AddOption(inputOption);
rootCommand.AddOption(verboseOption);
rootCommand.SetHandler((input, verbose) => { /* ... */ }, inputOption, verboseOption);
await rootCommand.InvokeAsync(args);
// clap's derive macro approach is more concise and type-safe
| C# | Rust | Notes |
|---|---|---|
Console.ReadLine() | io::stdin().read_line(&mut buf) | Must provide buffer, returns Result |
int.TryParse(s, out n) | s.parse::<i32>() | Returns Result<i32, ParseIntError> |
args[0] | env::args().nth(1) | Rust args[0] = program name |
Environment.GetEnvironmentVariable | env::var("KEY") | Returns Result, not nullable |
System.CommandLine | clap | Derive-based, auto-generates help |
Essential Keywords Reference (optional)
Essential Rust Keywords for C# Developers
What you’ll learn: A quick-reference mapping of Rust keywords to their C# equivalents — visibility modifiers, ownership keywords, control flow, type definitions, and pattern matching syntax.
Difficulty: 🟢 Beginner
Understanding Rust’s keywords and their purposes helps C# developers navigate the language more effectively.
Visibility and Access Control Keywords
C# Access Modifiers
public class Example
{
public int PublicField; // Accessible everywhere
private int privateField; // Only within this class
protected int protectedField; // This class and subclasses
internal int internalField; // Within this assembly
protected internal int protectedInternalField; // Combination
}
Rust Visibility Keywords
#![allow(unused)]
fn main() {
// pub - Makes items public (like C# public)
pub struct PublicStruct {
pub public_field: i32, // Public field
private_field: i32, // Private by default (no keyword)
}
pub mod my_module {
pub(crate) fn crate_public() {} // Public within current crate (like internal)
pub(super) fn parent_public() {} // Public to parent module
pub(self) fn self_public() {} // Public within current module (same as private)
pub use super::PublicStruct; // Re-export (like using alias)
}
// No direct equivalent to C# protected - use composition instead
}
Memory and Ownership Keywords
C# Memory Keywords
// ref - Pass by reference
public void Method(ref int value) { value = 10; }
// out - Output parameter
public bool TryParse(string input, out int result) { /* */ }
// in - Readonly reference (C# 7.2+)
public void ReadOnly(in LargeStruct data) { /* Cannot modify data */ }
Rust Ownership Keywords
#![allow(unused)]
fn main() {
// & - Immutable reference (like C# in parameter)
fn read_only(data: &Vec<i32>) {
println!("Length: {}", data.len()); // Can read, cannot modify
}
// &mut - Mutable reference (like C# ref parameter)
fn modify(data: &mut Vec<i32>) {
data.push(42); // Can modify
}
// move - Force move capture in closures
let data = vec![1, 2, 3];
let closure = move || {
println!("{:?}", data); // data is moved into closure
};
// data is no longer accessible here
// Box - Heap allocation (like C# new for reference types)
let boxed_data = Box::new(42); // Allocate on heap
}
Control Flow Keywords
C# Control Flow
// return - Exit function with value
public int GetValue() { return 42; }
// yield return - Iterator pattern
public IEnumerable<int> GetNumbers()
{
yield return 1;
yield return 2;
}
// break/continue - Loop control
foreach (var item in items)
{
if (item == null) continue;
if (item.Stop) break;
}
Rust Control Flow Keywords
#![allow(unused)]
fn main() {
// return - Explicit return (usually not needed)
fn get_value() -> i32 {
return 42; // Explicit return
// OR just: 42 (implicit return)
}
// break/continue - Loop control with optional values
fn find_value() -> Option<i32> {
loop {
let value = get_next();
if value < 0 { continue; }
if value > 100 { break None; } // Break with value
if value == 42 { break Some(value); } // Break with success
}
}
// loop - Infinite loop (like while(true))
loop {
if condition { break; }
}
// while - Conditional loop
while condition {
// code
}
// for - Iterator loop
for item in collection {
// code
}
}
Type Definition Keywords
C# Type Keywords
// class - Reference type
public class MyClass { }
// struct - Value type
public struct MyStruct { }
// interface - Contract definition
public interface IMyInterface { }
// enum - Enumeration
public enum MyEnum { Value1, Value2 }
// delegate - Function pointer
public delegate void MyDelegate(int value);
Rust Type Keywords
#![allow(unused)]
fn main() {
// struct - Data structure (like C# class/struct combined)
struct MyStruct {
field: i32,
}
// enum - Algebraic data type (much more powerful than C# enum)
enum MyEnum {
Variant1,
Variant2(i32), // Can hold data
Variant3 { x: i32, y: i32 }, // Struct-like variant
}
// trait - Interface definition (like C# interface but more powerful)
trait MyTrait {
fn method(&self);
// Default implementation (like C# 8+ default interface methods)
fn default_method(&self) {
println!("Default implementation");
}
}
// type - Type alias (like C# using alias)
type UserId = u32;
type Result<T> = std::result::Result<T, MyError>;
// impl - Implementation block (no C# equivalent - methods defined separately)
impl MyStruct {
fn new() -> MyStruct {
MyStruct { field: 0 }
}
}
impl MyTrait for MyStruct {
fn method(&self) {
println!("Implementation");
}
}
}
Function Definition Keywords
C# Function Keywords
// static - Class method
public static void StaticMethod() { }
// virtual - Can be overridden
public virtual void VirtualMethod() { }
// override - Override base method
public override void VirtualMethod() { }
// abstract - Must be implemented
public abstract void AbstractMethod();
// async - Asynchronous method
public async Task<int> AsyncMethod() { return await SomeTask(); }
Rust Function Keywords
#![allow(unused)]
fn main() {
// fn - Function definition (like C# method but standalone)
fn regular_function() {
println!("Hello");
}
// const fn - Compile-time function (like C# const but for functions)
const fn compile_time_function() -> i32 {
42 // Can be evaluated at compile time
}
// async fn - Asynchronous function (like C# async)
async fn async_function() -> i32 {
some_async_operation().await
}
// unsafe fn - Function that may violate memory safety
unsafe fn unsafe_function() {
// Can perform unsafe operations
}
// extern fn - Foreign function interface
extern "C" fn c_compatible_function() {
// Can be called from C
}
}
Variable Declaration Keywords
C# Variable Keywords
// var - Type inference
var name = "John"; // Inferred as string
// const - Compile-time constant
const int MaxSize = 100;
// readonly - Runtime constant (fields only, not local variables)
// readonly DateTime createdAt = DateTime.Now;
// static - Class-level variable
static int instanceCount = 0;
Rust Variable Keywords
#![allow(unused)]
fn main() {
// let - Variable binding (like C# var)
let name = "John"; // Immutable by default
// let mut - Mutable variable binding
let mut count = 0; // Can be changed
count += 1;
// const - Compile-time constant (like C# const)
const MAX_SIZE: usize = 100;
// static - Global variable (like C# static)
static INSTANCE_COUNT: std::sync::atomic::AtomicUsize =
std::sync::atomic::AtomicUsize::new(0);
}
Pattern Matching Keywords
C# Pattern Matching (C# 8+)
// switch expression
string result = value switch
{
1 => "One",
2 => "Two",
_ => "Other"
};
// is pattern
if (obj is string str)
{
Console.WriteLine(str.Length);
}
Rust Pattern Matching Keywords
#![allow(unused)]
fn main() {
// match - Pattern matching (like C# switch but much more powerful)
let result = match value {
1 => "One",
2 => "Two",
3..=10 => "Between 3 and 10", // Range patterns
_ => "Other", // Wildcard (like C# _)
};
// if let - Conditional pattern matching
if let Some(value) = optional {
println!("Got value: {}", value);
}
// while let - Loop with pattern matching
while let Some(item) = iterator.next() {
println!("Item: {}", item);
}
// let with patterns - Destructuring
let (x, y) = point; // Destructure tuple
let Some(value) = optional else {
return; // Early return if pattern doesn't match
};
}
Memory Safety Keywords
C# Memory Keywords
// unsafe - Disable safety checks
unsafe
{
int* ptr = &variable;
*ptr = 42;
}
// fixed - Pin managed memory
unsafe
{
fixed (byte* ptr = array)
{
// Use ptr
}
}
Rust Safety Keywords
#![allow(unused)]
fn main() {
// unsafe - Disable borrow checker (use sparingly!)
unsafe {
let ptr = &variable as *const i32;
let value = *ptr; // Dereference raw pointer
}
// Raw pointer types (no C# equivalent - usually not needed)
let ptr: *const i32 = &42; // Immutable raw pointer
let ptr: *mut i32 = &mut 42; // Mutable raw pointer
}
Common Rust Keywords Not in C#
#![allow(unused)]
fn main() {
// where - Generic constraints (more flexible than C# where)
fn generic_function<T>()
where
T: Clone + Send + Sync,
{
// T must implement Clone, Send, and Sync traits
}
// dyn - Dynamic trait objects (like C# object but type-safe)
let drawable: Box<dyn Draw> = Box::new(Circle::new());
// Self - Refer to the implementing type (like C# this but for types)
impl MyStruct {
fn new() -> Self { // Self = MyStruct
Self { field: 0 }
}
}
// self - Method receiver
impl MyStruct {
fn method(&self) { } // Immutable borrow
fn method_mut(&mut self) { } // Mutable borrow
fn consume(self) { } // Take ownership
}
// crate - Refer to current crate root
use crate::models::User; // Absolute path from crate root
// super - Refer to parent module
use super::utils; // Import from parent module
}
Keywords Summary for C# Developers
| Purpose | C# | Rust | Key Difference |
|---|---|---|---|
| Visibility | public, private, internal | pub, default private | More granular with pub(crate) |
| Variables | var, readonly, const | let, let mut, const | Immutable by default |
| Functions | method() | fn | Standalone functions |
| Types | class, struct, interface | struct, enum, trait | Enums are algebraic types |
| Generics | <T> where T : IFoo | <T> where T: Foo | More flexible constraints |
| References | ref, out, in | &, &mut | Compile-time borrow checking |
| Patterns | switch, is | match, if let | Exhaustive matching required |
3. Built-in Types and Variables
Variables and Mutability
What you’ll learn: Rust’s variable declaration and mutability model vs C#’s
var/const, primitive type mappings, the criticalStringvs&strdistinction, type inference, and how Rust handles casting and conversions differently from C#.Difficulty: 🟢 Beginner
C# Variable Declaration
// C# - Variables are mutable by default
int count = 0; // Mutable
count = 5; // ✅ Works
// readonly fields (class-level only, not for local variables)
// readonly int maxSize = 100; // Immutable after initialization
const int BUFFER_SIZE = 1024; // Compile-time constant (works as local or field)
Rust Variable Declaration
#![allow(unused)]
fn main() {
// Rust - Variables are immutable by default
let count = 0; // Immutable by default
// count = 5; // ❌ Compile error: cannot assign twice to immutable variable
let mut count = 0; // Explicitly mutable
count = 5; // ✅ Works
const BUFFER_SIZE: usize = 1024; // Compile-time constant
}
Key Mental Shift for C# Developers
#![allow(unused)]
fn main() {
// Think of 'let' as C#'s readonly field semantics applied to all variables
let name = "John"; // Like a readonly field: once set, cannot change
let mut age = 30; // Like: int age = 30;
// Variable shadowing (unique to Rust)
let spaces = " "; // String
let spaces = spaces.len(); // Now it's a number (usize)
// This is different from mutation - we're creating a new variable
}
Practical Example: Counter
// C# version
public class Counter
{
private int value = 0;
public void Increment()
{
value++; // Mutation
}
public int GetValue() => value;
}
#![allow(unused)]
fn main() {
// Rust version
pub struct Counter {
value: i32, // Private by default
}
impl Counter {
pub fn new() -> Counter {
Counter { value: 0 }
}
pub fn increment(&mut self) { // &mut needed for mutation
self.value += 1;
}
pub fn get_value(&self) -> i32 {
self.value
}
}
}
Data Types Comparison
Primitive Types
| C# Type | Rust Type | Size | Range |
|---|---|---|---|
byte | u8 | 8 bits | 0 to 255 |
sbyte | i8 | 8 bits | -128 to 127 |
short | i16 | 16 bits | -32,768 to 32,767 |
ushort | u16 | 16 bits | 0 to 65,535 |
int | i32 | 32 bits | -2³¹ to 2³¹-1 |
uint | u32 | 32 bits | 0 to 2³²-1 |
long | i64 | 64 bits | -2⁶³ to 2⁶³-1 |
ulong | u64 | 64 bits | 0 to 2⁶⁴-1 |
float | f32 | 32 bits | IEEE 754 |
double | f64 | 64 bits | IEEE 754 |
bool | bool | 1 bit | true/false |
char | char | 32 bits | Unicode scalar |
Size Types (Important!)
// C# - int is always 32-bit
int arrayIndex = 0;
long fileSize = file.Length;
#![allow(unused)]
fn main() {
// Rust - size types match pointer size (32-bit or 64-bit)
let array_index: usize = 0; // Like size_t in C
let file_size: u64 = file.len(); // Explicit 64-bit
}
Type Inference
// C# - var keyword
var name = "John"; // string
var count = 42; // int
var price = 29.99; // double
#![allow(unused)]
fn main() {
// Rust - automatic type inference
let name = "John"; // &str (string slice)
let count = 42; // i32 (default integer)
let price = 29.99; // f64 (default float)
// Explicit type annotations
let count: u32 = 42;
let price: f32 = 29.99;
}
Arrays and Collections Overview
// C# - reference types, heap allocated
int[] numbers = new int[5]; // Fixed size
List<int> list = new List<int>(); // Dynamic size
#![allow(unused)]
fn main() {
// Rust - multiple options
let numbers: [i32; 5] = [1, 2, 3, 4, 5]; // Stack array, fixed size
let mut list: Vec<i32> = Vec::new(); // Heap vector, dynamic size
}
String Types: String vs &str
This is one of the most confusing concepts for C# developers, so let’s break it down carefully.
C# String Handling
// C# - Simple string model
string name = "John"; // String literal
string greeting = "Hello, " + name; // String concatenation
string upper = name.ToUpper(); // Method call
Rust String Types
#![allow(unused)]
fn main() {
// Rust - Two main string types
// 1. &str (string slice) - like ReadOnlySpan<char> in C#
let name: &str = "John"; // String literal (immutable, borrowed)
// 2. String - like StringBuilder or mutable string
let mut greeting = String::new(); // Empty string
greeting.push_str("Hello, "); // Append
greeting.push_str(name); // Append
// Or create directly
let greeting = String::from("Hello, John");
let greeting = "Hello, John".to_string(); // Convert &str to String
}
When to Use Which?
| Scenario | Use | C# Equivalent |
|---|---|---|
| String literals | &str | string literal |
| Function parameters (read-only) | &str | string or ReadOnlySpan<char> |
| Owned, mutable strings | String | StringBuilder |
| Return owned strings | String | string |
Practical Examples
// Function that accepts any string type
fn greet(name: &str) { // Accepts both String and &str
println!("Hello, {}!", name);
}
fn main() {
let literal = "John"; // &str
let owned = String::from("Jane"); // String
greet(literal); // Works
greet(&owned); // Works (borrow String as &str)
greet("Bob"); // Works
}
// Function that returns owned string
fn create_greeting(name: &str) -> String {
format!("Hello, {}!", name) // format! macro returns String
}
C# Developers: Think of it This Way
#![allow(unused)]
fn main() {
// &str is like ReadOnlySpan<char> - a view into string data
// String is like a char[] that you own and can modify
let borrowed: &str = "I don't own this data";
let owned: String = String::from("I own this data");
// Convert between them
let owned_copy: String = borrowed.to_string(); // Copy to owned
let borrowed_view: &str = &owned; // Borrow from owned
}
Printing and String Formatting
C# developers rely heavily on Console.WriteLine and string interpolation ($""). Rust’s formatting system is equally powerful but uses macros and format specifiers instead.
Basic Output
// C# output
Console.Write("no newline");
Console.WriteLine("with newline");
Console.Error.WriteLine("to stderr");
// String interpolation (C# 6+)
string name = "Alice";
int age = 30;
Console.WriteLine($"{name} is {age} years old");
#![allow(unused)]
fn main() {
// Rust output — all macros (note the !)
print!("no newline"); // → stdout, no newline
println!("with newline"); // → stdout + newline
eprint!("to stderr"); // → stderr, no newline
eprintln!("to stderr with newline"); // → stderr + newline
// String formatting (like $"" interpolation)
let name = "Alice";
let age = 30;
println!("{name} is {age} years old"); // Inline variable capture (Rust 1.58+)
println!("{} is {} years old", name, age); // Positional arguments
// format! returns a String instead of printing
let msg = format!("{name} is {age} years old");
}
Format Specifiers
// C# format specifiers
Console.WriteLine($"{price:F2}"); // Fixed decimal: 29.99
Console.WriteLine($"{count:D5}"); // Padded integer: 00042
Console.WriteLine($"{value,10}"); // Right-aligned, width 10
Console.WriteLine($"{value,-10}"); // Left-aligned, width 10
Console.WriteLine($"{hex:X}"); // Hexadecimal: FF
Console.WriteLine($"{ratio:P1}"); // Percentage: 85.0%
#![allow(unused)]
fn main() {
// Rust format specifiers
println!("{price:.2}"); // 2 decimal places: 29.99
println!("{count:05}"); // Zero-padded, width 5: 00042
println!("{value:>10}"); // Right-aligned, width 10
println!("{value:<10}"); // Left-aligned, width 10
println!("{value:^10}"); // Center-aligned, width 10
println!("{hex:#X}"); // Hex with prefix: 0xFF
println!("{hex:08X}"); // Hex zero-padded: 000000FF
println!("{bits:#010b}"); // Binary with prefix: 0b00001010
println!("{big}", big = 1_000_000); // Named parameter
}
Debug vs Display Printing
#![allow(unused)]
fn main() {
// {:?} — Debug trait (for developers, auto-derived)
// {:#?} — Pretty-printed Debug (indented, multi-line)
// {} — Display trait (for users, must implement manually)
#[derive(Debug)] // Auto-generates Debug output
struct Point { x: f64, y: f64 }
let p = Point { x: 1.5, y: 2.7 };
println!("{:?}", p); // Point { x: 1.5, y: 2.7 } — compact debug
println!("{:#?}", p); // Point { — pretty debug
// x: 1.5,
// y: 2.7,
// }
// println!("{}", p); // ❌ ERROR: Point doesn't implement Display
// Implement Display for user-facing output:
use std::fmt;
impl fmt::Display for Point {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "({}, {})", self.x, self.y)
}
}
println!("{}", p); // (1.5, 2.7) — user-friendly
}
// C# equivalent:
// {:?} ≈ object.GetType().ToString() or reflection dump
// {} ≈ object.ToString()
// In C# you override ToString(); in Rust you implement Display
Quick Reference
| C# | Rust | Output |
|---|---|---|
Console.WriteLine(x) | println!("{x}") | Display formatting |
$"{x}" (interpolation) | format!("{x}") | Returns String |
x.ToString() | x.to_string() | Requires Display trait |
Override ToString() | impl Display | User-facing output |
| Debugger view | {:?} or dbg!(x) | Developer output |
String.Format("{0:F2}", x) | format!("{x:.2}") | Formatted String |
Console.Error.WriteLine | eprintln!() | Write to stderr |
Type Casting and Conversions
C# has implicit conversions, explicit casts (int)x, and Convert.To*(). Rust is stricter — there are no implicit numeric conversions.
Numeric Conversions
// C# — implicit and explicit conversions
int small = 42;
long big = small; // Implicit widening: OK
double d = small; // Implicit widening: OK
int truncated = (int)3.14; // Explicit narrowing: 3
byte b = (byte)300; // Silent overflow: 44
// Safe conversion
if (int.TryParse("42", out int parsed)) { /* ... */ }
#![allow(unused)]
fn main() {
// Rust — ALL numeric conversions are explicit
let small: i32 = 42;
let big: i64 = small as i64; // Widening: explicit with 'as'
let d: f64 = small as f64; // Int to float: explicit
let truncated: i32 = 3.14_f64 as i32; // Narrowing: 3 (truncates)
let b: u8 = 300_u16 as u8; // Overflow: wraps to 44 (like C# unchecked)
// Safe conversion with TryFrom
use std::convert::TryFrom;
let safe: Result<u8, _> = u8::try_from(300_u16); // Err — out of range
let ok: Result<u8, _> = u8::try_from(42_u16); // Ok(42)
// String parsing — returns Result, not bool + out param
let parsed: Result<i32, _> = "42".parse::<i32>(); // Ok(42)
let bad: Result<i32, _> = "abc".parse::<i32>(); // Err(ParseIntError)
// With turbofish syntax:
let n = "42".parse::<f64>().unwrap(); // 42.0
}
String Conversions
// C#
int n = 42;
string s = n.ToString(); // "42"
string formatted = $"{n:X}";
int back = int.Parse(s); // 42 or throws
bool ok = int.TryParse(s, out int result);
#![allow(unused)]
fn main() {
// Rust — to_string() via Display, parse() via FromStr
let n: i32 = 42;
let s: String = n.to_string(); // "42" (uses Display trait)
let formatted = format!("{n:X}"); // "2A"
let back: i32 = s.parse().unwrap(); // 42 or panics
let result: Result<i32, _> = s.parse(); // Ok(42) — safe version
// &str ↔ String conversions (most common conversion in Rust)
let owned: String = "hello".to_string(); // &str → String
let owned2: String = String::from("hello"); // &str → String (equivalent)
let borrowed: &str = &owned; // String → &str (free, just a borrow)
}
Reference Conversions (No Inheritance Casting!)
// C# — upcasting and downcasting
Animal a = new Dog(); // Upcast (implicit)
Dog d = (Dog)a; // Downcast (explicit, can throw)
if (a is Dog dog) { /* ... */ } // Safe downcast with pattern match
#![allow(unused)]
fn main() {
// Rust — No inheritance, no upcasting/downcasting
// Use trait objects for polymorphism:
let animal: Box<dyn Animal> = Box::new(Dog);
// "Downcasting" requires the Any trait (rarely needed):
use std::any::Any;
if let Some(dog) = animal_any.downcast_ref::<Dog>() {
// Use dog
}
// In practice, use enums instead of downcasting:
enum Animal {
Dog(Dog),
Cat(Cat),
}
match animal {
Animal::Dog(d) => { /* use d */ }
Animal::Cat(c) => { /* use c */ }
}
}
Quick Reference
| C# | Rust | Notes |
|---|---|---|
(int)x | x as i32 | Truncating/wrapping cast |
| Implicit widening | Must use as | No implicit numeric conversion |
Convert.ToInt32(x) | i32::try_from(x) | Safe, returns Result |
int.Parse(s) | s.parse::<i32>().unwrap() | Panics on failure |
int.TryParse(s, out n) | s.parse::<i32>() | Returns Result<i32, _> |
(Dog)animal | Not available | Use enums or Any |
as Dog / is Dog | downcast_ref::<Dog>() | Via Any trait; prefer enums |
Comments and Documentation
Regular Comments
// C# comments
// Single line comment
/* Multi-line
comment */
/// <summary>
/// XML documentation comment
/// </summary>
/// <param name="name">The user's name</param>
/// <returns>A greeting string</returns>
public string Greet(string name)
{
return $"Hello, {name}!";
}
#![allow(unused)]
fn main() {
// Rust comments
// Single line comment
/* Multi-line
comment */
/// Documentation comment (like C# ///)
/// This function greets a user by name.
///
/// # Arguments
///
/// * `name` - The user's name as a string slice
///
/// # Returns
///
/// A `String` containing the greeting
///
/// # Examples
///
/// ```
/// let greeting = greet("Alice");
/// assert_eq!(greeting, "Hello, Alice!");
/// ```
pub fn greet(name: &str) -> String {
format!("Hello, {}!", name)
}
}
Documentation Generation
# Generate documentation (like XML docs in C#)
cargo doc --open
# Run documentation tests
cargo test --doc
Exercises
🏋️ Exercise: Type-Safe Temperature (click to expand)
Create a Rust program that:
- Declares a
constfor absolute zero in Celsius (-273.15) - Declares a
staticcounter for how many conversions have been performed (useAtomicU32) - Writes a function
celsius_to_fahrenheit(c: f64) -> f64that rejects temperatures below absolute zero by returningf64::NAN - Demonstrates shadowing by parsing a string
"98.6"into anf64, then converting it
🔑 Solution
use std::sync::atomic::{AtomicU32, Ordering};
const ABSOLUTE_ZERO_C: f64 = -273.15;
static CONVERSION_COUNT: AtomicU32 = AtomicU32::new(0);
fn celsius_to_fahrenheit(c: f64) -> f64 {
if c < ABSOLUTE_ZERO_C {
return f64::NAN;
}
CONVERSION_COUNT.fetch_add(1, Ordering::Relaxed);
c * 9.0 / 5.0 + 32.0
}
fn main() {
let temp = "98.6"; // &str
let temp: f64 = temp.parse().unwrap(); // shadow as f64
let temp = celsius_to_fahrenheit(temp); // shadow as Fahrenheit
println!("{temp:.1}°F");
println!("Conversions: {}", CONVERSION_COUNT.load(Ordering::Relaxed));
}
True Immutability vs Record Illusions
True Immutability vs Record Illusions
What you’ll learn: Why C#
recordtypes aren’t truly immutable (mutable fields, reflection bypass), how Rust enforces real immutability at compile time, and when to use interior mutability patterns.Difficulty: 🟡 Intermediate
C# Records - Immutability Theater
// C# records look immutable but have escape hatches
public record Person(string Name, int Age, List<string> Hobbies);
var person = new Person("John", 30, new List<string> { "reading" });
// These all "look" like they create new instances:
var older = person with { Age = 31 }; // New record
var renamed = person with { Name = "Jonathan" }; // New record
// But the reference types are still mutable!
person.Hobbies.Add("gaming"); // Mutates the original!
Console.WriteLine(older.Hobbies.Count); // 2 - older person affected!
Console.WriteLine(renamed.Hobbies.Count); // 2 - renamed person also affected!
// Init-only properties can still be set via reflection
typeof(Person).GetProperty("Age")?.SetValue(person, 25);
// Collection expressions help but don't solve the fundamental issue
public record BetterPerson(string Name, int Age, IReadOnlyList<string> Hobbies);
var betterPerson = new BetterPerson("Jane", 25, new List<string> { "painting" });
// Still mutable via casting:
((List<string>)betterPerson.Hobbies).Add("hacking the system");
// Even "immutable" collections aren't truly immutable
using System.Collections.Immutable;
public record SafePerson(string Name, int Age, ImmutableList<string> Hobbies);
// This is better, but requires discipline and has performance overhead
Rust - True Immutability by Default
#![allow(unused)]
fn main() {
#[derive(Debug, Clone)]
struct Person {
name: String,
age: u32,
hobbies: Vec<String>,
}
let person = Person {
name: "John".to_string(),
age: 30,
hobbies: vec!["reading".to_string()],
};
// This simply won't compile:
// person.age = 31; // ERROR: cannot assign to immutable field
// person.hobbies.push("gaming".to_string()); // ERROR: cannot borrow as mutable
// To modify, you must explicitly opt-in with 'mut':
let mut older_person = person.clone();
older_person.age = 31; // Now it's clear this is mutation
// Or use functional update patterns:
let renamed = Person {
name: "Jonathan".to_string(),
..person // Copies other fields (move semantics apply)
};
// The original is guaranteed unchanged (until moved):
println!("{:?}", person.hobbies); // Always ["reading"] - immutable
// Structural sharing with efficient immutable data structures
use std::rc::Rc;
#[derive(Debug, Clone)]
struct EfficientPerson {
name: String,
age: u32,
hobbies: Rc<Vec<String>>, // Shared, immutable reference
}
// Creating new versions shares data efficiently
let person1 = EfficientPerson {
name: "Alice".to_string(),
age: 30,
hobbies: Rc::new(vec!["reading".to_string(), "cycling".to_string()]),
};
let person2 = EfficientPerson {
name: "Bob".to_string(),
age: 25,
hobbies: Rc::clone(&person1.hobbies), // Shared reference, no deep copy
};
}
graph TD
subgraph "C# Records - Shallow Immutability"
CS_RECORD["record Person(...)"]
CS_WITH["with expressions"]
CS_SHALLOW["⚠️ Only top-level immutable"]
CS_REF_MUT["❌ Reference types still mutable"]
CS_REFLECTION["❌ Reflection can bypass"]
CS_RUNTIME["❌ Runtime surprises"]
CS_DISCIPLINE["😓 Requires team discipline"]
CS_RECORD --> CS_WITH
CS_WITH --> CS_SHALLOW
CS_SHALLOW --> CS_REF_MUT
CS_RECORD --> CS_REFLECTION
CS_REF_MUT --> CS_RUNTIME
CS_RUNTIME --> CS_DISCIPLINE
end
subgraph "Rust - True Immutability"
RUST_STRUCT["struct Person { ... }"]
RUST_DEFAULT["✅ Immutable by default"]
RUST_COMPILE["✅ Compile-time enforcement"]
RUST_MUT["🔒 Explicit 'mut' required"]
RUST_MOVE["🔄 Move semantics"]
RUST_ZERO["⚡ Zero runtime overhead"]
RUST_SAFE["🛡️ Memory safe"]
RUST_STRUCT --> RUST_DEFAULT
RUST_DEFAULT --> RUST_COMPILE
RUST_COMPILE --> RUST_MUT
RUST_MUT --> RUST_MOVE
RUST_MOVE --> RUST_ZERO
RUST_ZERO --> RUST_SAFE
end
style CS_REF_MUT fill:#ffcdd2,color:#000
style CS_REFLECTION fill:#ffcdd2,color:#000
style CS_RUNTIME fill:#ffcdd2,color:#000
style RUST_COMPILE fill:#c8e6c9,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
Exercises
🏋️ Exercise: Prove the Immutability (click to expand)
A C# colleague claims their record is immutable. Translate this C# code to Rust and explain why Rust’s version is truly immutable:
public record Config(string Host, int Port, List<string> AllowedOrigins);
var config = new Config("localhost", 8080, new List<string> { "example.com" });
// "Immutable" record... but:
config.AllowedOrigins.Add("evil.com"); // Compiles! List is mutable.
- Create an equivalent Rust struct that is truly immutable
- Show that attempting to mutate
allowed_originsis a compile error - Write a function that creates a modified copy (new host) without mutation
🔑 Solution
#[derive(Debug, Clone)]
struct Config {
host: String,
port: u16,
allowed_origins: Vec<String>,
}
impl Config {
fn with_host(&self, host: impl Into<String>) -> Self {
Config {
host: host.into(),
..self.clone()
}
}
}
fn main() {
let config = Config {
host: "localhost".into(),
port: 8080,
allowed_origins: vec!["example.com".into()],
};
// config.allowed_origins.push("evil.com".into());
// ❌ ERROR: cannot borrow `config.allowed_origins` as mutable
let production = config.with_host("prod.example.com");
println!("Dev: {:?}", config); // original unchanged
println!("Prod: {:?}", production); // new copy with different host
}
Key insight: In Rust, let config = ... (no mut) makes the entire value tree immutable — including nested Vec. C# records only make the reference immutable, not the contents.
4. Control Flow
Functions vs Methods
What you’ll learn: Functions and methods in Rust vs C#, the critical distinction between expressions and statements,
if/match/loop/while/forsyntax, and how Rust’s expression-oriented design eliminates the need for ternary operators.Difficulty: 🟢 Beginner
C# Function Declaration
// C# - Methods in classes
public class Calculator
{
// Instance method
public int Add(int a, int b)
{
return a + b;
}
// Static method
public static int Multiply(int a, int b)
{
return a * b;
}
// Method with ref parameter
public void Increment(ref int value)
{
value++;
}
}
Rust Function Declaration
// Rust - Standalone functions
fn add(a: i32, b: i32) -> i32 {
a + b // No 'return' needed for final expression
}
fn multiply(a: i32, b: i32) -> i32 {
return a * b; // Explicit return is also fine
}
// Function with mutable reference
fn increment(value: &mut i32) {
*value += 1;
}
fn main() {
let result = add(5, 3);
println!("5 + 3 = {}", result);
let mut x = 10;
increment(&mut x);
println!("After increment: {}", x);
}
Expression vs Statement (Important!)
graph LR
subgraph "C# — Statements"
CS1["if (cond)"] --> CS2["return 42;"]
CS1 --> CS3["return 0;"]
CS2 --> CS4["Value exits via return"]
CS3 --> CS4
end
subgraph "Rust — Expressions"
RS1["if cond"] --> RS2["42 (no semicolon)"]
RS1 --> RS3["0 (no semicolon)"]
RS2 --> RS4["Block IS the value"]
RS3 --> RS4
end
style CS4 fill:#bbdefb,color:#000
style RS4 fill:#c8e6c9,color:#000
// C# - Statements vs expressions
public int GetValue()
{
if (condition)
{
return 42; // Statement
}
return 0; // Statement
}
#![allow(unused)]
fn main() {
// Rust - Everything can be an expression
fn get_value(condition: bool) -> i32 {
if condition {
42 // Expression (no semicolon)
} else {
0 // Expression (no semicolon)
}
// The if-else block itself is an expression that returns a value
}
// Or even simpler
fn get_value_ternary(condition: bool) -> i32 {
if condition { 42 } else { 0 }
}
}
Function Parameters and Return Types
// No parameters, no return value (returns unit type ())
fn say_hello() {
println!("Hello!");
}
// Multiple parameters
fn greet(name: &str, age: u32) {
println!("{} is {} years old", name, age);
}
// Multiple return values using tuple
fn divide_and_remainder(dividend: i32, divisor: i32) -> (i32, i32) {
(dividend / divisor, dividend % divisor)
}
fn main() {
let (quotient, remainder) = divide_and_remainder(10, 3);
println!("10 ÷ 3 = {} remainder {}", quotient, remainder);
}
Control Flow Basics
Conditional Statements
// C# if statements
int x = 5;
if (x > 10)
{
Console.WriteLine("Big number");
}
else if (x > 5)
{
Console.WriteLine("Medium number");
}
else
{
Console.WriteLine("Small number");
}
// C# ternary operator
string message = x > 10 ? "Big" : "Small";
#![allow(unused)]
fn main() {
// Rust if expressions
let x = 5;
if x > 10 {
println!("Big number");
} else if x > 5 {
println!("Medium number");
} else {
println!("Small number");
}
// Rust if as expression (like ternary)
let message = if x > 10 { "Big" } else { "Small" };
// Multiple conditions
let message = if x > 10 {
"Big"
} else if x > 5 {
"Medium"
} else {
"Small"
};
}
Loops
// C# loops
// For loop
for (int i = 0; i < 5; i++)
{
Console.WriteLine(i);
}
// Foreach loop
var numbers = new[] { 1, 2, 3, 4, 5 };
foreach (var num in numbers)
{
Console.WriteLine(num);
}
// While loop
int count = 0;
while (count < 3)
{
Console.WriteLine(count);
count++;
}
#![allow(unused)]
fn main() {
// Rust loops
// Range-based for loop
for i in 0..5 { // 0 to 4 (exclusive end)
println!("{}", i);
}
// Iterate over collection
let numbers = vec![1, 2, 3, 4, 5];
for num in numbers { // Takes ownership
println!("{}", num);
}
// Iterate over references (more common)
let numbers = vec![1, 2, 3, 4, 5];
for num in &numbers { // Borrows elements
println!("{}", num);
}
// While loop
let mut count = 0;
while count < 3 {
println!("{}", count);
count += 1;
}
// Infinite loop with break
let mut counter = 0;
loop {
if counter >= 3 {
break;
}
println!("{}", counter);
counter += 1;
}
}
Loop Control
// C# loop control
for (int i = 0; i < 10; i++)
{
if (i == 3) continue;
if (i == 7) break;
Console.WriteLine(i);
}
#![allow(unused)]
fn main() {
// Rust loop control
for i in 0..10 {
if i == 3 { continue; }
if i == 7 { break; }
println!("{}", i);
}
// Loop labels (for nested loops)
'outer: for i in 0..3 {
'inner: for j in 0..3 {
if i == 1 && j == 1 {
break 'outer; // Break out of outer loop
}
println!("i: {}, j: {}", i, j);
}
}
}
🏋️ Exercise: Temperature Converter (click to expand)
Challenge: Convert this C# program to idiomatic Rust. Use expressions, pattern matching, and proper error handling.
// C# — convert this to Rust
public static double Convert(double value, string from, string to)
{
double celsius = from switch
{
"F" => (value - 32.0) * 5.0 / 9.0,
"K" => value - 273.15,
"C" => value,
_ => throw new ArgumentException($"Unknown unit: {from}")
};
return to switch
{
"F" => celsius * 9.0 / 5.0 + 32.0,
"K" => celsius + 273.15,
"C" => celsius,
_ => throw new ArgumentException($"Unknown unit: {to}")
};
}
🔑 Solution
#[derive(Debug, Clone, Copy)]
enum TempUnit { Celsius, Fahrenheit, Kelvin }
fn parse_unit(s: &str) -> Result<TempUnit, String> {
match s {
"C" => Ok(TempUnit::Celsius),
"F" => Ok(TempUnit::Fahrenheit),
"K" => Ok(TempUnit::Kelvin),
_ => Err(format!("Unknown unit: {s}")),
}
}
fn convert(value: f64, from: TempUnit, to: TempUnit) -> f64 {
let celsius = match from {
TempUnit::Fahrenheit => (value - 32.0) * 5.0 / 9.0,
TempUnit::Kelvin => value - 273.15,
TempUnit::Celsius => value,
};
match to {
TempUnit::Fahrenheit => celsius * 9.0 / 5.0 + 32.0,
TempUnit::Kelvin => celsius + 273.15,
TempUnit::Celsius => celsius,
}
}
fn main() -> Result<(), String> {
let from = parse_unit("F")?;
let to = parse_unit("C")?;
println!("212°F = {:.1}°C", convert(212.0, from, to));
Ok(())
}
Key takeaways:
- Enums replace magic strings — exhaustive matching catches missing units at compile time
Result<T, E>replaces exceptions — the caller sees possible failures in the signaturematchis an expression that returns a value — noreturnstatements needed
5. Data Structures and Collections
Tuples and Destructuring
What you’ll learn: Rust tuples vs C#
ValueTuple, arrays and slices, structs vs classes, the newtype pattern for domain modeling with zero-cost type safety, and destructuring syntax.Difficulty: 🟢 Beginner
C# has ValueTuple (since C# 7). Rust tuples are similar but more deeply integrated into the language.
C# Tuples
// C# ValueTuple (C# 7+)
var point = (10, 20); // (int, int)
var named = (X: 10, Y: 20); // Named elements
Console.WriteLine($"{named.X}, {named.Y}");
// Tuple as return type
public (int Quotient, int Remainder) Divide(int a, int b)
{
return (a / b, a % b);
}
var (q, r) = Divide(10, 3); // Deconstruction
Console.WriteLine($"{q} remainder {r}");
// Discards
var (_, remainder) = Divide(10, 3); // Ignore quotient
Rust Tuples
#![allow(unused)]
fn main() {
// Rust tuples — immutable by default, no named elements
let point = (10, 20); // (i32, i32)
let point3d: (f64, f64, f64) = (1.0, 2.0, 3.0);
// Access by index (0-based)
println!("x={}, y={}", point.0, point.1);
// Tuple as return type
fn divide(a: i32, b: i32) -> (i32, i32) {
(a / b, a % b)
}
let (q, r) = divide(10, 3); // Destructuring
println!("{q} remainder {r}");
// Discards with _
let (_, remainder) = divide(10, 3);
// Unit type () — the "empty tuple" (like C# void)
fn greet() { // implicit return type is ()
println!("hi");
}
}
Key Differences
| Feature | C# ValueTuple | Rust Tuple |
|---|---|---|
| Named elements | (int X, int Y) | Not supported — use structs |
| Max arity | ~8 (nesting for more) | Unlimited (practical limit ~12) |
| Comparisons | Automatic | Automatic for tuples ≤ 12 elements |
| Used as dict key | Yes | Yes (if elements implement Hash) |
| Return from functions | Common | Common |
| Mutable elements | Always mutable | Only with let mut |
Tuple Structs (Newtypes)
#![allow(unused)]
fn main() {
// When a plain tuple isn't descriptive enough, use a tuple struct:
struct Meters(f64); // Single-field "newtype" wrapper
struct Celsius(f64);
struct Fahrenheit(f64);
// The compiler treats these as DIFFERENT types:
let distance = Meters(100.0);
let temp = Celsius(36.6);
// distance == temp; // ❌ ERROR: can't compare Meters with Celsius
// Newtype pattern prevents unit-confusion bugs at compile time!
// In C# you'd need a full class/struct for the same safety.
}
// C# equivalent requires more ceremony:
public readonly record struct Meters(double Value);
public readonly record struct Celsius(double Value);
// Not interchangeable, but records add overhead vs Rust's zero-cost newtypes
The Newtype Pattern in Depth: Domain Modeling with Zero Cost
Newtypes go far beyond preventing unit confusion. They’re Rust’s primary tool for encoding business rules into the type system — replacing the “guard clause” and “validation class” patterns common in C#.
C# Validation Approach: Runtime Guards
// C# — validation happens at runtime, every time
public class UserService
{
public User CreateUser(string email, int age)
{
if (string.IsNullOrWhiteSpace(email) || !email.Contains('@'))
throw new ArgumentException("Invalid email");
if (age < 0 || age > 150)
throw new ArgumentException("Invalid age");
return new User { Email = email, Age = age };
}
public void SendEmail(string email)
{
// Must re-validate — or trust the caller?
if (!email.Contains('@')) throw new ArgumentException("Invalid email");
// ...
}
}
Rust Newtype Approach: Compile-Time Proof
#![allow(unused)]
fn main() {
/// A validated email address — the type itself IS the proof of validity.
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct Email(String);
impl Email {
/// The ONLY way to create an Email — validation happens once at construction.
pub fn new(raw: &str) -> Result<Self, &'static str> {
if raw.contains('@') && raw.len() > 3 {
Ok(Email(raw.to_lowercase()))
} else {
Err("invalid email format")
}
}
/// Safe access to the inner value
pub fn as_str(&self) -> &str { &self.0 }
}
/// A validated age — impossible to create an invalid one.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct Age(u8);
impl Age {
pub fn new(raw: u8) -> Result<Self, &'static str> {
if raw <= 150 { Ok(Age(raw)) } else { Err("age out of range") }
}
pub fn value(&self) -> u8 { self.0 }
}
// Now functions take PROVEN types — no re-validation needed!
fn create_user(email: Email, age: Age) -> User {
// email is GUARANTEED valid — it's a type invariant
User { email, age }
}
fn send_email(to: &Email) {
// No validation needed — Email type proves validity
println!("Sending to: {}", to.as_str());
}
}
Common Newtype Uses for C# Developers
| C# Pattern | Rust Newtype | What It Prevents |
|---|---|---|
string for UserId, Email, etc. | struct UserId(Uuid) | Passing wrong string to wrong parameter |
int for Port, Count, Index | struct Port(u16) | Port and Count are not interchangeable |
| Guard clauses everywhere | Constructor validation once | Re-validation, missed validation |
decimal for USD, EUR | struct Usd(Decimal) | Adding USD to EUR by accident |
TimeSpan for different semantics | struct Timeout(Duration) | Passing connection timeout as request timeout |
#![allow(unused)]
fn main() {
// Zero-cost: newtypes compile to the same assembly as the inner type.
// This Rust code:
struct UserId(u64);
fn lookup(id: UserId) -> Option<User> { /* ... */ }
// Generates the SAME machine code as:
fn lookup(id: u64) -> Option<User> { /* ... */ }
// But with full type safety at compile time!
}
Arrays and Slices
Understanding the difference between arrays, slices, and vectors is crucial.
C# Arrays
// C# arrays
int[] numbers = new int[5]; // Fixed size, heap allocated
int[] initialized = { 1, 2, 3, 4, 5 }; // Array literal
// Access
numbers[0] = 10;
int first = numbers[0];
// Length
int length = numbers.Length;
// Array as parameter (reference type)
void ProcessArray(int[] array)
{
array[0] = 99; // Modifies original
}
Rust Arrays, Slices, and Vectors
#![allow(unused)]
fn main() {
// 1. Arrays - Fixed size, stack allocated
let numbers: [i32; 5] = [1, 2, 3, 4, 5]; // Type: [i32; 5]
let zeros = [0; 10]; // 10 zeros
// Access
let first = numbers[0];
// numbers[0] = 10; // ❌ Error: arrays are immutable by default
let mut mut_array = [1, 2, 3, 4, 5];
mut_array[0] = 10; // ✅ Works with mut
// 2. Slices - Views into arrays or vectors
let slice: &[i32] = &numbers[1..4]; // Elements 1, 2, 3
let all_slice: &[i32] = &numbers; // Entire array as slice
// 3. Vectors - Dynamic size, heap allocated (covered earlier)
let mut vec = vec![1, 2, 3, 4, 5];
vec.push(6); // Can grow
}
Slices as Function Parameters
// C# - Method that works with arrays
public void ProcessNumbers(int[] numbers)
{
for (int i = 0; i < numbers.Length; i++)
{
Console.WriteLine(numbers[i]);
}
}
// Works with arrays only
ProcessNumbers(new int[] { 1, 2, 3 });
// Rust - Function that works with any sequence
fn process_numbers(numbers: &[i32]) { // Slice parameter
for (i, num) in numbers.iter().enumerate() {
println!("Index {}: {}", i, num);
}
}
fn main() {
let array = [1, 2, 3, 4, 5];
let vec = vec![1, 2, 3, 4, 5];
// Same function works with both!
process_numbers(&array); // Array as slice
process_numbers(&vec); // Vector as slice
process_numbers(&vec[1..4]); // Partial slice
}
String Slices (&str) Revisited
#![allow(unused)]
fn main() {
// String and &str relationship
fn string_slice_example() {
let owned = String::from("Hello, World!");
let slice: &str = &owned[0..5]; // "Hello"
let slice2: &str = &owned[7..]; // "World!"
println!("{}", slice); // "Hello"
println!("{}", slice2); // "World!"
// Function that accepts any string type
print_string("String literal"); // &str
print_string(&owned); // String as &str
print_string(slice); // &str slice
}
fn print_string(s: &str) {
println!("{}", s);
}
}
Structs vs Classes
Structs in Rust are similar to classes in C#, but with some key differences around ownership and methods.
graph TD
subgraph "C# Class (Heap)"
CObj["Object Header\n+ vtable ptr"] --> CFields["Name: string ref\nAge: int\nHobbies: List ref"]
CFields --> CHeap1["#quot;Alice#quot; on heap"]
CFields --> CHeap2["List<string> on heap"]
end
subgraph "Rust Struct (Stack)"
RFields["name: String\n ptr | len | cap\nage: i32\nhobbies: Vec\n ptr | len | cap"]
RFields --> RHeap1["#quot;Alice#quot; heap buffer"]
RFields --> RHeap2["Vec heap buffer"]
end
style CObj fill:#bbdefb,color:#000
style RFields fill:#c8e6c9,color:#000
Key insight: C# classes always live on the heap behind a reference. Rust structs live on the stack by default — only the dynamically-sized data (like
Stringcontents) goes to the heap. This eliminates GC overhead for small, frequently-created objects.
C# Class Definition
// C# class with properties and methods
public class Person
{
public string Name { get; set; }
public int Age { get; set; }
public List<string> Hobbies { get; set; }
public Person(string name, int age)
{
Name = name;
Age = age;
Hobbies = new List<string>();
}
public void AddHobby(string hobby)
{
Hobbies.Add(hobby);
}
public string GetInfo()
{
return $"{Name} is {Age} years old";
}
}
Rust Struct Definition
#![allow(unused)]
fn main() {
// Rust struct with associated functions and methods
#[derive(Debug)] // Automatically implement Debug trait
pub struct Person {
pub name: String, // Public field
pub age: u32, // Public field
hobbies: Vec<String>, // Private field (no pub)
}
impl Person {
// Associated function (like static method)
pub fn new(name: String, age: u32) -> Person {
Person {
name,
age,
hobbies: Vec::new(),
}
}
// Method (takes &self, &mut self, or self)
pub fn add_hobby(&mut self, hobby: String) {
self.hobbies.push(hobby);
}
// Method that borrows immutably
pub fn get_info(&self) -> String {
format!("{} is {} years old", self.name, self.age)
}
// Getter for private field
pub fn hobbies(&self) -> &Vec<String> {
&self.hobbies
}
}
}
Creating and Using Instances
// C# object creation and usage
var person = new Person("Alice", 30);
person.AddHobby("Reading");
person.AddHobby("Swimming");
Console.WriteLine(person.GetInfo());
Console.WriteLine($"Hobbies: {string.Join(", ", person.Hobbies)}");
// Modify properties directly
person.Age = 31;
#![allow(unused)]
fn main() {
// Rust struct creation and usage
let mut person = Person::new("Alice".to_string(), 30);
person.add_hobby("Reading".to_string());
person.add_hobby("Swimming".to_string());
println!("{}", person.get_info());
println!("Hobbies: {:?}", person.hobbies());
// Modify public fields directly
person.age = 31;
// Debug print the entire struct
println!("{:?}", person);
}
Struct Initialization Patterns
// C# object initialization
var person = new Person("Bob", 25)
{
Hobbies = new List<string> { "Gaming", "Coding" }
};
// Anonymous types
var anonymous = new { Name = "Charlie", Age = 35 };
#![allow(unused)]
fn main() {
// Rust struct initialization
let person = Person {
name: "Bob".to_string(),
age: 25,
hobbies: vec!["Gaming".to_string(), "Coding".to_string()],
};
// Struct update syntax (like object spread)
let older_person = Person {
age: 26,
..person // Use remaining fields from person (moves person!)
};
// Tuple structs (like anonymous types)
#[derive(Debug)]
struct Point(i32, i32);
let point = Point(10, 20);
println!("Point: ({}, {})", point.0, point.1);
}
Methods and Associated Functions
Understanding the difference between methods and associated functions is key.
C# Method Types
public class Calculator
{
private int memory = 0;
// Instance method
public int Add(int a, int b)
{
return a + b;
}
// Instance method that uses state
public void StoreInMemory(int value)
{
memory = value;
}
// Static method
public static int Multiply(int a, int b)
{
return a * b;
}
// Static factory method
public static Calculator CreateWithMemory(int initialMemory)
{
var calc = new Calculator();
calc.memory = initialMemory;
return calc;
}
}
Rust Method Types
#[derive(Debug)]
pub struct Calculator {
memory: i32,
}
impl Calculator {
// Associated function (like static method) - no self parameter
pub fn new() -> Calculator {
Calculator { memory: 0 }
}
// Associated function with parameters
pub fn with_memory(initial_memory: i32) -> Calculator {
Calculator { memory: initial_memory }
}
// Method that borrows immutably (&self)
pub fn add(&self, a: i32, b: i32) -> i32 {
a + b
}
// Method that borrows mutably (&mut self)
pub fn store_in_memory(&mut self, value: i32) {
self.memory = value;
}
// Method that takes ownership (self)
pub fn into_memory(self) -> i32 {
self.memory // Calculator is consumed
}
// Getter method
pub fn memory(&self) -> i32 {
self.memory
}
}
fn main() {
// Associated functions called with ::
let mut calc = Calculator::new();
let calc2 = Calculator::with_memory(42);
// Methods called with .
let result = calc.add(5, 3);
calc.store_in_memory(result);
println!("Memory: {}", calc.memory());
// Consuming method
let memory_value = calc.into_memory(); // calc is no longer usable
println!("Final memory: {}", memory_value);
}
Method Receiver Types Explained
#![allow(unused)]
fn main() {
impl Person {
// &self - Immutable borrow (most common)
// Use when you only need to read the data
pub fn get_name(&self) -> &str {
&self.name
}
// &mut self - Mutable borrow
// Use when you need to modify the data
pub fn set_name(&mut self, name: String) {
self.name = name;
}
// self - Take ownership (less common)
// Use when you want to consume the struct
pub fn consume(self) -> String {
self.name // Person is moved, no longer accessible
}
}
fn method_examples() {
let mut person = Person::new("Alice".to_string(), 30);
// Immutable borrow
let name = person.get_name(); // person can still be used
println!("Name: {}", name);
// Mutable borrow
person.set_name("Alice Smith".to_string()); // person can still be used
// Taking ownership
let final_name = person.consume(); // person is no longer usable
println!("Final name: {}", final_name);
}
}
Exercises
🏋️ Exercise: Slice Window Average (click to expand)
Challenge: Write a function that takes a slice of f64 values and a window size, and returns a Vec<f64> of rolling averages. For example, [1.0, 2.0, 3.0, 4.0, 5.0] with window 3 → [2.0, 3.0, 4.0].
fn rolling_average(data: &[f64], window: usize) -> Vec<f64> {
// Your implementation here
todo!()
}
fn main() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let avgs = rolling_average(&data, 3);
println!("{avgs:?}"); // [2.0, 3.0, 4.0]
}
🔑 Solution
fn rolling_average(data: &[f64], window: usize) -> Vec<f64> {
data.windows(window)
.map(|w| w.iter().sum::<f64>() / w.len() as f64)
.collect()
}
fn main() {
let data = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let avgs = rolling_average(&data, 3);
assert_eq!(avgs, vec![2.0, 3.0, 4.0]);
println!("{avgs:?}");
}
Key takeaway: Slices have powerful built-in methods like .windows(), .chunks(), and .split() that replace manual index arithmetic. In C#, you’d use Enumerable.Range or LINQ .Skip().Take().
🏋️ Exercise: Mini Address Book (click to expand)
Build a small address book using structs, enums, and methods:
- Define an enum
PhoneType { Mobile, Home, Work } - Define a struct
Contactwithname: Stringandphones: Vec<(PhoneType, String)> - Implement
Contact::new(name: impl Into<String>) -> Self - Implement
Contact::add_phone(&mut self, kind: PhoneType, number: impl Into<String>) - Implement
Contact::mobile_numbers(&self) -> Vec<&str>that returns only mobile numbers - In
main, create a contact, add two phones, and print the mobile numbers
🔑 Solution
#[derive(Debug, PartialEq)]
enum PhoneType { Mobile, Home, Work }
#[derive(Debug)]
struct Contact {
name: String,
phones: Vec<(PhoneType, String)>,
}
impl Contact {
fn new(name: impl Into<String>) -> Self {
Contact { name: name.into(), phones: Vec::new() }
}
fn add_phone(&mut self, kind: PhoneType, number: impl Into<String>) {
self.phones.push((kind, number.into()));
}
fn mobile_numbers(&self) -> Vec<&str> {
self.phones
.iter()
.filter(|(kind, _)| *kind == PhoneType::Mobile)
.map(|(_, num)| num.as_str())
.collect()
}
}
fn main() {
let mut alice = Contact::new("Alice");
alice.add_phone(PhoneType::Mobile, "+1-555-0100");
alice.add_phone(PhoneType::Work, "+1-555-0200");
alice.add_phone(PhoneType::Mobile, "+1-555-0101");
println!("{}'s mobile numbers: {:?}", alice.name, alice.mobile_numbers());
}
Constructor Patterns
Constructor Patterns
What you’ll learn: How to create Rust structs without traditional constructors —
new()conventions, theDefaulttrait, factory methods, and the builder pattern for complex initialization.Difficulty: 🟢 Beginner
C# Constructor Patterns
public class Configuration
{
public string DatabaseUrl { get; set; }
public int MaxConnections { get; set; }
public bool EnableLogging { get; set; }
// Default constructor
public Configuration()
{
DatabaseUrl = "localhost";
MaxConnections = 10;
EnableLogging = false;
}
// Parameterized constructor
public Configuration(string databaseUrl, int maxConnections)
{
DatabaseUrl = databaseUrl;
MaxConnections = maxConnections;
EnableLogging = false;
}
// Factory method
public static Configuration ForProduction()
{
return new Configuration("prod.db.server", 100)
{
EnableLogging = true
};
}
}
Rust Constructor Patterns
#[derive(Debug)]
pub struct Configuration {
pub database_url: String,
pub max_connections: u32,
pub enable_logging: bool,
}
impl Configuration {
// Default constructor
pub fn new() -> Configuration {
Configuration {
database_url: "localhost".to_string(),
max_connections: 10,
enable_logging: false,
}
}
// Parameterized constructor
pub fn with_database(database_url: String, max_connections: u32) -> Configuration {
Configuration {
database_url,
max_connections,
enable_logging: false,
}
}
// Factory method
pub fn for_production() -> Configuration {
Configuration {
database_url: "prod.db.server".to_string(),
max_connections: 100,
enable_logging: true,
}
}
// Builder pattern method
pub fn enable_logging(mut self) -> Configuration {
self.enable_logging = true;
self // Return self for chaining
}
pub fn max_connections(mut self, count: u32) -> Configuration {
self.max_connections = count;
self
}
}
// Default trait implementation
impl Default for Configuration {
fn default() -> Self {
Self::new()
}
}
fn main() {
// Different construction patterns
let config1 = Configuration::new();
let config2 = Configuration::with_database("localhost:5432".to_string(), 20);
let config3 = Configuration::for_production();
// Builder pattern
let config4 = Configuration::new()
.enable_logging()
.max_connections(50);
// Using Default trait
let config5 = Configuration::default();
println!("{:?}", config4);
}
Builder Pattern Implementation
// More complex builder pattern
#[derive(Debug)]
pub struct DatabaseConfig {
host: String,
port: u16,
username: String,
password: Option<String>,
ssl_enabled: bool,
timeout_seconds: u64,
}
pub struct DatabaseConfigBuilder {
host: Option<String>,
port: Option<u16>,
username: Option<String>,
password: Option<String>,
ssl_enabled: bool,
timeout_seconds: u64,
}
impl DatabaseConfigBuilder {
pub fn new() -> Self {
DatabaseConfigBuilder {
host: None,
port: None,
username: None,
password: None,
ssl_enabled: false,
timeout_seconds: 30,
}
}
pub fn host(mut self, host: impl Into<String>) -> Self {
self.host = Some(host.into());
self
}
pub fn port(mut self, port: u16) -> Self {
self.port = Some(port);
self
}
pub fn username(mut self, username: impl Into<String>) -> Self {
self.username = Some(username.into());
self
}
pub fn password(mut self, password: impl Into<String>) -> Self {
self.password = Some(password.into());
self
}
pub fn enable_ssl(mut self) -> Self {
self.ssl_enabled = true;
self
}
pub fn timeout(mut self, seconds: u64) -> Self {
self.timeout_seconds = seconds;
self
}
pub fn build(self) -> Result<DatabaseConfig, String> {
let host = self.host.ok_or("Host is required")?;
let port = self.port.ok_or("Port is required")?;
let username = self.username.ok_or("Username is required")?;
Ok(DatabaseConfig {
host,
port,
username,
password: self.password,
ssl_enabled: self.ssl_enabled,
timeout_seconds: self.timeout_seconds,
})
}
}
fn main() {
let config = DatabaseConfigBuilder::new()
.host("localhost")
.port(5432)
.username("admin")
.password("secret123")
.enable_ssl()
.timeout(60)
.build()
.expect("Failed to build config");
println!("{:?}", config);
}
Exercises
🏋️ Exercise: Builder with Validation (click to expand)
Create an EmailBuilder that:
- Requires
toandsubject(builder won’t compile without them — use a typestate or validate inbuild()) - Has optional
bodyandcc(Vec of addresses) build()returnsResult<Email, String>— rejects emptytoorsubject- Write tests proving invalid inputs are rejected
🔑 Solution
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Email {
to: String,
subject: String,
body: Option<String>,
cc: Vec<String>,
}
#[derive(Default)]
struct EmailBuilder {
to: Option<String>,
subject: Option<String>,
body: Option<String>,
cc: Vec<String>,
}
impl EmailBuilder {
fn new() -> Self { Self::default() }
fn to(mut self, to: impl Into<String>) -> Self {
self.to = Some(to.into()); self
}
fn subject(mut self, subject: impl Into<String>) -> Self {
self.subject = Some(subject.into()); self
}
fn body(mut self, body: impl Into<String>) -> Self {
self.body = Some(body.into()); self
}
fn cc(mut self, addr: impl Into<String>) -> Self {
self.cc.push(addr.into()); self
}
fn build(self) -> Result<Email, String> {
let to = self.to.filter(|s| !s.is_empty())
.ok_or("'to' is required")?;
let subject = self.subject.filter(|s| !s.is_empty())
.ok_or("'subject' is required")?;
Ok(Email { to, subject, body: self.body, cc: self.cc })
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_email() {
let email = EmailBuilder::new()
.to("alice@example.com")
.subject("Hello")
.build();
assert!(email.is_ok());
}
#[test]
fn missing_to_fails() {
let email = EmailBuilder::new().subject("Hello").build();
assert!(email.is_err());
}
}
}
Collections — Vec, HashMap, and Iterators
Vec<T> vs List<T>
What you’ll learn:
Vec<T>vsList<T>,HashMapvsDictionary, safe access patterns (why Rust returnsOptioninstead of throwing), and the ownership implications of collections.Difficulty: 🟢 Beginner
Vec<T> is Rust’s equivalent to C#’s List<T>, but with ownership semantics.
C# List<T>
// C# List<T> - Reference type, heap allocated
var numbers = new List<int>();
numbers.Add(1);
numbers.Add(2);
numbers.Add(3);
// Pass to method - reference is copied
ProcessList(numbers);
Console.WriteLine(numbers.Count); // Still accessible
void ProcessList(List<int> list)
{
list.Add(4); // Modifies original list
Console.WriteLine($"Count in method: {list.Count}");
}
Rust Vec<T>
#![allow(unused)]
fn main() {
// Rust Vec<T> - Owned type, heap allocated
let mut numbers = Vec::new();
numbers.push(1);
numbers.push(2);
numbers.push(3);
// Method that takes ownership
process_vec(numbers);
// println!("{:?}", numbers); // ❌ Error: numbers was moved
// Method that borrows
let mut numbers = vec![1, 2, 3]; // vec! macro for convenience
process_vec_borrowed(&mut numbers);
println!("{:?}", numbers); // ✅ Still accessible
fn process_vec(mut vec: Vec<i32>) { // Takes ownership
vec.push(4);
println!("Count in method: {}", vec.len());
// vec is dropped here
}
fn process_vec_borrowed(vec: &mut Vec<i32>) { // Borrows mutably
vec.push(4);
println!("Count in method: {}", vec.len());
}
}
Creating and Initializing Vectors
// C# List initialization
var numbers = new List<int> { 1, 2, 3, 4, 5 };
var empty = new List<int>();
var sized = new List<int>(10); // Initial capacity
// From other collections
var fromArray = new List<int>(new[] { 1, 2, 3 });
#![allow(unused)]
fn main() {
// Rust Vec initialization
let numbers = vec![1, 2, 3, 4, 5]; // vec! macro
let empty: Vec<i32> = Vec::new(); // Type annotation needed for empty
let sized = Vec::with_capacity(10); // Pre-allocate capacity
// From iterator
let from_range: Vec<i32> = (1..=5).collect();
let from_array = vec![1, 2, 3];
}
Common Operations Comparison
// C# List operations
var list = new List<int> { 1, 2, 3 };
list.Add(4); // Add element
list.Insert(0, 0); // Insert at index
list.Remove(2); // Remove first occurrence
list.RemoveAt(1); // Remove at index
list.Clear(); // Remove all
int first = list[0]; // Index access
int count = list.Count; // Get count
bool contains = list.Contains(3); // Check if contains
#![allow(unused)]
fn main() {
// Rust Vec operations
let mut vec = vec![1, 2, 3];
vec.push(4); // Add element
vec.insert(0, 0); // Insert at index
vec.retain(|&x| x != 2); // Remove elements (functional style)
vec.remove(1); // Remove at index
vec.clear(); // Remove all
let first = vec[0]; // Index access (panics if out of bounds)
let safe_first = vec.get(0); // Safe access, returns Option<&T>
let count = vec.len(); // Get count
let contains = vec.contains(&3); // Check if contains
}
Safe Access Patterns
// C# - Exception-based bounds checking
public int SafeAccess(List<int> list, int index)
{
try
{
return list[index];
}
catch (ArgumentOutOfRangeException)
{
return -1; // Default value
}
}
// Rust - Option-based safe access
fn safe_access(vec: &Vec<i32>, index: usize) -> Option<i32> {
vec.get(index).copied() // Returns Option<i32>
}
fn main() {
let vec = vec![1, 2, 3];
// Safe access patterns
match vec.get(10) {
Some(value) => println!("Value: {}", value),
None => println!("Index out of bounds"),
}
// Or with unwrap_or
let value = vec.get(10).copied().unwrap_or(-1);
println!("Value: {}", value);
}
HashMap vs Dictionary
HashMap is Rust’s equivalent to C#’s Dictionary<K,V>.
C# Dictionary
// C# Dictionary<TKey, TValue>
var scores = new Dictionary<string, int>
{
["Alice"] = 100,
["Bob"] = 85,
["Charlie"] = 92
};
// Add/Update
scores["Dave"] = 78;
scores["Alice"] = 105; // Update existing
// Safe access
if (scores.TryGetValue("Eve", out int score))
{
Console.WriteLine($"Eve's score: {score}");
}
else
{
Console.WriteLine("Eve not found");
}
// Iteration
foreach (var kvp in scores)
{
Console.WriteLine($"{kvp.Key}: {kvp.Value}");
}
Rust HashMap
#![allow(unused)]
fn main() {
use std::collections::HashMap;
// Create and initialize HashMap
let mut scores = HashMap::new();
scores.insert("Alice".to_string(), 100);
scores.insert("Bob".to_string(), 85);
scores.insert("Charlie".to_string(), 92);
// Or use from iterator
let scores: HashMap<String, i32> = [
("Alice".to_string(), 100),
("Bob".to_string(), 85),
("Charlie".to_string(), 92),
].into_iter().collect();
// Add/Update
let mut scores = scores; // Make mutable
scores.insert("Dave".to_string(), 78);
scores.insert("Alice".to_string(), 105); // Update existing
// Safe access
match scores.get("Eve") {
Some(score) => println!("Eve's score: {}", score),
None => println!("Eve not found"),
}
// Iteration
for (name, score) in &scores {
println!("{}: {}", name, score);
}
}
HashMap Operations
// C# Dictionary operations
var dict = new Dictionary<string, int>();
dict["key"] = 42; // Insert/update
bool exists = dict.ContainsKey("key"); // Check existence
bool removed = dict.Remove("key"); // Remove
dict.Clear(); // Clear all
// Get with default
int value = dict.GetValueOrDefault("missing", 0);
#![allow(unused)]
fn main() {
use std::collections::HashMap;
// Rust HashMap operations
let mut map = HashMap::new();
map.insert("key".to_string(), 42); // Insert/update
let exists = map.contains_key("key"); // Check existence
let removed = map.remove("key"); // Remove, returns Option<V>
map.clear(); // Clear all
// Entry API for advanced operations
let mut map = HashMap::new();
map.entry("key".to_string()).or_insert(42); // Insert if not exists
map.entry("key".to_string()).and_modify(|v| *v += 1); // Modify if exists
// Get with default
let value = map.get("missing").copied().unwrap_or(0);
}
Ownership with HashMap Keys and Values
#![allow(unused)]
fn main() {
// Understanding ownership with HashMap
fn ownership_example() {
let mut map = HashMap::new();
// String keys and values are moved into the map
let key = String::from("name");
let value = String::from("Alice");
map.insert(key, value);
// println!("{}", key); // ❌ Error: key was moved
// println!("{}", value); // ❌ Error: value was moved
// Access via references
if let Some(name) = map.get("name") {
println!("Name: {}", name); // Borrowing the value
}
}
// Using &str keys (no ownership transfer)
fn string_slice_keys() {
let mut map = HashMap::new();
map.insert("name", "Alice"); // &str keys and values
map.insert("age", "30");
// No ownership issues with string literals
println!("Name exists: {}", map.contains_key("name"));
}
}
Working with Collections
Iteration Patterns
// C# iteration patterns
var numbers = new List<int> { 1, 2, 3, 4, 5 };
// For loop with index
for (int i = 0; i < numbers.Count; i++)
{
Console.WriteLine($"Index {i}: {numbers[i]}");
}
// Foreach loop
foreach (int num in numbers)
{
Console.WriteLine(num);
}
// LINQ methods
var doubled = numbers.Select(x => x * 2).ToList();
var evens = numbers.Where(x => x % 2 == 0).ToList();
#![allow(unused)]
fn main() {
// Rust iteration patterns
let numbers = vec![1, 2, 3, 4, 5];
// For loop with index
for (i, num) in numbers.iter().enumerate() {
println!("Index {}: {}", i, num);
}
// For loop over values
for num in &numbers { // Borrow each element
println!("{}", num);
}
// Iterator methods (like LINQ)
let doubled: Vec<i32> = numbers.iter().map(|x| x * 2).collect();
let evens: Vec<i32> = numbers.iter().filter(|&x| x % 2 == 0).cloned().collect();
// Or more efficiently, consuming iterator
let doubled: Vec<i32> = numbers.into_iter().map(|x| x * 2).collect();
}
Iterator vs IntoIterator vs Iter
#![allow(unused)]
fn main() {
// Understanding different iteration methods
fn iteration_methods() {
let vec = vec![1, 2, 3, 4, 5];
// 1. iter() - borrows elements (&T)
for item in vec.iter() {
println!("{}", item); // item is &i32
}
// vec is still usable here
// 2. into_iter() - takes ownership (T)
for item in vec.into_iter() {
println!("{}", item); // item is i32
}
// vec is no longer usable here
let mut vec = vec![1, 2, 3, 4, 5];
// 3. iter_mut() - mutable borrows (&mut T)
for item in vec.iter_mut() {
*item *= 2; // item is &mut i32
}
println!("{:?}", vec); // [2, 4, 6, 8, 10]
}
}
Collecting Results
// C# - Processing collections with potential errors
public List<int> ParseNumbers(List<string> inputs)
{
var results = new List<int>();
foreach (string input in inputs)
{
if (int.TryParse(input, out int result))
{
results.Add(result);
}
// Silently skip invalid inputs
}
return results;
}
// Rust - Explicit error handling with collect
fn parse_numbers(inputs: Vec<String>) -> Result<Vec<i32>, std::num::ParseIntError> {
inputs.into_iter()
.map(|s| s.parse::<i32>()) // Returns Result<i32, ParseIntError>
.collect() // Collects into Result<Vec<i32>, ParseIntError>
}
// Alternative: Filter out errors
fn parse_numbers_filter(inputs: Vec<String>) -> Vec<i32> {
inputs.into_iter()
.filter_map(|s| s.parse::<i32>().ok()) // Keep only Ok values
.collect()
}
fn main() {
let inputs = vec!["1".to_string(), "2".to_string(), "invalid".to_string(), "4".to_string()];
// Version that fails on first error
match parse_numbers(inputs.clone()) {
Ok(numbers) => println!("All parsed: {:?}", numbers),
Err(error) => println!("Parse error: {}", error),
}
// Version that skips errors
let numbers = parse_numbers_filter(inputs);
println!("Successfully parsed: {:?}", numbers); // [1, 2, 4]
}
Exercises
🏋️ Exercise: LINQ to Iterators (click to expand)
Translate this C# LINQ query to idiomatic Rust iterators:
var result = students
.Where(s => s.Grade >= 90)
.OrderByDescending(s => s.Grade)
.Select(s => $"{s.Name}: {s.Grade}")
.Take(3)
.ToList();
Use this struct:
#![allow(unused)]
fn main() {
struct Student { name: String, grade: u32 }
}
Return a Vec<String> of the top 3 students with grade ≥ 90, formatted as "Name: Grade".
🔑 Solution
#[derive(Debug)]
struct Student { name: String, grade: u32 }
fn top_students(students: &mut [Student]) -> Vec<String> {
students.sort_by(|a, b| b.grade.cmp(&a.grade)); // sort descending
students.iter()
.filter(|s| s.grade >= 90)
.take(3)
.map(|s| format!("{}: {}", s.name, s.grade))
.collect()
}
fn main() {
let mut students = vec![
Student { name: "Alice".into(), grade: 95 },
Student { name: "Bob".into(), grade: 88 },
Student { name: "Carol".into(), grade: 92 },
Student { name: "Dave".into(), grade: 97 },
Student { name: "Eve".into(), grade: 91 },
];
let result = top_students(&mut students);
assert_eq!(result, vec!["Dave: 97", "Alice: 95", "Carol: 92"]);
println!("{result:?}");
}
Key difference from C#: Rust iterators are lazy (like LINQ), but .sort_by() is eager and in-place — there’s no lazy OrderBy. You sort first, then chain lazy operations.
6. Enums and Pattern Matching
Algebraic Data Types vs C# Unions
What you’ll learn: Rust’s algebraic data types (enums with data) vs C#’s limited discriminated unions,
matchexpressions with exhaustive checking, guard clauses, and nested pattern destructuring.Difficulty: 🟡 Intermediate
C# Discriminated Unions (Limited)
// C# - Limited union support with inheritance
public abstract class Result
{
public abstract T Match<T>(Func<Success, T> onSuccess, Func<Error, T> onError);
}
public class Success : Result
{
public string Value { get; }
public Success(string value) => Value = value;
public override T Match<T>(Func<Success, T> onSuccess, Func<Error, T> onError)
=> onSuccess(this);
}
public class Error : Result
{
public string Message { get; }
public Error(string message) => Message = message;
public override T Match<T>(Func<Success, T> onSuccess, Func<Error, T> onError)
=> onError(this);
}
// C# 9+ Records with pattern matching (better)
public abstract record Shape;
public record Circle(double Radius) : Shape;
public record Rectangle(double Width, double Height) : Shape;
public static double Area(Shape shape) => shape switch
{
Circle(var radius) => Math.PI * radius * radius,
Rectangle(var width, var height) => width * height,
_ => throw new ArgumentException("Unknown shape") // [ERROR] Runtime error possible
};
Rust Algebraic Data Types (Enums)
#![allow(unused)]
fn main() {
// Rust - True algebraic data types with exhaustive pattern matching
#[derive(Debug, Clone)]
pub enum Result<T, E> {
Ok(T),
Err(E),
}
#[derive(Debug, Clone)]
pub enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
impl Shape {
pub fn area(&self) -> f64 {
match self {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Triangle { base, height } => 0.5 * base * height,
// [OK] Compiler error if any variant is missing!
}
}
}
// Advanced: Enums can hold different types
#[derive(Debug)]
pub enum Value {
Integer(i64),
Float(f64),
Text(String),
Boolean(bool),
List(Vec<Value>), // Recursive types!
}
impl Value {
pub fn type_name(&self) -> &'static str {
match self {
Value::Integer(_) => "integer",
Value::Float(_) => "float",
Value::Text(_) => "text",
Value::Boolean(_) => "boolean",
Value::List(_) => "list",
}
}
}
}
graph TD
subgraph "C# Discriminated Unions (Workarounds)"
CS_ABSTRACT["abstract class Result"]
CS_SUCCESS["class Success : Result"]
CS_ERROR["class Error : Result"]
CS_MATCH["Manual Match method<br/>or switch expressions"]
CS_RUNTIME["[ERROR] Runtime exceptions<br/>for missing cases"]
CS_HEAP["[ERROR] Heap allocation<br/>for class inheritance"]
CS_ABSTRACT --> CS_SUCCESS
CS_ABSTRACT --> CS_ERROR
CS_SUCCESS --> CS_MATCH
CS_ERROR --> CS_MATCH
CS_MATCH --> CS_RUNTIME
CS_ABSTRACT --> CS_HEAP
end
subgraph "Rust Algebraic Data Types"
RUST_ENUM["enum Shape { ... }"]
RUST_VARIANTS["Circle { radius }<br/>Rectangle { width, height }<br/>Triangle { base, height }"]
RUST_MATCH["match shape { ... }"]
RUST_EXHAUSTIVE["[OK] Exhaustive checking<br/>Compile-time guarantee"]
RUST_STACK["[OK] Stack allocation<br/>Efficient memory use"]
RUST_ZERO["[OK] Zero-cost abstraction"]
RUST_ENUM --> RUST_VARIANTS
RUST_VARIANTS --> RUST_MATCH
RUST_MATCH --> RUST_EXHAUSTIVE
RUST_ENUM --> RUST_STACK
RUST_STACK --> RUST_ZERO
end
style CS_RUNTIME fill:#ffcdd2,color:#000
style CS_HEAP fill:#fff3e0,color:#000
style RUST_EXHAUSTIVE fill:#c8e6c9,color:#000
style RUST_STACK fill:#c8e6c9,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
Enums and Pattern Matching
Rust enums are much more powerful than C# enums - they can hold data and are the foundation of type-safe programming.
C# Enum Limitations
// C# enum - just named constants
public enum Status
{
Pending,
Approved,
Rejected
}
// C# enum with backing values
public enum HttpStatusCode
{
OK = 200,
NotFound = 404,
InternalServerError = 500
}
// Need separate classes for complex data
public abstract class Result
{
public abstract bool IsSuccess { get; }
}
public class Success : Result
{
public string Value { get; }
public override bool IsSuccess => true;
public Success(string value)
{
Value = value;
}
}
public class Error : Result
{
public string Message { get; }
public override bool IsSuccess => false;
public Error(string message)
{
Message = message;
}
}
Rust Enum Power
#![allow(unused)]
fn main() {
// Simple enum (like C# enum)
#[derive(Debug, PartialEq)]
enum Status {
Pending,
Approved,
Rejected,
}
// Enum with data (this is where Rust shines!)
#[derive(Debug)]
enum Result<T, E> {
Ok(T), // Success variant holding value of type T
Err(E), // Error variant holding error of type E
}
// Complex enum with different data types
#[derive(Debug)]
enum Message {
Quit, // No data
Move { x: i32, y: i32 }, // Struct-like variant
Write(String), // Tuple-like variant
ChangeColor(i32, i32, i32), // Multiple values
}
// Real-world example: HTTP Response
#[derive(Debug)]
enum HttpResponse {
Ok { body: String, headers: Vec<String> },
NotFound { path: String },
InternalError { message: String, code: u16 },
Redirect { location: String },
}
}
Pattern Matching with Match
// C# switch statement (limited)
public string HandleStatus(Status status)
{
switch (status)
{
case Status.Pending:
return "Waiting for approval";
case Status.Approved:
return "Request approved";
case Status.Rejected:
return "Request rejected";
default:
return "Unknown status"; // Always need default
}
}
// C# pattern matching (C# 8+)
public string HandleResult(Result result)
{
return result switch
{
Success success => $"Success: {success.Value}",
Error error => $"Error: {error.Message}",
_ => "Unknown result" // Still need catch-all
};
}
#![allow(unused)]
fn main() {
// Rust match - exhaustive and powerful
fn handle_status(status: Status) -> String {
match status {
Status::Pending => "Waiting for approval".to_string(),
Status::Approved => "Request approved".to_string(),
Status::Rejected => "Request rejected".to_string(),
// No default needed - compiler ensures exhaustiveness
}
}
// Pattern matching with data extraction
fn handle_result<T, E>(result: Result<T, E>) -> String
where
T: std::fmt::Debug,
E: std::fmt::Debug,
{
match result {
Result::Ok(value) => format!("Success: {:?}", value),
Result::Err(error) => format!("Error: {:?}", error),
// Exhaustive - no default needed
}
}
// Complex pattern matching
fn handle_message(msg: Message) -> String {
match msg {
Message::Quit => "Goodbye!".to_string(),
Message::Move { x, y } => format!("Move to ({}, {})", x, y),
Message::Write(text) => format!("Write: {}", text),
Message::ChangeColor(r, g, b) => format!("Change color to RGB({}, {}, {})", r, g, b),
}
}
// HTTP response handling
fn handle_http_response(response: HttpResponse) -> String {
match response {
HttpResponse::Ok { body, headers } => {
format!("Success! Body: {}, Headers: {:?}", body, headers)
},
HttpResponse::NotFound { path } => {
format!("404: Path '{}' not found", path)
},
HttpResponse::InternalError { message, code } => {
format!("Error {}: {}", code, message)
},
HttpResponse::Redirect { location } => {
format!("Redirect to: {}", location)
},
}
}
}
Guards and Advanced Patterns
#![allow(unused)]
fn main() {
// Pattern matching with guards
fn describe_number(x: i32) -> String {
match x {
n if n < 0 => "negative".to_string(),
0 => "zero".to_string(),
n if n < 10 => "single digit".to_string(),
n if n < 100 => "double digit".to_string(),
_ => "large number".to_string(),
}
}
// Matching ranges
fn describe_age(age: u32) -> String {
match age {
0..=12 => "child".to_string(),
13..=19 => "teenager".to_string(),
20..=64 => "adult".to_string(),
65.. => "senior".to_string(),
}
}
// Destructuring structs and tuples
}
🏋️ Exercise: Command Parser (click to expand)
Challenge: Model a CLI command system using Rust enums. Parse string input into a Command enum and execute each variant. Handle unknown commands with proper error handling.
#![allow(unused)]
fn main() {
// Starter code — fill in the blanks
#[derive(Debug)]
enum Command {
// TODO: Add variants for Quit, Echo(String), Move { x: i32, y: i32 }, Count(u32)
}
fn parse_command(input: &str) -> Result<Command, String> {
let parts: Vec<&str> = input.splitn(2, ' ').collect();
// TODO: match on parts[0] and parse arguments
todo!()
}
fn execute(cmd: &Command) -> String {
// TODO: match on each variant and return a description
todo!()
}
}
🔑 Solution
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum Command {
Quit,
Echo(String),
Move { x: i32, y: i32 },
Count(u32),
}
fn parse_command(input: &str) -> Result<Command, String> {
let parts: Vec<&str> = input.splitn(2, ' ').collect();
match parts[0] {
"quit" => Ok(Command::Quit),
"echo" => {
let msg = parts.get(1).unwrap_or(&"").to_string();
Ok(Command::Echo(msg))
}
"move" => {
let args = parts.get(1).ok_or("move requires 'x y'")?;
let coords: Vec<&str> = args.split_whitespace().collect();
let x = coords.get(0).ok_or("missing x")?.parse::<i32>().map_err(|e| e.to_string())?;
let y = coords.get(1).ok_or("missing y")?.parse::<i32>().map_err(|e| e.to_string())?;
Ok(Command::Move { x, y })
}
"count" => {
let n = parts.get(1).ok_or("count requires a number")?
.parse::<u32>().map_err(|e| e.to_string())?;
Ok(Command::Count(n))
}
other => Err(format!("Unknown command: {other}")),
}
}
fn execute(cmd: &Command) -> String {
match cmd {
Command::Quit => "Goodbye!".to_string(),
Command::Echo(msg) => msg.clone(),
Command::Move { x, y } => format!("Moving to ({x}, {y})"),
Command::Count(n) => format!("Counted to {n}"),
}
}
}
Key takeaways:
- Each enum variant can hold different data — no need for class hierarchies
matchforces you to handle every variant, preventing forgotten cases?operator chains error propagation cleanly — no nested try-catch
Exhaustive Matching and Null Safety
Exhaustive Pattern Matching: Compiler Guarantees vs Runtime Errors
What you’ll learn: Why C#
switchexpressions silently miss cases while Rust’smatchcatches them at compile time,Option<T>vsNullable<T>for null safety, and custom error types withResult<T, E>.Difficulty: 🟡 Intermediate
C# Switch Expressions - Still Incomplete
// C# switch expressions look exhaustive but aren't guaranteed
public enum HttpStatus { Ok, NotFound, ServerError, Unauthorized }
public string HandleResponse(HttpStatus status) => status switch
{
HttpStatus.Ok => "Success",
HttpStatus.NotFound => "Resource not found",
HttpStatus.ServerError => "Internal error",
// Missing Unauthorized case — compiles with warning CS8524, but NOT an error!
// Runtime: SwitchExpressionException if status is Unauthorized
};
// Even with nullable warnings, this compiles:
public class User
{
public string Name { get; set; }
public bool IsActive { get; set; }
}
public string ProcessUser(User? user) => user switch
{
{ IsActive: true } => $"Active: {user.Name}",
{ IsActive: false } => $"Inactive: {user.Name}",
// Missing null case — compiler warning CS8655, but NOT an error!
// Runtime: SwitchExpressionException when user is null
};
// Adding an enum variant later doesn't break compilation of existing switches
public enum HttpStatus
{
Ok,
NotFound,
ServerError,
Unauthorized,
Forbidden // Adding this produces another CS8524 warning but doesn't break compilation!
}
Rust Pattern Matching - True Exhaustiveness
#![allow(unused)]
fn main() {
#[derive(Debug)]
enum HttpStatus {
Ok,
NotFound,
ServerError,
Unauthorized,
}
fn handle_response(status: HttpStatus) -> &'static str {
match status {
HttpStatus::Ok => "Success",
HttpStatus::NotFound => "Resource not found",
HttpStatus::ServerError => "Internal error",
HttpStatus::Unauthorized => "Authentication required",
// Compiler ERROR if any case is missing!
// This literally will not compile
}
}
// Adding a new variant breaks compilation everywhere it's used
#[derive(Debug)]
enum HttpStatus {
Ok,
NotFound,
ServerError,
Unauthorized,
Forbidden, // Adding this breaks compilation in handle_response()
}
// The compiler forces you to handle ALL cases
// Option<T> pattern matching is also exhaustive
fn process_optional_value(value: Option<i32>) -> String {
match value {
Some(n) => format!("Got value: {}", n),
None => "No value".to_string(),
// Forgetting either case = compilation error
}
}
}
graph TD
subgraph "C# Pattern Matching Limitations"
CS_SWITCH["switch expression"]
CS_WARNING["⚠️ Compiler warnings only"]
CS_COMPILE["✅ Compiles successfully"]
CS_RUNTIME["💥 Runtime exceptions"]
CS_DEPLOY["❌ Bugs reach production"]
CS_SILENT["😰 Silent failures on enum changes"]
CS_SWITCH --> CS_WARNING
CS_WARNING --> CS_COMPILE
CS_COMPILE --> CS_RUNTIME
CS_RUNTIME --> CS_DEPLOY
CS_SWITCH --> CS_SILENT
end
subgraph "Rust Exhaustive Matching"
RUST_MATCH["match expression"]
RUST_ERROR["🛑 Compilation fails"]
RUST_FIX["✅ Must handle all cases"]
RUST_SAFE["✅ Zero runtime surprises"]
RUST_EVOLUTION["🔄 Enum changes break compilation"]
RUST_REFACTOR["🛠️ Forced refactoring"]
RUST_MATCH --> RUST_ERROR
RUST_ERROR --> RUST_FIX
RUST_FIX --> RUST_SAFE
RUST_MATCH --> RUST_EVOLUTION
RUST_EVOLUTION --> RUST_REFACTOR
end
style CS_RUNTIME fill:#ffcdd2,color:#000
style CS_DEPLOY fill:#ffcdd2,color:#000
style CS_SILENT fill:#ffcdd2,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
style RUST_REFACTOR fill:#c8e6c9,color:#000
Null Safety: Nullable<T> vs Option<T>
C# Null Handling Evolution
// C# - Traditional null handling (error-prone)
public class User
{
public string Name { get; set; } // Can be null!
public string Email { get; set; } // Can be null!
}
public string GetUserDisplayName(User user)
{
if (user?.Name != null) // Null conditional operator
{
return user.Name;
}
return "Unknown User";
}
// C# 8+ Nullable Reference Types
public class User
{
public string Name { get; set; } // Non-nullable
public string? Email { get; set; } // Explicitly nullable
}
// C# Nullable<T> for value types
int? maybeNumber = GetNumber();
if (maybeNumber.HasValue)
{
Console.WriteLine(maybeNumber.Value);
}
Rust Option<T> System
#![allow(unused)]
fn main() {
// Rust - Explicit null handling with Option<T>
#[derive(Debug)]
pub struct User {
name: String, // Never null
email: Option<String>, // Explicitly optional
}
impl User {
pub fn get_display_name(&self) -> &str {
&self.name // No null check needed - guaranteed to exist
}
pub fn get_email_or_default(&self) -> String {
self.email
.as_ref()
.map(|e| e.clone())
.unwrap_or_else(|| "no-email@example.com".to_string())
}
}
// Pattern matching forces handling of None case
fn handle_optional_user(user: Option<User>) {
match user {
Some(u) => println!("User: {}", u.get_display_name()),
None => println!("No user found"),
// Compiler error if None case is not handled!
}
}
}
graph TD
subgraph "C# Null Handling Evolution"
CS_NULL["Traditional: string name<br/>[ERROR] Can be null"]
CS_NULLABLE["Nullable<T>: int? value<br/>[OK] Explicit for value types"]
CS_NRT["Nullable Reference Types<br/>string? name<br/>[WARNING] Compile-time warnings only"]
CS_RUNTIME["Runtime NullReferenceException<br/>[ERROR] Can still crash"]
CS_NULL --> CS_RUNTIME
CS_NRT -.-> CS_RUNTIME
CS_CHECKS["Manual null checks<br/>if (obj?.Property != null)"]
end
subgraph "Rust Option<T> System"
RUST_OPTION["Option<T><br/>Some(value) | None"]
RUST_FORCE["Compiler forces handling<br/>[OK] Cannot ignore None"]
RUST_MATCH["Pattern matching<br/>match option { ... }"]
RUST_METHODS["Rich API<br/>.map(), .unwrap_or(), .and_then()"]
RUST_OPTION --> RUST_FORCE
RUST_FORCE --> RUST_MATCH
RUST_FORCE --> RUST_METHODS
RUST_SAFE["Compile-time null safety<br/>[OK] No null pointer exceptions"]
RUST_MATCH --> RUST_SAFE
RUST_METHODS --> RUST_SAFE
end
style CS_RUNTIME fill:#ffcdd2,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
style CS_NRT fill:#fff3e0,color:#000
style RUST_FORCE fill:#c8e6c9,color:#000
#![allow(unused)]
fn main() {
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn describe_point(point: Point) -> String {
match point {
Point { x: 0, y: 0 } => "origin".to_string(),
Point { x: 0, y } => format!("on y-axis at y={}", y),
Point { x, y: 0 } => format!("on x-axis at x={}", x),
Point { x, y } if x == y => format!("on diagonal at ({}, {})", x, y),
Point { x, y } => format!("point at ({}, {})", x, y),
}
}
}
Option and Result Types
// C# nullable reference types (C# 8+)
public class PersonService
{
private Dictionary<int, string> people = new();
public string? FindPerson(int id)
{
return people.TryGetValue(id, out string? name) ? name : null;
}
public string GetPersonOrDefault(int id)
{
return FindPerson(id) ?? "Unknown";
}
// Exception-based error handling
public void SavePerson(int id, string name)
{
if (string.IsNullOrEmpty(name))
throw new ArgumentException("Name cannot be empty");
people[id] = name;
}
}
use std::collections::HashMap;
// Rust uses Option<T> instead of null
struct PersonService {
people: HashMap<i32, String>,
}
impl PersonService {
fn new() -> Self {
PersonService {
people: HashMap::new(),
}
}
// Returns Option<T> - no null!
fn find_person(&self, id: i32) -> Option<&String> {
self.people.get(&id)
}
// Pattern matching on Option
fn get_person_or_default(&self, id: i32) -> String {
match self.find_person(id) {
Some(name) => name.clone(),
None => "Unknown".to_string(),
}
}
// Using Option methods (more functional style)
fn get_person_or_default_functional(&self, id: i32) -> String {
self.find_person(id)
.map(|name| name.clone())
.unwrap_or_else(|| "Unknown".to_string())
}
// Result<T, E> for error handling
fn save_person(&mut self, id: i32, name: String) -> Result<(), String> {
if name.is_empty() {
return Err("Name cannot be empty".to_string());
}
self.people.insert(id, name);
Ok(())
}
// Chaining operations
fn get_person_length(&self, id: i32) -> Option<usize> {
self.find_person(id).map(|name| name.len())
}
}
fn main() {
let mut service = PersonService::new();
// Handle Result
match service.save_person(1, "Alice".to_string()) {
Ok(()) => println!("Person saved successfully"),
Err(error) => println!("Error: {}", error),
}
// Handle Option
match service.find_person(1) {
Some(name) => println!("Found: {}", name),
None => println!("Person not found"),
}
// Functional style with Option
let name_length = service.get_person_length(1)
.unwrap_or(0);
println!("Name length: {}", name_length);
// Question mark operator for early returns
fn try_operation(service: &mut PersonService) -> Result<String, String> {
service.save_person(2, "Bob".to_string())?; // Early return if error
let name = service.find_person(2).ok_or("Person not found")?; // Convert Option to Result
Ok(format!("Hello, {}", name))
}
match try_operation(&mut service) {
Ok(message) => println!("{}", message),
Err(error) => println!("Operation failed: {}", error),
}
}
Custom Error Types
#![allow(unused)]
fn main() {
// Define custom error enum
#[derive(Debug)]
enum PersonError {
NotFound(i32),
InvalidName(String),
DatabaseError(String),
}
impl std::fmt::Display for PersonError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
PersonError::NotFound(id) => write!(f, "Person with ID {} not found", id),
PersonError::InvalidName(name) => write!(f, "Invalid name: '{}'", name),
PersonError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
}
impl std::error::Error for PersonError {}
// Enhanced PersonService with custom errors
impl PersonService {
fn save_person_enhanced(&mut self, id: i32, name: String) -> Result<(), PersonError> {
if name.is_empty() || name.len() > 50 {
return Err(PersonError::InvalidName(name));
}
// Simulate database operation that might fail
if id < 0 {
return Err(PersonError::DatabaseError("Negative IDs not allowed".to_string()));
}
self.people.insert(id, name);
Ok(())
}
fn find_person_enhanced(&self, id: i32) -> Result<&String, PersonError> {
self.people.get(&id).ok_or(PersonError::NotFound(id))
}
}
fn demo_error_handling() {
let mut service = PersonService::new();
// Handle different error types
match service.save_person_enhanced(-1, "Invalid".to_string()) {
Ok(()) => println!("Success"),
Err(PersonError::NotFound(id)) => println!("Not found: {}", id),
Err(PersonError::InvalidName(name)) => println!("Invalid name: {}", name),
Err(PersonError::DatabaseError(msg)) => println!("DB Error: {}", msg),
}
}
}
Exercises
🏋️ Exercise: Option Combinators (click to expand)
Rewrite this deeply nested C# null-checking code using Rust Option combinators (and_then, map, unwrap_or):
string GetCityName(User? user)
{
if (user != null)
if (user.Address != null)
if (user.Address.City != null)
return user.Address.City.ToUpper();
return "UNKNOWN";
}
Use these Rust types:
#![allow(unused)]
fn main() {
struct User { address: Option<Address> }
struct Address { city: Option<String> }
}
Write it as a single expression with no if let or match.
🔑 Solution
struct User { address: Option<Address> }
struct Address { city: Option<String> }
fn get_city_name(user: Option<&User>) -> String {
user.and_then(|u| u.address.as_ref())
.and_then(|a| a.city.as_ref())
.map(|c| c.to_uppercase())
.unwrap_or_else(|| "UNKNOWN".to_string())
}
fn main() {
let user = User {
address: Some(Address { city: Some("seattle".to_string()) }),
};
assert_eq!(get_city_name(Some(&user)), "SEATTLE");
assert_eq!(get_city_name(None), "UNKNOWN");
let no_city = User { address: Some(Address { city: None }) };
assert_eq!(get_city_name(Some(&no_city)), "UNKNOWN");
}
Key insight: and_then is Rust’s ?. operator for Option. Each step returns Option, and the chain short-circuits on None — exactly like C#’s null-conditional operator ?., but explicit and type-safe.
7. Ownership and Borrowing
Understanding Ownership
What you’ll learn: Rust’s ownership system — why
let s2 = s1invalidatess1(unlike C# reference copying), the three ownership rules,CopyvsMovetypes, borrowing with&and&mut, and how the borrow checker replaces garbage collection.Difficulty: 🟡 Intermediate
Ownership is Rust’s most unique feature and the biggest conceptual shift for C# developers. Let’s approach it step by step.
C# Memory Model (Review)
// C# - Automatic memory management
public void ProcessData()
{
var data = new List<int> { 1, 2, 3, 4, 5 };
ProcessList(data);
// data is still accessible here
Console.WriteLine(data.Count); // Works fine
// GC will clean up when no references remain
}
public void ProcessList(List<int> list)
{
list.Add(6); // Modifies the original list
}
Rust Ownership Rules
- Each value has exactly one owner (unless you opt into shared ownership with
Rc<T>/Arc<T>— see Smart Pointers) - When the owner goes out of scope, the value is dropped (deterministic cleanup — see Drop)
- Ownership can be transferred (moved)
#![allow(unused)]
fn main() {
// Rust - Explicit ownership management
fn process_data() {
let data = vec![1, 2, 3, 4, 5]; // data owns the vector
process_list(data); // Ownership moved to function
// println!("{:?}", data); // ❌ Error: data no longer owned here
}
fn process_list(mut list: Vec<i32>) { // list now owns the vector
list.push(6);
// list is dropped here when function ends
}
}
Understanding “Move” for C# Developers
// C# - References are copied, objects stay in place
// (Only reference types — classes — work this way;
// C# value types like struct behave differently)
var original = new List<int> { 1, 2, 3 };
var reference = original; // Both variables point to same object
original.Add(4);
Console.WriteLine(reference.Count); // 4 - same object
#![allow(unused)]
fn main() {
// Rust - Ownership is transferred
let original = vec![1, 2, 3];
let moved = original; // Ownership transferred
// println!("{:?}", original); // ❌ Error: original no longer owns the data
println!("{:?}", moved); // ✅ Works: moved now owns the data
}
Copy Types vs Move Types
#![allow(unused)]
fn main() {
// Copy types (like C# value types) - copied, not moved
let x = 5; // i32 implements Copy
let y = x; // x is copied to y
println!("{}", x); // ✅ Works: x is still valid
// Move types (like C# reference types) - moved, not copied
let s1 = String::from("hello"); // String doesn't implement Copy
let s2 = s1; // s1 is moved to s2
// println!("{}", s1); // ❌ Error: s1 is no longer valid
}
Practical Example: Swapping Values
// C# - Simple reference swapping
public void SwapLists(ref List<int> a, ref List<int> b)
{
var temp = a;
a = b;
b = temp;
}
#![allow(unused)]
fn main() {
// Rust - Ownership-aware swapping
fn swap_vectors(a: &mut Vec<i32>, b: &mut Vec<i32>) {
std::mem::swap(a, b); // Built-in swap function
}
// Or manual approach
fn manual_swap() {
let mut a = vec![1, 2, 3];
let mut b = vec![4, 5, 6];
let temp = a; // Move a to temp
a = b; // Move b to a
b = temp; // Move temp to b
println!("a: {:?}, b: {:?}", a, b);
}
}
Borrowing Basics
Borrowing is like getting a reference in C#, but with compile-time safety guarantees.
C# Reference Parameters
// C# - ref and out parameters
public void ModifyValue(ref int value)
{
value += 10;
}
public void ReadValue(in int value) // readonly reference
{
Console.WriteLine(value);
}
public bool TryParse(string input, out int result)
{
return int.TryParse(input, out result);
}
Rust Borrowing
// Rust - borrowing with & and &mut
fn modify_value(value: &mut i32) { // Mutable borrow
*value += 10;
}
fn read_value(value: &i32) { // Immutable borrow
println!("{}", value);
}
fn main() {
let mut x = 5;
read_value(&x); // Borrow immutably
modify_value(&mut x); // Borrow mutably
println!("{}", x); // x is still owned here
}
Borrowing Rules (Enforced at Compile Time!)
#![allow(unused)]
fn main() {
fn borrowing_rules() {
let mut data = vec![1, 2, 3];
// Rule 1: Multiple immutable borrows are OK
let r1 = &data;
let r2 = &data;
println!("{:?} {:?}", r1, r2); // ✅ Works
// Rule 2: Only one mutable borrow at a time
let r3 = &mut data;
// let r4 = &mut data; // ❌ Error: cannot borrow mutably twice
// let r5 = &data; // ❌ Error: cannot borrow immutably while borrowed mutably
r3.push(4); // Use the mutable borrow
// r3 goes out of scope here
// Rule 3: Can borrow again after previous borrows end
let r6 = &data; // ✅ Works now
println!("{:?}", r6);
}
}
C# vs Rust: Reference Safety
// C# - Potential runtime errors
public class ReferenceSafety
{
private List<int> data = new List<int>();
public List<int> GetData() => data; // Returns reference to internal data
public void UnsafeExample()
{
var reference = GetData();
// Another thread could modify data here!
Thread.Sleep(1000);
// reference might be invalid or changed
reference.Add(42); // Potential race condition
}
}
#![allow(unused)]
fn main() {
// Rust - Compile-time safety
pub struct SafeContainer {
data: Vec<i32>,
}
impl SafeContainer {
// Return immutable borrow - caller can't modify
// Prefer &[i32] over &Vec<i32> — accept the broadest type
pub fn get_data(&self) -> &[i32] {
&self.data
}
// Return mutable borrow - exclusive access guaranteed
pub fn get_data_mut(&mut self) -> &mut Vec<i32> {
&mut self.data
}
}
fn safe_example() {
let mut container = SafeContainer { data: vec![1, 2, 3] };
let reference = container.get_data();
// container.get_data_mut(); // ❌ Error: can't borrow mutably while immutably borrowed
println!("{:?}", reference); // Use immutable reference
// reference goes out of scope here
let mut_reference = container.get_data_mut(); // ✅ Now OK
mut_reference.push(4);
}
}
Move Semantics
C# Value Types vs Reference Types
// C# - Value types are copied
struct Point
{
public int X { get; set; }
public int Y { get; set; }
}
var p1 = new Point { X = 1, Y = 2 };
var p2 = p1; // Copy
p2.X = 10;
Console.WriteLine(p1.X); // Still 1
// C# - Reference types share the object
var list1 = new List<int> { 1, 2, 3 };
var list2 = list1; // Reference copy
list2.Add(4);
Console.WriteLine(list1.Count); // 4 - same object
Rust Move Semantics
#![allow(unused)]
fn main() {
// Rust - Move by default for non-Copy types
#[derive(Debug)]
struct Point {
x: i32,
y: i32,
}
fn move_example() {
let p1 = Point { x: 1, y: 2 };
let p2 = p1; // Move (not copy)
// println!("{:?}", p1); // ❌ Error: p1 was moved
println!("{:?}", p2); // ✅ Works
}
// To enable copying, implement Copy trait
#[derive(Debug, Copy, Clone)]
struct CopyablePoint {
x: i32,
y: i32,
}
fn copy_example() {
let p1 = CopyablePoint { x: 1, y: 2 };
let p2 = p1; // Copy (because it implements Copy)
println!("{:?}", p1); // ✅ Works
println!("{:?}", p2); // ✅ Works
}
}
When Values Are Moved
#![allow(unused)]
fn main() {
fn demonstrate_moves() {
let s = String::from("hello");
// 1. Assignment moves
let s2 = s; // s moved to s2
// 2. Function calls move
take_ownership(s2); // s2 moved into function
// 3. Returning from functions moves
let s3 = give_ownership(); // Return value moved to s3
println!("{}", s3); // s3 is valid
}
fn take_ownership(s: String) {
println!("{}", s);
// s is dropped here
}
fn give_ownership() -> String {
String::from("yours") // Ownership moved to caller
}
}
Avoiding Moves with Borrowing
#![allow(unused)]
fn main() {
fn demonstrate_borrowing() {
let s = String::from("hello");
// Borrow instead of move
let len = calculate_length(&s); // s is borrowed
println!("'{}' has length {}", s, len); // s is still valid
}
fn calculate_length(s: &String) -> usize {
s.len() // s is not owned, so it's not dropped
}
}
Memory Management: GC vs RAII
C# Garbage Collection
// C# - Automatic memory management
public class Person
{
public string Name { get; set; }
public List<string> Hobbies { get; set; } = new List<string>();
public void AddHobby(string hobby)
{
Hobbies.Add(hobby); // Memory allocated automatically
}
// No explicit cleanup needed - GC handles it
// But IDisposable pattern for resources
}
using var file = new FileStream("data.txt", FileMode.Open);
// 'using' ensures Dispose() is called
Rust Ownership and RAII
#![allow(unused)]
fn main() {
// Rust - Compile-time memory management
pub struct Person {
name: String,
hobbies: Vec<String>,
}
impl Person {
pub fn add_hobby(&mut self, hobby: String) {
self.hobbies.push(hobby); // Memory management tracked at compile time
}
// Drop trait automatically implemented - cleanup is guaranteed
// Compare to C#'s IDisposable:
// C#: using var file = new FileStream(...) // Dispose() called at end of using block
// Rust: let file = File::open(...)? // drop() called at end of scope — no 'using' needed
}
// RAII - Resource Acquisition Is Initialization
{
let file = std::fs::File::open("data.txt")?;
// File automatically closed when 'file' goes out of scope
// No 'using' statement needed - handled by type system
}
}
graph TD
subgraph "C# Memory Management"
CS_ALLOC["Object Allocation<br/>new Person()"]
CS_HEAP["Managed Heap"]
CS_REF["References point to heap"]
CS_GC_CHECK["GC periodically checks<br/>for unreachable objects"]
CS_SWEEP["Mark and sweep<br/>collection"]
CS_PAUSE["[ERROR] GC pause times"]
CS_ALLOC --> CS_HEAP
CS_HEAP --> CS_REF
CS_REF --> CS_GC_CHECK
CS_GC_CHECK --> CS_SWEEP
CS_SWEEP --> CS_PAUSE
CS_ISSUES["[ERROR] Non-deterministic cleanup<br/>[ERROR] Memory pressure<br/>[ERROR] Finalization complexity<br/>[OK] Easy to use"]
end
subgraph "Rust Ownership System"
RUST_ALLOC["Value Creation<br/>Person { ... }"]
RUST_OWNER["Single owner<br/>on stack or heap"]
RUST_BORROW["Borrowing system<br/>&T, &mut T"]
RUST_SCOPE["Scope-based cleanup<br/>Drop trait"]
RUST_COMPILE["Compile-time verification"]
RUST_ALLOC --> RUST_OWNER
RUST_OWNER --> RUST_BORROW
RUST_BORROW --> RUST_SCOPE
RUST_SCOPE --> RUST_COMPILE
RUST_BENEFITS["[OK] Deterministic cleanup<br/>[OK] Zero runtime cost<br/>[OK] No memory leaks<br/>[ERROR] Learning curve"]
end
style CS_ISSUES fill:#ffebee,color:#000
style RUST_BENEFITS fill:#e8f5e8,color:#000
style CS_PAUSE fill:#ffcdd2,color:#000
style RUST_COMPILE fill:#c8e6c9,color:#000
🏋️ Exercise: Fix the Borrow Checker Errors (click to expand)
Challenge: Each snippet below has a borrow checker error. Fix them without changing the output.
#![allow(unused)]
fn main() {
// 1. Move after use
fn problem_1() {
let name = String::from("Alice");
let greeting = format!("Hello, {name}!");
let upper = name.to_uppercase(); // hint: borrow instead of move
println!("{greeting} — {upper}");
}
// 2. Mutable + immutable borrow overlap
fn problem_2() {
let mut numbers = vec![1, 2, 3];
let first = &numbers[0];
numbers.push(4); // hint: reorder operations
println!("first = {first}");
}
// 3. Returning a reference to a local
fn problem_3() -> String {
let s = String::from("hello");
s // hint: return owned value, not &str
}
}
🔑 Solution
#![allow(unused)]
fn main() {
// 1. format! already borrows — the fix is that format! takes a reference.
// The original code actually compiles! But if we had `let greeting = name;`
// then fix by using &name:
fn solution_1() {
let name = String::from("Alice");
let greeting = format!("Hello, {}!", &name); // borrow
let upper = name.to_uppercase(); // name still valid
println!("{greeting} — {upper}");
}
// 2. Use the immutable borrow before the mutable operation:
fn solution_2() {
let mut numbers = vec![1, 2, 3];
let first = numbers[0]; // copy the i32 value (i32 is Copy)
numbers.push(4);
println!("first = {first}");
}
// 3. Return the owned String (already correct — common beginner confusion):
fn solution_3() -> String {
let s = String::from("hello");
s // ownership transferred to caller — this is the correct pattern
}
}
Key takeaways:
format!()borrows its arguments — it doesn’t move them- Primitive types like
i32implementCopy, so indexing copies the value - Returning an owned value transfers ownership to the caller — no lifetime issues
Memory Safety Deep Dive
References vs Pointers
What you’ll learn: Rust references vs C# pointers and unsafe contexts, lifetime basics, and why compile-time safety proofs are stronger than C#’s runtime checks (bounds checking, null guards).
Difficulty: 🟡 Intermediate
C# Pointers (Unsafe Context)
// C# unsafe pointers (rarely used)
unsafe void UnsafeExample()
{
int value = 42;
int* ptr = &value; // Pointer to value
*ptr = 100; // Dereference and modify
Console.WriteLine(value); // 100
}
Rust References (Safe by Default)
#![allow(unused)]
fn main() {
// Rust references (always safe)
fn safe_example() {
let mut value = 42;
let ptr = &mut value; // Mutable reference
*ptr = 100; // Dereference and modify
println!("{}", value); // 100
}
// No "unsafe" keyword needed - borrow checker ensures safety
}
Lifetime Basics for C# Developers
// C# - Can return references that might become invalid
public class LifetimeIssues
{
public string GetFirstWord(string input)
{
return input.Split(' ')[0]; // Returns new string (safe)
}
public unsafe char* GetFirstChar(string input)
{
// This would be dangerous - returning pointer to managed memory
fixed (char* ptr = input)
return ptr; // ❌ Bad: ptr becomes invalid after method ends
}
}
#![allow(unused)]
fn main() {
// Rust - Lifetime checking prevents dangling references
fn get_first_word(input: &str) -> &str {
input.split_whitespace().next().unwrap_or("")
// ✅ Safe: returned reference has same lifetime as input
}
fn invalid_reference() -> &str {
let temp = String::from("hello");
&temp // ❌ Compile error: temp doesn't live long enough
// temp would be dropped at end of function
}
fn valid_reference() -> String {
let temp = String::from("hello");
temp // ✅ Works: ownership is transferred to caller
}
}
Memory Safety: Runtime Checks vs Compile-Time Proofs
C# - Runtime Safety Net
// C# relies on runtime checks and GC
public class Buffer
{
private byte[] data;
public Buffer(int size)
{
data = new byte[size];
}
public void ProcessData(int index)
{
// Runtime bounds checking
if (index >= data.Length)
throw new IndexOutOfRangeException();
data[index] = 42; // Safe, but checked at runtime
}
// Memory leaks still possible with events/static references
public static event Action<string> GlobalEvent;
public void Subscribe()
{
GlobalEvent += HandleEvent; // Can create memory leaks
// Forgot to unsubscribe - object won't be collected
}
private void HandleEvent(string message) { /* ... */ }
// Null reference exceptions are still possible
public void ProcessUser(User user)
{
Console.WriteLine(user.Name.ToUpper()); // NullReferenceException if user.Name is null
}
// Array access can fail at runtime
public int GetValue(int[] array, int index)
{
return array[index]; // IndexOutOfRangeException possible
}
}
Rust - Compile-Time Guarantees
#![allow(unused)]
fn main() {
struct Buffer {
data: Vec<u8>,
}
impl Buffer {
fn new(size: usize) -> Self {
Buffer {
data: vec![0; size],
}
}
fn process_data(&mut self, index: usize) {
// Bounds checking can be optimized away by compiler when proven safe
if let Some(item) = self.data.get_mut(index) {
*item = 42; // Safe access, proven at compile time
}
// Or use indexing with explicit bounds check:
// self.data[index] = 42; // Panics in debug, but memory-safe
}
// Memory leaks impossible - ownership system prevents them
fn process_with_closure<F>(&mut self, processor: F)
where F: FnOnce(&mut Vec<u8>)
{
processor(&mut self.data);
// When processor goes out of scope, it's automatically cleaned up
// No way to create dangling references or memory leaks
}
// Null pointer dereferences impossible - no null pointers!
fn process_user(&self, user: &User) {
println!("{}", user.name.to_uppercase()); // user.name cannot be null
}
// Array access is bounds-checked or explicitly unsafe
fn get_value(array: &[i32], index: usize) -> Option<i32> {
array.get(index).copied() // Returns None if out of bounds
}
// Or explicitly unsafe if you know what you're doing:
/// # Safety
/// `index` must be less than `array.len()`.
unsafe fn get_value_unchecked(array: &[i32], index: usize) -> i32 {
*array.get_unchecked(index) // Fast but must prove bounds manually
}
}
struct User {
name: String, // String cannot be null in Rust
}
// Ownership prevents use-after-free
fn ownership_example() {
let data = vec![1, 2, 3, 4, 5];
let reference = &data[0]; // Borrow data
// drop(data); // ERROR: cannot drop while borrowed
println!("{}", reference); // This is guaranteed safe
}
// Borrowing prevents data races
fn borrowing_example(data: &mut Vec<i32>) {
let first = &data[0]; // Immutable borrow
// data.push(6); // ERROR: cannot mutably borrow while immutably borrowed
println!("{}", first); // Guaranteed no data race
}
}
graph TD
subgraph "C# Runtime Safety"
CS_RUNTIME["Runtime Checks"]
CS_GC["Garbage Collector"]
CS_EXCEPTIONS["Exception Handling"]
CS_BOUNDS["Runtime bounds checking"]
CS_NULL["Null reference exceptions"]
CS_LEAKS["Memory leaks possible"]
CS_OVERHEAD["Performance overhead"]
CS_RUNTIME --> CS_BOUNDS
CS_RUNTIME --> CS_NULL
CS_GC --> CS_LEAKS
CS_EXCEPTIONS --> CS_OVERHEAD
end
subgraph "Rust Compile-Time Safety"
RUST_OWNERSHIP["Ownership System"]
RUST_BORROWING["Borrow Checker"]
RUST_TYPES["Type System"]
RUST_ZERO_COST["Zero-cost abstractions"]
RUST_NO_NULL["No null pointers"]
RUST_NO_LEAKS["No memory leaks"]
RUST_FAST["Optimal performance"]
RUST_OWNERSHIP --> RUST_NO_LEAKS
RUST_BORROWING --> RUST_NO_NULL
RUST_TYPES --> RUST_ZERO_COST
RUST_ZERO_COST --> RUST_FAST
end
style CS_NULL fill:#ffcdd2,color:#000
style CS_LEAKS fill:#ffcdd2,color:#000
style CS_OVERHEAD fill:#fff3e0,color:#000
style RUST_NO_NULL fill:#c8e6c9,color:#000
style RUST_NO_LEAKS fill:#c8e6c9,color:#000
style RUST_FAST fill:#c8e6c9,color:#000
Exercises
🏋️ Exercise: Spot the Safety Bug (click to expand)
This C# code has a subtle safety bug. Identify it, then write the Rust equivalent and explain why the Rust version won’t compile:
public List<int> GetEvenNumbers(List<int> numbers)
{
var result = new List<int>();
foreach (var n in numbers)
{
if (n % 2 == 0)
{
result.Add(n);
numbers.Remove(n); // Bug: modifying collection while iterating
}
}
return result;
}
🔑 Solution
C# bug: Modifying numbers while iterating throws InvalidOperationException at runtime. Easy to miss in code review.
fn get_even_numbers(numbers: &mut Vec<i32>) -> Vec<i32> {
let mut result = Vec::new();
for &n in numbers.iter() {
if n % 2 == 0 {
result.push(n);
// numbers.retain(|&x| x != n);
// ❌ ERROR: cannot borrow `*numbers` as mutable because
// it is also borrowed as immutable (by the iterator)
}
}
result
}
// Idiomatic Rust: use partition or retain
fn get_even_numbers_idiomatic(numbers: &mut Vec<i32>) -> Vec<i32> {
let evens: Vec<i32> = numbers.iter().copied().filter(|n| n % 2 == 0).collect();
numbers.retain(|n| n % 2 != 0); // remove evens after iteration
evens
}
fn main() {
let mut nums = vec![1, 2, 3, 4, 5, 6];
let evens = get_even_numbers_idiomatic(&mut nums);
assert_eq!(evens, vec![2, 4, 6]);
assert_eq!(nums, vec![1, 3, 5]);
}
Key insight: Rust’s borrow checker prevents the entire category of “mutate while iterating” bugs at compile time. C# catches this at runtime; many languages don’t catch it at all.
Lifetimes Deep Dive
Lifetimes: Telling the Compiler How Long References Live
What you’ll learn: Why lifetimes exist (no GC means the compiler needs proof), lifetime annotation syntax, elision rules, struct lifetimes, the
'staticlifetime, and common borrow checker errors with fixes.Difficulty: 🔴 Advanced
C# developers never think about reference lifetimes — the garbage collector handles reachability. In Rust, the compiler needs proof that every reference is valid for as long as it’s used. Lifetimes are that proof.
Why Lifetimes Exist
#![allow(unused)]
fn main() {
// This won't compile — the compiler can't prove the returned reference is valid
fn longest(a: &str, b: &str) -> &str {
if a.len() > b.len() { a } else { b }
}
// ERROR: missing lifetime specifier — the compiler doesn't know
// whether the return value borrows from `a` or `b`
}
Lifetime Annotations
// Lifetime 'a says: "the return value lives at least as long as BOTH inputs"
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() > b.len() { a } else { b }
}
fn main() {
let result;
let string1 = String::from("long string");
{
let string2 = String::from("xyz");
result = longest(&string1, &string2);
println!("Longest: {result}"); // ✅ both references still valid here
}
// println!("{result}"); // ❌ ERROR: string2 doesn't live long enough
}
C# Comparison
// C# — the GC keeps objects alive as long as any reference exists
string Longest(string a, string b) => a.Length > b.Length ? a : b;
// No lifetime issues — GC tracks reachability automatically
// But: GC pauses, unpredictable memory usage, no compile-time proof
Lifetime Elision Rules
Most of the time you don’t need to write lifetime annotations. The compiler applies three rules automatically:
| Rule | Description | Example |
|---|---|---|
| Rule 1 | Each reference parameter gets its own lifetime | fn foo(x: &str, y: &str) → fn foo<'a, 'b>(x: &'a str, y: &'b str) |
| Rule 2 | If there’s exactly one input lifetime, it’s assigned to all output lifetimes | fn first(s: &str) -> &str → fn first<'a>(s: &'a str) -> &'a str |
| Rule 3 | If one input is &self or &mut self, that lifetime is assigned to all outputs | fn name(&self) -> &str → works because of &self |
#![allow(unused)]
fn main() {
// These are equivalent — the compiler adds lifetimes automatically:
fn first_word(s: &str) -> &str { /* ... */ } // elided
fn first_word<'a>(s: &'a str) -> &'a str { /* ... */ } // explicit
// But this REQUIRES explicit annotation — two inputs, which one does output borrow?
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str { /* ... */ }
}
Struct Lifetimes
// A struct that borrows data (instead of owning it)
struct Excerpt<'a> {
text: &'a str, // borrows from some String that must outlive this struct
}
impl<'a> Excerpt<'a> {
fn new(text: &'a str) -> Self {
Excerpt { text }
}
fn first_sentence(&self) -> &str {
self.text.split('.').next().unwrap_or(self.text)
}
}
fn main() {
let novel = String::from("Call me Ishmael. Some years ago...");
let excerpt = Excerpt::new(&novel); // excerpt borrows from novel
println!("First sentence: {}", excerpt.first_sentence());
// novel must stay alive as long as excerpt exists
}
// C# equivalent — no lifetime concerns, but no compile-time guarantee either
class Excerpt
{
public string Text { get; }
public Excerpt(string text) => Text = text;
public string FirstSentence() => Text.Split('.')[0];
}
// What if the string is mutated elsewhere? Runtime surprise.
The 'static Lifetime
#![allow(unused)]
fn main() {
// 'static means "lives for the entire program duration"
let s: &'static str = "I'm a string literal"; // stored in binary, always valid
// Common places you see 'static:
// 1. String literals
// 2. Global constants
// 3. Thread::spawn requires 'static (thread might outlive the caller)
std::thread::spawn(move || {
// Closures sent to threads must own their data or use 'static references
println!("{s}"); // OK: &'static str
});
// 'static does NOT mean "immortal" — it means "CAN live forever if needed"
let owned = String::from("hello");
// owned is NOT 'static, but it can be moved into a thread (ownership transfer)
}
Common Borrow Checker Errors and Fixes
| Error | Cause | Fix |
|---|---|---|
missing lifetime specifier | Multiple input references, ambiguous output | Add <'a> annotation tying output to correct input |
does not live long enough | Reference outlives the data it points to | Extend the data’s scope, or return owned data instead |
cannot borrow as mutable | Immutable borrow still active | Use the immutable reference before mutating, or restructure |
cannot move out of borrowed content | Trying to take ownership of borrowed data | Use .clone(), or restructure to avoid the move |
lifetime may not live long enough | Struct borrow outlives source | Ensure the source data’s scope encompasses the struct’s usage |
Visualizing Lifetime Scopes
graph TD
subgraph "Scope Visualization"
direction TB
A["fn main()"] --> B["let s1 = String::from("hello")"]
B --> C["{ // inner scope"]
C --> D["let s2 = String::from("world")"]
D --> E["let r = longest(&s1, &s2)"]
E --> F["println!("{r}") ✅ both alive"]
F --> G["} // s2 dropped here"]
G --> H["println!("{r}") ❌ s2 gone!"]
end
style F fill:#c8e6c9,color:#000
style H fill:#ffcdd2,color:#000
Multiple Lifetime Parameters
Sometimes references come from different sources with different lifetimes:
// Two independent lifetimes: the return borrows only from 'a, not 'b
fn first_with_context<'a, 'b>(data: &'a str, _context: &'b str) -> &'a str {
// Return borrows from 'data' only — 'context' can have a shorter lifetime
data.split(',').next().unwrap_or(data)
}
fn main() {
let data = String::from("alice,bob,charlie");
let result;
{
let context = String::from("user lookup"); // shorter lifetime
result = first_with_context(&data, &context);
} // context dropped — but result borrows from data, not context ✅
println!("{result}");
}
// C# — no lifetime tracking means you can't express "borrows from A but not B"
string FirstWithContext(string data, string context) => data.Split(',')[0];
// Fine for GC'd languages, but Rust can prove safety without a GC
Real-World Lifetime Patterns
Pattern 1: Iterator returning references
#![allow(unused)]
fn main() {
// A parser that yields borrowed slices from the input
struct CsvRow<'a> {
fields: Vec<&'a str>,
}
fn parse_csv_line(line: &str) -> CsvRow<'_> {
// '_ tells the compiler "infer the lifetime from the input"
CsvRow {
fields: line.split(',').collect(),
}
}
}
Pattern 2: “Return owned when in doubt”
#![allow(unused)]
fn main() {
// When lifetimes get complex, returning owned data is the pragmatic fix
fn format_greeting(first: &str, last: &str) -> String {
// Returns owned String — no lifetime annotation needed
format!("Hello, {first} {last}!")
}
// Only borrow when:
// 1. Performance matters (avoiding allocation)
// 2. The relationship between input and output lifetime is clear
}
Pattern 3: Lifetime bounds on generics
#![allow(unused)]
fn main() {
// "T must live at least as long as 'a"
fn store_reference<'a, T: 'a>(cache: &mut Vec<&'a T>, item: &'a T) {
cache.push(item);
}
// Common in trait objects: Box<dyn Display + 'a>
fn make_printer<'a>(text: &'a str) -> Box<dyn std::fmt::Display + 'a> {
Box::new(text)
}
}
When to Reach for 'static
| Scenario | Use 'static? | Alternative |
|---|---|---|
| String literals | ✅ Yes — they’re always 'static | — |
thread::spawn closure | Often — thread outlives caller | Use thread::scope for borrowed data |
| Global config | ✅ lazy_static! or OnceLock | Pass references through params |
| Trait objects stored long-term | Often — Box<dyn Trait + 'static> | Parameterize the container with 'a |
| Temporary borrowing | ❌ Never — over-constraining | Use the actual lifetime |
🏋️ Exercise: Lifetime Annotations (click to expand)
Challenge: Add the correct lifetime annotations to make this compile:
#![allow(unused)]
fn main() {
struct Config {
db_url: String,
api_key: String,
}
// TODO: Add lifetime annotations
fn get_connection_info(config: &Config) -> (&str, &str) {
(&config.db_url, &config.api_key)
}
// TODO: This struct borrows from Config — add lifetime parameter
struct ConnectionInfo {
db_url: &str,
api_key: &str,
}
}
🔑 Solution
#![allow(unused)]
fn main() {
struct Config {
db_url: String,
api_key: String,
}
// Rule 3 doesn't apply (no &self), Rule 2 applies (one input → output)
// So the compiler handles this automatically — no annotation needed!
fn get_connection_info(config: &Config) -> (&str, &str) {
(&config.db_url, &config.api_key)
}
// Struct lifetime annotation needed:
struct ConnectionInfo<'a> {
db_url: &'a str,
api_key: &'a str,
}
fn make_info<'a>(config: &'a Config) -> ConnectionInfo<'a> {
ConnectionInfo {
db_url: &config.db_url,
api_key: &config.api_key,
}
}
}
Key takeaway: Lifetime elision often saves you from writing annotations on functions, but structs that borrow data always need explicit <'a>.
Smart Pointers — Beyond Single Ownership
Smart Pointers: When Single Ownership Isn’t Enough
What you’ll learn:
Box<T>,Rc<T>,Arc<T>,Cell<T>,RefCell<T>, andCow<'a, T>— when to use each, how they compare to C#’s GC-managed references,Dropas Rust’sIDisposable,Derefcoercion, and a decision tree for choosing the right smart pointer.Difficulty: 🔴 Advanced
In C#, every object is essentially reference-counted by the GC. In Rust, single ownership is the default — but sometimes you need shared ownership, heap allocation, or interior mutability. That’s where smart pointers come in.
Box<T> — Simple Heap Allocation
#![allow(unused)]
fn main() {
// Stack allocation (default in Rust)
let x = 42; // on the stack
// Heap allocation with Box
let y = Box::new(42); // on the heap, like C# `new int(42)` (boxed)
println!("{}", y); // auto-derefs: prints 42
// Common use: recursive types (can't know size at compile time)
#[derive(Debug)]
enum List {
Cons(i32, Box<List>), // Box gives a known pointer size
Nil,
}
let list = List::Cons(1, Box::new(List::Cons(2, Box::new(List::Nil))));
}
// C# — everything on the heap already (reference types)
// Box<T> is only needed in Rust because stack is the default
var list = new LinkedListNode<int>(1); // always heap-allocated
Rc<T> — Shared Ownership (Single Thread)
#![allow(unused)]
fn main() {
use std::rc::Rc;
// Multiple owners of the same data — like multiple C# references
let shared = Rc::new(vec![1, 2, 3]);
let clone1 = Rc::clone(&shared); // reference count: 2
let clone2 = Rc::clone(&shared); // reference count: 3
println!("Count: {}", Rc::strong_count(&shared)); // 3
// Data is dropped when last Rc goes out of scope
// Common use: shared configuration, graph nodes, tree structures
}
Arc<T> — Shared Ownership (Thread-Safe)
#![allow(unused)]
fn main() {
use std::sync::Arc;
use std::thread;
// Arc = Atomic Reference Counting — safe to share across threads
let data = Arc::new(vec![1, 2, 3]);
let handles: Vec<_> = (0..3).map(|i| {
let data = Arc::clone(&data);
thread::spawn(move || {
println!("Thread {i}: {:?}", data);
})
}).collect();
for h in handles { h.join().unwrap(); }
}
// C# — all references are thread-safe by default (GC handles it)
var data = new List<int> { 1, 2, 3 };
// Can share freely across threads (but mutation is still unsafe!)
Cell<T> and RefCell<T> — Interior Mutability
#![allow(unused)]
fn main() {
use std::cell::RefCell;
// Sometimes you need to mutate data behind a shared reference.
// RefCell moves borrow checking from compile time to runtime.
struct Logger {
entries: RefCell<Vec<String>>,
}
impl Logger {
fn new() -> Self {
Logger { entries: RefCell::new(Vec::new()) }
}
fn log(&self, msg: &str) { // &self, not &mut self!
self.entries.borrow_mut().push(msg.to_string());
}
fn dump(&self) {
for entry in self.entries.borrow().iter() {
println!("{entry}");
}
}
}
// ⚠️ RefCell panics at runtime if borrow rules are violated
// Use sparingly — prefer compile-time checking when possible
}
Cow<’a, str> — Clone on Write
#![allow(unused)]
fn main() {
use std::borrow::Cow;
// Sometimes you have a &str that MIGHT need to become a String
fn normalize(input: &str) -> Cow<'_, str> {
if input.contains('\t') {
// Only allocate when we need to modify
Cow::Owned(input.replace('\t', " "))
} else {
// Borrow the original — zero allocation
Cow::Borrowed(input)
}
}
let clean = normalize("hello"); // Cow::Borrowed — no allocation
let dirty = normalize("hello\tworld"); // Cow::Owned — allocated
// Both can be used as &str via Deref
println!("{clean} / {dirty}");
}
Drop: Rust’s IDisposable
In C#, IDisposable + using handles resource cleanup. Rust’s equivalent is the Drop trait — but it’s automatic, not opt-in:
// C# — must remember to use 'using' or call Dispose()
using var file = File.OpenRead("data.bin");
// Dispose() called at end of scope
// Forgetting 'using' is a resource leak!
var file2 = File.OpenRead("data.bin");
// GC will *eventually* finalize, but timing is unpredictable
// Rust — Drop runs automatically when value goes out of scope
{
let file = File::open("data.bin")?;
// use file...
} // file.drop() called HERE, deterministically — no 'using' needed
// Custom Drop (like implementing IDisposable)
struct TempFile {
path: std::path::PathBuf,
}
impl Drop for TempFile {
fn drop(&mut self) {
// Guaranteed to run when TempFile goes out of scope
let _ = std::fs::remove_file(&self.path);
println!("Cleaned up {:?}", self.path);
}
}
fn main() {
let tmp = TempFile { path: "scratch.tmp".into() };
// ... use tmp ...
} // scratch.tmp deleted automatically here
Key difference from C#: In Rust, every type can have deterministic cleanup. You never forget using because there’s nothing to forget — Drop runs when the owner goes out of scope. This pattern is called RAII (Resource Acquisition Is Initialization).
Rule: If your type holds a resource (file handle, network connection, lock guard, temp file), implement
Drop. The ownership system guarantees it runs exactly once.
Deref Coercion: Automatic Smart Pointer Unwrapping
Rust automatically “unwraps” smart pointers when you call methods or pass them to functions. This is called Deref coercion:
#![allow(unused)]
fn main() {
let boxed: Box<String> = Box::new(String::from("hello"));
// Deref coercion chain: Box<String> → String → str
println!("Length: {}", boxed.len()); // calls str::len() — auto-deref!
fn greet(name: &str) {
println!("Hello, {name}");
}
let s = String::from("Alice");
greet(&s); // &String → &str via Deref coercion
greet(&boxed); // &Box<String> → &String → &str — two levels!
}
// C# has no equivalent — you'd need explicit casts or .ToString()
// Closest: implicit conversion operators, but those require explicit definition
Why this matters: You can pass &String where &str is expected, &Vec<T> where &[T] is expected, and &Box<T> where &T is expected — all without explicit conversion. This is why Rust APIs typically accept &str and &[T] rather than &String and &Vec<T>.
Rc vs Arc: When to Use Which
Rc<T> | Arc<T> | |
|---|---|---|
| Thread safety | ❌ Single-thread only | ✅ Thread-safe (atomic ops) |
| Overhead | Lower (non-atomic refcount) | Higher (atomic refcount) |
| Compiler enforced | Won’t compile across thread::spawn | Works everywhere |
| Combine with | RefCell<T> for mutation | Mutex<T> or RwLock<T> for mutation |
Rule of thumb: Start with Rc. The compiler will tell you if you need Arc.
Decision Tree: Which Smart Pointer?
graph TD
START["Need shared ownership<br/>or heap allocation?"]
HEAP["Just need heap allocation?"]
SHARED["Shared ownership needed?"]
THREADED["Shared across threads?"]
MUTABLE["Need interior mutability?"]
MAYBE_OWN["Sometimes borrowed,<br/>sometimes owned?"]
BOX["Use Box<T>"]
RC["Use Rc<T>"]
ARC["Use Arc<T>"]
REFCELL["Use RefCell<T><br/>(or Rc<RefCell<T>>)"]
MUTEX["Use Arc<Mutex<T>>"]
COW["Use Cow<'a, T>"]
OWN["Use owned type<br/>(String, Vec, etc.)"]
START -->|Yes| HEAP
START -->|No| OWN
HEAP -->|Yes| BOX
HEAP -->|Shared| SHARED
SHARED -->|Single thread| RC
SHARED -->|Multi thread| THREADED
THREADED -->|Read only| ARC
THREADED -->|Read + write| MUTEX
RC -->|Need mutation?| MUTABLE
MUTABLE -->|Yes| REFCELL
MAYBE_OWN -->|Yes| COW
style BOX fill:#e3f2fd,color:#000
style RC fill:#e8f5e8,color:#000
style ARC fill:#c8e6c9,color:#000
style REFCELL fill:#fff3e0,color:#000
style MUTEX fill:#fff3e0,color:#000
style COW fill:#e3f2fd,color:#000
style OWN fill:#f5f5f5,color:#000
🏋️ Exercise: Choose the Right Smart Pointer (click to expand)
Challenge: For each scenario, choose the correct smart pointer and explain why.
- A recursive tree data structure
- A shared configuration object read by multiple components (single thread)
- A request counter shared across HTTP handler threads
- A cache that might return borrowed or owned strings
- A logging buffer that needs mutation through a shared reference
🔑 Solution
Box<T>— recursive types need indirection for known size at compile timeRc<T>— shared read-only access, single thread, noArcoverhead neededArc<Mutex<u64>>— shared across threads (Arc) with mutation (Mutex)Cow<'a, str>— sometimes returns&str(cache hit), sometimesString(cache miss)RefCell<Vec<String>>— interior mutability behind&self(single thread)
Rule of thumb: Start with owned types. Reach for Box when you need indirection, Rc/Arc when you need sharing, RefCell/Mutex when you need interior mutability, Cow when you want zero-copy for the common case.
8. Crates and Modules
Modules and Crates: Code Organization
What you’ll learn: Rust’s module system vs C# namespaces and assemblies,
pub/pub(crate)/pub(super)visibility, file-based module organization, and how crates map to .NET assemblies.Difficulty: 🟢 Beginner
Understanding Rust’s module system is essential for organizing code and managing dependencies. For C# developers, this is analogous to understanding namespaces, assemblies, and NuGet packages.
Rust Modules vs C# Namespaces
C# Namespace Organization
// File: Models/User.cs
namespace MyApp.Models
{
public class User
{
public string Name { get; set; }
public int Age { get; set; }
}
}
// File: Services/UserService.cs
using MyApp.Models;
namespace MyApp.Services
{
public class UserService
{
public User CreateUser(string name, int age)
{
return new User { Name = name, Age = age };
}
}
}
// File: Program.cs
using MyApp.Models;
using MyApp.Services;
namespace MyApp
{
class Program
{
static void Main(string[] args)
{
var service = new UserService();
var user = service.CreateUser("Alice", 30);
}
}
}
Rust Module Organization
// File: src/models.rs
pub struct User {
pub name: String,
pub age: u32,
}
impl User {
pub fn new(name: String, age: u32) -> User {
User { name, age }
}
}
// File: src/services.rs
use crate::models::User;
pub struct UserService;
impl UserService {
pub fn create_user(name: String, age: u32) -> User {
User::new(name, age)
}
}
// File: src/lib.rs (or main.rs)
pub mod models;
pub mod services;
use models::User;
use services::UserService;
fn main() {
let service = UserService;
let user = UserService::create_user("Alice".to_string(), 30);
}
Module Hierarchy and Visibility
graph TD
Crate["crate (root)"] --> ModA["mod data"]
Crate --> ModB["mod api"]
ModA --> SubA1["pub struct Repo"]
ModA --> SubA2["fn helper (private)"]
ModB --> SubB1["pub fn handle()"]
ModB --> SubB2["pub(crate) fn internal()"]
ModB --> SubB3["pub(super) fn parent_only()"]
style SubA1 fill:#c8e6c9,color:#000
style SubA2 fill:#ffcdd2,color:#000
style SubB1 fill:#c8e6c9,color:#000
style SubB2 fill:#fff9c4,color:#000
style SubB3 fill:#fff9c4,color:#000
🟢 Green = public everywhere | 🟡 Yellow = restricted visibility | 🔴 Red = private
C# Visibility Modifiers
namespace MyApp.Data
{
// public - accessible from anywhere
public class Repository
{
// private - only within this class
private string connectionString;
// internal - within this assembly
internal void Connect() { }
// protected - this class and subclasses
protected virtual void Initialize() { }
// public - accessible from anywhere
public void Save(object data) { }
}
}
Rust Visibility Rules
#![allow(unused)]
fn main() {
// Everything is private by default in Rust
mod data {
struct Repository { // Private struct
connection_string: String, // Private field
}
impl Repository {
fn new() -> Repository { // Private function
Repository {
connection_string: "localhost".to_string(),
}
}
pub fn connect(&self) { // Public method
// Only accessible within this module and its children
}
pub(crate) fn initialize(&self) { // Crate-level public
// Accessible anywhere in this crate
}
pub(super) fn internal_method(&self) { // Parent module public
// Accessible in parent module
}
}
// Public struct - accessible from outside the module
pub struct PublicRepository {
pub data: String, // Public field
private_data: String, // Private field (no pub)
}
}
pub use data::PublicRepository; // Re-export for external use
}
Module File Organization
C# Project Structure
MyApp/
├── MyApp.csproj
├── Models/
│ ├── User.cs
│ └── Product.cs
├── Services/
│ ├── UserService.cs
│ └── ProductService.cs
├── Controllers/
│ └── ApiController.cs
└── Program.cs
Rust Module File Structure
my_app/
├── Cargo.toml
└── src/
├── main.rs (or lib.rs)
├── models/
│ ├── mod.rs // Module declaration
│ ├── user.rs
│ └── product.rs
├── services/
│ ├── mod.rs // Module declaration
│ ├── user_service.rs
│ └── product_service.rs
└── controllers/
├── mod.rs
└── api_controller.rs
Module Declaration Patterns
#![allow(unused)]
fn main() {
// src/models/mod.rs
pub mod user; // Declares user.rs as a submodule
pub mod product; // Declares product.rs as a submodule
// Re-export commonly used types
pub use user::User;
pub use product::Product;
// src/main.rs
mod models; // Declares models/ as a module
mod services; // Declares services/ as a module
// Import specific items
use models::{User, Product};
use services::UserService;
// Or import the entire module
use models::user::*; // Import all public items from user module
}
Crates vs .NET Assemblies
Understanding Crates
In Rust, a crate is the fundamental unit of compilation and code distribution, similar to how an assembly works in .NET.
C# Assembly Model
// MyLibrary.dll - Compiled assembly
namespace MyLibrary
{
public class Calculator
{
public int Add(int a, int b) => a + b;
}
}
// MyApp.exe - Executable assembly that references MyLibrary.dll
using MyLibrary;
class Program
{
static void Main()
{
var calc = new Calculator();
Console.WriteLine(calc.Add(2, 3));
}
}
Rust Crate Model
# Cargo.toml for library crate
[package]
name = "my_calculator"
version = "0.1.0"
edition = "2021"
[lib]
name = "my_calculator"
#![allow(unused)]
fn main() {
// src/lib.rs - Library crate
pub struct Calculator;
impl Calculator {
pub fn add(&self, a: i32, b: i32) -> i32 {
a + b
}
}
}
# Cargo.toml for binary crate that uses the library
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
my_calculator = { path = "../my_calculator" }
// src/main.rs - Binary crate
use my_calculator::Calculator;
fn main() {
let calc = Calculator;
println!("{}", calc.add(2, 3));
}
Crate Types Comparison
| C# Concept | Rust Equivalent | Purpose |
|---|---|---|
| Class Library (.dll) | Library crate | Reusable code |
| Console App (.exe) | Binary crate | Executable program |
| NuGet Package | Published crate | Distribution unit |
| Assembly (.dll/.exe) | Compiled crate | Compilation unit |
| Solution (.sln) | Workspace | Multi-project organization |
Workspace vs Solution
C# Solution Structure
<!-- MySolution.sln structure -->
<Solution>
<Project Include="WebApi/WebApi.csproj" />
<Project Include="Business/Business.csproj" />
<Project Include="DataAccess/DataAccess.csproj" />
<Project Include="Tests/Tests.csproj" />
</Solution>
Rust Workspace Structure
# Cargo.toml at workspace root
[workspace]
members = [
"web_api",
"business",
"data_access",
"tests"
]
[workspace.dependencies]
serde = "1.0" # Shared dependency versions
tokio = "1.0"
# web_api/Cargo.toml
[package]
name = "web_api"
version = "0.1.0"
edition = "2021"
[dependencies]
business = { path = "../business" }
serde = { workspace = true } # Use workspace version
tokio = { workspace = true }
Exercises
🏋️ Exercise: Design a Module Tree (click to expand)
Given this C# project layout, design the equivalent Rust module tree:
// C#
namespace MyApp.Services { public class AuthService { } }
namespace MyApp.Services { internal class TokenStore { } }
namespace MyApp.Models { public class User { } }
namespace MyApp.Models { public class Session { } }
Requirements:
AuthServiceand both models must be publicTokenStoremust be private to theservicesmodule- Provide the file layout and the
mod/pubdeclarations inlib.rs
🔑 Solution
File layout:
src/
├── lib.rs
├── services/
│ ├── mod.rs
│ ├── auth_service.rs
│ └── token_store.rs
└── models/
├── mod.rs
├── user.rs
└── session.rs
// src/lib.rs
pub mod services;
pub mod models;
// src/services/mod.rs
mod token_store; // private — like C# internal
pub mod auth_service; // public
// src/services/auth_service.rs
use super::token_store::TokenStore; // visible within the module
pub struct AuthService;
impl AuthService {
pub fn login(&self) { /* uses TokenStore internally */ }
}
// src/services/token_store.rs
pub(super) struct TokenStore; // visible to parent (services) only
// src/models/mod.rs
pub mod user;
pub mod session;
// src/models/user.rs
pub struct User {
pub name: String,
}
// src/models/session.rs
pub struct Session {
pub user_id: u64,
}
Package Management — Cargo vs NuGet
Package Management: Cargo vs NuGet
What you’ll learn:
Cargo.tomlvs.csproj, version specifiers,Cargo.lock, feature flags for conditional compilation, and common Cargo commands mapped to their NuGet/dotnet equivalents.Difficulty: 🟢 Beginner
Dependency Declaration
C# NuGet Dependencies
<!-- MyApp.csproj -->
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
</PropertyGroup>
<PackageReference Include="Newtonsoft.Json" Version="13.0.3" />
<PackageReference Include="Serilog" Version="3.0.1" />
<PackageReference Include="Microsoft.AspNetCore.App" />
<ProjectReference Include="../MyLibrary/MyLibrary.csproj" />
</Project>
Rust Cargo Dependencies
# Cargo.toml
[package]
name = "my_app"
version = "0.1.0"
edition = "2021"
[dependencies]
serde_json = "1.0" # From crates.io (like NuGet)
serde = { version = "1.0", features = ["derive"] } # With features
log = "0.4"
tokio = { version = "1.0", features = ["full"] }
# Local dependencies (like ProjectReference)
my_library = { path = "../my_library" }
# Git dependencies
my_git_crate = { git = "https://github.com/user/repo" }
# Development dependencies (like test packages)
[dev-dependencies]
criterion = "0.5" # Benchmarking
proptest = "1.0" # Property testing
Version Management
C# Package Versioning
<!-- Centralized package management (Directory.Packages.props) -->
<Project>
<PropertyGroup>
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
</PropertyGroup>
<PackageVersion Include="Newtonsoft.Json" Version="13.0.3" />
<PackageVersion Include="Serilog" Version="3.0.1" />
</Project>
<!-- packages.lock.json for reproducible builds -->
Rust Version Management
# Cargo.toml - Semantic versioning
[dependencies]
serde = "1.0" # Compatible with 1.x.x (>=1.0.0, <2.0.0)
log = "0.4.17" # Compatible with 0.4.x (>=0.4.17, <0.5.0)
regex = "=1.5.4" # Exact version
chrono = "^0.4" # Caret requirements (default)
uuid = "~1.3.0" # Tilde requirements (>=1.3.0, <1.4.0)
# Cargo.lock - Exact versions for reproducible builds (auto-generated)
[[package]]
name = "serde"
version = "1.0.163"
# ... exact dependency tree
Package Sources
C# Package Sources
<!-- nuget.config -->
<configuration>
<packageSources>
<add key="nuget.org" value="https://api.nuget.org/v3/index.json" />
<add key="MyCompanyFeed" value="https://pkgs.dev.azure.com/company/_packaging/feed/nuget/v3/index.json" />
</packageSources>
</configuration>
Rust Package Sources
# .cargo/config.toml
[source.crates-io]
replace-with = "my-awesome-registry"
[source.my-awesome-registry]
registry = "https://my-intranet:8080/index"
# Alternative registries
[registries]
my-registry = { index = "https://my-intranet:8080/index" }
# In Cargo.toml
[dependencies]
my_crate = { version = "1.0", registry = "my-registry" }
Common Commands Comparison
| Task | C# Command | Rust Command |
|---|---|---|
| Restore packages | dotnet restore | cargo fetch |
| Add package | dotnet add package Newtonsoft.Json | cargo add serde_json |
| Remove package | dotnet remove package Newtonsoft.Json | cargo remove serde_json |
| Update packages | dotnet update | cargo update |
| List packages | dotnet list package | cargo tree |
| Audit security | dotnet list package --vulnerable | cargo audit |
| Clean build | dotnet clean | cargo clean |
Features: Conditional Compilation
C# Conditional Compilation
#if DEBUG
Console.WriteLine("Debug mode");
#elif RELEASE
Console.WriteLine("Release mode");
#endif
// Project file features
<PropertyGroup Condition="'$(Configuration)'=='Debug'">
<DefineConstants>DEBUG;TRACE</DefineConstants>
</PropertyGroup>
Rust Feature Gates
# Cargo.toml
[features]
default = ["json"] # Default features
json = ["serde_json"] # Feature that enables serde_json
xml = ["serde_xml"] # Alternative serialization
advanced = ["json", "xml"] # Composite feature
[dependencies]
serde_json = { version = "1.0", optional = true }
serde_xml = { version = "0.4", optional = true }
#![allow(unused)]
fn main() {
// Conditional compilation based on features
#[cfg(feature = "json")]
use serde_json;
#[cfg(feature = "xml")]
use serde_xml;
pub fn serialize_data(data: &MyStruct) -> String {
#[cfg(feature = "json")]
return serde_json::to_string(data).unwrap();
#[cfg(feature = "xml")]
return serde_xml::to_string(data).unwrap();
#[cfg(not(any(feature = "json", feature = "xml")))]
return "No serialization feature enabled".to_string();
}
}
Using External Crates
Popular Crates for C# Developers
| C# Library | Rust Crate | Purpose |
|---|---|---|
| Newtonsoft.Json | serde_json | JSON serialization |
| HttpClient | reqwest | HTTP client |
| Entity Framework | diesel / sqlx | ORM / SQL toolkit |
| NLog/Serilog | log + env_logger | Logging |
| xUnit/NUnit | Built-in #[test] | Unit testing |
| Moq | mockall | Mocking |
| Flurl | url | URL manipulation |
| Polly | tower | Resilience patterns |
Example: HTTP Client Migration
// C# HttpClient usage
public class ApiClient
{
private readonly HttpClient _httpClient;
public async Task<User> GetUserAsync(int id)
{
var response = await _httpClient.GetAsync($"/users/{id}");
var json = await response.Content.ReadAsStringAsync();
return JsonConvert.DeserializeObject<User>(json);
}
}
#![allow(unused)]
fn main() {
// Rust reqwest usage
use reqwest;
use serde::Deserialize;
#[derive(Deserialize)]
struct User {
id: u32,
name: String,
}
struct ApiClient {
client: reqwest::Client,
}
impl ApiClient {
async fn get_user(&self, id: u32) -> Result<User, reqwest::Error> {
let user = self.client
.get(&format!("https://api.example.com/users/{}", id))
.send()
.await?
.json::<User>()
.await?;
Ok(user)
}
}
}
9. Error Handling
Exceptions vs Result<T, E>
What you’ll learn: Why Rust replaces exceptions with
Result<T, E>andOption<T>, the?operator for concise error propagation, and how explicit error handling eliminates hidden control flow that plagues C#try/catchcode.Difficulty: 🟡 Intermediate
See also: Crate-Level Error Types for production error patterns with
thiserrorandanyhow, and Essential Crates for the error crate ecosystem.
C# Exception-Based Error Handling
// C# - Exception-based error handling
public class UserService
{
public User GetUser(int userId)
{
if (userId <= 0)
{
throw new ArgumentException("User ID must be positive");
}
var user = database.FindUser(userId);
if (user == null)
{
throw new UserNotFoundException($"User {userId} not found");
}
return user;
}
public async Task<string> GetUserEmailAsync(int userId)
{
try
{
var user = GetUser(userId);
return user.Email ?? throw new InvalidOperationException("User has no email");
}
catch (UserNotFoundException ex)
{
logger.Warning("User not found: {UserId}", userId);
return "noreply@company.com";
}
catch (Exception ex)
{
logger.Error(ex, "Unexpected error getting user email");
throw; // Re-throw
}
}
}
Rust Result-Based Error Handling
#![allow(unused)]
fn main() {
use std::fmt;
#[derive(Debug)]
pub enum UserError {
InvalidId(i32),
NotFound(i32),
NoEmail,
DatabaseError(String),
}
impl fmt::Display for UserError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
UserError::InvalidId(id) => write!(f, "Invalid user ID: {}", id),
UserError::NotFound(id) => write!(f, "User {} not found", id),
UserError::NoEmail => write!(f, "User has no email address"),
UserError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
}
}
}
impl std::error::Error for UserError {}
pub struct UserService {
// database connection, etc.
}
impl UserService {
pub fn get_user(&self, user_id: i32) -> Result<User, UserError> {
if user_id <= 0 {
return Err(UserError::InvalidId(user_id));
}
// Simulate database lookup
self.database_find_user(user_id)
.ok_or(UserError::NotFound(user_id))
}
pub fn get_user_email(&self, user_id: i32) -> Result<String, UserError> {
let user = self.get_user(user_id)?; // ? operator propagates errors
user.email
.ok_or(UserError::NoEmail)
}
pub fn get_user_email_or_default(&self, user_id: i32) -> String {
match self.get_user_email(user_id) {
Ok(email) => email,
Err(UserError::NotFound(_)) => {
log::warn!("User not found: {}", user_id);
"noreply@company.com".to_string()
}
Err(err) => {
log::error!("Error getting user email: {}", err);
"error@company.com".to_string()
}
}
}
}
}
graph TD
subgraph "C# Exception Model"
CS_CALL["Method Call"]
CS_SUCCESS["Success Path"]
CS_EXCEPTION["throw Exception"]
CS_STACK["Stack unwinding<br/>(Runtime cost)"]
CS_CATCH["try/catch block"]
CS_HIDDEN["[ERROR] Hidden control flow<br/>[ERROR] Performance cost<br/>[ERROR] Easy to ignore"]
CS_CALL --> CS_SUCCESS
CS_CALL --> CS_EXCEPTION
CS_EXCEPTION --> CS_STACK
CS_STACK --> CS_CATCH
CS_EXCEPTION --> CS_HIDDEN
end
subgraph "Rust Result Model"
RUST_CALL["Function Call"]
RUST_OK["Ok(value)"]
RUST_ERR["Err(error)"]
RUST_MATCH["match result"]
RUST_QUESTION["? operator<br/>(early return)"]
RUST_EXPLICIT["[OK] Explicit error handling<br/>[OK] Zero runtime cost<br/>[OK] Cannot ignore errors"]
RUST_CALL --> RUST_OK
RUST_CALL --> RUST_ERR
RUST_OK --> RUST_MATCH
RUST_ERR --> RUST_MATCH
RUST_ERR --> RUST_QUESTION
RUST_MATCH --> RUST_EXPLICIT
RUST_QUESTION --> RUST_EXPLICIT
end
style CS_HIDDEN fill:#ffcdd2,color:#000
style RUST_EXPLICIT fill:#c8e6c9,color:#000
style CS_STACK fill:#fff3e0,color:#000
style RUST_QUESTION fill:#c8e6c9,color:#000
The ? Operator: Propagating Errors Concisely
// C# - Exception propagation (implicit)
public async Task<string> ProcessFileAsync(string path)
{
var content = await File.ReadAllTextAsync(path); // Throws on error
var processed = ProcessContent(content); // Throws on error
return processed;
}
#![allow(unused)]
fn main() {
// Rust - Error propagation with ?
fn process_file(path: &str) -> Result<String, ConfigError> {
let content = read_config(path)?; // ? propagates error if Err
let processed = process_content(&content)?; // ? propagates error if Err
Ok(processed) // Wrap success value in Ok
}
fn process_content(content: &str) -> Result<String, ConfigError> {
if content.is_empty() {
Err(ConfigError::InvalidFormat)
} else {
Ok(content.to_uppercase())
}
}
}
Option<T> for Nullable Values
// C# - Nullable reference types
public string? FindUserName(int userId)
{
var user = database.FindUser(userId);
return user?.Name; // Returns null if user not found
}
public void ProcessUser(int userId)
{
string? name = FindUserName(userId);
if (name != null)
{
Console.WriteLine($"User: {name}");
}
else
{
Console.WriteLine("User not found");
}
}
#![allow(unused)]
fn main() {
// Rust - Option<T> for optional values
fn find_user_name(user_id: u32) -> Option<String> {
// Simulate database lookup
if user_id == 1 {
Some("Alice".to_string())
} else {
None
}
}
fn process_user(user_id: u32) {
match find_user_name(user_id) {
Some(name) => println!("User: {}", name),
None => println!("User not found"),
}
// Or use if let (pattern matching shorthand)
if let Some(name) = find_user_name(user_id) {
println!("User: {}", name);
} else {
println!("User not found");
}
}
}
Combining Option and Result
fn safe_divide(a: f64, b: f64) -> Option<f64> {
if b != 0.0 {
Some(a / b)
} else {
None
}
}
fn parse_and_divide(a_str: &str, b_str: &str) -> Result<Option<f64>, ParseFloatError> {
let a: f64 = a_str.parse()?; // Return parse error if invalid
let b: f64 = b_str.parse()?; // Return parse error if invalid
Ok(safe_divide(a, b)) // Return Ok(Some(result)) or Ok(None)
}
use std::num::ParseFloatError;
fn main() {
match parse_and_divide("10.0", "2.0") {
Ok(Some(result)) => println!("Result: {}", result),
Ok(None) => println!("Division by zero"),
Err(error) => println!("Parse error: {}", error),
}
}
🏋️ Exercise: Build a Crate-Level Error Type (click to expand)
Challenge: Create an AppError enum for a file processing application that can fail due to I/O errors, JSON parse errors, and validation errors. Implement From conversions for automatic ? propagation.
#![allow(unused)]
fn main() {
// Starter code
use std::io;
// TODO: Define AppError with variants:
// Io(io::Error), Json(serde_json::Error), Validation(String)
// TODO: Implement Display and Error traits
// TODO: Implement From<io::Error> and From<serde_json::Error>
// TODO: Define type alias: type Result<T> = std::result::Result<T, AppError>;
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)?; // io::Error → AppError
let config: Config = serde_json::from_str(&content)?; // serde error → AppError
if config.name.is_empty() {
return Err(AppError::Validation("name cannot be empty".into()));
}
Ok(config)
}
}
🔑 Solution
#![allow(unused)]
fn main() {
use std::io;
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("I/O error: {0}")]
Io(#[from] io::Error),
#[error("JSON error: {0}")]
Json(#[from] serde_json::Error),
#[error("Validation: {0}")]
Validation(String),
}
pub type Result<T> = std::result::Result<T, AppError>;
#[derive(serde::Deserialize)]
struct Config {
name: String,
port: u16,
}
fn load_config(path: &str) -> Result<Config> {
let content = std::fs::read_to_string(path)?;
let config: Config = serde_json::from_str(&content)?;
if config.name.is_empty() {
return Err(AppError::Validation("name cannot be empty".into()));
}
Ok(config)
}
}
Key takeaways:
thiserrorgeneratesDisplayandErrorimpls from attributes#[from]generatesFrom<T>impls, enabling automatic?conversion- The
Result<T>alias eliminates boilerplate throughout your crate - Unlike C# exceptions, the error type is visible in every function signature
Crate-Level Error Types and Result Aliases
Crate-Level Error Types and Result Aliases
What you’ll learn: The production pattern of defining a per-crate error enum with
thiserror, creating aResult<T>type alias, and when to choosethiserror(libraries) vsanyhow(applications).Difficulty: 🟡 Intermediate
A critical pattern for production Rust: define a per-crate error enum and a Result type alias to eliminate boilerplate.
The Pattern
#![allow(unused)]
fn main() {
// src/error.rs
use thiserror::Error;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Serialization error: {0}")]
Serialization(#[from] serde_json::Error),
#[error("Validation error: {message}")]
Validation { message: String },
#[error("Not found: {entity} with id {id}")]
NotFound { entity: String, id: String },
}
/// Crate-wide Result alias — every function returns this
pub type Result<T> = std::result::Result<T, AppError>;
}
Usage Throughout Your Crate
#![allow(unused)]
fn main() {
use crate::error::{AppError, Result};
pub async fn get_user(id: Uuid) -> Result<User> {
let user = sqlx::query_as!(User, "SELECT * FROM users WHERE id = $1", id)
.fetch_optional(&pool)
.await?; // sqlx::Error → AppError::Database via #[from]
user.ok_or_else(|| AppError::NotFound {
entity: "User".into(),
id: id.to_string(),
})
}
pub async fn create_user(req: CreateUserRequest) -> Result<User> {
if req.name.trim().is_empty() {
return Err(AppError::Validation {
message: "Name cannot be empty".into(),
});
}
// ...
}
}
C# Comparison
// C# equivalent pattern
public class AppException : Exception
{
public string ErrorCode { get; }
public AppException(string code, string message) : base(message)
{
ErrorCode = code;
}
}
// But in C#, callers don't know what exceptions to expect!
// In Rust, the error type is in the function signature.
Why This Matters
thiserrorgeneratesDisplayandErrorimpls automatically#[from]enables the?operator to convert library errors automatically- The
Result<T>alias means every function signature is clean:fn foo() -> Result<Bar> - Unlike C# exceptions, callers see all possible error variants in the type
thiserror vs anyhow: When to Use Which
Two crates dominate Rust error handling. Choosing between them is the first decision you’ll make:
thiserror | anyhow | |
|---|---|---|
| Purpose | Define structured error types for libraries | Quick error handling for applications |
| Output | Custom enum you control | Opaque anyhow::Error wrapper |
| Caller sees | All error variants in the type | Just anyhow::Error — opaque |
| Best for | Library crates, APIs, any code with consumers | Binaries, scripts, prototypes, CLI tools |
| Downcasting | match on variants directly | error.downcast_ref::<MyError>() |
#![allow(unused)]
fn main() {
// thiserror — for LIBRARIES (callers need to match on error variants)
use thiserror::Error;
#[derive(Error, Debug)]
pub enum StorageError {
#[error("File not found: {path}")]
NotFound { path: String },
#[error("Permission denied: {0}")]
PermissionDenied(String),
#[error(transparent)]
Io(#[from] std::io::Error),
}
pub fn read_config(path: &str) -> Result<String, StorageError> {
std::fs::read_to_string(path).map_err(|e| match e.kind() {
std::io::ErrorKind::NotFound => StorageError::NotFound { path: path.into() },
std::io::ErrorKind::PermissionDenied => StorageError::PermissionDenied(path.into()),
_ => StorageError::Io(e),
})
}
}
// anyhow — for APPLICATIONS (just propagate errors, don't define types)
use anyhow::{Context, Result};
fn main() -> Result<()> {
let config = std::fs::read_to_string("config.toml")
.context("Failed to read config file")?;
let port: u16 = config.parse()
.context("Failed to parse port number")?;
println!("Listening on port {port}");
Ok(())
}
// anyhow::Result<T> = Result<T, anyhow::Error>
// .context() adds human-readable context to any error
// C# comparison:
// thiserror ≈ defining custom exception classes with specific properties
// anyhow ≈ catching Exception and wrapping with message:
// throw new InvalidOperationException("Failed to read config", ex);
Guideline: If your code is a library (other code calls it), use thiserror. If your code is an application (the final binary), use anyhow. Many projects use both — thiserror for the library crate’s public API, anyhow in the main() binary.
Error Recovery Patterns
C# developers are used to try/catch blocks that recover from specific exceptions. Rust uses combinators on Result for the same purpose:
#![allow(unused)]
fn main() {
use std::fs;
// Pattern 1: Recover with a fallback value
let config = fs::read_to_string("config.toml")
.unwrap_or_else(|_| String::from("port = 8080")); // default if missing
// Pattern 2: Recover from specific errors, propagate others
fn read_or_create(path: &str) -> Result<String, std::io::Error> {
match fs::read_to_string(path) {
Ok(content) => Ok(content),
Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
let default = String::from("# new file");
fs::write(path, &default)?;
Ok(default)
}
Err(e) => Err(e), // propagate permission errors, etc.
}
}
// Pattern 3: Add context before propagating
use anyhow::Context;
fn load_config() -> anyhow::Result<Config> {
let text = fs::read_to_string("config.toml")
.context("Failed to read config.toml")?;
let config: Config = toml::from_str(&text)
.context("Failed to parse config.toml")?;
Ok(config)
}
// Pattern 4: Map errors to your domain type
fn parse_port(s: &str) -> Result<u16, AppError> {
s.parse::<u16>()
.map_err(|_| AppError::Validation {
message: format!("Invalid port: {s}"),
})
}
}
// C# equivalents:
try { config = File.ReadAllText("config.toml"); }
catch (FileNotFoundException) { config = "port = 8080"; } // Pattern 1
try { /* ... */ }
catch (FileNotFoundException) { /* create file */ } // Pattern 2
catch { throw; } // re-throw others
When to recover vs propagate:
- Recover when the error has a sensible default or retry strategy
- Propagate with
?when the caller should decide what to do - Add context (
.context()) at module boundaries to build an error trail
Exercises
🏋️ Exercise: Design a Crate Error Type (click to expand)
You’re building a user registration service. Design the error type using thiserror:
- Define
RegistrationErrorwith variants:DuplicateEmail(String),WeakPassword(String),DatabaseError(#[from] sqlx::Error),RateLimited { retry_after_secs: u64 } - Create a
type Result<T> = std::result::Result<T, RegistrationError>;alias - Write a
register_user(email: &str, password: &str) -> Result<()>that demonstrates?propagation and explicit error construction
🔑 Solution
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum RegistrationError {
#[error("Email already registered: {0}")]
DuplicateEmail(String),
#[error("Password too weak: {0}")]
WeakPassword(String),
#[error("Database error")]
Database(#[from] sqlx::Error),
#[error("Rate limited — retry after {retry_after_secs}s")]
RateLimited { retry_after_secs: u64 },
}
pub type Result<T> = std::result::Result<T, RegistrationError>;
pub fn register_user(email: &str, password: &str) -> Result<()> {
if password.len() < 8 {
return Err(RegistrationError::WeakPassword(
"must be at least 8 characters".into(),
));
}
// This ? converts sqlx::Error → RegistrationError::Database automatically
// db.check_email_unique(email).await?;
// This is explicit construction for domain logic
if email.contains("+spam") {
return Err(RegistrationError::DuplicateEmail(email.to_string()));
}
Ok(())
}
}
Key pattern: #[from] enables ? for library errors; explicit Err(...) for domain logic. The Result alias keeps every signature clean.
10. Traits and Generics
Traits - Rust’s Interfaces
What you’ll learn: Traits vs C# interfaces, default method implementations, trait objects (
dyn Trait) vs generic bounds (impl Trait), derived traits, common standard library traits, associated types, and operator overloading via traits.Difficulty: 🟡 Intermediate
Traits are Rust’s way of defining shared behavior, similar to interfaces in C# but more powerful.
C# Interface Comparison
// C# interface definition
public interface IAnimal
{
string Name { get; }
void MakeSound();
// Default implementation (C# 8+)
string Describe()
{
return $"{Name} makes a sound";
}
}
// C# interface implementation
public class Dog : IAnimal
{
public string Name { get; }
public Dog(string name)
{
Name = name;
}
public void MakeSound()
{
Console.WriteLine("Woof!");
}
// Can override default implementation
public string Describe()
{
return $"{Name} is a loyal dog";
}
}
// Generic constraints
public void ProcessAnimal<T>(T animal) where T : IAnimal
{
animal.MakeSound();
Console.WriteLine(animal.Describe());
}
Rust Trait Definition and Implementation
// Trait definition
trait Animal {
fn name(&self) -> &str;
fn make_sound(&self);
// Default implementation
fn describe(&self) -> String {
format!("{} makes a sound", self.name())
}
// Default implementation using other trait methods
fn introduce(&self) {
println!("Hi, I'm {}", self.name());
self.make_sound();
}
}
// Struct definition
#[derive(Debug)]
struct Dog {
name: String,
breed: String,
}
impl Dog {
fn new(name: String, breed: String) -> Dog {
Dog { name, breed }
}
}
// Trait implementation
impl Animal for Dog {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Woof!");
}
// Override default implementation
fn describe(&self) -> String {
format!("{} is a loyal {} dog", self.name, self.breed)
}
}
// Another implementation
#[derive(Debug)]
struct Cat {
name: String,
indoor: bool,
}
impl Animal for Cat {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Meow!");
}
// Use default describe() implementation
}
// Generic function with trait bounds
fn process_animal<T: Animal>(animal: &T) {
animal.make_sound();
println!("{}", animal.describe());
animal.introduce();
}
// Multiple trait bounds
fn process_animal_debug<T: Animal + std::fmt::Debug>(animal: &T) {
println!("Debug: {:?}", animal);
process_animal(animal);
}
fn main() {
let dog = Dog::new("Buddy".to_string(), "Golden Retriever".to_string());
let cat = Cat { name: "Whiskers".to_string(), indoor: true };
process_animal(&dog);
process_animal(&cat);
process_animal_debug(&dog);
}
Trait Objects and Dynamic Dispatch
// C# dynamic polymorphism
public void ProcessAnimals(List<IAnimal> animals)
{
foreach (var animal in animals)
{
animal.MakeSound(); // Dynamic dispatch
Console.WriteLine(animal.Describe());
}
}
// Usage
var animals = new List<IAnimal>
{
new Dog("Buddy"),
new Cat("Whiskers"),
new Dog("Rex")
};
ProcessAnimals(animals);
// Rust trait objects for dynamic dispatch
fn process_animals(animals: &[Box<dyn Animal>]) {
for animal in animals {
animal.make_sound(); // Dynamic dispatch
println!("{}", animal.describe());
}
}
// Alternative: using references
fn process_animal_refs(animals: &[&dyn Animal]) {
for animal in animals {
animal.make_sound();
println!("{}", animal.describe());
}
}
fn main() {
// Using Box<dyn Trait>
let animals: Vec<Box<dyn Animal>> = vec![
Box::new(Dog::new("Buddy".to_string(), "Golden Retriever".to_string())),
Box::new(Cat { name: "Whiskers".to_string(), indoor: true }),
Box::new(Dog::new("Rex".to_string(), "German Shepherd".to_string())),
];
process_animals(&animals);
// Using references
let dog = Dog::new("Buddy".to_string(), "Golden Retriever".to_string());
let cat = Cat { name: "Whiskers".to_string(), indoor: true };
let animal_refs: Vec<&dyn Animal> = vec![&dog, &cat];
process_animal_refs(&animal_refs);
}
Derived Traits
// Automatically derive common traits
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct Person {
name: String,
age: u32,
}
// What this generates (simplified):
impl std::fmt::Debug for Person {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("Person")
.field("name", &self.name)
.field("age", &self.age)
.finish()
}
}
impl Clone for Person {
fn clone(&self) -> Self {
Person {
name: self.name.clone(),
age: self.age,
}
}
}
impl PartialEq for Person {
fn eq(&self, other: &Self) -> bool {
self.name == other.name && self.age == other.age
}
}
// Usage
fn main() {
let person1 = Person {
name: "Alice".to_string(),
age: 30,
};
let person2 = person1.clone(); // Clone trait
println!("{:?}", person1); // Debug trait
println!("Equal: {}", person1 == person2); // PartialEq trait
}
Common Standard Library Traits
use std::collections::HashMap;
// Display trait for user-friendly output
impl std::fmt::Display for Person {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{} (age {})", self.name, self.age)
}
}
// From trait for conversions
impl From<(String, u32)> for Person {
fn from((name, age): (String, u32)) -> Self {
Person { name, age }
}
}
// Into trait is automatically implemented when From is implemented
fn create_person() {
let person: Person = ("Alice".to_string(), 30).into();
println!("{}", person);
}
// Iterator trait implementation
struct PersonIterator {
people: Vec<Person>,
index: usize,
}
impl Iterator for PersonIterator {
type Item = Person;
fn next(&mut self) -> Option<Self::Item> {
if self.index < self.people.len() {
let person = self.people[self.index].clone();
self.index += 1;
Some(person)
} else {
None
}
}
}
impl Person {
fn iterator(people: Vec<Person>) -> PersonIterator {
PersonIterator { people, index: 0 }
}
}
fn main() {
let people = vec![
Person::from(("Alice".to_string(), 30)),
Person::from(("Bob".to_string(), 25)),
Person::from(("Charlie".to_string(), 35)),
];
// Use our custom iterator
for person in Person::iterator(people.clone()) {
println!("{}", person); // Uses Display trait
}
}
🏋️ Exercise: Trait-Based Drawing System (click to expand)
Challenge: Implement a Drawable trait with an area() method and a draw() default method. Create Circle and Rect structs. Write a function that accepts &[Box<dyn Drawable>] and prints total area.
🔑 Solution
use std::f64::consts::PI;
trait Drawable {
fn area(&self) -> f64;
fn draw(&self) {
println!("Drawing shape with area {:.2}", self.area());
}
}
struct Circle { radius: f64 }
struct Rect { w: f64, h: f64 }
impl Drawable for Circle {
fn area(&self) -> f64 { PI * self.radius * self.radius }
}
impl Drawable for Rect {
fn area(&self) -> f64 { self.w * self.h }
}
fn total_area(shapes: &[Box<dyn Drawable>]) -> f64 {
shapes.iter().map(|s| s.area()).sum()
}
fn main() {
let shapes: Vec<Box<dyn Drawable>> = vec![
Box::new(Circle { radius: 5.0 }),
Box::new(Rect { w: 4.0, h: 6.0 }),
Box::new(Circle { radius: 2.0 }),
];
for s in &shapes { s.draw(); }
println!("Total area: {:.2}", total_area(&shapes));
}
Key takeaways:
dyn Traitgives runtime polymorphism (like C#IDrawable)Box<dyn Trait>is heap-allocated, needed for heterogeneous collections- Default methods work exactly like C# 8+ default interface methods
Associated Types: Traits With Type Members
C# interfaces don’t have associated types — Rust traits do. This is how Iterator works:
#![allow(unused)]
fn main() {
// The Iterator trait has an associated type 'Item'
trait Iterator {
type Item; // Each implementor defines what Item is
fn next(&mut self) -> Option<Self::Item>;
}
struct Counter { max: u32, current: u32 }
impl Iterator for Counter {
type Item = u32; // This Counter yields u32 values
fn next(&mut self) -> Option<u32> {
if self.current < self.max {
self.current += 1;
Some(self.current)
} else {
None
}
}
}
}
In C#, IEnumerator<T> uses a generic parameter (T) for this purpose. Rust’s associated types are different: Iterator has one Item type per implementation, not a generic parameter at the trait level. This makes trait bounds simpler: impl Iterator<Item = u32> vs C#’s IEnumerable<int>.
Operator Overloading via Traits
In C#, you define public static MyType operator+(MyType a, MyType b). In Rust, every operator maps to a trait in std::ops:
#![allow(unused)]
fn main() {
use std::ops::Add;
#[derive(Debug, Clone, Copy)]
struct Vec2 { x: f64, y: f64 }
impl Add for Vec2 {
type Output = Vec2;
fn add(self, rhs: Vec2) -> Vec2 {
Vec2 { x: self.x + rhs.x, y: self.y + rhs.y }
}
}
let a = Vec2 { x: 1.0, y: 2.0 };
let b = Vec2 { x: 3.0, y: 4.0 };
let c = a + b; // calls <Vec2 as Add>::add(a, b)
}
| C# | Rust | Notes |
|---|---|---|
operator+ | impl Add | self by value — consumes for non-Copy types |
operator== | impl PartialEq | Usually #[derive(PartialEq)] |
operator< | impl PartialOrd | Usually #[derive(PartialOrd)] |
ToString() | impl fmt::Display | Used by println!("{}", x) |
| Implicit conversion | No equivalent | Rust has no implicit conversions — use From/Into |
Coherence: The Orphan Rule
You can only implement a trait if you own either the trait or the type. This prevents conflicting implementations across crates:
#![allow(unused)]
fn main() {
// ✅ OK — you own MyType
impl Display for MyType { ... }
// ✅ OK — you own MyTrait
impl MyTrait for String { ... }
// ❌ ERROR — you own neither Display nor String
impl Display for String { ... }
}
C# has no equivalent restriction — any code can add extension methods to any type, which can lead to ambiguity.
impl Trait: Returning Traits Without Boxing
C# interfaces can always be used as return types. In Rust, returning a trait requires a decision: static dispatch (impl Trait) or dynamic dispatch (dyn Trait).
impl Trait in Argument Position (Shorthand for Generics)
#![allow(unused)]
fn main() {
// These two are equivalent:
fn print_animal(animal: &impl Animal) { animal.make_sound(); }
fn print_animal<T: Animal>(animal: &T) { animal.make_sound(); }
// impl Trait is just syntactic sugar for a generic parameter
// The compiler generates a specialized copy for each concrete type (monomorphization)
}
impl Trait in Return Position (The Key Difference)
// Return an iterator without exposing the concrete type
fn even_squares(limit: u32) -> impl Iterator<Item = u32> {
(0..limit)
.filter(|n| n % 2 == 0)
.map(|n| n * n)
}
// The caller sees "some type that implements Iterator<Item = u32>"
// The actual type (Filter<Map<Range<u32>, ...>>) is unnameable — impl Trait solves this.
fn main() {
for n in even_squares(20) {
print!("{n} ");
}
// Output: 0 4 16 36 64 100 144 196 256 324
}
// C# — returning an interface (always dynamic dispatch, heap-allocated iterator object)
public IEnumerable<int> EvenSquares(int limit) =>
Enumerable.Range(0, limit)
.Where(n => n % 2 == 0)
.Select(n => n * n);
// The return type hides the concrete iterator behind the IEnumerable interface
// Unlike Rust's Box<dyn Trait>, C# doesn't explicitly box — the runtime handles allocation
Returning Closures: impl Fn vs Box<dyn Fn>
#![allow(unused)]
fn main() {
// Return a closure — you CANNOT name the closure type, so impl Fn is essential
fn make_adder(x: i32) -> impl Fn(i32) -> i32 {
move |y| x + y
}
let add5 = make_adder(5);
println!("{}", add5(3)); // 8
// If you need to return DIFFERENT closures conditionally, you need Box:
fn choose_op(add: bool) -> Box<dyn Fn(i32, i32) -> i32> {
if add {
Box::new(|a, b| a + b)
} else {
Box::new(|a, b| a * b)
}
}
// impl Trait requires a SINGLE concrete type; different closures are different types
}
// C# — delegates handle this naturally (always heap-allocated)
Func<int, int> MakeAdder(int x) => y => x + y;
Func<int, int, int> ChooseOp(bool add) => add ? (a, b) => a + b : (a, b) => a * b;
The Dispatch Decision: impl Trait vs dyn Trait vs Generics
This is an architectural decision C# developers face immediately in Rust. Here’s the complete guide:
graph TD
START["Function accepts or returns<br/>a trait-based type?"]
POSITION["Argument or return position?"]
ARG_SAME["All callers pass<br/>the same type?"]
RET_SINGLE["Always returns the<br/>same concrete type?"]
COLLECTION["Storing in a collection<br/>or as struct field?"]
GENERIC["Use generics<br/><code>fn foo<T: Trait>(x: T)</code>"]
IMPL_ARG["Use impl Trait<br/><code>fn foo(x: impl Trait)</code>"]
IMPL_RET["Use impl Trait<br/><code>fn foo() -> impl Trait</code>"]
DYN_BOX["Use Box<dyn Trait><br/>Dynamic dispatch"]
DYN_REF["Use &dyn Trait<br/>Borrowed dynamic dispatch"]
START --> POSITION
POSITION -->|Argument| ARG_SAME
POSITION -->|Return| RET_SINGLE
ARG_SAME -->|"Yes (syntactic sugar)"| IMPL_ARG
ARG_SAME -->|"Complex bounds/multiple uses"| GENERIC
RET_SINGLE -->|Yes| IMPL_RET
RET_SINGLE -->|"No (conditional types)"| DYN_BOX
RET_SINGLE -->|"Heterogeneous collection"| COLLECTION
COLLECTION -->|Owned| DYN_BOX
COLLECTION -->|Borrowed| DYN_REF
style GENERIC fill:#c8e6c9,color:#000
style IMPL_ARG fill:#c8e6c9,color:#000
style IMPL_RET fill:#c8e6c9,color:#000
style DYN_BOX fill:#fff3e0,color:#000
style DYN_REF fill:#fff3e0,color:#000
| Approach | Dispatch | Allocation | When to Use |
|---|---|---|---|
fn foo<T: Trait>(x: T) | Static (monomorphized) | Stack | Multiple trait bounds, turbofish needed, same type reused |
fn foo(x: impl Trait) | Static (monomorphized) | Stack | Simple bounds, cleaner syntax, one-off parameters |
fn foo() -> impl Trait | Static | Stack | Single concrete return type, iterators, closures |
fn foo() -> Box<dyn Trait> | Dynamic (vtable) | Heap | Different return types, trait objects in collections |
&dyn Trait / &mut dyn Trait | Dynamic (vtable) | No alloc | Borrowed heterogeneous references, function parameters |
#![allow(unused)]
fn main() {
// Summary: from fastest to most flexible
fn static_dispatch(x: impl Display) { /* fastest, no alloc */ }
fn generic_dispatch<T: Display + Clone>(x: T) { /* fastest, multiple bounds */ }
fn dynamic_dispatch(x: &dyn Display) { /* vtable lookup, no alloc */ }
fn boxed_dispatch(x: Box<dyn Display>) { /* vtable lookup + heap alloc */ }
}
Generic Constraints
Generic Constraints: where vs trait bounds
What you’ll learn: Rust’s trait bounds vs C#’s
whereconstraints, thewhereclause syntax, conditional trait implementations, associated types, and higher-ranked trait bounds (HRTBs).Difficulty: 🔴 Advanced
C# Generic Constraints
// C# Generic constraints with where clause
public class Repository<T> where T : class, IEntity, new()
{
public T Create()
{
return new T(); // new() constraint allows parameterless constructor
}
public void Save(T entity)
{
if (entity.Id == 0) // IEntity constraint provides Id property
{
entity.Id = GenerateId();
}
// Save to database
}
}
// Multiple type parameters with constraints
public class Converter<TInput, TOutput>
where TInput : IConvertible
where TOutput : class, new()
{
public TOutput Convert(TInput input)
{
var output = new TOutput();
// Conversion logic using IConvertible
return output;
}
}
// Variance in generics
public interface IRepository<out T> where T : IEntity
{
IEnumerable<T> GetAll(); // Covariant - can return more derived types
}
public interface IWriter<in T> where T : IEntity
{
void Write(T entity); // Contravariant - can accept more base types
}
Rust Generic Constraints with Trait Bounds
#![allow(unused)]
fn main() {
use std::fmt::{Debug, Display};
use std::clone::Clone;
// Basic trait bounds
pub struct Repository<T>
where
T: Clone + Debug + Default,
{
items: Vec<T>,
}
impl<T> Repository<T>
where
T: Clone + Debug + Default,
{
pub fn new() -> Self {
Repository { items: Vec::new() }
}
pub fn create(&self) -> T {
T::default() // Default trait provides default value
}
pub fn add(&mut self, item: T) {
println!("Adding item: {:?}", item); // Debug trait for printing
self.items.push(item);
}
pub fn get_all(&self) -> Vec<T> {
self.items.clone() // Clone trait for duplication
}
}
// Multiple trait bounds with different syntaxes
pub fn process_data<T, U>(input: T) -> U
where
T: Display + Clone,
U: From<T> + Debug,
{
println!("Processing: {}", input); // Display trait
let cloned = input.clone(); // Clone trait
let output = U::from(cloned); // From trait for conversion
println!("Result: {:?}", output); // Debug trait
output
}
// Associated types (similar to C# generic constraints)
pub trait Iterator {
type Item; // Associated type instead of generic parameter
fn next(&mut self) -> Option<Self::Item>;
}
pub trait Collect<T> {
fn collect<I: Iterator<Item = T>>(iter: I) -> Self;
}
// Higher-ranked trait bounds (advanced)
fn apply_to_all<F>(items: &[String], f: F) -> Vec<String>
where
F: for<'a> Fn(&'a str) -> String, // Function works with any lifetime
{
items.iter().map(|s| f(s)).collect()
}
// Conditional trait implementations
impl<T> PartialEq for Repository<T>
where
T: PartialEq + Clone + Debug + Default,
{
fn eq(&self, other: &Self) -> bool {
self.items == other.items
}
}
}
graph TD
subgraph "C# Generic Constraints"
CS_WHERE["where T : class, IInterface, new()"]
CS_RUNTIME["[ERROR] Some runtime type checking<br/>Virtual method dispatch"]
CS_VARIANCE["[OK] Covariance/Contravariance<br/>in/out keywords"]
CS_REFLECTION["[ERROR] Runtime reflection possible<br/>typeof(T), is, as operators"]
CS_BOXING["[ERROR] Value type boxing<br/>for interface constraints"]
CS_WHERE --> CS_RUNTIME
CS_WHERE --> CS_VARIANCE
CS_WHERE --> CS_REFLECTION
CS_WHERE --> CS_BOXING
end
subgraph "Rust Trait Bounds"
RUST_WHERE["where T: Trait + Clone + Debug"]
RUST_COMPILE["[OK] Compile-time resolution<br/>Monomorphization"]
RUST_ZERO["[OK] Zero-cost abstractions<br/>No runtime overhead"]
RUST_ASSOCIATED["[OK] Associated types<br/>More flexible than generics"]
RUST_HKT["[OK] Higher-ranked trait bounds<br/>Advanced type relationships"]
RUST_WHERE --> RUST_COMPILE
RUST_WHERE --> RUST_ZERO
RUST_WHERE --> RUST_ASSOCIATED
RUST_WHERE --> RUST_HKT
end
subgraph "Flexibility Comparison"
CS_FLEX["C# Flexibility<br/>[OK] Variance<br/>[OK] Runtime type info<br/>[ERROR] Performance cost"]
RUST_FLEX["Rust Flexibility<br/>[OK] Zero cost<br/>[OK] Compile-time safety<br/>[ERROR] No variance (yet)"]
end
style CS_RUNTIME fill:#fff3e0,color:#000
style CS_BOXING fill:#ffcdd2,color:#000
style RUST_COMPILE fill:#c8e6c9,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
style CS_FLEX fill:#e3f2fd,color:#000
style RUST_FLEX fill:#c8e6c9,color:#000
Exercises
🏋️ Exercise: Generic Repository (click to expand)
Translate this C# generic repository interface to Rust traits:
public interface IRepository<T> where T : IEntity, new()
{
T GetById(int id);
IEnumerable<T> Find(Func<T, bool> predicate);
void Save(T entity);
}
Requirements:
- Define an
Entitytrait withfn id(&self) -> u64 - Define a
Repository<T>trait whereT: Entity + Clone - Implement a
InMemoryRepository<T>that stores items in aVec<T> - The
findmethod should acceptimpl Fn(&T) -> bool
🔑 Solution
trait Entity: Clone {
fn id(&self) -> u64;
}
trait Repository<T: Entity> {
fn get_by_id(&self, id: u64) -> Option<&T>;
fn find(&self, predicate: impl Fn(&T) -> bool) -> Vec<&T>;
fn save(&mut self, entity: T);
}
struct InMemoryRepository<T> {
items: Vec<T>,
}
impl<T: Entity> InMemoryRepository<T> {
fn new() -> Self { Self { items: Vec::new() } }
}
impl<T: Entity> Repository<T> for InMemoryRepository<T> {
fn get_by_id(&self, id: u64) -> Option<&T> {
self.items.iter().find(|item| item.id() == id)
}
fn find(&self, predicate: impl Fn(&T) -> bool) -> Vec<&T> {
self.items.iter().filter(|item| predicate(item)).collect()
}
fn save(&mut self, entity: T) {
if let Some(pos) = self.items.iter().position(|e| e.id() == entity.id()) {
self.items[pos] = entity;
} else {
self.items.push(entity);
}
}
}
#[derive(Clone, Debug)]
struct User { user_id: u64, name: String }
impl Entity for User {
fn id(&self) -> u64 { self.user_id }
}
fn main() {
let mut repo = InMemoryRepository::new();
repo.save(User { user_id: 1, name: "Alice".into() });
repo.save(User { user_id: 2, name: "Bob".into() });
let found = repo.find(|u| u.name.starts_with('A'));
assert_eq!(found.len(), 1);
}
Key differences from C#: No new() constraint (use Default trait instead). Fn(&T) -> bool replaces Func<T, bool>. Return Option instead of throwing.
Inheritance vs Composition
Inheritance vs Composition
What you’ll learn: Why Rust has no class inheritance, how traits + structs replace deep class hierarchies, and practical patterns for achieving polymorphism through composition.
Difficulty: 🟡 Intermediate
// C# - Class-based inheritance
public abstract class Animal
{
public string Name { get; protected set; }
public abstract void MakeSound();
public virtual void Sleep()
{
Console.WriteLine($"{Name} is sleeping");
}
}
public class Dog : Animal
{
public Dog(string name) { Name = name; }
public override void MakeSound()
{
Console.WriteLine("Woof!");
}
public void Fetch()
{
Console.WriteLine($"{Name} is fetching");
}
}
// Interface-based contracts
public interface IFlyable
{
void Fly();
}
public class Bird : Animal, IFlyable
{
public Bird(string name) { Name = name; }
public override void MakeSound()
{
Console.WriteLine("Tweet!");
}
public void Fly()
{
Console.WriteLine($"{Name} is flying");
}
}
Rust Composition Model
#![allow(unused)]
fn main() {
// Rust - Composition over inheritance with traits
pub trait Animal {
fn name(&self) -> &str;
fn make_sound(&self);
// Default implementation (like C# virtual methods)
fn sleep(&self) {
println!("{} is sleeping", self.name());
}
}
pub trait Flyable {
fn fly(&self);
}
// Separate data from behavior
#[derive(Debug)]
pub struct Dog {
name: String,
}
#[derive(Debug)]
pub struct Bird {
name: String,
wingspan: f64,
}
// Implement behaviors for types
impl Animal for Dog {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Woof!");
}
}
impl Dog {
pub fn new(name: String) -> Self {
Dog { name }
}
pub fn fetch(&self) {
println!("{} is fetching", self.name);
}
}
impl Animal for Bird {
fn name(&self) -> &str {
&self.name
}
fn make_sound(&self) {
println!("Tweet!");
}
}
impl Flyable for Bird {
fn fly(&self) {
println!("{} is flying with {:.1}m wingspan", self.name, self.wingspan);
}
}
// Multiple trait bounds (like multiple interfaces)
fn make_flying_animal_sound<T>(animal: &T)
where
T: Animal + Flyable,
{
animal.make_sound();
animal.fly();
}
}
graph TD
subgraph "C# Inheritance Hierarchy"
CS_ANIMAL["Animal (abstract class)"]
CS_DOG["Dog : Animal"]
CS_BIRD["Bird : Animal, IFlyable"]
CS_VTABLE["Virtual method dispatch<br/>Runtime cost"]
CS_COUPLING["[ERROR] Tight coupling<br/>[ERROR] Diamond problem<br/>[ERROR] Deep hierarchies"]
CS_ANIMAL --> CS_DOG
CS_ANIMAL --> CS_BIRD
CS_DOG --> CS_VTABLE
CS_BIRD --> CS_VTABLE
CS_ANIMAL --> CS_COUPLING
end
subgraph "Rust Composition Model"
RUST_ANIMAL["trait Animal"]
RUST_FLYABLE["trait Flyable"]
RUST_DOG["struct Dog"]
RUST_BIRD["struct Bird"]
RUST_IMPL1["impl Animal for Dog"]
RUST_IMPL2["impl Animal for Bird"]
RUST_IMPL3["impl Flyable for Bird"]
RUST_STATIC["Static dispatch<br/>Zero cost"]
RUST_FLEXIBLE["[OK] Flexible composition<br/>[OK] No hierarchy limits<br/>[OK] Mix and match traits"]
RUST_DOG --> RUST_IMPL1
RUST_BIRD --> RUST_IMPL2
RUST_BIRD --> RUST_IMPL3
RUST_IMPL1 --> RUST_ANIMAL
RUST_IMPL2 --> RUST_ANIMAL
RUST_IMPL3 --> RUST_FLYABLE
RUST_IMPL1 --> RUST_STATIC
RUST_IMPL2 --> RUST_STATIC
RUST_IMPL3 --> RUST_STATIC
RUST_ANIMAL --> RUST_FLEXIBLE
RUST_FLYABLE --> RUST_FLEXIBLE
end
style CS_COUPLING fill:#ffcdd2,color:#000
style RUST_FLEXIBLE fill:#c8e6c9,color:#000
style CS_VTABLE fill:#fff3e0,color:#000
style RUST_STATIC fill:#c8e6c9,color:#000
Exercises
🏋️ Exercise: Replace Inheritance with Traits (click to expand)
This C# code uses inheritance. Rewrite it in Rust using trait composition:
public abstract class Shape { public abstract double Area(); }
public abstract class Shape3D : Shape { public abstract double Volume(); }
public class Cylinder : Shape3D
{
public double Radius { get; }
public double Height { get; }
public Cylinder(double r, double h) { Radius = r; Height = h; }
public override double Area() => 2.0 * Math.PI * Radius * (Radius + Height);
public override double Volume() => Math.PI * Radius * Radius * Height;
}
Requirements:
HasAreatrait withfn area(&self) -> f64HasVolumetrait withfn volume(&self) -> f64Cylinderstruct implementing both- A function
fn print_shape_info(shape: &(impl HasArea + HasVolume))— note the trait bound composition (no inheritance needed)
🔑 Solution
use std::f64::consts::PI;
trait HasArea {
fn area(&self) -> f64;
}
trait HasVolume {
fn volume(&self) -> f64;
}
struct Cylinder {
radius: f64,
height: f64,
}
impl HasArea for Cylinder {
fn area(&self) -> f64 {
2.0 * PI * self.radius * (self.radius + self.height)
}
}
impl HasVolume for Cylinder {
fn volume(&self) -> f64 {
PI * self.radius * self.radius * self.height
}
}
fn print_shape_info(shape: &(impl HasArea + HasVolume)) {
println!("Area: {:.2}", shape.area());
println!("Volume: {:.2}", shape.volume());
}
fn main() {
let c = Cylinder { radius: 3.0, height: 5.0 };
print_shape_info(&c);
}
Key insight: C# needs a 3-level hierarchy (Shape → Shape3D → Cylinder). Rust uses flat trait composition — impl HasArea + HasVolume combines capabilities without inheritance depth.
11. From and Into Traits
Type Conversions in Rust
What you’ll learn:
From/Intotraits vs C#’s implicit/explicit operators,TryFrom/TryIntofor fallible conversions,FromStrfor parsing, and idiomatic string conversion patterns.Difficulty: 🟡 Intermediate
C# uses implicit/explicit conversions and casting operators. Rust uses the From and Into traits for safe, explicit conversions.
C# Conversion Patterns
// C# implicit/explicit conversions
public class Temperature
{
public double Celsius { get; }
public Temperature(double celsius) { Celsius = celsius; }
// Implicit conversion
public static implicit operator double(Temperature t) => t.Celsius;
// Explicit conversion
public static explicit operator Temperature(double d) => new Temperature(d);
}
double temp = new Temperature(100.0); // implicit
Temperature t = (Temperature)37.5; // explicit
Rust From and Into
#[derive(Debug)]
struct Temperature {
celsius: f64,
}
impl From<f64> for Temperature {
fn from(celsius: f64) -> Self {
Temperature { celsius }
}
}
impl From<Temperature> for f64 {
fn from(temp: Temperature) -> f64 {
temp.celsius
}
}
fn main() {
// From
let temp = Temperature::from(100.0);
// Into (automatically available when From is implemented)
let temp2: Temperature = 37.5.into();
// Works in function arguments too
fn process_temp(temp: impl Into<Temperature>) {
let t: Temperature = temp.into();
println!("Temperature: {:.1}°C", t.celsius);
}
process_temp(98.6);
process_temp(Temperature { celsius: 0.0 });
}
graph LR
A["impl From<f64> for Temperature"] -->|"auto-generates"| B["impl Into<Temperature> for f64"]
C["Temperature::from(37.5)"] -->|"explicit"| D["Temperature"]
E["37.5.into()"] -->|"implicit via Into"| D
F["fn process(t: impl Into<Temperature>)"] -->|"accepts both"| D
style A fill:#c8e6c9,color:#000
style B fill:#bbdefb,color:#000
Rule of thumb: Implement
From, and you getIntofor free. Callers can use whichever reads better.
TryFrom for Fallible Conversions
use std::convert::TryFrom;
impl TryFrom<i32> for Temperature {
type Error = String;
fn try_from(value: i32) -> Result<Self, Self::Error> {
if value < -273 {
Err(format!("Temperature {}°C is below absolute zero", value))
} else {
Ok(Temperature { celsius: value as f64 })
}
}
}
fn main() {
match Temperature::try_from(-300) {
Ok(t) => println!("Valid: {:?}", t),
Err(e) => println!("Error: {}", e),
}
}
String Conversions
#![allow(unused)]
fn main() {
// ToString via Display trait
impl std::fmt::Display for Temperature {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{:.1}°C", self.celsius)
}
}
// Now .to_string() works automatically
let s = Temperature::from(100.0).to_string(); // "100.0°C"
// FromStr for parsing
use std::str::FromStr;
impl FromStr for Temperature {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim_end_matches("°C").trim();
let celsius: f64 = s.parse().map_err(|e| format!("Invalid temp: {}", e))?;
Ok(Temperature { celsius })
}
}
let t: Temperature = "100.0°C".parse().unwrap();
}
Exercises
🏋️ Exercise: Currency Converter (click to expand)
Create a Money struct that demonstrates the full conversion ecosystem:
Money { cents: i64 }(stores value in cents to avoid floating-point issues)- Implement
From<i64>(treats input as whole dollars →cents = dollars * 100) - Implement
TryFrom<f64>— reject negative amounts, round to nearest cent - Implement
Displayto show"$1.50"format - Implement
FromStrto parse"$1.50"or"1.50"back intoMoney - Write a function
fn total(items: &[impl Into<Money> + Copy]) -> Moneythat sums values
🔑 Solution
use std::fmt;
use std::str::FromStr;
#[derive(Debug, Clone, Copy)]
struct Money { cents: i64 }
impl From<i64> for Money {
fn from(dollars: i64) -> Self {
Money { cents: dollars * 100 }
}
}
impl TryFrom<f64> for Money {
type Error = String;
fn try_from(value: f64) -> Result<Self, Self::Error> {
if value < 0.0 {
Err(format!("negative amount: {value}"))
} else {
Ok(Money { cents: (value * 100.0).round() as i64 })
}
}
}
impl fmt::Display for Money {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "${}.{:02}", self.cents / 100, self.cents.abs() % 100)
}
}
impl FromStr for Money {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let s = s.trim_start_matches('$');
let val: f64 = s.parse().map_err(|e| format!("{e}"))?;
Money::try_from(val)
}
}
fn main() {
let a = Money::from(10); // $10.00
let b = Money::try_from(3.50).unwrap(); // $3.50
let c: Money = "$7.25".parse().unwrap(); // $7.25
println!("{a} + {b} + {c}");
}
12. Closures and Iterators
Rust Closures
What you’ll learn: Closures with ownership-aware captures (
Fn/FnMut/FnOnce) vs C# lambdas, Rust iterators as a zero-cost replacement for LINQ, lazy vs eager evaluation, and parallel iteration withrayon.Difficulty: 🟡 Intermediate
Closures in Rust are similar to C# lambdas and delegates, but with ownership-aware captures.
C# Lambdas and Delegates
// C# - Lambdas capture by reference
Func<int, int> doubler = x => x * 2;
Action<string> printer = msg => Console.WriteLine(msg);
// Closure capturing outer variables
int multiplier = 3;
Func<int, int> multiply = x => x * multiplier;
Console.WriteLine(multiply(5)); // 15
// LINQ uses lambdas extensively
var evens = numbers.Where(n => n % 2 == 0).ToList();
Rust Closures
#![allow(unused)]
fn main() {
// Rust closures - ownership-aware
let doubler = |x: i32| x * 2;
let printer = |msg: &str| println!("{}", msg);
// Closure capturing by reference (default for immutable)
let multiplier = 3;
let multiply = |x: i32| x * multiplier; // borrows multiplier
println!("{}", multiply(5)); // 15
println!("{}", multiplier); // still accessible
// Closure capturing by move
let data = vec![1, 2, 3];
let owns_data = move || {
println!("{:?}", data); // data moved into closure
};
owns_data();
// println!("{:?}", data); // ERROR: data was moved
// Using closures with iterators
let numbers = vec![1, 2, 3, 4, 5];
let evens: Vec<&i32> = numbers.iter().filter(|&&n| n % 2 == 0).collect();
}
Closure Types
// Fn - borrows captured values immutably
fn apply_fn(f: impl Fn(i32) -> i32, x: i32) -> i32 {
f(x)
}
// FnMut - borrows captured values mutably
fn apply_fn_mut(mut f: impl FnMut(i32), values: &[i32]) {
for &v in values {
f(v);
}
}
// FnOnce - takes ownership of captured values
fn apply_fn_once(f: impl FnOnce() -> Vec<i32>) -> Vec<i32> {
f() // can only call once
}
fn main() {
// Fn example
let multiplier = 3;
let result = apply_fn(|x| x * multiplier, 5);
// FnMut example
let mut sum = 0;
apply_fn_mut(|x| sum += x, &[1, 2, 3, 4, 5]);
println!("Sum: {}", sum); // 15
// FnOnce example
let data = vec![1, 2, 3];
let result = apply_fn_once(move || data); // moves data
}
LINQ vs Rust Iterators
C# LINQ (Language Integrated Query)
// C# LINQ - Declarative data processing
var numbers = new[] { 1, 2, 3, 4, 5, 6, 7, 8, 9, 10 };
var result = numbers
.Where(n => n % 2 == 0) // Filter even numbers
.Select(n => n * n) // Square them
.Where(n => n > 10) // Filter > 10
.OrderByDescending(n => n) // Sort descending
.Take(3) // Take first 3
.ToList(); // Materialize
// LINQ with complex objects
var users = GetUsers();
var activeAdults = users
.Where(u => u.IsActive && u.Age >= 18)
.GroupBy(u => u.Department)
.Select(g => new {
Department = g.Key,
Count = g.Count(),
AverageAge = g.Average(u => u.Age)
})
.OrderBy(x => x.Department)
.ToList();
// Async LINQ (with additional libraries)
var results = await users
.ToAsyncEnumerable()
.WhereAwait(async u => await IsActiveAsync(u.Id))
.SelectAwait(async u => await EnrichUserAsync(u))
.ToListAsync();
Rust Iterators
#![allow(unused)]
fn main() {
// Rust iterators - Lazy, zero-cost abstractions
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
let result: Vec<i32> = numbers
.iter()
.filter(|&&n| n % 2 == 0) // Filter even numbers
.map(|&n| n * n) // Square them
.filter(|&n| n > 10) // Filter > 10
.collect::<Vec<_>>() // Collect to Vec
.into_iter()
.rev() // Reverse (descending sort)
.take(3) // Take first 3
.collect(); // Materialize
// Complex iterator chains
use std::collections::HashMap;
#[derive(Debug, Clone)]
struct User {
name: String,
age: u32,
department: String,
is_active: bool,
}
fn process_users(users: Vec<User>) -> HashMap<String, (usize, f64)> {
users
.into_iter()
.filter(|u| u.is_active && u.age >= 18)
.fold(HashMap::new(), |mut acc, user| {
let entry = acc.entry(user.department.clone()).or_insert((0, 0.0));
entry.0 += 1; // count
entry.1 += user.age as f64; // sum of ages
acc
})
.into_iter()
.map(|(dept, (count, sum))| (dept, (count, sum / count as f64))) // average
.collect()
}
// Parallel processing with rayon
use rayon::prelude::*;
fn parallel_processing(numbers: Vec<i32>) -> Vec<i32> {
numbers
.par_iter() // Parallel iterator
.filter(|&&n| n % 2 == 0)
.map(|&n| expensive_computation(n))
.collect()
}
fn expensive_computation(n: i32) -> i32 {
// Simulate heavy computation
(0..1000).fold(n, |acc, _| acc + 1)
}
}
graph TD
subgraph "C# LINQ Characteristics"
CS_LINQ["LINQ Expression"]
CS_EAGER["Often eager evaluation<br/>(ToList(), ToArray())"]
CS_REFLECTION["[ERROR] Some runtime reflection<br/>Expression trees"]
CS_ALLOCATIONS["[ERROR] Intermediate collections<br/>Garbage collection pressure"]
CS_ASYNC["[OK] Async support<br/>(with additional libraries)"]
CS_SQL["[OK] LINQ to SQL/EF integration"]
CS_LINQ --> CS_EAGER
CS_LINQ --> CS_REFLECTION
CS_LINQ --> CS_ALLOCATIONS
CS_LINQ --> CS_ASYNC
CS_LINQ --> CS_SQL
end
subgraph "Rust Iterator Characteristics"
RUST_ITER["Iterator Chain"]
RUST_LAZY["[OK] Lazy evaluation<br/>No work until .collect()"]
RUST_ZERO["[OK] Zero-cost abstractions<br/>Compiles to optimal loops"]
RUST_NO_ALLOC["[OK] No intermediate allocations<br/>Stack-based processing"]
RUST_PARALLEL["[OK] Easy parallelization<br/>(rayon crate)"]
RUST_FUNCTIONAL["[OK] Functional programming<br/>Immutable by default"]
RUST_ITER --> RUST_LAZY
RUST_ITER --> RUST_ZERO
RUST_ITER --> RUST_NO_ALLOC
RUST_ITER --> RUST_PARALLEL
RUST_ITER --> RUST_FUNCTIONAL
end
subgraph "Performance Comparison"
CS_PERF["C# LINQ Performance<br/>[ERROR] Allocation overhead<br/>[ERROR] Virtual dispatch<br/>[OK] Good enough for most cases"]
RUST_PERF["Rust Iterator Performance<br/>[OK] Hand-optimized speed<br/>[OK] No allocations<br/>[OK] Compile-time optimization"]
end
style CS_REFLECTION fill:#ffcdd2,color:#000
style CS_ALLOCATIONS fill:#fff3e0,color:#000
style RUST_ZERO fill:#c8e6c9,color:#000
style RUST_LAZY fill:#c8e6c9,color:#000
style RUST_NO_ALLOC fill:#c8e6c9,color:#000
style CS_PERF fill:#fff3e0,color:#000
style RUST_PERF fill:#c8e6c9,color:#000
🏋️ Exercise: LINQ to Iterators Translation (click to expand)
Challenge: Translate this C# LINQ pipeline to idiomatic Rust iterators.
// C# — translate to Rust
record Employee(string Name, string Dept, int Salary);
var result = employees
.Where(e => e.Salary > 50_000)
.GroupBy(e => e.Dept)
.Select(g => new {
Department = g.Key,
Count = g.Count(),
AvgSalary = g.Average(e => e.Salary)
})
.OrderByDescending(x => x.AvgSalary)
.ToList();
🔑 Solution
#![allow(unused)]
fn main() {
use std::collections::HashMap;
struct Employee { name: String, dept: String, salary: u32 }
#[derive(Debug)]
struct DeptStats { department: String, count: usize, avg_salary: f64 }
fn department_stats(employees: &[Employee]) -> Vec<DeptStats> {
let mut by_dept: HashMap<&str, Vec<u32>> = HashMap::new();
for e in employees.iter().filter(|e| e.salary > 50_000) {
by_dept.entry(&e.dept).or_default().push(e.salary);
}
let mut stats: Vec<DeptStats> = by_dept
.into_iter()
.map(|(dept, salaries)| {
let count = salaries.len();
let avg = salaries.iter().sum::<u32>() as f64 / count as f64;
DeptStats { department: dept.to_string(), count, avg_salary: avg }
})
.collect();
stats.sort_by(|a, b| b.avg_salary.partial_cmp(&a.avg_salary).unwrap());
stats
}
}
Key takeaways:
- Rust has no built-in
group_byon iterators —HashMap+fold/foris the idiomatic pattern itertoolscrate adds.group_by()for more LINQ-like syntax- Iterator chains are zero-cost — the compiler optimizes them to simple loops
itertools: The Missing LINQ Operations
Standard Rust iterators cover map, filter, fold, take, and collect. But C# developers using GroupBy, Zip, Chunk, SelectMany, and Distinct will immediately notice gaps. The itertools crate fills them.
# Cargo.toml
[dependencies]
itertools = "0.12"
Side-by-Side: LINQ vs itertools
// C# — GroupBy
var byDept = employees.GroupBy(e => e.Department)
.Select(g => new { Dept = g.Key, Count = g.Count() });
// C# — Chunk (batching)
var batches = items.Chunk(100); // IEnumerable<T[]>
// C# — Distinct / DistinctBy
var unique = users.DistinctBy(u => u.Email);
// C# — SelectMany (flatten)
var allTags = posts.SelectMany(p => p.Tags);
// C# — Zip
var pairs = names.Zip(scores, (n, s) => new { Name = n, Score = s });
// C# — Sliding window
var windows = data.Zip(data.Skip(1), data.Skip(2))
.Select(triple => (triple.First + triple.Second + triple.Third) / 3.0);
#![allow(unused)]
fn main() {
use itertools::Itertools;
// Rust — group_by (requires sorted input)
let by_dept = employees.iter()
.sorted_by_key(|e| &e.department)
.group_by(|e| &e.department);
for (dept, group) in &by_dept {
println!("{}: {} employees", dept, group.count());
}
// Rust — chunks (batching)
let batches = items.iter().chunks(100);
for batch in &batches {
process_batch(batch.collect::<Vec<_>>());
}
// Rust — unique / unique_by
let unique: Vec<_> = users.iter().unique_by(|u| &u.email).collect();
// Rust — flat_map (SelectMany equivalent — built-in!)
let all_tags: Vec<&str> = posts.iter().flat_map(|p| &p.tags).collect();
// Rust — zip (built-in!)
let pairs: Vec<_> = names.iter().zip(scores.iter()).collect();
// Rust — tuple_windows (sliding window)
let moving_avg: Vec<f64> = data.iter()
.tuple_windows::<(_, _, _)>()
.map(|(a, b, c)| (*a + *b + *c) as f64 / 3.0)
.collect();
}
itertools Quick Reference
| LINQ Method | itertools Equivalent | Notes |
|---|---|---|
GroupBy(key) | .sorted_by_key().group_by() | Requires sorted input (unlike LINQ) |
Chunk(n) | .chunks(n) | Returns iterator of iterators |
Distinct() | .unique() | Requires Eq + Hash |
DistinctBy(key) | .unique_by(key) | |
SelectMany() | .flat_map() | Built into std — no crate needed |
Zip() | .zip() | Built into std |
Aggregate() | .fold() | Built into std |
Any() / All() | .any() / .all() | Built into std |
First() / Last() | .next() / .last() | Built into std |
Skip(n) / Take(n) | .skip(n) / .take(n) | Built into std |
OrderBy() | .sorted() / .sorted_by() | itertools (std has none) |
ThenBy() | .sorted_by(|a,b| a.x.cmp(&b.x).then(a.y.cmp(&b.y))) | Chained Ordering::then |
Intersect() | HashSet intersection | No direct iterator method |
Concat() | .chain() | Built into std |
| Sliding window | .tuple_windows() | Fixed-size tuples |
| Cartesian product | .cartesian_product() | itertools |
| Interleave | .interleave() | itertools |
| Permutations | .permutations(k) | itertools |
Real-World Example: Log Analysis Pipeline
#![allow(unused)]
fn main() {
use itertools::Itertools;
use std::collections::HashMap;
#[derive(Debug)]
struct LogEntry { level: String, module: String, message: String }
fn analyze_logs(entries: &[LogEntry]) {
// Top 5 noisiest modules (like LINQ GroupBy + OrderByDescending + Take)
let noisy: Vec<_> = entries.iter()
.into_group_map_by(|e| &e.module) // itertools: direct group into HashMap
.into_iter()
.sorted_by(|a, b| b.1.len().cmp(&a.1.len()))
.take(5)
.collect();
for (module, entries) in &noisy {
println!("{}: {} entries", module, entries.len());
}
// Error rate per 100-entry window (sliding window)
let error_rates: Vec<f64> = entries.iter()
.map(|e| if e.level == "ERROR" { 1.0 } else { 0.0 })
.collect::<Vec<_>>()
.windows(100) // std slice method
.map(|w| w.iter().sum::<f64>() / 100.0)
.collect();
// Deduplicate consecutive identical messages
let deduped: Vec<_> = entries.iter().dedup_by(|a, b| a.message == b.message).collect();
println!("Deduped {} → {} entries", entries.len(), deduped.len());
}
}
Macros Primer
Macros: Code That Writes Code
What you’ll learn: Why Rust needs macros (no overloading, no variadic args),
macro_rules!basics, the!suffix convention, common derive macros, anddbg!()for quick debugging.Difficulty: 🟡 Intermediate
C# has no direct equivalent to Rust macros. Understanding why they exist and how they work removes a major source of confusion for C# developers.
Why Macros Exist in Rust
graph LR
SRC["vec![1, 2, 3]"] -->|"compile time"| EXP["{
let mut v = Vec::new();
v.push(1);
v.push(2);
v.push(3);
v
}"]
EXP -->|"compiles to"| BIN["machine code"]
style SRC fill:#fff9c4,color:#000
style EXP fill:#c8e6c9,color:#000
// C# has features that make macros unnecessary:
Console.WriteLine("Hello"); // Method overloading (1-16 params)
Console.WriteLine("{0}, {1}", a, b); // Variadic via params array
var list = new List<int> { 1, 2, 3 }; // Collection initializer syntax
#![allow(unused)]
fn main() {
// Rust has NO function overloading, NO variadic arguments, NO special syntax.
// Macros fill these gaps:
println!("Hello"); // Macro — handles 0+ args at compile time
println!("{}, {}", a, b); // Macro — type-checked at compile time
let list = vec![1, 2, 3]; // Macro — expands to Vec::new() + push()
}
Recognizing Macros: The ! Suffix
Every macro invocation ends with !. If you see !, it’s a macro, not a function:
#![allow(unused)]
fn main() {
println!("hello"); // macro — generates format string code at compile time
format!("{x}"); // macro — returns String, compile-time format checking
vec![1, 2, 3]; // macro — creates and populates a Vec
todo!(); // macro — panics with "not yet implemented"
dbg!(expression); // macro — prints file:line + expression + value, returns value
assert_eq!(a, b); // macro — panics with diff if a ≠ b
cfg!(target_os = "linux"); // macro — compile-time platform detection
}
Writing a Simple Macro with macro_rules!
// Define a macro that creates a HashMap from key-value pairs
macro_rules! hashmap {
// Pattern: key => value pairs separated by commas
( $( $key:expr => $value:expr ),* $(,)? ) => {{
let mut map = std::collections::HashMap::new();
$( map.insert($key, $value); )*
map
}};
}
fn main() {
let scores = hashmap! {
"Alice" => 100,
"Bob" => 85,
"Carol" => 92,
};
println!("{scores:?}");
}
Derive Macros: Auto-Implementing Traits
#![allow(unused)]
fn main() {
// #[derive] is a procedural macro that generates trait implementations
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
struct User {
name: String,
age: u32,
}
// The compiler generates Debug::fmt, Clone::clone, PartialEq::eq, etc.
// automatically by examining the struct fields.
}
// C# equivalent: none — you'd manually implement IEquatable, ICloneable, etc.
// Or use records: public record User(string Name, int Age);
// Records auto-generate Equals, GetHashCode, ToString — similar idea!
Common Derive Macros
| Derive | Purpose | C# Equivalent |
|---|---|---|
Debug | {:?} format string output | ToString() override |
Clone | Deep copy via .clone() | ICloneable |
Copy | Implicit bitwise copy (no .clone() needed) | Value type (struct) semantics |
PartialEq, Eq | == comparison | IEquatable<T> |
PartialOrd, Ord | <, > comparison + sorting | IComparable<T> |
Hash | Hashing for HashMap keys | GetHashCode() |
Default | Default values via Default::default() | Parameterless constructor |
Serialize, Deserialize | JSON/TOML/etc. (serde) | [JsonProperty] attributes |
Rule of thumb: Start with
#[derive(Debug)]on every type. AddClone,PartialEqwhen needed. AddSerialize, Deserializefor any type that crosses a boundary (API, file, database).
Procedural & Attribute Macros (Awareness Level)
Derive macros are one kind of procedural macro — code that runs at compile time to generate code. You’ll encounter two other forms:
Attribute macros — attached to items with #[...]:
#[tokio::main] // turns main() into an async runtime entry point
async fn main() { }
#[test] // marks a function as a unit test
fn it_works() { assert_eq!(2 + 2, 4); }
#[cfg(test)] // conditionally compile this module only during testing
mod tests { /* ... */ }
Function-like macros — look like function calls:
#![allow(unused)]
fn main() {
// sqlx::query! verifies your SQL against the database at compile time
let users = sqlx::query!("SELECT id, name FROM users WHERE active = $1", true)
.fetch_all(&pool)
.await?;
}
Key insight for C# developers: You rarely write procedural macros — they’re an advanced library-author tool. But you use them constantly (
#[derive(...)],#[tokio::main],#[test]). Think of them like C# source generators: you benefit from them without implementing them.
Conditional Compilation with #[cfg]
Rust’s #[cfg] attributes are like C#’s #if DEBUG preprocessor directives, but type-checked:
#![allow(unused)]
fn main() {
// Compile this function only on Linux
#[cfg(target_os = "linux")]
fn platform_specific() {
println!("Running on Linux");
}
// Debug-only assertions (like C# Debug.Assert)
#[cfg(debug_assertions)]
fn expensive_check(data: &[u8]) {
assert!(data.len() < 1_000_000, "data unexpectedly large");
}
// Feature flags (like C# #if FEATURE_X, but declared in Cargo.toml)
#[cfg(feature = "json")]
pub fn to_json<T: Serialize>(val: &T) -> String {
serde_json::to_string(val).unwrap()
}
}
// C# equivalent
#if DEBUG
Debug.Assert(data.Length < 1_000_000);
#endif
dbg!() — Your Best Friend for Debugging
#![allow(unused)]
fn main() {
fn calculate(x: i32) -> i32 {
let intermediate = dbg!(x * 2); // prints: [src/main.rs:3] x * 2 = 10
let result = dbg!(intermediate + 1); // prints: [src/main.rs:4] intermediate + 1 = 11
result
}
// dbg! prints to stderr, includes file:line, and returns the value
// Far more useful than Console.WriteLine for debugging!
}
🏋️ Exercise: Write a min! Macro (click to expand)
Challenge: Write a min! macro that accepts 2 or more arguments and returns the smallest.
#![allow(unused)]
fn main() {
// Should work like:
let smallest = min!(5, 3, 8, 1, 4); // → 1
let pair = min!(10, 20); // → 10
}
🔑 Solution
macro_rules! min {
// Base case: single value
($x:expr) => ($x);
// Recursive: compare first with min of rest
($x:expr, $($rest:expr),+) => {{
let first = $x;
let rest = min!($($rest),+);
if first < rest { first } else { rest }
}};
}
fn main() {
assert_eq!(min!(5, 3, 8, 1, 4), 1);
assert_eq!(min!(10, 20), 10);
assert_eq!(min!(42), 42);
println!("All assertions passed!");
}
Key takeaway: macro_rules! uses pattern matching on token trees — it’s like match but for code structure instead of values.
13. Concurrency
Thread Safety: Convention vs Type System Guarantees
What you’ll learn: How Rust enforces thread safety at compile time vs C#’s convention-based approach,
Arc<Mutex<T>>vslock, channels vsConcurrentQueue,Send/Synctraits, scoped threads, and the bridge to async/await.Difficulty: 🔴 Advanced
Deep dive: For production async patterns (stream processing, graceful shutdown, connection pooling, cancellation safety), see the companion Async Rust Training guide.
Prerequisites: Ownership & Borrowing and Smart Pointers (Rc vs Arc decision tree).
C# - Thread Safety by Convention
// C# collections aren't thread-safe by default
public class UserService
{
private readonly List<string> items = new();
private readonly Dictionary<int, User> cache = new();
// This can cause data races:
public void AddItem(string item)
{
items.Add(item); // Not thread-safe!
}
// Must use locks manually:
private readonly object lockObject = new();
public void SafeAddItem(string item)
{
lock (lockObject)
{
items.Add(item); // Safe, but runtime overhead
}
// Easy to forget the lock elsewhere
}
// ConcurrentCollection helps but limited:
private readonly ConcurrentBag<string> safeItems = new();
public void ConcurrentAdd(string item)
{
safeItems.Add(item); // Thread-safe but limited operations
}
// Complex shared state management
private readonly ConcurrentDictionary<int, User> threadSafeCache = new();
private volatile bool isShutdown = false;
public async Task ProcessUser(int userId)
{
if (isShutdown) return; // Race condition possible!
var user = await GetUser(userId);
threadSafeCache.TryAdd(userId, user); // Must remember which collections are safe
}
// Thread-local storage requires careful management
private static readonly ThreadLocal<Random> threadLocalRandom =
new ThreadLocal<Random>(() => new Random());
public int GetRandomNumber()
{
return threadLocalRandom.Value.Next(); // Safe but manual management
}
}
// Event handling with potential race conditions
public class EventProcessor
{
public event Action<string> DataReceived;
private readonly List<string> eventLog = new();
public void OnDataReceived(string data)
{
// Race condition - event might be null between check and invocation
if (DataReceived != null)
{
DataReceived(data);
}
// Another race condition - list not thread-safe
eventLog.Add($"Processed: {data}");
}
}
Rust - Thread Safety Guaranteed by Type System
#![allow(unused)]
fn main() {
use std::sync::{Arc, Mutex, RwLock};
use std::thread;
use std::collections::HashMap;
use tokio::sync::{mpsc, broadcast};
// Rust prevents data races at compile time
pub struct UserService {
items: Arc<Mutex<Vec<String>>>,
cache: Arc<RwLock<HashMap<i32, User>>>,
}
impl UserService {
pub fn new() -> Self {
UserService {
items: Arc::new(Mutex::new(Vec::new())),
cache: Arc::new(RwLock::new(HashMap::new())),
}
}
pub fn add_item(&self, item: String) {
let mut items = self.items.lock().unwrap();
items.push(item);
// Lock automatically released when `items` goes out of scope
}
// Multiple readers, single writer - automatically enforced
pub async fn get_user(&self, user_id: i32) -> Option<User> {
let cache = self.cache.read().unwrap();
cache.get(&user_id).cloned()
}
pub async fn cache_user(&self, user_id: i32, user: User) {
let mut cache = self.cache.write().unwrap();
cache.insert(user_id, user);
}
// Clone the Arc for thread sharing
pub fn process_in_background(&self) {
let items = Arc::clone(&self.items);
thread::spawn(move || {
let items = items.lock().unwrap();
for item in items.iter() {
println!("Processing: {}", item);
}
});
}
}
// Channel-based communication - no shared state needed
pub struct MessageProcessor {
sender: mpsc::UnboundedSender<String>,
}
impl MessageProcessor {
pub fn new() -> (Self, mpsc::UnboundedReceiver<String>) {
let (tx, rx) = mpsc::unbounded_channel();
(MessageProcessor { sender: tx }, rx)
}
pub fn send_message(&self, message: String) -> Result<(), mpsc::error::SendError<String>> {
self.sender.send(message)
}
}
// This won't compile - Rust prevents sharing mutable data unsafely:
fn impossible_data_race() {
let mut items = vec![1, 2, 3];
// This won't compile - cannot move `items` into multiple closures
/*
thread::spawn(move || {
items.push(4); // ERROR: use of moved value
});
thread::spawn(move || {
items.push(5); // ERROR: use of moved value
});
*/
}
// Safe concurrent data processing
use rayon::prelude::*;
fn parallel_processing() {
let data = vec![1, 2, 3, 4, 5];
// Parallel iteration - guaranteed thread-safe
let results: Vec<i32> = data
.par_iter()
.map(|&x| x * x)
.collect();
println!("{:?}", results);
}
// Async concurrency with message passing
async fn async_message_passing() {
let (tx, mut rx) = mpsc::channel(100);
// Producer task
let producer = tokio::spawn(async move {
for i in 0..10 {
if tx.send(i).await.is_err() {
break;
}
}
});
// Consumer task
let consumer = tokio::spawn(async move {
while let Some(value) = rx.recv().await {
println!("Received: {}", value);
}
});
// Wait for both tasks
let (producer_result, consumer_result) = tokio::join!(producer, consumer);
producer_result.unwrap();
consumer_result.unwrap();
}
#[derive(Clone)]
struct User {
id: i32,
name: String,
}
}
graph TD
subgraph "C# Thread Safety Challenges"
CS_MANUAL["Manual synchronization"]
CS_LOCKS["lock statements"]
CS_CONCURRENT["ConcurrentCollections"]
CS_VOLATILE["volatile fields"]
CS_FORGET["😰 Easy to forget locks"]
CS_DEADLOCK["💀 Deadlock possible"]
CS_RACE["🏃 Race conditions"]
CS_OVERHEAD["⚡ Runtime overhead"]
CS_MANUAL --> CS_LOCKS
CS_MANUAL --> CS_CONCURRENT
CS_MANUAL --> CS_VOLATILE
CS_LOCKS --> CS_FORGET
CS_LOCKS --> CS_DEADLOCK
CS_FORGET --> CS_RACE
CS_LOCKS --> CS_OVERHEAD
end
subgraph "Rust Type System Guarantees"
RUST_OWNERSHIP["Ownership system"]
RUST_BORROWING["Borrow checker"]
RUST_SEND["Send trait"]
RUST_SYNC["Sync trait"]
RUST_ARC["Arc<Mutex<T>>"]
RUST_CHANNELS["Message passing"]
RUST_SAFE["✅ Data races impossible"]
RUST_FAST["⚡ Zero-cost abstractions"]
RUST_OWNERSHIP --> RUST_BORROWING
RUST_BORROWING --> RUST_SEND
RUST_SEND --> RUST_SYNC
RUST_SYNC --> RUST_ARC
RUST_ARC --> RUST_CHANNELS
RUST_CHANNELS --> RUST_SAFE
RUST_SAFE --> RUST_FAST
end
style CS_FORGET fill:#ffcdd2,color:#000
style CS_DEADLOCK fill:#ffcdd2,color:#000
style CS_RACE fill:#ffcdd2,color:#000
style RUST_SAFE fill:#c8e6c9,color:#000
style RUST_FAST fill:#c8e6c9,color:#000
🏋️ Exercise: Thread-Safe Counter (click to expand)
Challenge: Implement a thread-safe counter that can be incremented from 10 threads simultaneously. Each thread increments 1000 times. The final count should be exactly 10,000.
🔑 Solution
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let counter = Arc::new(Mutex::new(0u64));
let mut handles = vec![];
for _ in 0..10 {
let counter = Arc::clone(&counter);
handles.push(thread::spawn(move || {
for _ in 0..1000 {
let mut count = counter.lock().unwrap();
*count += 1;
}
}));
}
for h in handles { h.join().unwrap(); }
assert_eq!(*counter.lock().unwrap(), 10_000);
println!("Final count: {}", counter.lock().unwrap());
}
Or with atomics (faster, no locking):
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::thread;
fn main() {
let counter = Arc::new(AtomicU64::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
thread::spawn(move || {
for _ in 0..1000 {
counter.fetch_add(1, Ordering::Relaxed);
}
})
}).collect();
for h in handles { h.join().unwrap(); }
assert_eq!(counter.load(Ordering::SeqCst), 10_000);
}
Key takeaway: Arc<Mutex<T>> is the general pattern. For simple counters, AtomicU64 avoids lock overhead entirely.
Why Rust prevents data races: Send and Sync
Rust uses two marker traits to enforce thread safety at compile time — there is no C# equivalent:
Send: A type can be safely transferred to another thread (e.g., moved into a closure passed tothread::spawn)Sync: A type can be safely shared (via&T) between threads
Most types are automatically Send + Sync. Notable exceptions:
Rc<T>is neither Send nor Sync — the compiler will refuse to let you pass it tothread::spawn(useArc<T>instead)Cell<T>andRefCell<T>are not Sync — useMutex<T>orRwLock<T>for thread-safe interior mutability- Raw pointers (
*const T,*mut T) are neither Send nor Sync
In C#, List<T> is not thread-safe but the compiler won’t stop you from sharing it across threads. In Rust, the equivalent mistake is a compile error, not a runtime race condition.
Scoped threads: borrowing from the stack
thread::scope() lets spawned threads borrow local variables — no Arc needed:
use std::thread;
fn main() {
let data = vec![1, 2, 3, 4, 5];
// Scoped threads can borrow 'data' — scope waits for all threads to finish
thread::scope(|s| {
s.spawn(|| println!("Thread 1: {data:?}"));
s.spawn(|| println!("Thread 2: sum = {}", data.iter().sum::<i32>()));
});
// 'data' is still valid here — threads are guaranteed to have finished
}
This is similar to C#’s Parallel.ForEach in that the calling code waits for completion, but Rust’s borrow checker proves there are no data races at compile time.
Bridging to async/await
C# developers typically reach for Task and async/await rather than raw threads. Rust has both paradigms:
| C# | Rust | When to use |
|---|---|---|
Thread | std::thread::spawn | CPU-bound work, OS thread per task |
Task.Run | tokio::spawn | Async task on a runtime |
async/await | async/await | I/O-bound concurrency |
lock | Mutex<T> | Sync mutual exclusion |
SemaphoreSlim | tokio::sync::Semaphore | Async concurrency limiting |
Interlocked | std::sync::atomic | Lock-free atomic operations |
CancellationToken | tokio_util::sync::CancellationToken | Cooperative cancellation |
The next chapter (Async/Await Deep Dive) covers Rust’s async model in detail — including how it differs from C#’s
Task-based model.
Async/Await Deep Dive
Async Programming: C# Task vs Rust Future
What you’ll learn: Rust’s lazy
Futurevs C#’s eagerTask, the executor model (tokio), cancellation viaDrop+select!vsCancellationToken, and real-world patterns for concurrent requests.Difficulty: 🔴 Advanced
C# developers are deeply familiar with async/await. Rust uses the same keywords but with a fundamentally different execution model.
The Executor Model
// C# — The runtime provides a built-in thread pool and task scheduler
// async/await "just works" out of the box
public async Task<string> FetchDataAsync(string url)
{
using var client = new HttpClient();
return await client.GetStringAsync(url); // Scheduled by .NET thread pool
}
// .NET manages the thread pool, task scheduling, and synchronization context
// Rust — No built-in async runtime. You choose an executor.
// The most popular is tokio.
async fn fetch_data(url: &str) -> Result<String, reqwest::Error> {
let body = reqwest::get(url).await?.text().await?;
Ok(body)
}
// You MUST have a runtime to execute async code:
#[tokio::main] // This macro sets up the tokio runtime
async fn main() {
let data = fetch_data("https://example.com").await.unwrap();
println!("{}", &data[..100]);
}
Future vs Task
C# Task<T> | Rust Future<Output = T> | |
|---|---|---|
| Execution | Starts immediately when created | Lazy — does nothing until .awaited |
| Runtime | Built-in (CLR thread pool) | External (tokio, async-std, etc.) |
| Cancellation | CancellationToken | Drop the Future (or tokio::select!) |
| State machine | Compiler-generated | Compiler-generated |
| Size | Heap-allocated | Stack-allocated until boxed |
#![allow(unused)]
fn main() {
// IMPORTANT: Futures are lazy in Rust!
async fn compute() -> i32 { println!("Computing!"); 42 }
let future = compute(); // Nothing printed! Future not polled yet.
let result = future.await; // NOW "Computing!" is printed
}
// C# Tasks start immediately!
var task = ComputeAsync(); // "Computing!" printed immediately
var result = await task; // Just waits for completion
Cancellation: CancellationToken vs Drop / select!
// C# — Cooperative cancellation with CancellationToken
public async Task ProcessAsync(CancellationToken ct)
{
while (!ct.IsCancellationRequested)
{
await Task.Delay(1000, ct); // Throws if cancelled
DoWork();
}
}
var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
await ProcessAsync(cts.Token);
#![allow(unused)]
fn main() {
// Rust — Cancellation by dropping the future, or with tokio::select!
use tokio::time::{sleep, Duration};
async fn process() {
loop {
sleep(Duration::from_secs(1)).await;
do_work();
}
}
// Timeout pattern with select!
async fn run_with_timeout() {
tokio::select! {
_ = process() => { println!("Completed"); }
_ = sleep(Duration::from_secs(5)) => { println!("Timed out!"); }
}
// When select! picks the timeout branch, the process() future is DROPPED
// — automatic cleanup, no CancellationToken needed
}
}
Real-World Pattern: Concurrent Requests with Timeout
// C# — Concurrent HTTP requests with timeout
public async Task<string[]> FetchAllAsync(string[] urls, CancellationToken ct)
{
var tasks = urls.Select(url => httpClient.GetStringAsync(url, ct));
return await Task.WhenAll(tasks);
}
#![allow(unused)]
fn main() {
// Rust — Concurrent requests with tokio::join! or futures::join_all
use futures::future::join_all;
async fn fetch_all(urls: &[&str]) -> Vec<Result<String, reqwest::Error>> {
let futures = urls.iter().map(|url| reqwest::get(*url));
let responses = join_all(futures).await;
let mut results = Vec::new();
for resp in responses {
results.push(resp?.text().await);
}
results
}
// With timeout:
async fn fetch_all_with_timeout(urls: &[&str]) -> Result<Vec<String>, &'static str> {
tokio::time::timeout(
Duration::from_secs(10),
async {
let futures: Vec<_> = urls.iter()
.map(|url| async { reqwest::get(*url).await?.text().await })
.collect();
let results = join_all(futures).await;
results.into_iter().collect::<Result<Vec<_>, _>>()
}
)
.await
.map_err(|_| "Request timed out")?
.map_err(|_| "Request failed")
}
}
🏋️ Exercise: Async Timeout Pattern (click to expand)
Challenge: Write an async function that fetches from two URLs concurrently, returns whichever responds first, and cancels the other. (This is Task.WhenAny in C#.)
🔑 Solution
use tokio::time::{sleep, Duration};
// Simulated async fetch
async fn fetch(url: &str, delay_ms: u64) -> String {
sleep(Duration::from_millis(delay_ms)).await;
format!("Response from {url}")
}
async fn fetch_first(url1: &str, url2: &str) -> String {
tokio::select! {
result = fetch(url1, 200) => {
println!("URL 1 won");
result
}
result = fetch(url2, 500) => {
println!("URL 2 won");
result
}
}
// The losing branch's future is automatically dropped (cancelled)
}
#[tokio::main]
async fn main() {
let result = fetch_first("https://fast.api", "https://slow.api").await;
println!("{result}");
}
Key takeaway: tokio::select! is Rust’s equivalent of Task.WhenAny — it races multiple futures, completes when the first one finishes, and drops (cancels) the rest.
Spawning Independent Tasks with tokio::spawn
In C#, Task.Run launches work that runs independently of the caller. Rust’s equivalent is tokio::spawn:
#![allow(unused)]
fn main() {
use tokio::task;
async fn background_work() {
// Runs independently — even if the caller's future is dropped
let handle = task::spawn(async {
tokio::time::sleep(Duration::from_secs(2)).await;
42
});
// Do other work while the spawned task runs...
println!("Doing other work");
// Await the result when you need it
let result = handle.await.unwrap(); // 42
}
}
// C# equivalent
var task = Task.Run(async () => {
await Task.Delay(2000);
return 42;
});
// Do other work...
var result = await task;
Key difference: A regular async {} block is lazy — it does nothing until awaited. tokio::spawn launches it on the runtime immediately, like C#’s Task.Run.
Pin: Why Rust Async Has a Concept C# Doesn’t
C# developers never encounter Pin — the CLR’s garbage collector moves objects freely and updates all references automatically. Rust has no GC. When the compiler transforms an async fn into a state machine, that struct may contain internal pointers to its own fields. Moving the struct would invalidate those pointers.
Pin<T> is a wrapper that says: “this value will not be moved in memory.”
#![allow(unused)]
fn main() {
// You'll see Pin in these contexts:
trait Future {
type Output;
fn poll(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<Self::Output>;
// ^^^^^^^^^^^^^^ pinned — internal references stay valid
}
// Returning a boxed future from a trait:
fn make_future() -> Pin<Box<dyn Future<Output = i32> + Send>> {
Box::pin(async { 42 })
}
}
In practice, you almost never write Pin yourself. The async fn and .await syntax handles it. You’ll encounter it only in:
- Compiler error messages (follow the suggestion)
tokio::select!(use thepin!()macro)- Trait methods returning
dyn Future(useBox::pin(async { ... }))
Want the deep dive? The companion Async Rust Training covers Pin, Unpin, self-referential structs, and structural pinning in full detail.
14. Unsafe Rust and FFI
Unsafe Rust
What you’ll learn: What
unsafepermits (raw pointers, FFI, unchecked casts), safe wrapper patterns, C# P/Invoke vs Rust FFI for calling native code, and the safety checklist forunsafeblocks.Difficulty: 🔴 Advanced
Unsafe Rust allows you to perform operations that the borrow checker cannot verify. Use it sparingly and with clear documentation.
Advanced coverage: For safe abstraction patterns over unsafe code (arena allocators, lock-free structures, custom vtables), see Rust Patterns.
When You Need Unsafe
#![allow(unused)]
fn main() {
// 1. Dereferencing raw pointers
let mut value = 42;
let ptr = &mut value as *mut i32;
// SAFETY: ptr points to a valid, live local variable.
unsafe {
*ptr = 100; // Must be in unsafe block
}
// 2. Calling unsafe functions
unsafe fn dangerous() {
// Internal implementation that requires caller to maintain invariants
}
// SAFETY: no invariants to uphold for this example function.
unsafe {
dangerous(); // Caller takes responsibility
}
// 3. Accessing mutable static variables
static mut COUNTER: u32 = 0;
// SAFETY: single-threaded context; no concurrent access to COUNTER.
unsafe {
COUNTER += 1; // Not thread-safe — caller must ensure synchronization
}
// 4. Implementing unsafe traits
unsafe trait UnsafeTrait {
fn do_something(&self);
}
}
C# Comparison: unsafe Keyword
// C# unsafe - similar concept, different scope
unsafe void UnsafeExample()
{
int value = 42;
int* ptr = &value;
*ptr = 100;
// C# unsafe is about pointer arithmetic
// Rust unsafe is about ownership/borrow rule relaxation
}
// C# fixed - pinning managed objects
unsafe void PinnedExample()
{
byte[] buffer = new byte[100];
fixed (byte* ptr = buffer)
{
// ptr is valid only within this block
}
}
Safe Wrappers
#![allow(unused)]
fn main() {
/// The key pattern: wrap unsafe code in a safe API
pub struct SafeBuffer {
data: Vec<u8>,
}
impl SafeBuffer {
pub fn new(size: usize) -> Self {
SafeBuffer { data: vec![0; size] }
}
/// Safe API — bounds-checked access
pub fn get(&self, index: usize) -> Option<u8> {
self.data.get(index).copied()
}
/// Fast unchecked access — unsafe but wrapped safely with bounds check
pub fn get_unchecked_safe(&self, index: usize) -> Option<u8> {
if index < self.data.len() {
// SAFETY: we just checked that index is in bounds
Some(unsafe { *self.data.get_unchecked(index) })
} else {
None
}
}
}
}
Interop with C# via FFI
Rust can expose C-compatible functions that C# can call via P/Invoke.
graph LR
subgraph "C# Process"
CS["C# Code"] -->|"P/Invoke"| MI["Marshal Layer\nUTF-16 → UTF-8\nstruct layout"]
end
MI -->|"C ABI call"| FFI["FFI Boundary"]
subgraph "Rust cdylib (.so / .dll)"
FFI --> RF["extern \"C\" fn\n#[no_mangle]"]
RF --> Safe["Safe Rust\ninternals"]
end
style FFI fill:#fff9c4,color:#000
style MI fill:#bbdefb,color:#000
style Safe fill:#c8e6c9,color:#000
Rust Library (compiled as cdylib)
#![allow(unused)]
fn main() {
// src/lib.rs
#[no_mangle]
pub extern "C" fn add_numbers(a: i32, b: i32) -> i32 {
a + b
}
#[no_mangle]
pub extern "C" fn process_string(input: *const std::os::raw::c_char) -> i32 {
// SAFETY: input is non-null (checked inside) and assumed null-terminated by caller.
let c_str = unsafe {
if input.is_null() {
return -1;
}
std::ffi::CStr::from_ptr(input)
};
match c_str.to_str() {
Ok(s) => s.len() as i32,
Err(_) => -1,
}
}
}
# Cargo.toml
[lib]
crate-type = ["cdylib"]
C# Consumer (P/Invoke)
using System.Runtime.InteropServices;
public static class RustInterop
{
[DllImport("my_rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int add_numbers(int a, int b);
[DllImport("my_rust_lib", CallingConvention = CallingConvention.Cdecl)]
public static extern int process_string(
[MarshalAs(UnmanagedType.LPUTF8Str)] string input);
}
// Usage
int sum = RustInterop.add_numbers(5, 3); // 8
int len = RustInterop.process_string("Hello from C#!"); // 15
FFI Safety Checklist
When exposing Rust functions to C#, these rules prevent the most common bugs:
-
Always use
extern "C"— without it, Rust uses its own (unstable) calling convention. C# P/Invoke expects the C ABI. -
#[no_mangle]— prevents the Rust compiler from mangling the function name. Without it, C# can’t find the symbol. -
Never let a panic cross the FFI boundary — a Rust panic unwinding into C# is undefined behavior. Catch panics at FFI entry points:
#![allow(unused)] fn main() { #[no_mangle] pub extern "C" fn safe_ffi_function() -> i32 { match std::panic::catch_unwind(|| { // actual logic here 42 }) { Ok(result) => result, Err(_) => -1, // Return error code instead of panicking into C# } } } -
Opaque vs transparent structs — if C# only holds a pointer (opaque handle),
#[repr(C)]is not needed. If C# reads struct fields viaStructLayout, you must use#[repr(C)]:#![allow(unused)] fn main() { // Opaque — C# only holds IntPtr. No #[repr(C)] needed. pub struct Connection { /* Rust-only fields */ } // Transparent — C# marshals fields directly. MUST use #[repr(C)]. #[repr(C)] pub struct Point { pub x: f64, pub y: f64 } } -
Null pointer checks — always validate pointers before dereferencing. C# can pass
IntPtr.Zero. -
String encoding — C# uses UTF-16 internally.
MarshalAs(UnmanagedType.LPUTF8Str)converts to UTF-8 for Rust’sCStr. Document this contract explicitly.
End-to-End Example: Opaque Handle with Lifecycle Management
This pattern is common in production: Rust owns an object, C# holds an opaque handle, and explicit create/destroy functions manage the lifecycle.
Rust side (src/lib.rs):
#![allow(unused)]
fn main() {
use std::ffi::{c_char, CStr};
pub struct ImageProcessor {
width: u32,
height: u32,
pixels: Vec<u8>,
}
/// Create a new processor. Returns null on invalid dimensions.
#[no_mangle]
pub extern "C" fn processor_new(width: u32, height: u32) -> *mut ImageProcessor {
if width == 0 || height == 0 {
return std::ptr::null_mut();
}
let proc = ImageProcessor {
width,
height,
pixels: vec![0u8; (width * height * 4) as usize],
};
Box::into_raw(Box::new(proc)) // Allocate on heap, return raw pointer
}
/// Apply a grayscale filter. Returns 0 on success, -1 on null pointer.
#[no_mangle]
pub extern "C" fn processor_grayscale(ptr: *mut ImageProcessor) -> i32 {
// SAFETY: ptr was created by Box::into_raw (non-null), still valid.
let proc = match unsafe { ptr.as_mut() } {
Some(p) => p,
None => return -1,
};
for chunk in proc.pixels.chunks_exact_mut(4) {
let gray = (0.299 * chunk[0] as f64
+ 0.587 * chunk[1] as f64
+ 0.114 * chunk[2] as f64) as u8;
chunk[0] = gray;
chunk[1] = gray;
chunk[2] = gray;
}
0
}
/// Destroy the processor. Safe to call with null.
#[no_mangle]
pub extern "C" fn processor_free(ptr: *mut ImageProcessor) {
if !ptr.is_null() {
// SAFETY: ptr was created by processor_new via Box::into_raw
unsafe { drop(Box::from_raw(ptr)); }
}
}
}
C# side:
using System.Runtime.InteropServices;
public sealed class ImageProcessor : IDisposable
{
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern IntPtr processor_new(uint width, uint height);
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern int processor_grayscale(IntPtr ptr);
[DllImport("image_rust", CallingConvention = CallingConvention.Cdecl)]
private static extern void processor_free(IntPtr ptr);
private IntPtr _handle;
public ImageProcessor(uint width, uint height)
{
_handle = processor_new(width, height);
if (_handle == IntPtr.Zero)
throw new ArgumentException("Invalid dimensions");
}
public void Grayscale()
{
if (processor_grayscale(_handle) != 0)
throw new InvalidOperationException("Processor is null");
}
public void Dispose()
{
if (_handle != IntPtr.Zero)
{
processor_free(_handle);
_handle = IntPtr.Zero;
}
}
}
// Usage — IDisposable ensures Rust memory is freed
using var proc = new ImageProcessor(1920, 1080);
proc.Grayscale();
// proc.Dispose() called automatically → processor_free() → Rust drops the Vec
Key insight: This is the Rust equivalent of C#’s
SafeHandlepattern. Rust’sBox::into_raw/Box::from_rawtransfers ownership across the FFI boundary, and the C#IDisposablewrapper ensures cleanup.
Exercises
🏋️ Exercise: Safe Wrapper for Raw Pointer (click to expand)
You receive a raw pointer from a C library. Write a safe Rust wrapper:
#![allow(unused)]
fn main() {
// Simulated C API
extern "C" {
fn lib_create_buffer(size: usize) -> *mut u8;
fn lib_free_buffer(ptr: *mut u8);
}
}
Requirements:
- Create a
SafeBufferstruct that wraps the raw pointer - Implement
Dropto calllib_free_buffer - Provide a safe
&[u8]view viaas_slice() - Ensure
SafeBuffer::new()returnsNoneif the pointer is null
🔑 Solution
struct SafeBuffer {
ptr: *mut u8,
len: usize,
}
impl SafeBuffer {
fn new(size: usize) -> Option<Self> {
// SAFETY: lib_create_buffer returns a valid pointer or null (checked below).
let ptr = unsafe { lib_create_buffer(size) };
if ptr.is_null() {
None
} else {
Some(SafeBuffer { ptr, len: size })
}
}
fn as_slice(&self) -> &[u8] {
// SAFETY: ptr is non-null (checked in new()), len is the
// allocated size, and we hold exclusive ownership.
unsafe { std::slice::from_raw_parts(self.ptr, self.len) }
}
}
impl Drop for SafeBuffer {
fn drop(&mut self) {
// SAFETY: ptr was allocated by lib_create_buffer
unsafe { lib_free_buffer(self.ptr); }
}
}
// Usage: all unsafe is contained in SafeBuffer
fn process(buf: &SafeBuffer) {
let data = buf.as_slice(); // completely safe API
println!("First byte: {}", data[0]);
}
Key pattern: Encapsulate unsafe in a small module with // SAFETY: comments. Expose a 100% safe public API. This is how Rust’s standard library works — Vec, String, HashMap all contain unsafe internally but present safe interfaces.
Testing
Testing in Rust vs C#
What you’ll learn: Built-in
#[test]vs xUnit, parameterized tests withrstest(like[Theory]), property testing withproptest, mocking withmockall, and async test patterns.Difficulty: 🟡 Intermediate
Unit Tests
// C# — xUnit
using Xunit;
public class CalculatorTests
{
[Fact]
public void Add_ReturnsSum()
{
var calc = new Calculator();
Assert.Equal(5, calc.Add(2, 3));
}
[Theory]
[InlineData(1, 2, 3)]
[InlineData(0, 0, 0)]
[InlineData(-1, 1, 0)]
public void Add_Theory(int a, int b, int expected)
{
Assert.Equal(expected, new Calculator().Add(a, b));
}
}
#![allow(unused)]
fn main() {
// Rust — built-in testing, no external framework needed
pub fn add(a: i32, b: i32) -> i32 { a + b }
#[cfg(test)] // Only compiled during `cargo test`
mod tests {
use super::*; // Import from parent module
#[test]
fn add_returns_sum() {
assert_eq!(add(2, 3), 5);
}
#[test]
fn add_negative_numbers() {
assert_eq!(add(-1, 1), 0);
}
#[test]
#[should_panic(expected = "overflow")]
fn add_overflow_panics() {
let _ = add(i32::MAX, 1); // panics in debug mode
}
}
}
Parameterized Tests (like [Theory])
#![allow(unused)]
fn main() {
// Use the `rstest` crate for parameterized tests
use rstest::rstest;
#[rstest]
#[case(1, 2, 3)]
#[case(0, 0, 0)]
#[case(-1, 1, 0)]
fn test_add(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
assert_eq!(add(a, b), expected);
}
// Fixtures — like test setup methods
#[rstest]
fn test_with_fixture(#[values(1, 2, 3)] x: i32) {
assert!(x > 0);
}
}
Assertions Comparison
| C# (xUnit) | Rust | Notes |
|---|---|---|
Assert.Equal(expected, actual) | assert_eq!(expected, actual) | Prints diff on failure |
Assert.NotEqual(a, b) | assert_ne!(a, b) | |
Assert.True(condition) | assert!(condition) | |
Assert.Contains("sub", str) | assert!(str.contains("sub")) | |
Assert.Throws<T>(() => ...) | #[should_panic] | Or use std::panic::catch_unwind |
Assert.Null(obj) | assert!(option.is_none()) | No nulls — use Option |
Test Organization
my_crate/
├── src/
│ ├── lib.rs # Unit tests in #[cfg(test)] mod tests { }
│ └── parser.rs # Each module can have its own test module
├── tests/ # Integration tests (each file is a separate crate)
│ ├── parser_test.rs # Tests the public API as an external consumer
│ └── api_test.rs
└── benches/ # Benchmarks (with criterion crate)
└── my_benchmark.rs
#![allow(unused)]
fn main() {
// tests/parser_test.rs — integration test
// Can only access PUBLIC API (like testing from outside the assembly)
use my_crate::parser;
#[test]
fn test_parse_valid_input() {
let result = parser::parse("valid input");
assert!(result.is_ok());
}
}
Async Tests
// C# — async test with xUnit
[Fact]
public async Task GetUser_ReturnsUser()
{
var service = new UserService();
var user = await service.GetUserAsync(1);
Assert.Equal("Alice", user.Name);
}
#![allow(unused)]
fn main() {
// Rust — async test with tokio
#[tokio::test]
async fn get_user_returns_user() {
let service = UserService::new();
let user = service.get_user(1).await.unwrap();
assert_eq!(user.name, "Alice");
}
}
Mocking with mockall
#![allow(unused)]
fn main() {
use mockall::automock;
#[automock] // Generates MockUserRepo struct
trait UserRepo {
fn find_by_id(&self, id: u32) -> Option<User>;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn service_returns_user_from_repo() {
let mut mock = MockUserRepo::new();
mock.expect_find_by_id()
.with(mockall::predicate::eq(1))
.returning(|_| Some(User { name: "Alice".into() }));
let service = UserService::new(mock);
let user = service.get_user(1).unwrap();
assert_eq!(user.name, "Alice");
}
}
}
// C# — Moq equivalent
var mock = new Mock<IUserRepo>();
mock.Setup(r => r.FindById(1)).Returns(new User { Name = "Alice" });
var service = new UserService(mock.Object);
Assert.Equal("Alice", service.GetUser(1).Name);
🏋️ Exercise: Write Comprehensive Tests (click to expand)
Challenge: Given this function, write tests covering: happy path, empty input, numeric strings, and Unicode.
#![allow(unused)]
fn main() {
pub fn title_case(input: &str) -> String {
input.split_whitespace()
.map(|word| {
let mut chars = word.chars();
match chars.next() {
Some(c) => format!("{}{}", c.to_uppercase(), chars.as_str().to_lowercase()),
None => String::new(),
}
})
.collect::<Vec<_>>()
.join(" ")
}
}
🔑 Solution
#![allow(unused)]
fn main() {
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn happy_path() {
assert_eq!(title_case("hello world"), "Hello World");
}
#[test]
fn empty_input() {
assert_eq!(title_case(""), "");
}
#[test]
fn single_word() {
assert_eq!(title_case("rust"), "Rust");
}
#[test]
fn already_title_case() {
assert_eq!(title_case("Hello World"), "Hello World");
}
#[test]
fn all_caps() {
assert_eq!(title_case("HELLO WORLD"), "Hello World");
}
#[test]
fn extra_whitespace() {
// split_whitespace handles multiple spaces
assert_eq!(title_case(" hello world "), "Hello World");
}
#[test]
fn unicode() {
assert_eq!(title_case("café résumé"), "Café Résumé");
}
#[test]
fn numeric_words() {
assert_eq!(title_case("hello 42 world"), "Hello 42 World");
}
}
}
Key takeaway: Rust’s built-in test framework handles most unit testing needs. Use rstest for parameterized tests and mockall for mocking — no need for a large test framework like xUnit.
Property Testing: Proving Correctness at Scale
C# developers familiar with FsCheck will recognize property-based testing: instead of writing individual test cases, you describe properties that must hold for all possible inputs, and the framework generates thousands of random inputs to try to break them.
Why Property Testing Matters
// C# — Hand-written unit tests check specific cases
[Fact]
public void Reverse_Twice_Returns_Original()
{
var list = new List<int> { 1, 2, 3 };
list.Reverse();
list.Reverse();
Assert.Equal(new[] { 1, 2, 3 }, list);
}
// But what about empty lists? Single elements? 10,000 elements? Negative numbers?
// You'd need dozens of hand-written cases.
#![allow(unused)]
fn main() {
// Rust — proptest generates thousands of inputs automatically
use proptest::prelude::*;
fn reverse<T: Clone>(v: &[T]) -> Vec<T> {
v.iter().rev().cloned().collect()
}
proptest! {
#[test]
fn reverse_twice_is_identity(ref v in prop::collection::vec(any::<i32>(), 0..1000)) {
let reversed_twice = reverse(&reverse(v));
prop_assert_eq!(v, &reversed_twice);
}
// proptest runs this with hundreds of random Vec<i32> values:
// [], [0], [i32::MIN, i32::MAX], [42; 999], random sequences...
// If it fails, it SHRINKS to the smallest failing input!
}
}
Getting Started with proptest
# Cargo.toml
[dev-dependencies]
proptest = "1.4"
Common Patterns for C# Developers
#![allow(unused)]
fn main() {
use proptest::prelude::*;
// 1. Roundtrip property: serialize → deserialize = identity
// (Like testing JsonSerializer.Serialize → Deserialize)
proptest! {
#[test]
fn json_roundtrip(name in "[a-zA-Z]{1,50}", age in 0u32..150) {
let user = User { name: name.clone(), age };
let json = serde_json::to_string(&user).unwrap();
let parsed: User = serde_json::from_str(&json).unwrap();
prop_assert_eq!(user, parsed);
}
}
// 2. Invariant property: output always satisfies a condition
proptest! {
#[test]
fn sort_output_is_sorted(ref v in prop::collection::vec(any::<i32>(), 0..500)) {
let mut sorted = v.clone();
sorted.sort();
// Every adjacent pair must be in order
for window in sorted.windows(2) {
prop_assert!(window[0] <= window[1]);
}
}
}
// 3. Oracle property: compare two implementations
proptest! {
#[test]
fn fast_path_matches_slow_path(input in "[0-9a-f]{1,100}") {
let result_fast = parse_hex_fast(&input);
let result_slow = parse_hex_slow(&input);
prop_assert_eq!(result_fast, result_slow);
}
}
// 4. Custom strategies: generate domain-specific test data
fn valid_email() -> impl Strategy<Value = String> {
("[a-z]{1,20}", "[a-z]{1,10}", prop::sample::select(vec!["com", "org", "io"]))
.prop_map(|(user, domain, tld)| format!("{}@{}.{}", user, domain, tld))
}
proptest! {
#[test]
fn email_parsing_accepts_valid_emails(email in valid_email()) {
let result = Email::new(&email);
prop_assert!(result.is_ok(), "Failed to parse: {}", email);
}
}
}
proptest vs FsCheck Comparison
| Feature | C# FsCheck | Rust proptest |
|---|---|---|
| Random input generation | Arb.Generate<T>() | any::<T>() |
| Custom generators | Arb.Register<T>() | impl Strategy<Value = T> |
| Shrinking on failure | Automatic | Automatic |
| String patterns | Manual | "[regex]" strategy |
| Collection generation | Gen.ListOf | prop::collection::vec(strategy, range) |
| Composing generators | Gen.Select | .prop_map(), .prop_flat_map() |
| Config (# of cases) | Config.MaxTest | #![proptest_config(ProptestConfig::with_cases(10000))] inside proptest! block |
When to Use Property Testing vs Unit Testing
| Use unit tests when | Use proptest when |
|---|---|
| Testing specific edge cases | Verifying invariants across all inputs |
| Testing error messages/codes | Roundtrip properties (parse ↔ format) |
| Integration/mock tests | Comparing two implementations |
| Behavior depends on exact values | “For all X, property P holds” |
Integration Tests: the tests/ Directory
Unit tests live inside src/ with #[cfg(test)]. Integration tests live in a separate tests/ directory and test your crate’s public API — just like how C# integration tests reference the project as an external assembly.
my_crate/
├── src/
│ ├── lib.rs // public API
│ └── internal.rs // private implementation
├── tests/
│ ├── smoke.rs // each file is a separate test binary
│ ├── api_tests.rs
│ └── common/
│ └── mod.rs // shared test helpers
└── Cargo.toml
Writing Integration Tests
Each file in tests/ is compiled as a separate crate that depends on your library:
#![allow(unused)]
fn main() {
// tests/smoke.rs — can only access pub items from my_crate
use my_crate::{process_order, Order, OrderResult};
#[test]
fn process_valid_order_returns_confirmation() {
let order = Order::new("SKU-001", 3);
let result = process_order(order);
assert!(matches!(result, OrderResult::Confirmed { .. }));
}
}
Shared Test Helpers
Put shared setup code in tests/common/mod.rs (not tests/common.rs, which would be treated as its own test file):
#![allow(unused)]
fn main() {
// tests/common/mod.rs
use my_crate::Config;
pub fn test_config() -> Config {
Config::builder()
.database_url("sqlite::memory:")
.build()
.expect("test config must be valid")
}
}
#![allow(unused)]
fn main() {
// tests/api_tests.rs
mod common;
use my_crate::App;
#[test]
fn app_starts_with_test_config() {
let config = common::test_config();
let app = App::new(config);
assert!(app.is_healthy());
}
}
Running Specific Test Types
cargo test # run all tests (unit + integration)
cargo test --lib # unit tests only (like dotnet test --filter Category=Unit)
cargo test --test smoke # run only tests/smoke.rs
cargo test --test api_tests # run only tests/api_tests.rs
Key difference from C#: Integration test files can only access your crate’s pub API. Private functions are invisible — this forces you to test through the public interface, which is generally better test design.
15. Migration Patterns and Case Studies
Common C# Patterns in Rust
What you’ll learn: How to translate the Repository pattern, Builder pattern, dependency injection, LINQ chains, Entity Framework queries, and configuration patterns from C# to idiomatic Rust.
Difficulty: 🟡 Intermediate
graph LR
subgraph "C# Pattern"
I["interface IRepo<T>"] --> DI["DI Container"]
EX["try / catch"] --> LOG["ILogger"]
LINQ["LINQ .Where().Select()"] --> LIST["List<T>"]
end
subgraph "Rust Equivalent"
TR["trait Repo<T>"] --> GEN["Generic<R: Repo>"]
RES["Result<T, E> + ?"] --> THISERR["thiserror / anyhow"]
ITER[".iter().filter().map()"] --> VEC["Vec<T>"]
end
I -->|"becomes"| TR
EX -->|"becomes"| RES
LINQ -->|"becomes"| ITER
style TR fill:#c8e6c9,color:#000
style RES fill:#c8e6c9,color:#000
style ITER fill:#c8e6c9,color:#000
Repository Pattern
// C# Repository Pattern
public interface IRepository<T> where T : IEntity
{
Task<T> GetByIdAsync(int id);
Task<IEnumerable<T>> GetAllAsync();
Task<T> AddAsync(T entity);
Task UpdateAsync(T entity);
Task DeleteAsync(int id);
}
public class UserRepository : IRepository<User>
{
private readonly DbContext _context;
public UserRepository(DbContext context)
{
_context = context;
}
public async Task<User> GetByIdAsync(int id)
{
return await _context.Users.FindAsync(id);
}
// ... other implementations
}
#![allow(unused)]
fn main() {
// Rust Repository Pattern with traits and generics
use async_trait::async_trait;
use std::fmt::Debug;
#[async_trait]
pub trait Repository<T, E>
where
T: Clone + Debug + Send + Sync,
E: std::error::Error + Send + Sync,
{
async fn get_by_id(&self, id: u64) -> Result<Option<T>, E>;
async fn get_all(&self) -> Result<Vec<T>, E>;
async fn add(&self, entity: T) -> Result<T, E>;
async fn update(&self, entity: T) -> Result<T, E>;
async fn delete(&self, id: u64) -> Result<(), E>;
}
#[derive(Debug, Clone)]
pub struct User {
pub id: u64,
pub name: String,
pub email: String,
}
#[derive(Debug)]
pub enum RepositoryError {
NotFound(u64),
DatabaseError(String),
ValidationError(String),
}
impl std::fmt::Display for RepositoryError {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
RepositoryError::NotFound(id) => write!(f, "Entity with id {} not found", id),
RepositoryError::DatabaseError(msg) => write!(f, "Database error: {}", msg),
RepositoryError::ValidationError(msg) => write!(f, "Validation error: {}", msg),
}
}
}
impl std::error::Error for RepositoryError {}
pub struct UserRepository {
// database connection pool, etc.
}
#[async_trait]
impl Repository<User, RepositoryError> for UserRepository {
async fn get_by_id(&self, id: u64) -> Result<Option<User>, RepositoryError> {
// Simulate database lookup
if id == 0 {
return Ok(None);
}
Ok(Some(User {
id,
name: format!("User {}", id),
email: format!("user{}@example.com", id),
}))
}
async fn get_all(&self) -> Result<Vec<User>, RepositoryError> {
// Implementation here
Ok(vec![])
}
async fn add(&self, entity: User) -> Result<User, RepositoryError> {
// Validation and database insertion
if entity.name.is_empty() {
return Err(RepositoryError::ValidationError("Name cannot be empty".to_string()));
}
Ok(entity)
}
async fn update(&self, entity: User) -> Result<User, RepositoryError> {
// Implementation here
Ok(entity)
}
async fn delete(&self, id: u64) -> Result<(), RepositoryError> {
// Implementation here
Ok(())
}
}
}
Builder Pattern
// C# Builder Pattern (fluent interface)
public class HttpClientBuilder
{
private TimeSpan? _timeout;
private string _baseAddress;
private Dictionary<string, string> _headers = new();
public HttpClientBuilder WithTimeout(TimeSpan timeout)
{
_timeout = timeout;
return this;
}
public HttpClientBuilder WithBaseAddress(string baseAddress)
{
_baseAddress = baseAddress;
return this;
}
public HttpClientBuilder WithHeader(string name, string value)
{
_headers[name] = value;
return this;
}
public HttpClient Build()
{
var client = new HttpClient();
if (_timeout.HasValue)
client.Timeout = _timeout.Value;
if (!string.IsNullOrEmpty(_baseAddress))
client.BaseAddress = new Uri(_baseAddress);
foreach (var header in _headers)
client.DefaultRequestHeaders.Add(header.Key, header.Value);
return client;
}
}
// Usage
var client = new HttpClientBuilder()
.WithTimeout(TimeSpan.FromSeconds(30))
.WithBaseAddress("https://api.example.com")
.WithHeader("Accept", "application/json")
.Build();
#![allow(unused)]
fn main() {
// Rust Builder Pattern (consuming builder)
use std::collections::HashMap;
use std::time::Duration;
#[derive(Debug)]
pub struct HttpClient {
timeout: Duration,
base_address: String,
headers: HashMap<String, String>,
}
pub struct HttpClientBuilder {
timeout: Option<Duration>,
base_address: Option<String>,
headers: HashMap<String, String>,
}
impl HttpClientBuilder {
pub fn new() -> Self {
HttpClientBuilder {
timeout: None,
base_address: None,
headers: HashMap::new(),
}
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = Some(timeout);
self
}
pub fn with_base_address<S: Into<String>>(mut self, base_address: S) -> Self {
self.base_address = Some(base_address.into());
self
}
pub fn with_header<K: Into<String>, V: Into<String>>(mut self, name: K, value: V) -> Self {
self.headers.insert(name.into(), value.into());
self
}
pub fn build(self) -> Result<HttpClient, String> {
let base_address = self.base_address.ok_or("Base address is required")?;
Ok(HttpClient {
timeout: self.timeout.unwrap_or(Duration::from_secs(30)),
base_address,
headers: self.headers,
})
}
}
// Usage
let client = HttpClientBuilder::new()
.with_timeout(Duration::from_secs(30))
.with_base_address("https://api.example.com")
.with_header("Accept", "application/json")
.build()?;
// Alternative: Using Default trait for common cases
impl Default for HttpClientBuilder {
fn default() -> Self {
Self::new()
}
}
}
C# to Rust Concept Mapping
Dependency Injection → Constructor Injection + Traits
// C# with DI container
services.AddScoped<IUserRepository, UserRepository>();
services.AddScoped<IUserService, UserService>();
public class UserService
{
private readonly IUserRepository _repository;
public UserService(IUserRepository repository)
{
_repository = repository;
}
}
#![allow(unused)]
fn main() {
// Rust: Constructor injection with traits
pub trait UserRepository {
async fn find_by_id(&self, id: Uuid) -> Result<Option<User>, Error>;
async fn save(&self, user: &User) -> Result<(), Error>;
}
pub struct UserService<R>
where
R: UserRepository,
{
repository: R,
}
impl<R> UserService<R>
where
R: UserRepository,
{
pub fn new(repository: R) -> Self {
Self { repository }
}
pub async fn get_user(&self, id: Uuid) -> Result<Option<User>, Error> {
self.repository.find_by_id(id).await
}
}
// Usage
let repository = PostgresUserRepository::new(pool);
let service = UserService::new(repository);
}
LINQ → Iterator Chains
// C# LINQ
var result = users
.Where(u => u.Age > 18)
.Select(u => u.Name.ToUpper())
.OrderBy(name => name)
.Take(10)
.ToList();
#![allow(unused)]
fn main() {
// Rust: Iterator chains (zero-cost!)
let result: Vec<String> = users
.iter()
.filter(|u| u.age > 18)
.map(|u| u.name.to_uppercase())
.collect::<Vec<_>>()
.into_iter()
.sorted()
.take(10)
.collect();
// Or with itertools crate for more LINQ-like operations
use itertools::Itertools;
let result: Vec<String> = users
.iter()
.filter(|u| u.age > 18)
.map(|u| u.name.to_uppercase())
.sorted()
.take(10)
.collect();
}
Entity Framework → SQLx + Migrations
// C# Entity Framework
public class ApplicationDbContext : DbContext
{
public DbSet<User> Users { get; set; }
}
var user = await context.Users
.Where(u => u.Email == email)
.FirstOrDefaultAsync();
#![allow(unused)]
fn main() {
// Rust: SQLx with compile-time checked queries
use sqlx::{PgPool, FromRow};
#[derive(FromRow)]
struct User {
id: Uuid,
email: String,
name: String,
}
// Compile-time checked query
let user = sqlx::query_as!(
User,
"SELECT id, email, name FROM users WHERE email = $1",
email
)
.fetch_optional(&pool)
.await?;
// Or with dynamic queries
let user = sqlx::query_as::<_, User>(
"SELECT id, email, name FROM users WHERE email = $1"
)
.bind(email)
.fetch_optional(&pool)
.await?;
}
Configuration → Config Crates
// C# Configuration
public class AppSettings
{
public string DatabaseUrl { get; set; }
public int Port { get; set; }
}
var config = builder.Configuration.Get<AppSettings>();
#![allow(unused)]
fn main() {
// Rust: Config with serde
use config::{Config, ConfigError, Environment, File};
use serde::Deserialize;
#[derive(Debug, Deserialize)]
struct AppSettings {
database_url: String,
port: u16,
}
impl AppSettings {
pub fn new() -> Result<Self, ConfigError> {
let s = Config::builder()
.add_source(File::with_name("config/default"))
.add_source(Environment::with_prefix("APP"))
.build()?;
s.try_deserialize()
}
}
// Usage
let settings = AppSettings::new()?;
}
Case Studies
Case Study 1: CLI Tool Migration (csvtool)
Background: A team maintained a C# console app (CsvProcessor) that read large CSV files, applied transformations, and wrote output. At 500 MB files, memory usage spiked to 4 GB and GC pauses caused 30-second stalls.
Migration approach: Rewrote in Rust over 2 weeks, one module at a time.
| Step | What Changed | C# → Rust |
|---|---|---|
| 1 | CSV parsing | CsvHelper → csv crate (streaming Reader) |
| 2 | Data model | class Record → struct Record (stack-allocated, #[derive(Deserialize)]) |
| 3 | Transformations | LINQ .Select().Where() → .iter().map().filter() |
| 4 | File I/O | StreamReader → BufReader<File> with ? error propagation |
| 5 | CLI args | System.CommandLine → clap with derive macros |
| 6 | Parallel processing | Parallel.ForEach → rayon’s .par_iter() |
Results:
- Memory: 4 GB → 12 MB (streaming instead of loading entire file)
- Speed: 45s → 3s for 500 MB file
- Binary size: single 2 MB executable, no runtime dependency
Key lesson: The biggest win wasn’t Rust itself — it was that Rust’s ownership model forced a streaming design. In C#, it was easy to .ToList() everything into memory. In Rust, the borrow checker naturally steered toward Iterator-based processing.
Case Study 2: Microservice Replacement (auth-gateway)
Background: A C# ASP.NET Core authentication gateway handled JWT validation and rate limiting for 50+ backend services. At 10K req/s, p99 latency hit 200ms with GC spikes.
Migration approach: Replaced with a Rust service using axum + tower, keeping the API contract identical.
#![allow(unused)]
fn main() {
// Before (C#): services.AddAuthentication().AddJwtBearer(...)
// After (Rust): tower middleware layer
use axum::{Router, middleware};
use tower::ServiceBuilder;
let app = Router::new()
.route("/api/*path", any(proxy_handler))
.layer(
ServiceBuilder::new()
.layer(middleware::from_fn(validate_jwt))
.layer(middleware::from_fn(rate_limit))
);
}
| Metric | C# (ASP.NET Core) | Rust (axum) |
|---|---|---|
| p50 latency | 5ms | 0.8ms |
| p99 latency | 200ms (GC spikes) | 4ms |
| Memory | 300 MB | 8 MB |
| Docker image | 210 MB (.NET runtime) | 12 MB (static binary) |
| Cold start | 2.1s | 0.05s |
Key lessons:
- Keep the same API contract — no client changes needed. Rust service was a drop-in replacement.
- Start with the hot path — JWT validation was the bottleneck. Migrating just that one middleware would have captured 80% of the win.
- Use
towermiddleware — it mirrors ASP.NET Core’s middleware pipeline pattern, so C# developers found the Rust architecture familiar. - p99 latency improvement came from eliminating GC pauses, not from faster code — Rust’s steady-state throughput was only 2x faster, but the absence of GC made the tail latency predictable.
Exercises
🏋️ Exercise: Migrate a C# Service (click to expand)
Translate this C# service to idiomatic Rust:
public interface IUserService
{
Task<User?> GetByIdAsync(int id);
Task<List<User>> SearchAsync(string query);
}
public class UserService : IUserService
{
private readonly IDatabase _db;
public UserService(IDatabase db) { _db = db; }
public async Task<User?> GetByIdAsync(int id)
{
try { return await _db.QuerySingleAsync<User>(id); }
catch (NotFoundException) { return null; }
}
public async Task<List<User>> SearchAsync(string query)
{
return await _db.QueryAsync<User>($"SELECT * WHERE name LIKE '%{query}%'");
}
}
Hints: Use a trait, Option<User> instead of null, Result instead of try/catch, and fix the SQL injection vulnerability.
🔑 Solution
#![allow(unused)]
fn main() {
use async_trait::async_trait;
#[derive(Debug, Clone)]
struct User { id: i64, name: String }
#[async_trait]
trait Database: Send + Sync {
async fn get_user(&self, id: i64) -> Result<Option<User>, sqlx::Error>;
async fn search_users(&self, query: &str) -> Result<Vec<User>, sqlx::Error>;
}
#[async_trait]
trait UserService: Send + Sync {
async fn get_by_id(&self, id: i64) -> Result<Option<User>, AppError>;
async fn search(&self, query: &str) -> Result<Vec<User>, AppError>;
}
struct UserServiceImpl<D: Database> {
db: D, // No Arc needed — Rust's ownership handles it
}
#[async_trait]
impl<D: Database> UserService for UserServiceImpl<D> {
async fn get_by_id(&self, id: i64) -> Result<Option<User>, AppError> {
// Option instead of null; Result instead of try/catch
Ok(self.db.get_user(id).await?)
}
async fn search(&self, query: &str) -> Result<Vec<User>, AppError> {
// Parameterized query — NO SQL injection!
// (sqlx uses $1 placeholders, not string interpolation)
self.db.search_users(query).await.map_err(Into::into)
}
}
}
Key changes from C#:
null→Option<User>(compile-time null safety)try/catch→Result+?(explicit error propagation)- SQL injection fixed: parameterized queries, not string interpolation
IDatabase _db→ genericD: Database(static dispatch, no boxing)
Essential Crates for C# Developers
Essential Crates for C# Developers
What you’ll learn: The Rust crate equivalents for common .NET libraries — serde (JSON.NET), reqwest (HttpClient), tokio (Task/async), sqlx (Entity Framework), and a deep dive on serde’s attribute system compared to
System.Text.Json.Difficulty: 🟡 Intermediate
Core Functionality Equivalents
#![allow(unused)]
fn main() {
// Cargo.toml dependencies for C# developers
[dependencies]
Serialization (like Newtonsoft.Json or System.Text.Json)
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
HTTP client (like HttpClient)
reqwest = { version = "0.11", features = ["json"] }
Async runtime (like Task.Run, async/await)
tokio = { version = "1.0", features = ["full"] }
Error handling (like custom exceptions)
thiserror = "1.0"
anyhow = "1.0"
Logging (like ILogger, Serilog)
log = "0.4"
env_logger = "0.10"
Date/time (like DateTime)
chrono = { version = "0.4", features = ["serde"] }
UUID (like System.Guid)
uuid = { version = "1.0", features = ["v4", "serde"] }
Collections (like List<T>, Dictionary<K,V>)
Built into std, but for advanced collections:
indexmap = "2.0" # Ordered HashMap
Configuration (like IConfiguration)
config = "0.13"
Database (like Entity Framework)
sqlx = { version = "0.7", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
Testing (like xUnit, NUnit)
Built into std, but for more features:
rstest = "0.18" # Parameterized tests
Mocking (like Moq)
mockall = "0.11"
Parallel processing (like Parallel.ForEach)
rayon = "1.7"
}
Example Usage Patterns
use serde::{Deserialize, Serialize};
use reqwest;
use tokio;
use thiserror::Error;
use chrono::{DateTime, Utc};
use uuid::Uuid;
// Data models (like C# POCOs with attributes)
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct User {
pub id: Uuid,
pub name: String,
pub email: String,
#[serde(with = "chrono::serde::ts_seconds")]
pub created_at: DateTime<Utc>,
}
// Custom error types (like custom exceptions)
#[derive(Error, Debug)]
pub enum ApiError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("Serialization failed: {0}")]
Serialization(#[from] serde_json::Error),
#[error("User not found: {id}")]
UserNotFound { id: Uuid },
#[error("Validation failed: {message}")]
Validation { message: String },
}
// Service class equivalent
pub struct UserService {
client: reqwest::Client,
base_url: String,
}
impl UserService {
pub fn new(base_url: String) -> Self {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to create HTTP client");
UserService { client, base_url }
}
// Async method (like C# async Task<User>)
pub async fn get_user(&self, id: Uuid) -> Result<User, ApiError> {
let url = format!("{}/users/{}", self.base_url, id);
let response = self.client
.get(&url)
.send()
.await?;
if response.status() == 404 {
return Err(ApiError::UserNotFound { id });
}
let user = response.json::<User>().await?;
Ok(user)
}
// Create user (like C# async Task<User>)
pub async fn create_user(&self, name: String, email: String) -> Result<User, ApiError> {
if name.trim().is_empty() {
return Err(ApiError::Validation {
message: "Name cannot be empty".to_string(),
});
}
let new_user = User {
id: Uuid::new_v4(),
name,
email,
created_at: Utc::now(),
};
let response = self.client
.post(&format!("{}/users", self.base_url))
.json(&new_user)
.send()
.await?;
let created_user = response.json::<User>().await?;
Ok(created_user)
}
}
// Usage example (like C# Main method)
#[tokio::main]
async fn main() -> Result<(), ApiError> {
// Initialize logging (like configuring ILogger)
env_logger::init();
let service = UserService::new("https://api.example.com".to_string());
// Create user
let user = service.create_user(
"John Doe".to_string(),
"john@example.com".to_string(),
).await?;
println!("Created user: {:?}", user);
// Get user
let retrieved_user = service.get_user(user.id).await?;
println!("Retrieved user: {:?}", retrieved_user);
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test] // Like C# [Test] or [Fact]
async fn test_user_creation() {
let service = UserService::new("http://localhost:8080".to_string());
let result = service.create_user(
"Test User".to_string(),
"test@example.com".to_string(),
).await;
assert!(result.is_ok());
let user = result.unwrap();
assert_eq!(user.name, "Test User");
assert_eq!(user.email, "test@example.com");
}
#[test]
fn test_validation() {
// Synchronous test
let error = ApiError::Validation {
message: "Invalid input".to_string(),
};
assert_eq!(error.to_string(), "Validation failed: Invalid input");
}
}
Serde Deep Dive: JSON Serialization for C# Developers
C# developers rely heavily on System.Text.Json or Newtonsoft.Json. In Rust, serde (serialize/deserialize) is the universal framework — understanding its attribute system unlocks most data-handling scenarios.
Basic Derive: The Starting Point
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct User {
name: String,
age: u32,
email: String,
}
let user = User { name: "Alice".into(), age: 30, email: "alice@co.com".into() };
let json = serde_json::to_string_pretty(&user)?;
let parsed: User = serde_json::from_str(&json)?;
}
// C# equivalent
public class User
{
public string Name { get; set; }
public int Age { get; set; }
public string Email { get; set; }
}
var json = JsonSerializer.Serialize(user, new JsonSerializerOptions { WriteIndented = true });
var parsed = JsonSerializer.Deserialize<User>(json);
Field-Level Attributes (Like [JsonProperty])
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
#[derive(Serialize, Deserialize, Debug)]
struct ApiResponse {
// Rename field in JSON output (like [JsonPropertyName("user_id")])
#[serde(rename = "user_id")]
id: u64,
// Use different names for serialize vs deserialize
#[serde(rename(serialize = "userName", deserialize = "user_name"))]
name: String,
// Skip this field entirely (like [JsonIgnore])
#[serde(skip)]
internal_cache: Option<String>,
// Skip during serialization only
#[serde(skip_serializing)]
password_hash: String,
// Default value if missing from JSON (like default constructor values)
#[serde(default)]
is_active: bool,
// Custom default
#[serde(default = "default_role")]
role: String,
// Flatten a nested struct into the parent (like [JsonExtensionData])
#[serde(flatten)]
metadata: Metadata,
// Skip if the value is None (omit null fields)
#[serde(skip_serializing_if = "Option::is_none")]
nickname: Option<String>,
}
fn default_role() -> String { "viewer".into() }
#[derive(Serialize, Deserialize, Debug)]
struct Metadata {
created_at: String,
version: u32,
}
}
// C# equivalent attributes
public class ApiResponse
{
[JsonPropertyName("user_id")]
public ulong Id { get; set; }
[JsonIgnore]
public string? InternalCache { get; set; }
[JsonExtensionData]
public Dictionary<string, JsonElement>? Metadata { get; set; }
}
Enum Representations (Critical Difference from C#)
Rust serde supports four different JSON representations for enums — a concept that has no direct C# equivalent because C# enums are always integers or strings.
#![allow(unused)]
fn main() {
use serde::{Deserialize, Serialize};
// 1. Externally tagged (DEFAULT) — most common
#[derive(Serialize, Deserialize)]
enum Message {
Text(String),
Image { url: String, width: u32 },
Ping,
}
// Text variant: {"Text": "hello"}
// Image variant: {"Image": {"url": "...", "width": 100}}
// Ping variant: "Ping"
// 2. Internally tagged — like discriminated unions in other languages
#[derive(Serialize, Deserialize)]
#[serde(tag = "type")]
enum Event {
Created { id: u64, name: String },
Deleted { id: u64 },
Updated { id: u64, fields: Vec<String> },
}
// {"type": "Created", "id": 1, "name": "Alice"}
// {"type": "Deleted", "id": 1}
// 3. Adjacently tagged — tag and content in separate fields
#[derive(Serialize, Deserialize)]
#[serde(tag = "t", content = "c")]
enum ApiResult {
Success(UserData),
Error(String),
}
// {"t": "Success", "c": {"name": "Alice"}}
// {"t": "Error", "c": "not found"}
// 4. Untagged — serde tries each variant in order
#[derive(Serialize, Deserialize)]
#[serde(untagged)]
enum FlexibleValue {
Integer(i64),
Float(f64),
Text(String),
Bool(bool),
}
// 42, 3.14, "hello", true — serde auto-detects the variant
}
Custom Serialization (Like JsonConverter)
#![allow(unused)]
fn main() {
use serde::{Deserialize, Deserializer, Serialize, Serializer};
// Custom serialization for a specific field
#[derive(Serialize, Deserialize)]
struct Config {
#[serde(serialize_with = "serialize_duration", deserialize_with = "deserialize_duration")]
timeout: std::time::Duration,
}
fn serialize_duration<S: Serializer>(dur: &std::time::Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(dur.as_millis() as u64)
}
fn deserialize_duration<'de, D: Deserializer<'de>>(d: D) -> Result<std::time::Duration, D::Error> {
let ms = u64::deserialize(d)?;
Ok(std::time::Duration::from_millis(ms))
}
// JSON: {"timeout": 5000} ↔ Config { timeout: Duration::from_millis(5000) }
}
Container-Level Attributes
#![allow(unused)]
fn main() {
#[derive(Serialize, Deserialize)]
#[serde(rename_all = "camelCase")] // All fields become camelCase in JSON
struct UserProfile {
first_name: String, // → "firstName"
last_name: String, // → "lastName"
email_address: String, // → "emailAddress"
}
#[derive(Serialize, Deserialize)]
#[serde(deny_unknown_fields)] // Reject JSON with extra fields (strict parsing)
struct StrictConfig {
port: u16,
host: String,
}
// serde_json::from_str::<StrictConfig>(r#"{"port":8080,"host":"localhost","extra":true}"#)
// → Error: unknown field `extra`
}
Quick Reference: Serde Attributes
| Attribute | Level | C# Equivalent | Purpose |
|---|---|---|---|
#[serde(rename = "...")] | Field | [JsonPropertyName] | Rename in JSON |
#[serde(skip)] | Field | [JsonIgnore] | Omit entirely |
#[serde(default)] | Field | Default value | Use Default::default() if missing |
#[serde(flatten)] | Field | [JsonExtensionData] | Merge nested struct into parent |
#[serde(skip_serializing_if = "...")] | Field | JsonIgnoreCondition | Conditional skip |
#[serde(rename_all = "camelCase")] | Container | JsonSerializerOptions.PropertyNamingPolicy | Naming convention |
#[serde(deny_unknown_fields)] | Container | — | Strict deserialization |
#[serde(tag = "type")] | Enum | Discriminator pattern | Internal tagging |
#[serde(untagged)] | Enum | — | Try variants in order |
#[serde(with = "...")] | Field | [JsonConverter] | Custom ser/de |
Beyond JSON: serde Works Everywhere
#![allow(unused)]
fn main() {
// The SAME derive works for ALL formats — just change the crate
let user = User { name: "Alice".into(), age: 30, email: "a@b.com".into() };
let json = serde_json::to_string(&user)?; // JSON
let toml = toml::to_string(&user)?; // TOML (config files)
let yaml = serde_yaml::to_string(&user)?; // YAML
let cbor = serde_cbor::to_vec(&user)?; // CBOR (binary, compact)
let msgpk = rmp_serde::to_vec(&user)?; // MessagePack (binary)
// One #[derive(Serialize, Deserialize)] — every format for free
}
Incremental Adoption Strategy
Incremental Adoption Strategy
What you’ll learn: A phased approach to introducing Rust in a C#/.NET organization — from learning exercises (weeks 1–4) to performance-critical replacements (weeks 5–8) to new microservices (weeks 9–12), with concrete team adoption timelines.
Difficulty: 🟡 Intermediate
Phase 1: Learning and Experimentation (Weeks 1-4)
// Start with command-line tools and utilities
// Example: Log file analyzer
use std::fs;
use std::collections::HashMap;
use clap::Parser;
#[derive(Parser)]
#[command(author, version, about)]
struct Args {
#[arg(short, long)]
file: String,
#[arg(short, long, default_value = "10")]
top: usize,
}
fn main() -> Result<(), Box<dyn std::error::Error>> {
let args = Args::parse();
let content = fs::read_to_string(&args.file)?;
let mut word_count = HashMap::new();
for line in content.lines() {
for word in line.split_whitespace() {
let word = word.to_lowercase();
*word_count.entry(word).or_insert(0) += 1;
}
}
let mut sorted: Vec<_> = word_count.into_iter().collect();
sorted.sort_by(|a, b| b.1.cmp(&a.1));
for (word, count) in sorted.into_iter().take(args.top) {
println!("{}: {}", word, count);
}
Ok(())
}
Phase 2: Replace Performance-Critical Components (Weeks 5-8)
// Replace CPU-intensive data processing
// Example: Image processing microservice
use image::{DynamicImage, ImageBuffer, Rgb};
use serde::{Deserialize, Serialize};
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use warp::Filter;
#[derive(Serialize, Deserialize)]
struct ProcessingRequest {
image_data: Vec<u8>,
operation: String,
parameters: serde_json::Value,
}
#[derive(Serialize)]
struct ProcessingResponse {
processed_image: Vec<u8>,
processing_time_ms: u64,
}
async fn process_image(request: ProcessingRequest) -> Result<ProcessingResponse, Box<dyn std::error::Error + Send + Sync>> {
let start = std::time::Instant::now();
let img = image::load_from_memory(&request.image_data)?;
let processed = match request.operation.as_str() {
"blur" => {
let radius = request.parameters["radius"].as_f64().unwrap_or(2.0) as f32;
img.blur(radius)
}
"grayscale" => img.grayscale(),
"resize" => {
let width = request.parameters["width"].as_u64().unwrap_or(100) as u32;
let height = request.parameters["height"].as_u64().unwrap_or(100) as u32;
img.resize(width, height, image::imageops::FilterType::Lanczos3)
}
_ => return Err("Unknown operation".into()),
};
let mut buffer = Vec::new();
processed.write_to(&mut std::io::Cursor::new(&mut buffer), image::ImageOutputFormat::Png)?;
Ok(ProcessingResponse {
processed_image: buffer,
processing_time_ms: start.elapsed().as_millis() as u64,
})
}
#[tokio::main]
async fn main() {
let process_route = warp::path("process")
.and(warp::post())
.and(warp::body::json())
.and_then(|req: ProcessingRequest| async move {
match process_image(req).await {
Ok(response) => Ok(warp::reply::json(&response)),
Err(e) => Err(warp::reject::custom(ProcessingError(e.to_string()))),
}
});
warp::serve(process_route)
.run(([127, 0, 0, 1], 3030))
.await;
}
#[derive(Debug)]
struct ProcessingError(String);
impl warp::reject::Reject for ProcessingError {}
Phase 3: New Microservices (Weeks 9-12)
// Build new services from scratch in Rust
// Example: Authentication service
use axum::{
extract::{Query, State},
http::StatusCode,
response::Json,
routing::{get, post},
Router,
};
use jsonwebtoken::{encode, decode, Header, Validation, EncodingKey, DecodingKey};
use serde::{Deserialize, Serialize};
use sqlx::{Pool, Postgres};
use uuid::Uuid;
use bcrypt::{hash, verify, DEFAULT_COST};
#[derive(Clone)]
struct AppState {
db: Pool<Postgres>,
jwt_secret: String,
}
#[derive(Serialize, Deserialize)]
struct Claims {
sub: String,
exp: usize,
}
#[derive(Deserialize)]
struct LoginRequest {
email: String,
password: String,
}
#[derive(Serialize)]
struct LoginResponse {
token: String,
user_id: Uuid,
}
async fn login(
State(state): State<AppState>,
Json(request): Json<LoginRequest>,
) -> Result<Json<LoginResponse>, StatusCode> {
let user = sqlx::query!(
"SELECT id, password_hash FROM users WHERE email = $1",
request.email
)
.fetch_optional(&state.db)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let user = user.ok_or(StatusCode::UNAUTHORIZED)?;
if !verify(&request.password, &user.password_hash)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
return Err(StatusCode::UNAUTHORIZED);
}
let claims = Claims {
sub: user.id.to_string(),
exp: (chrono::Utc::now() + chrono::Duration::hours(24)).timestamp() as usize,
};
let token = encode(
&Header::default(),
&claims,
&EncodingKey::from_secret(state.jwt_secret.as_ref()),
)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(LoginResponse {
token,
user_id: user.id,
}))
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let database_url = std::env::var("DATABASE_URL")?;
let jwt_secret = std::env::var("JWT_SECRET")?;
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(20)
.connect(&database_url)
.await?;
let app_state = AppState {
db: pool,
jwt_secret,
};
let app = Router::new()
.route("/login", post(login))
.with_state(app_state);
let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await?;
axum::serve(listener, app).await?;
Ok(())
}
Team Adoption Timeline
Month 1: Foundation
Week 1-2: Syntax and Ownership
- Basic syntax differences from C#
- Understanding ownership, borrowing, and lifetimes
- Small exercises: CLI tools, file processing
Week 3-4: Error Handling and Types
Result<T, E>vs exceptionsOption<T>vs nullable types- Pattern matching and exhaustive checking
Recommended exercises:
#![allow(unused)]
fn main() {
// Week 1-2: File processor
fn process_log_file(path: &str) -> Result<Vec<String>, std::io::Error> {
let content = std::fs::read_to_string(path)?;
let errors: Vec<String> = content
.lines()
.filter(|line| line.contains("ERROR"))
.map(|line| line.to_string())
.collect();
Ok(errors)
}
// Week 3-4: JSON processor with error handling
use serde::{Deserialize, Serialize};
#[derive(Deserialize, Serialize, Debug)]
struct LogEntry {
timestamp: String,
level: String,
message: String,
}
fn parse_log_entries(json_str: &str) -> Result<Vec<LogEntry>, Box<dyn std::error::Error>> {
let entries: Vec<LogEntry> = serde_json::from_str(json_str)?;
Ok(entries)
}
}
Month 2: Practical Applications
Week 5-6: Traits and Generics
- Trait system vs interfaces
- Generic constraints and bounds
- Common patterns and idioms
Week 7-8: Async Programming and Concurrency
async/awaitsimilarities and differences- Channels for communication
- Thread safety guarantees
Recommended projects:
#![allow(unused)]
fn main() {
// Week 5-6: Generic data processor
trait DataProcessor<T> {
type Output;
type Error;
fn process(&self, data: T) -> Result<Self::Output, Self::Error>;
}
struct JsonProcessor;
impl DataProcessor<&str> for JsonProcessor {
type Output = serde_json::Value;
type Error = serde_json::Error;
fn process(&self, data: &str) -> Result<Self::Output, Self::Error> {
serde_json::from_str(data)
}
}
// Week 7-8: Async web client
async fn fetch_and_process_data(urls: Vec<&str>) -> Result<(), Box<dyn std::error::Error>> {
let client = reqwest::Client::new();
let tasks: Vec<_> = urls
.into_iter()
.map(|url| {
let client = client.clone();
tokio::spawn(async move {
let response = client.get(url).send().await?;
let text = response.text().await?;
println!("Fetched {} bytes from {}", text.len(), url);
Ok::<(), reqwest::Error>(())
})
})
.collect();
for task in tasks {
task.await??;
}
Ok(())
}
}
Month 3+: Production Integration
Week 9-12: Real Project Work
- Choose a non-critical component to rewrite
- Implement comprehensive error handling
- Add logging, metrics, and testing
- Performance profiling and optimization
Ongoing: Team Review and Mentoring
- Code reviews focusing on Rust idioms
- Pair programming sessions
- Knowledge sharing sessions
16. Best Practices
Best Practices for C# Developers
What you’ll learn: Five critical mindset shifts (GC→ownership, exceptions→Results, inheritance→composition), idiomatic project organization, error handling strategy, testing patterns, and the most common mistakes C# developers make in Rust.
Difficulty: 🟡 Intermediate
1. Mindset Shifts
- From GC to Ownership: Think about who owns data and when it’s freed
- From Exceptions to Results: Make error handling explicit and visible
- From Inheritance to Composition: Use traits to compose behavior
- From Null to Option: Make absence of values explicit in the type system
2. Code Organization
#![allow(unused)]
fn main() {
// Structure projects like C# solutions
src/
├── main.rs // Program.cs equivalent
├── lib.rs // Library entry point
├── models/ // Like Models/ folder in C#
│ ├── mod.rs
│ ├── user.rs
│ └── product.rs
├── services/ // Like Services/ folder
│ ├── mod.rs
│ ├── user_service.rs
│ └── product_service.rs
├── controllers/ // Like Controllers/ (for web apps)
├── repositories/ // Like Repositories/
└── utils/ // Like Utilities/
}
3. Error Handling Strategy
#![allow(unused)]
fn main() {
// Create a common Result type for your application
pub type AppResult<T> = Result<T, AppError>;
#[derive(Error, Debug)]
pub enum AppError {
#[error("Database error: {0}")]
Database(#[from] sqlx::Error),
#[error("HTTP error: {0}")]
Http(#[from] reqwest::Error),
#[error("Validation error: {message}")]
Validation { message: String },
#[error("Business logic error: {message}")]
Business { message: String },
}
// Use throughout your application
pub async fn create_user(data: CreateUserRequest) -> AppResult<User> {
validate_user_data(&data)?; // Returns AppError::Validation
let user = repository.create_user(data).await?; // Returns AppError::Database
Ok(user)
}
}
4. Testing Patterns
#![allow(unused)]
fn main() {
// Structure tests like C# unit tests
#[cfg(test)]
mod tests {
use super::*;
use rstest::*; // For parameterized tests like C# [Theory]
#[test]
fn test_basic_functionality() {
// Arrange
let input = "test data";
// Act
let result = process_data(input);
// Assert
assert_eq!(result, "expected output");
}
#[rstest]
#[case(1, 2, 3)]
#[case(5, 5, 10)]
#[case(0, 0, 0)]
fn test_addition(#[case] a: i32, #[case] b: i32, #[case] expected: i32) {
assert_eq!(add(a, b), expected);
}
#[tokio::test] // For async tests
async fn test_async_functionality() {
let result = async_function().await;
assert!(result.is_ok());
}
}
}
5. Common Mistakes to Avoid
#![allow(unused)]
fn main() {
// [ERROR] Don't try to implement inheritance
// Instead of:
// struct Manager : Employee // This doesn't exist in Rust
// [OK] Use composition with traits
trait Employee {
fn get_salary(&self) -> u32;
}
trait Manager: Employee {
fn get_team_size(&self) -> usize;
}
// [ERROR] Don't use unwrap() everywhere (like ignoring exceptions)
let value = might_fail().unwrap(); // Can panic!
// [OK] Handle errors properly
let value = match might_fail() {
Ok(v) => v,
Err(e) => {
log::error!("Operation failed: {}", e);
return Err(e.into());
}
};
// [ERROR] Don't clone everything (like copying objects unnecessarily)
let data = expensive_data.clone(); // Expensive!
// [OK] Use borrowing when possible
let data = &expensive_data; // Just a reference
// [ERROR] Don't use RefCell everywhere (like making everything mutable)
struct Data {
value: RefCell<i32>, // Interior mutability - use sparingly
}
// [OK] Prefer owned or borrowed data
struct Data {
value: i32, // Simple and clear
}
}
This guide provides C# developers with a comprehensive understanding of how their existing knowledge translates to Rust, highlighting both the similarities and the fundamental differences in approach. The key is understanding that Rust’s constraints (like ownership) are designed to prevent entire classes of bugs that are possible in C#, at the cost of some initial complexity.
6. Avoiding Excessive clone() 🟡
C# developers instinctively clone data because the GC handles the cost. In Rust, every .clone() is an explicit allocation. Most can be eliminated with borrowing.
#![allow(unused)]
fn main() {
// [ERROR] C# habit: cloning strings to pass around
fn greet(name: String) {
println!("Hello, {name}");
}
let user_name = String::from("Alice");
greet(user_name.clone()); // unnecessary allocation
greet(user_name.clone()); // and again
// [OK] Borrow instead — zero allocation
fn greet(name: &str) {
println!("Hello, {name}");
}
let user_name = String::from("Alice");
greet(&user_name); // borrows
greet(&user_name); // borrows again — no cost
}
When clone is appropriate:
- Moving data into a thread or
'staticclosure (Arc::cloneis cheap — it bumps a counter) - Caching: you genuinely need an independent copy
- Prototyping: get it working, then remove clones later
Decision checklist:
- Can you pass
&Tor&strinstead? → Do that - Does the callee need ownership? → Pass by move, not clone
- Is it shared across threads? → Use
Arc<T>(clone is just a reference count bump) - None of the above? →
clone()is justified
7. Avoiding unwrap() in Production Code 🟡
C# developers who ignore exceptions write .unwrap() everywhere in Rust. Both are equally dangerous.
#![allow(unused)]
fn main() {
// [ERROR] The "I'll fix this later" trap
let config = std::fs::read_to_string("config.toml").unwrap();
let port: u16 = config_value.parse().unwrap();
let conn = db_pool.get().await.unwrap();
// [OK] Propagate with ? in application code
let config = std::fs::read_to_string("config.toml")?;
let port: u16 = config_value.parse()?;
let conn = db_pool.get().await?;
// [OK] Use expect() only when failure is truly a bug
let home = std::env::var("HOME")
.expect("HOME environment variable must be set"); // documents the invariant
}
Rule of thumb:
| Method | When to use |
|---|---|
? | Application/library code — propagate to caller |
expect("reason") | Startup assertions, invariants that must hold |
unwrap() | Tests only, or after an is_some()/is_ok() check |
unwrap_or(default) | When you have a sensible fallback |
| `unwrap_or_else( |
8. Fighting the Borrow Checker (and How to Stop) 🟡
Every C# developer hits a phase where the borrow checker rejects valid-seeming code. The fix is usually a structural change, not a workaround.
#![allow(unused)]
fn main() {
// [ERROR] Trying to mutate while iterating (C# foreach + modify pattern)
let mut items = vec![1, 2, 3, 4, 5];
for item in &items {
if *item > 3 {
items.push(*item * 2); // ERROR: can't borrow items as mutable
}
}
// [OK] Collect first, then mutate
let extras: Vec<i32> = items.iter()
.filter(|&&x| x > 3)
.map(|&x| x * 2)
.collect();
items.extend(extras);
}
#![allow(unused)]
fn main() {
// [ERROR] Returning a reference to a local (C# returns references freely via GC)
fn get_greeting() -> &str {
let s = String::from("hello");
&s // ERROR: s is dropped at end of function
}
// [OK] Return owned data
fn get_greeting() -> String {
String::from("hello") // caller owns it
}
}
Common patterns that resolve borrow checker conflicts:
| C# habit | Rust solution |
|---|---|
| Store references in structs | Use owned data, or add lifetime parameters |
| Mutate shared state freely | Use Arc<Mutex<T>> or restructure to avoid sharing |
| Return references to locals | Return owned values |
| Modify collection while iterating | Collect changes, then apply |
| Multiple mutable references | Split struct into independent parts |
9. Collapsing Assignment Pyramids 🟢
C# developers write chains of if (x != null) { if (x.Value > 0) { ... } }. Rust’s match, if let, and ? flatten these.
#![allow(unused)]
fn main() {
// [ERROR] Nested null-checking style from C#
fn process(input: Option<String>) -> Option<usize> {
match input {
Some(s) => {
if !s.is_empty() {
match s.parse::<usize>() {
Ok(n) => {
if n > 0 {
Some(n * 2)
} else {
None
}
}
Err(_) => None,
}
} else {
None
}
}
None => None,
}
}
// [OK] Flatten with combinators
fn process(input: Option<String>) -> Option<usize> {
input
.filter(|s| !s.is_empty())
.and_then(|s| s.parse::<usize>().ok())
.filter(|&n| n > 0)
.map(|n| n * 2)
}
}
Key combinators every C# developer should know:
| Combinator | What it does | C# equivalent |
|---|---|---|
map | Transform the inner value | Select / null-conditional ?. |
and_then | Chain operations that return Option/Result | SelectMany / ?.Method() |
filter | Keep value only if predicate passes | Where |
unwrap_or | Provide default | ?? defaultValue |
ok() | Convert Result to Option (discard error) | — |
transpose | Flip Option<Result> to Result<Option> | — |
Performance Comparison and Migration
Performance Comparison: Managed vs Native
What you’ll learn: Real-world performance differences between C# and Rust — startup time, memory usage, throughput benchmarks, CPU-intensive workloads, and a decision tree for when to migrate vs when to stay in C#.
Difficulty: 🟡 Intermediate
Real-World Performance Characteristics
| Aspect | C# (.NET) | Rust | Performance Impact |
|---|---|---|---|
| Startup Time | 100-500ms (JIT); 5-30ms (.NET 8 AOT) | 1-10ms (native binary) | 🚀 10-50x faster (vs JIT) |
| Memory Usage | +30-100% (GC overhead + metadata) | Baseline (minimal runtime) | 💾 30-50% less RAM |
| GC Pauses | 1-100ms periodic pauses | Never (no GC) | ⚡ Consistent latency |
| CPU Usage | +10-20% (GC + JIT overhead) | Baseline (direct execution) | 🔋 10-20% better efficiency |
| Binary Size | 30-200MB (with runtime); 10-30MB (AOT trimmed) | 1-20MB (static binary) | 📦 Smaller deployments |
| Memory Safety | Runtime checks | Compile-time proofs | 🛡️ Zero overhead safety |
| Concurrent Performance | Good (with careful synchronization) | Excellent (fearless concurrency) | 🏃 Superior scalability |
Note on .NET 8+ AOT: Native AOT compilation closes the startup gap significantly (5-30ms). For throughput and memory, GC overhead and pauses remain. When evaluating a migration, benchmark your specific workload — headline numbers can be misleading.
Benchmark Examples
// C# - JSON processing benchmark
public class JsonProcessor
{
public async Task<List<User>> ProcessJsonFile(string path)
{
var json = await File.ReadAllTextAsync(path);
var users = JsonSerializer.Deserialize<List<User>>(json);
return users.Where(u => u.Age > 18)
.OrderBy(u => u.Name)
.Take(1000)
.ToList();
}
}
// Typical performance: ~200ms for 100MB file
// Memory usage: ~500MB peak (GC overhead)
// Binary size: ~80MB (self-contained)
#![allow(unused)]
fn main() {
// Rust - Equivalent JSON processing
use serde::{Deserialize, Serialize};
use tokio::fs;
#[derive(Deserialize, Serialize)]
struct User {
name: String,
age: u32,
}
pub async fn process_json_file(path: &str) -> Result<Vec<User>, Box<dyn std::error::Error>> {
let json = fs::read_to_string(path).await?;
let mut users: Vec<User> = serde_json::from_str(&json)?;
users.retain(|u| u.age > 18);
users.sort_by(|a, b| a.name.cmp(&b.name));
users.truncate(1000);
Ok(users)
}
// Typical performance: ~120ms for same 100MB file
// Memory usage: ~200MB peak (no GC overhead)
// Binary size: ~8MB (static binary)
}
CPU-Intensive Workloads
// C# - Mathematical computation
public class Mandelbrot
{
public static int[,] Generate(int width, int height, int maxIterations)
{
var result = new int[height, width];
Parallel.For(0, height, y =>
{
for (int x = 0; x < width; x++)
{
var c = new Complex(
(x - width / 2.0) * 4.0 / width,
(y - height / 2.0) * 4.0 / height);
result[y, x] = CalculateIterations(c, maxIterations);
}
});
return result;
}
}
// Performance: ~2.3 seconds (8-core machine)
// Memory: ~500MB
#![allow(unused)]
fn main() {
// Rust - Same computation with Rayon
use rayon::prelude::*;
use num_complex::Complex;
pub fn generate_mandelbrot(width: usize, height: usize, max_iterations: u32) -> Vec<Vec<u32>> {
(0..height)
.into_par_iter()
.map(|y| {
(0..width)
.map(|x| {
let c = Complex::new(
(x as f64 - width as f64 / 2.0) * 4.0 / width as f64,
(y as f64 - height as f64 / 2.0) * 4.0 / height as f64,
);
calculate_iterations(c, max_iterations)
})
.collect()
})
.collect()
}
// Performance: ~1.1 seconds (same 8-core machine)
// Memory: ~200MB
// 2x faster with 60% less memory usage
}
When to Choose Each Language
Choose C# when:
- Rapid development is crucial - Rich tooling ecosystem
- Team expertise in .NET - Existing knowledge and skills
- Enterprise integration - Heavy use of Microsoft ecosystem
- Moderate performance requirements - Performance is adequate
- Rich UI applications - WPF, WinUI, Blazor applications
- Prototyping and MVPs - Fast time to market
Choose Rust when:
- Performance is critical - CPU/memory-intensive applications
- Resource constraints matter - Embedded, edge computing, serverless
- Long-running services - Web servers, databases, system services
- System-level programming - OS components, drivers, network tools
- High reliability requirements - Financial systems, safety-critical applications
- Concurrent/parallel workloads - High-throughput data processing
Migration Strategy Decision Tree
graph TD
START["Considering Rust?"]
PERFORMANCE["Is performance critical?"]
TEAM["Team has time to learn?"]
EXISTING["Large existing C# codebase?"]
NEW_PROJECT["New project or component?"]
INCREMENTAL["Incremental adoption:<br/>• CLI tools first<br/>• Performance-critical components<br/>• New microservices"]
FULL_RUST["Full Rust adoption:<br/>• Greenfield projects<br/>• System-level services<br/>• High-performance APIs"]
STAY_CSHARP["Stay with C#:<br/>• Optimize existing code<br/>• Use .NET AOT / performance features<br/>• Consider .NET Native"]
START --> PERFORMANCE
PERFORMANCE -->|Yes| TEAM
PERFORMANCE -->|No| STAY_CSHARP
TEAM -->|Yes| EXISTING
TEAM -->|No| STAY_CSHARP
EXISTING -->|Yes| NEW_PROJECT
EXISTING -->|No| FULL_RUST
NEW_PROJECT -->|New| FULL_RUST
NEW_PROJECT -->|Existing| INCREMENTAL
style FULL_RUST fill:#c8e6c9,color:#000
style INCREMENTAL fill:#fff3e0,color:#000
style STAY_CSHARP fill:#e3f2fd,color:#000
Learning Path and Resources
Learning Path and Next Steps
What you’ll learn: A structured learning roadmap (weeks 1–2, months 1–3+), recommended books and resources, common pitfalls for C# developers (ownership confusion, fighting the borrow checker), and structured observability with
tracingvsILogger.Difficulty: 🟢 Beginner
Immediate Next Steps (Week 1-2)
-
Set up your environment
- Install Rust via rustup.rs
- Configure VS Code with rust-analyzer extension
- Create your first
cargo new hello_worldproject
-
Master the basics
- Practice ownership with simple exercises
- Write functions with different parameter types (
&str,String,&mut) - Implement basic structs and methods
-
Error handling practice
- Convert C# try-catch code to Result-based patterns
- Practice with
?operator andmatchstatements - Implement custom error types
Intermediate Goals (Month 1-2)
-
Collections and iterators
- Master
Vec<T>,HashMap<K,V>, andHashSet<T> - Learn iterator methods:
map,filter,collect,fold - Practice with
forloops vs iterator chains
- Master
-
Traits and generics
- Implement common traits:
Debug,Clone,PartialEq - Write generic functions and structs
- Understand trait bounds and where clauses
- Implement common traits:
-
Project structure
- Organize code into modules
- Understand
pubvisibility - Work with external crates from crates.io
Advanced Topics (Month 3+)
-
Concurrency
- Learn about
SendandSynctraits - Use
std::threadfor basic parallelism - Explore
tokiofor async programming
- Learn about
-
Memory management
- Understand
Rc<T>andArc<T>for shared ownership - Learn when to use
Box<T>for heap allocation - Master lifetimes for complex scenarios
- Understand
-
Real-world projects
- Build a CLI tool with
clap - Create a web API with
axumorwarp - Write a library and publish to crates.io
- Build a CLI tool with
Recommended Learning Resources
Books
- “The Rust Programming Language” (free online) - The official book
- “Rust by Example” (free online) - Hands-on examples
- “Programming Rust” by Jim Blandy - Deep technical coverage
Online Resources
- Rust Playground - Try code in browser
- Rustlings - Interactive exercises
- Rust by Example - Practical examples
Practice Projects
- Command-line calculator - Practice with enums and pattern matching
- File organizer - Work with filesystem and error handling
- JSON processor - Learn serde and data transformation
- HTTP server - Understand async programming and networking
- Database library - Master traits, generics, and error handling
Common Pitfalls for C# Developers
Ownership Confusion
#![allow(unused)]
fn main() {
// DON'T: Trying to use moved values
fn wrong_way() {
let s = String::from("hello");
takes_ownership(s);
// println!("{}", s); // ERROR: s was moved
}
// DO: Use references or clone when needed
fn right_way() {
let s = String::from("hello");
borrows_string(&s);
println!("{}", s); // OK: s is still owned here
}
fn takes_ownership(s: String) { /* s is moved here */ }
fn borrows_string(s: &str) { /* s is borrowed here */ }
}
Fighting the Borrow Checker
#![allow(unused)]
fn main() {
// DON'T: Multiple mutable references
fn wrong_borrowing() {
let mut v = vec![1, 2, 3];
let r1 = &mut v;
// let r2 = &mut v; // ERROR: cannot borrow as mutable more than once
}
// DO: Limit scope of mutable borrows
fn right_borrowing() {
let mut v = vec![1, 2, 3];
{
let r1 = &mut v;
r1.push(4);
} // r1 goes out of scope here
let r2 = &mut v; // OK: no other mutable borrows exist
r2.push(5);
}
}
Expecting Null Values
#![allow(unused)]
fn main() {
// DON'T: Expecting null-like behavior
fn no_null_in_rust() {
// let s: String = null; // NO null in Rust!
}
// DO: Use Option<T> explicitly
fn use_option_instead() {
let maybe_string: Option<String> = None;
match maybe_string {
Some(s) => println!("Got string: {}", s),
None => println!("No string available"),
}
}
}
Final Tips
- Embrace the compiler - Rust’s compiler errors are helpful, not hostile
- Start small - Begin with simple programs and gradually add complexity
- Read other people’s code - Study popular crates on GitHub
- Ask for help - The Rust community is welcoming and helpful
- Practice regularly - Rust’s concepts become natural with practice
Remember: Rust has a learning curve, but it pays off with memory safety, performance, and fearless concurrency. The ownership system that seems restrictive at first becomes a powerful tool for writing correct, efficient programs.
Congratulations! You now have a solid foundation for transitioning from C# to Rust. Start with simple projects, be patient with the learning process, and gradually work your way up to more complex applications. The safety and performance benefits of Rust make the initial learning investment worthwhile.
Structured Observability: tracing vs ILogger and Serilog
C# developers are accustomed to structured logging via ILogger, Serilog, or NLog — where log messages carry typed key-value properties. Rust’s log crate provides basic leveled logging, but tracing is the production standard for structured observability with spans, async awareness, and distributed tracing support.
Why tracing Over log
| Feature | log crate | tracing crate | C# Equivalent |
|---|---|---|---|
| Leveled messages | ✅ info!(), error!() | ✅ info!(), error!() | ILogger.LogInformation() |
| Structured fields | ❌ String interpolation only | ✅ Typed key-value fields | Serilog Log.Information("{User}", user) |
| Spans (scoped context) | ❌ | ✅ #[instrument], span!() | ILogger.BeginScope() |
| Async-aware | ❌ Loses context across .await | ✅ Spans follow across .await | Activity / DiagnosticSource |
| Distributed tracing | ❌ | ✅ OpenTelemetry integration | System.Diagnostics.Activity |
| Multiple output formats | Basic | JSON, pretty, compact, OTLP | Serilog sinks |
Getting Started
# Cargo.toml
[dependencies]
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter", "json"] }
Basic Usage: Structured Logging
// C# Serilog
Log.Information("Processing order {OrderId} for {Customer}, total {Total:C}",
orderId, customer.Name, order.Total);
// Output: Processing order 12345 for Alice, total $99.95
// JSON: {"OrderId": 12345, "Customer": "Alice", "Total": 99.95, ...}
#![allow(unused)]
fn main() {
use tracing::{info, warn, error, debug, instrument};
// Structured fields — typed, not string-interpolated
info!(order_id = 12345, customer = "Alice", total = 99.95,
"Processing order");
// Output: INFO Processing order order_id=12345 customer="Alice" total=99.95
// JSON: {"order_id": 12345, "customer": "Alice", "total": 99.95, ...}
// Dynamic values
let order_id = 12345;
info!(order_id, "Order received"); // field name = variable name shorthand
// Conditional fields
if let Some(promo) = promo_code {
info!(order_id, promo_code = %promo, "Promo applied");
// ^ % means use Display formatting
// ? would use Debug formatting
}
}
Spans: The Killer Feature for Async Code
Spans are scoped contexts that carry fields across function calls and .await points — like ILogger.BeginScope() but async-safe.
// C# — Activity / BeginScope
using var activity = new Activity("ProcessOrder").Start();
activity.SetTag("order_id", orderId);
using (_logger.BeginScope(new Dictionary<string, object> { ["OrderId"] = orderId }))
{
_logger.LogInformation("Starting processing");
await ProcessPaymentAsync();
_logger.LogInformation("Payment complete"); // OrderId still in scope
}
#![allow(unused)]
fn main() {
use tracing::{info, instrument, Instrument};
// #[instrument] automatically creates a span with function args as fields
#[instrument(skip(db), fields(customer_name))]
async fn process_order(order_id: u64, db: &Database) -> Result<(), AppError> {
let order = db.get_order(order_id).await?;
// Add a field to the current span dynamically
tracing::Span::current().record("customer_name", &order.customer_name.as_str());
info!("Starting processing");
process_payment(&order).await?; // span context preserved across .await!
info!(items = order.items.len(), "Payment complete");
Ok(())
}
// Every log message inside this function automatically includes:
// order_id=12345 customer_name="Alice"
// Even in nested async calls!
// Manual span creation (like BeginScope)
async fn batch_process(orders: Vec<u64>, db: &Database) {
for order_id in orders {
let span = tracing::info_span!("process_order", order_id);
// .instrument(span) attaches the span to the future
process_order(order_id, db)
.instrument(span)
.await
.unwrap_or_else(|e| error!("Failed: {e}"));
}
}
}
Subscriber Configuration (Like Serilog Sinks)
#![allow(unused)]
fn main() {
use tracing_subscriber::{fmt, EnvFilter, layer::SubscriberExt, util::SubscriberInitExt};
fn init_tracing() {
// Development: human-readable, colored output
tracing_subscriber::registry()
.with(EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "my_app=debug,tower_http=info".into()))
.with(fmt::layer().pretty()) // Colored, indented spans
.init();
}
fn init_tracing_production() {
// Production: JSON output for log aggregation (like Serilog JSON sink)
tracing_subscriber::registry()
.with(EnvFilter::new("my_app=info"))
.with(fmt::layer().json()) // Structured JSON
.init();
// Output: {"timestamp":"...","level":"INFO","fields":{"order_id":123},...}
}
}
# Control log levels via environment variable (like Serilog MinimumLevel)
RUST_LOG=my_app=debug,hyper=warn cargo run
RUST_LOG=trace cargo run # everything
Serilog → tracing Migration Cheat Sheet
| Serilog / ILogger | tracing | Notes |
|---|---|---|
Log.Information("{Key}", val) | info!(key = val, "message") | Fields are typed, not interpolated |
Log.ForContext("Key", val) | span.record("key", val) | Add fields to current span |
using BeginScope(...) | #[instrument] or info_span!() | Automatic with #[instrument] |
.WriteTo.Console() | fmt::layer() | Human-readable |
.WriteTo.Seq() / .File() | fmt::layer().json() + file redirect | Or use tracing-appender |
.Enrich.WithProperty() | span!(Level::INFO, "name", key = val) | Span fields |
LogEventLevel.Debug | tracing::Level::DEBUG | Same concept |
{@Object} destructuring | field = ?value (Debug) or %value (Display) | ? = Debug, % = Display |
OpenTelemetry Integration
# For distributed tracing (like System.Diagnostics + OTLP exporter)
[dependencies]
tracing-opentelemetry = "0.22"
opentelemetry = "0.21"
opentelemetry-otlp = "0.14"
#![allow(unused)]
fn main() {
// Add OpenTelemetry layer alongside console output
use tracing_opentelemetry::OpenTelemetryLayer;
fn init_otel() {
let tracer = opentelemetry_otlp::new_pipeline()
.tracing()
.with_exporter(opentelemetry_otlp::new_exporter().tonic())
.install_batch(opentelemetry_sdk::runtime::Tokio)
.expect("Failed to create OTLP tracer");
tracing_subscriber::registry()
.with(OpenTelemetryLayer::new(tracer)) // Send spans to Jaeger/Tempo
.with(fmt::layer()) // Also print to console
.init();
}
// Now #[instrument] spans automatically become distributed traces!
}
Rust Tooling Ecosystem
Essential Rust Tooling for C# Developers
What you’ll learn: Rust’s development tools mapped to their C# equivalents — Clippy (Roslyn analyzers), rustfmt (dotnet format), cargo doc (XML docs), cargo watch (dotnet watch), and VS Code extensions.
Difficulty: 🟢 Beginner
Tool Comparison
| C# Tool | Rust Equivalent | Install | Purpose |
|---|---|---|---|
| Roslyn analyzers | Clippy | rustup component add clippy | Lint + style suggestions |
dotnet format | rustfmt | rustup component add rustfmt | Auto-formatting |
| XML doc comments | cargo doc | Built-in | Generate HTML docs |
| OmniSharp / Roslyn | rust-analyzer | VS Code extension | IDE support |
dotnet watch | cargo-watch | cargo install cargo-watch | Auto-rebuild on save |
| — | cargo-expand | cargo install cargo-expand | See macro expansion |
dotnet audit | cargo-audit | cargo install cargo-audit | Security vulnerability scan |
Clippy: Your Automated Code Reviewer
# Run Clippy on your project
cargo clippy
# Treat warnings as errors (CI/CD)
cargo clippy -- -D warnings
# Auto-fix suggestions
cargo clippy --fix
#![allow(unused)]
fn main() {
// Clippy catches hundreds of anti-patterns:
// Before Clippy:
if x == true { } // warning: equality check with bool
let _ = vec.len() == 0; // warning: use .is_empty() instead
for i in 0..vec.len() { } // warning: use .iter().enumerate()
// After Clippy suggestions:
if x { }
let _ = vec.is_empty();
for (i, item) in vec.iter().enumerate() { }
}
rustfmt: Consistent Formatting
# Format all files
cargo fmt
# Check formatting without changing (CI/CD)
cargo fmt -- --check
# rustfmt.toml — customize formatting (like .editorconfig)
max_width = 100
tab_spaces = 4
use_field_init_shorthand = true
cargo doc: Documentation Generation
# Generate and open docs (including dependencies)
cargo doc --open
# Run documentation tests
cargo test --doc
#![allow(unused)]
fn main() {
/// Calculate the area of a circle.
///
/// # Arguments
/// * `radius` - The radius of the circle (must be non-negative)
///
/// # Examples
/// ```
/// let area = my_crate::circle_area(5.0);
/// assert!((area - 78.54).abs() < 0.01);
/// ```
///
/// # Panics
/// Panics if `radius` is negative.
pub fn circle_area(radius: f64) -> f64 {
assert!(radius >= 0.0, "radius must be non-negative");
std::f64::consts::PI * radius * radius
}
// The code in /// ``` blocks is compiled and run during `cargo test`!
}
cargo watch: Auto-Rebuild
# Rebuild on file changes (like dotnet watch)
cargo watch -x check # Type-check only (fastest)
cargo watch -x test # Run tests on save
cargo watch -x 'run -- args' # Run program on save
cargo watch -x clippy # Lint on save
cargo expand: See What Macros Generate
# See the expanded output of derive macros
cargo expand --lib # Expand lib.rs
cargo expand module_name # Expand specific module
Recommended VS Code Extensions
| Extension | Purpose |
|---|---|
| rust-analyzer | Code completion, inline errors, refactoring |
| CodeLLDB | Debugger (like Visual Studio debugger) |
| Even Better TOML | Cargo.toml syntax highlighting |
| crates | Show latest crate versions in Cargo.toml |
| Error Lens | Inline error/warning display |
For deeper exploration of advanced topics mentioned in this guide, see the companion training documents:
- Rust Patterns — Pin projections, custom allocators, arena patterns, lock-free data structures, and advanced unsafe patterns
- Async Rust Training — Deep dive into tokio, async cancellation safety, stream processing, and production async architectures
- Rust Training for C++ Developers — Useful if your team also has C++ experience; covers move semantics mapping, RAII differences, and template vs generics
- Rust Training for C Developers — Relevant for interop scenarios; covers FFI patterns, embedded Rust debugging, and
no_stdprogramming
17. Capstone Project: Build a CLI Weather Tool
Capstone Project: Build a CLI Weather Tool
What you’ll learn: How to combine everything — structs, traits, error handling, async, modules, serde, and CLI argument parsing — into a working Rust application. This mirrors the kind of tool a C# developer would build with
HttpClient,System.Text.Json, andSystem.CommandLine.Difficulty: 🟡 Intermediate
This capstone pulls together concepts from every part of the book. You’ll build weather-cli, a command-line tool that fetches weather data from an API and displays it. The project is structured as a mini-crate with proper module layout, error types, and tests.
Project Overview
graph TD
CLI["main.rs\nclap CLI parser"] --> Client["client.rs\nreqwest + tokio"]
Client -->|"HTTP GET"| API["Weather API"]
Client -->|"JSON → struct"| Model["weather.rs\nserde Deserialize"]
Model --> Display["display.rs\nfmt::Display"]
CLI --> Err["error.rs\nthiserror"]
Client --> Err
style CLI fill:#bbdefb,color:#000
style Err fill:#ffcdd2,color:#000
style Model fill:#c8e6c9,color:#000
What you’ll build:
$ weather-cli --city "Seattle"
🌧 Seattle: 12°C, Overcast clouds
Humidity: 82% Wind: 5.4 m/s
Concepts exercised:
| Book Chapter | Concept Used Here |
|---|---|
| Ch05 (Structs) | WeatherReport, Config data types |
| Ch08 (Modules) | src/lib.rs, src/client.rs, src/display.rs |
| Ch09 (Errors) | Custom WeatherError with thiserror |
| Ch10 (Traits) | Display impl for formatted output |
| Ch11 (From/Into) | JSON deserialization via serde |
| Ch12 (Iterators) | Processing API response arrays |
| Ch13 (Async) | reqwest + tokio for HTTP calls |
| Ch14-1 (Testing) | Unit tests + integration test |
Step 1: Project Setup
cargo new weather-cli
cd weather-cli
Add dependencies to Cargo.toml:
[package]
name = "weather-cli"
version = "0.1.0"
edition = "2021"
[dependencies]
clap = { version = "4", features = ["derive"] } # CLI args (like System.CommandLine)
reqwest = { version = "0.12", features = ["json"] } # HTTP client (like HttpClient)
serde = { version = "1", features = ["derive"] } # Serialization (like System.Text.Json)
serde_json = "1"
thiserror = "2" # Error types
tokio = { version = "1", features = ["full"] } # Async runtime
// C# equivalent dependencies:
// dotnet add package System.CommandLine
// dotnet add package System.Net.Http.Json
// (System.Text.Json and HttpClient are built-in)
Step 2: Define Your Data Types
Create src/weather.rs:
#![allow(unused)]
fn main() {
use serde::Deserialize;
/// Raw API response (matches JSON shape)
#[derive(Deserialize, Debug)]
pub struct ApiResponse {
pub main: MainData,
pub weather: Vec<WeatherCondition>,
pub wind: WindData,
pub name: String,
}
#[derive(Deserialize, Debug)]
pub struct MainData {
pub temp: f64,
pub humidity: u32,
}
#[derive(Deserialize, Debug)]
pub struct WeatherCondition {
pub description: String,
pub icon: String,
}
#[derive(Deserialize, Debug)]
pub struct WindData {
pub speed: f64,
}
/// Our domain type (clean, decoupled from API)
#[derive(Debug, Clone)]
pub struct WeatherReport {
pub city: String,
pub temp_celsius: f64,
pub description: String,
pub humidity: u32,
pub wind_speed: f64,
}
impl From<ApiResponse> for WeatherReport {
fn from(api: ApiResponse) -> Self {
let description = api.weather
.first()
.map(|w| w.description.clone())
.unwrap_or_else(|| "Unknown".to_string());
WeatherReport {
city: api.name,
temp_celsius: api.main.temp,
description,
humidity: api.main.humidity,
wind_speed: api.wind.speed,
}
}
}
}
// C# equivalent:
// public record ApiResponse(MainData Main, List<WeatherCondition> Weather, ...);
// public record WeatherReport(string City, double TempCelsius, ...);
// Manual mapping or AutoMapper
Key difference: #[derive(Deserialize)] + From impl replaces C#’s JsonSerializer.Deserialize<T>() + AutoMapper. Both happen at compile time in Rust — no reflection.
Step 3: Error Type
Create src/error.rs:
#![allow(unused)]
fn main() {
use thiserror::Error;
#[derive(Error, Debug)]
pub enum WeatherError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("City not found: {0}")]
CityNotFound(String),
#[error("API key not set — export WEATHER_API_KEY")]
MissingApiKey,
}
pub type Result<T> = std::result::Result<T, WeatherError>;
}
Step 4: HTTP Client
Create src/client.rs:
#![allow(unused)]
fn main() {
use crate::error::{WeatherError, Result};
use crate::weather::{ApiResponse, WeatherReport};
pub struct WeatherClient {
api_key: String,
http: reqwest::Client,
}
impl WeatherClient {
pub fn new(api_key: String) -> Self {
WeatherClient {
api_key,
http: reqwest::Client::new(),
}
}
pub async fn get_weather(&self, city: &str) -> Result<WeatherReport> {
let url = format!(
"https://api.openweathermap.org/data/2.5/weather?q={}&appid={}&units=metric",
city, self.api_key
);
let response = self.http.get(&url).send().await?;
if response.status() == reqwest::StatusCode::NOT_FOUND {
return Err(WeatherError::CityNotFound(city.to_string()));
}
let api_data: ApiResponse = response.json().await?;
Ok(WeatherReport::from(api_data))
}
}
}
// C# equivalent:
// var response = await _httpClient.GetAsync(url);
// if (response.StatusCode == HttpStatusCode.NotFound)
// throw new CityNotFoundException(city);
// var data = await response.Content.ReadFromJsonAsync<ApiResponse>();
Key differences:
?operator replacestry/catch— errors propagate automatically viaResultWeatherReport::from(api_data)uses theFromtrait instead of AutoMapper- No
IHttpClientFactory—reqwest::Clienthandles connection pooling internally
Step 5: Display Formatting
Create src/display.rs:
#![allow(unused)]
fn main() {
use std::fmt;
use crate::weather::WeatherReport;
impl fmt::Display for WeatherReport {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
let icon = weather_icon(&self.description);
writeln!(f, "{} {}: {:.0}°C, {}",
icon, self.city, self.temp_celsius, self.description)?;
write!(f, " Humidity: {}% Wind: {:.1} m/s",
self.humidity, self.wind_speed)
}
}
fn weather_icon(description: &str) -> &str {
let desc = description.to_lowercase();
if desc.contains("clear") { "☀️" }
else if desc.contains("cloud") { "☁️" }
else if desc.contains("rain") || desc.contains("drizzle") { "🌧" }
else if desc.contains("snow") { "❄️" }
else if desc.contains("thunder") { "⛈" }
else { "🌡" }
}
}
Step 6: Wire It All Together
src/lib.rs:
#![allow(unused)]
fn main() {
pub mod client;
pub mod display;
pub mod error;
pub mod weather;
}
src/main.rs:
use clap::Parser;
use weather_cli::{client::WeatherClient, error::WeatherError};
#[derive(Parser)]
#[command(name = "weather-cli", about = "Fetch weather from the command line")]
struct Cli {
/// City name to look up
#[arg(short, long)]
city: String,
}
#[tokio::main]
async fn main() {
let cli = Cli::parse();
let api_key = match std::env::var("WEATHER_API_KEY") {
Ok(key) => key,
Err(_) => {
eprintln!("Error: {}", WeatherError::MissingApiKey);
std::process::exit(1);
}
};
let client = WeatherClient::new(api_key);
match client.get_weather(&cli.city).await {
Ok(report) => println!("{report}"),
Err(WeatherError::CityNotFound(city)) => {
eprintln!("City not found: {city}");
std::process::exit(1);
}
Err(e) => {
eprintln!("Error: {e}");
std::process::exit(1);
}
}
}
Step 7: Tests
#![allow(unused)]
fn main() {
// In src/weather.rs or tests/weather_test.rs
#[cfg(test)]
mod tests {
use super::*;
fn sample_api_response() -> ApiResponse {
serde_json::from_str(r#"{
"main": {"temp": 12.3, "humidity": 82},
"weather": [{"description": "overcast clouds", "icon": "04d"}],
"wind": {"speed": 5.4},
"name": "Seattle"
}"#).unwrap()
}
#[test]
fn api_response_to_weather_report() {
let report = WeatherReport::from(sample_api_response());
assert_eq!(report.city, "Seattle");
assert!((report.temp_celsius - 12.3).abs() < 0.01);
assert_eq!(report.description, "overcast clouds");
}
#[test]
fn display_format_includes_icon() {
let report = WeatherReport {
city: "Test".into(),
temp_celsius: 20.0,
description: "clear sky".into(),
humidity: 50,
wind_speed: 3.0,
};
let output = format!("{report}");
assert!(output.contains("☀️"));
assert!(output.contains("20°C"));
}
#[test]
fn empty_weather_array_defaults_to_unknown() {
let json = r#"{
"main": {"temp": 0.0, "humidity": 0},
"weather": [],
"wind": {"speed": 0.0},
"name": "Nowhere"
}"#;
let api: ApiResponse = serde_json::from_str(json).unwrap();
let report = WeatherReport::from(api);
assert_eq!(report.description, "Unknown");
}
}
}
Final File Layout
weather-cli/
├── Cargo.toml
├── src/
│ ├── main.rs # CLI entry point (clap)
│ ├── lib.rs # Module declarations
│ ├── client.rs # HTTP client (reqwest + tokio)
│ ├── weather.rs # Data types + From impl + tests
│ ├── display.rs # Display formatting
│ └── error.rs # WeatherError + Result alias
└── tests/
└── integration.rs # Integration tests
Compare to the C# equivalent:
WeatherCli/
├── WeatherCli.csproj
├── Program.cs
├── Services/
│ └── WeatherClient.cs
├── Models/
│ ├── ApiResponse.cs
│ └── WeatherReport.cs
└── Tests/
└── WeatherTests.cs
The Rust version is remarkably similar in structure. The main differences are:
moddeclarations instead of namespacesResult<T, E>instead of exceptionsFromtrait instead of AutoMapper- Explicit
#[tokio::main]instead of built-in async runtime
Bonus: Integration Test Stub
Create tests/integration.rs to test the public API without hitting a real server:
#![allow(unused)]
fn main() {
// tests/integration.rs
use weather_cli::weather::WeatherReport;
#[test]
fn weather_report_display_roundtrip() {
let report = WeatherReport {
city: "Seattle".into(),
temp_celsius: 12.3,
description: "overcast clouds".into(),
humidity: 82,
wind_speed: 5.4,
};
let output = format!("{report}");
assert!(output.contains("Seattle"));
assert!(output.contains("12°C"));
assert!(output.contains("82%"));
}
}
Run with cargo test — Rust discovers tests in both src/ (#[cfg(test)] modules) and tests/ (integration tests) automatically. No test framework configuration needed — compare that to setting up xUnit/NUnit in C#.
Extension Challenges
Once it works, try these to deepen your skills:
-
Add caching — Store the last API response in a file. On startup, check if it’s less than 10 minutes old and skip the HTTP call. This exercises
std::fs,serde_json::to_writer, andSystemTime. -
Add multiple cities — Accept
--city "Seattle,Portland,Vancouver"and fetch all concurrently withtokio::join!. This exercises concurrent async. -
Add a
--format jsonflag — Output the report as JSON instead of human-readable text usingserde_json::to_string_pretty. This exercises conditional formatting andSerialize. -
Write an integration test — Create
tests/integration.rsthat tests the full flow with a mock HTTP server usingwiremock. This exercises thetests/directory pattern from ch14-1.