Rust is a systems programming language focused on safety, speed, and concurrency. It achieves memory safety without a garbage collector through its unique ownership model, and its rich type system catches whole classes of bugs at compile time. These notes target the Rust 2021 edition.
Why Rust?
Rust gives you low-level control like C/C++ but guarantees no data races, no use-after-free, and no null-pointer dereferences at compile time. If it compiles, an entire category of runtime crashes is already ruled out.
Variables and Mutability
Bindings are created with let and are immutable by default. Add mut to allow reassignment. Shadowing lets you redeclare a name (even with a new type), and const declares compile-time constants that must be type-annotated.
let x = 5; // immutable
let mut y = 10; // mutable
y += 1;
let x = x + 1; // shadowing: new binding, x is now 6
let x = "now a string"; // shadowing can change type
const MAX_POINTS: u32 = 100_000; // must be UPPER_SNAKE_CASE, typed
Scalar and Compound Types
Scalar types: integers (i8..i128, u8..u128, isize/usize), floats (f32, f64), bool, and char (a 4-byte Unicode scalar). Compound types are tuples and arrays.
let tup: (i32, f64, char) = (500, 6.4, 'z');
let (a, b, c) = tup; // destructuring
let first = tup.0; // index access
let arr: [i32; 5] = [1, 2, 3, 4, 5]; // fixed-size, stack allocated
let zeros = [0; 3]; // [0, 0, 0]
let slice = &arr[1..3]; // slice: &[i32] = [2, 3]
Functions and Control Flow
Functions use fn. The last expression without a semicolon is the return value. if is an expression, and Rust has loop, while, and for.
fn add(a: i32, b: i32) -> i32 {
a + b // no semicolon = returned expression
}
let n = if add(2, 3) > 4 { "big" } else { "small" }; // if is an expression
for i in 0..5 { println!("{i}"); } // range 0..4
for item in &vec { /* borrow each */ }
let result = loop { // loop can return a value
break 42;
};
Ownership
Ownership is Rust's central feature. Every value has a single owner; when the owner goes out of scope, the value is dropped (freed). Assigning or passing a heap value moves ownership, invalidating the original binding. Types that implement Copy (integers, bools, chars, floats, and tuples of such) are copied instead of moved.
let s1 = String::from("hello");
let s2 = s1; // MOVE: s1 is no longer valid
// println!("{s1}"); // compile error: value borrowed after move
let s3 = s2.clone(); // deep copy: both s2 and s3 valid
let n1 = 5;
let n2 = n1; // COPY: i32 is Copy, n1 still valid
println!("{n1} {n2}");
The three ownership rules
1) Each value has exactly one owner. 2) There can only be one owner at a time. 3) When the owner goes out of scope, the value is dropped.
Borrowing and References
Instead of moving, you can borrow a value with a reference &T (shared) or &mut T (mutable). The borrow checker enforces that at any given time you may have either one mutable reference or any number of shared references, but never both. This "one-mut-XOR-many-shared" rule is what prevents data races.
fn len(s: &String) -> usize { s.len() } // borrows, does not take ownership
let mut s = String::from("hi");
let r1 = &s; // shared borrow
let r2 = &s; // another shared borrow is fine
println!("{r1} {r2}");
let m = &mut s; // exclusive mutable borrow
m.push_str(" there");
// let bad = &s; // error: cannot borrow while &mut is live
| Rule | Meaning |
|---|---|
&T | Any number of shared (read-only) references allowed at once. |
&mut T | Exactly one mutable reference, and no shared references simultaneously. |
| Validity | A reference must never outlive the value it points to (no dangling refs). |
Lifetimes Basics
Lifetimes are named regions of code (written 'a) that describe how long references are valid. Most are inferred, but you sometimes annotate them when a function returns a reference derived from its inputs, so the compiler knows the returned reference lives at least as long as the inputs.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
if x.len() > y.len() { x } else { y }
}
struct Excerpt<'a> { part: &'a str } // struct holding a reference
Structs and Enums
Structs group related data; enums encode a value that is one of several variants, and each variant can carry data. Methods are defined in impl blocks.
struct Point { x: f64, y: f64 }
impl Point {
fn new(x: f64, y: f64) -> Self { Point { x, y } } // associated fn
fn dist(&self) -> f64 { (self.x * self.x + self.y * self.y).sqrt() }
}
enum Shape {
Circle(f64), // tuple variant
Rect { w: f64, h: f64 }, // struct variant
Unit, // unit variant
}
Pattern Matching
match is exhaustive and powerful, destructuring values as it branches. if let and while let handle single-pattern cases concisely.
fn area(s: &Shape) -> f64 {
match s {
Shape::Circle(r) => std::f64::consts::PI * r * r,
Shape::Rect { w, h } => w * h,
Shape::Unit => 0.0,
}
}
if let Some(x) = maybe_value { println!("{x}"); } // only the Some case
while let Some(top) = stack.pop() { process(top); }
let (a, .., z) = (1, 2, 3, 4); // destructure with rest
match n { 1 | 2 => "low", 3..=9 => "mid", _ => "high" };
Option and Result, and the ? Operator
Rust has no null. Absence is modeled by Option<T> (Some(T) or None) and recoverable errors by Result<T, E> (Ok(T) or Err(E)). The ? operator propagates errors: it returns early with the Err/None or unwraps the success value.
fn parse_add(a: &str, b: &str) -> Result<i32, std::num::ParseIntError> {
let x: i32 = a.parse()?; // returns Err early if parse fails
let y: i32 = b.parse()?;
Ok(x + y)
}
let v = Some(3).map(|n| n * 2).unwrap_or(0); // 6
let first = list.first().copied(); // Option<i32>
Error Handling
For unrecoverable errors, panic! aborts the current thread. For recoverable ones, return Result<T, E>. You can define custom error enums implementing std::error::Error. In practice, the thiserror crate reduces boilerplate for library error types, and anyhow provides an ergonomic catch-all error type for applications.
#[derive(thiserror::Error, Debug)]
enum ConfigError {
#[error("missing field: {0}")]
Missing(String),
#[error(transparent)]
Io(#[from] std::io::Error), // auto-convert with ?
}
// anyhow in an application:
fn run() -> anyhow::Result<()> {
let data = std::fs::read_to_string("cfg.toml")?; // any error -> anyhow::Error
Ok(())
}
Traits
Traits define shared behavior (like interfaces). Implement them with impl Trait for Type. Traits can provide default methods, and common ones can be auto-generated with #[derive(...)]. Trait objects (Box<dyn Trait>) enable dynamic dispatch over heterogeneous types.
trait Speak {
fn name(&self) -> String;
fn greet(&self) -> String { format!("Hi, {}", self.name()) } // default
}
#[derive(Debug, Clone, PartialEq)]
struct Dog { id: u32 }
impl Speak for Dog {
fn name(&self) -> String { format!("Dog#{}", self.id) }
}
let animals: Vec<Box<dyn Speak>> = vec![Box::new(Dog { id: 1 })]; // dynamic dispatch
Generics and Trait Bounds
Generics avoid duplication. Constrain type parameters with trait bounds inline (T: Ord) or in a where clause for readability. Generics use static dispatch (monomorphization), so there is no runtime cost.
fn largest<T: PartialOrd + Copy>(list: &[T]) -> T {
let mut max = list[0];
for &item in list { if item > max { max = item; } }
max
}
fn print_all<T>(items: &[T])
where
T: std::fmt::Display,
{
for it in items { println!("{it}"); }
}
Collections
The standard library provides growable, heap-allocated collections. Vec<T> is a dynamic array, String is a growable UTF-8 string, and HashMap<K, V> maps keys to values. Slices (&[T], &str) are borrowed views into contiguous data.
let mut v: Vec<i32> = vec![1, 2, 3];
v.push(4);
let slice: &[i32] = &v[1..]; // borrowed view
let mut s = String::from("Hello");
s.push_str(", world");
use std::collections::HashMap;
let mut scores: HashMap<String, i32> = HashMap::new();
scores.insert("blue".into(), 10);
*scores.entry("blue".into()).or_insert(0) += 5; // upsert
Iterators and Closures
Iterators are lazy: adapters like map and filter do nothing until a consumer like collect, sum, or a for loop drives them. Closures (|args| body) capture their environment and are the arguments these adapters take.
let nums = vec![1, 2, 3, 4, 5, 6];
let evens_squared: Vec<i32> = nums
.iter()
.filter(|&&n| n % 2 == 0) // lazy
.map(|&n| n * n) // lazy
.collect(); // consumer: runs the chain -> [4, 16, 36]
let total: i32 = nums.iter().sum();
let factor = 10;
let scale = |x: i32| x * factor; // closure captures `factor`
Smart Pointers
Smart pointers own data on the heap and add capabilities. Choose based on ownership needs and thread safety.
| Type | Purpose |
|---|---|
Box<T> | Single-owner heap allocation; used for recursion and trait objects. |
Rc<T> | Reference-counted shared ownership, single-threaded only. |
Arc<T> | Atomically reference-counted; shared ownership across threads. |
RefCell<T> | Interior mutability with borrow rules checked at runtime. |
use std::rc::Rc;
use std::cell::RefCell;
let shared = Rc::new(RefCell::new(vec![1, 2]));
let clone = Rc::clone(&shared); // increments ref count
clone.borrow_mut().push(3); // mutate through shared handle
println!("{:?}", shared.borrow()); // [1, 2, 3]
Concurrency
Rust's ownership rules deliver fearless concurrency: data races are compile errors. Spawn threads with thread::spawn and a move closure to transfer ownership. Share mutable state across threads with Arc<Mutex<T>>, or communicate over channels. For I/O-bound work, use async/.await with a runtime such as tokio.
use std::sync::{Arc, Mutex};
use std::thread;
let counter = Arc::new(Mutex::new(0));
let mut handles = vec![];
for _ in 0..4 {
let c = Arc::clone(&counter);
handles.push(thread::spawn(move || {
*c.lock().unwrap() += 1; // move closure takes ownership of `c`
}));
}
for h in handles { h.join().unwrap(); }
println!("{}", *counter.lock().unwrap()); // 4
// Channels
use std::sync::mpsc;
let (tx, rx) = mpsc::channel();
thread::spawn(move || tx.send("ping").unwrap());
println!("{}", rx.recv().unwrap());
// Async with tokio
#[tokio::main]
async fn main() {
let body = fetch().await; // suspends without blocking the thread
}
Send and Sync
The marker traits Send (safe to move to another thread) and Sync (safe to share by reference across threads) are how the compiler statically rejects unsafe sharing. This is why Rc won't compile across threads but Arc will.
Cargo and Tooling
Cargo is Rust's build system and package manager. Dependencies (crates) are declared in Cargo.toml and pulled from crates.io.
| Command | Description |
|---|---|
cargo new / init | Create a new project / initialize in the current dir. |
cargo build | Compile (add --release for optimized builds). |
cargo run | Build and run the binary. |
cargo test | Run unit and integration tests. |
cargo clippy | Lint for common mistakes and idiom improvements. |
cargo fmt | Auto-format code with rustfmt. |
Practice Exercises
- Write a function that takes ownership of a
String, then rewrite it to borrow instead. Explain in a comment why the borrowing version lets the caller keep using the value. - Implement an enum
ShapewithCircleandRectanglevariants and anarea()method using amatch. - Write a function returning
Result<i32, String>that parses two strings and adds them, using the?operator to propagate parse errors. - Define a trait
Summarywith a default method, implement it for two structs, and store both in aVec<Box<dyn Summary>>. - Given a
Vec<i32>, use an iterator chain to produce a new vector containing the squares of only the odd numbers. - Spawn four threads that each increment a shared counter guarded by
Arc<Mutex<i32>>, join them, and print the final value.