Working through examples in the Rust Book
Cargo.lockkeeps track of all your dependencies for your rust project. Never have to manually alter.- Time taken for
cargo build>>> Time taken forcargo checkbuildproduces an executable at the end.checkis for iterative development to check for compilation.
- Set of items that are brought into every rust program is called the prelude [Link]
Cargo.tomldependencies use semantic versioning, which means:-
0.8.5 is actually shorthand for ^0.8.5, which means any version that is at least 0.8.5 but below 0.9.0.
-
- After building for the first time, a frozen set of versions is written to
Cargo.lock, so on future fresh builds, ifCargo.lockis present we get a reproducible build. cargo updatewill ignoreCargo.lockand find the most updated packages that are in complaince with yourCargo.toml
-
You can shadow a variable by doing:
let x = 5; let x = x + 1; // You can change the data type let spaces = " " let spaces = spaces.len()
-
We make a "new" variable that wil take it's spot for any future references.
-
When compiling/building with
--releaseflag, Rust does not include checks for integer overflow. So, if integer overflow happens, Rust will not panic and silently wrap around. -
Tuple Type: Once declared, cannot grow in size.
let x: (i32, f64, u8) = (500, 6.4, 1); let five_hundred = x.0; let six_point_four = x.1; let one = x.2;
-
Array Syntax:
let a: [i32; 5] = [1, 2, 3, 4, 5]; let a = [3; 5]; // [3, 3, 3, 3, 3]
-
ifin aletStatement:let condition = true; let number = if condition { 5 } else { 6 };
-
Rust supports
loop {},while {},for _ in (lb, ub+1) {}or(lb, ub+1).rev().
-
Ownership Rules
- Each value in Rust has an owner
- There can only be one owner at a time
- When the owner goes out of scope, the value will be dropped.
-
Copytrait: The variables that use it do not move, but are trivially copied, because their size is known at compile-time. Example:let x = 5; let y = x; println!("x = {x}, y = {y}"); // x = 5, y = 5
-
The same struct/type cannot implement the
Drop+Copytrait.Copyis a trivial duplication with no cleanupDropmeans guaranteed cleanup excatly once
-
Theoretically if both traits were implemented:
- The compiler would generate implicit copies
- Each copy would run
drop()when it goes out of scope - Multiple destructor calls for the same logical resource.
-
Referencing =
&, Dereferencing =* -
The act of creating a reference is called borrowing, a reference cannot modify the value.
- Need to create a mutable reference, with
&mut varif you want to modify the underlying value. - Similar to write & read locks:
- You can issue as many read locks as long as no write locks have been issued
- But once a write lock has been issued, cannot give/acquire a read lock, till write lock has been released.
- Need to create a mutable reference, with
-
When a reference falls out of scope, the value being pointed to, is not dropped because the reference does not own the value.
-
Slices are non-owning references to contiguous segment of a collection.
-
Example: String Slice
let s = String::from("hello world"); let hello = &s[0..5]; let world = &s[6..11];
-
A slice is internally a pointer + length, representing
[start, end)in bytes. -
For example:
fn first_word(s: &String) -> &str { for (i, &b) in s.as_bytes().iter().enumerate() { if b == b' ' { return &s[0..i];} } &s[..] // s is one word with no spaces found } fn main() { let mut s = String::from("hello world"); let word = first_word(&s); s.clear() // compile-time error: cannot mutate while slice exists }
-
Structs vs. Tuples:
- Clarity: Fields have names, instead of tuples where you have to do
var_tuple.idx - Ordering: Structs do not care about ordering during instantiation
- Type Specificity: Each struct is it's own type, tuples are only distinguished by shape
- Clarity: Fields have names, instead of tuples where you have to do
-
Example:
#[derive(Debug)] struct User {active: bool, username: String, email: String, sign_in_count: u64} // Instantiation let u1 = User {active: true, username: String::from("u"), email: String::from("e"), sign_in_count: 1}; // Update (use the existing fields from u1 with ..u1) let u2 = User {email: String::from("another@email.com"), ..u1} // With the debug trait, we can do the following: println!("User is {u2:?}"); dbg!(&u2); // Unit Struct = No data but a useful marker for implementing traits struct AlwaysEqual;
-
Ownerships in Structs
- Using
Stringfields ensures that struct owns its data. References require explicit lifetimes. - Without lifetimes, the compiler cannot verify if the referenced data remains valid.
- Here is an example without lifetimes:
struct User<'a> { username: &'a str, email: &'a str, } fn main() { let username = String::from("name"); let email = String::from("name@gmail.com"); let u = User{username: &username, email: &email}; drop(email); // Releases memory that u still points to. println!("{}", u.username); // Points to freed memory }
- The
drop(email)will cause a compile-time error because of the explicit lifetime stated in User with'a. - User needs
'alifetime because the struct must not outlive the referenced data.
- Using
-
Structs are
ANDtypes:- A
Rectanglehas a width and a height.
- A
-
Enums are
ORtypes:- A
Shapeis aCircleor aRectangleor aPolygon
- A
-
Structs group different data types, Enums distinguish
-
Whenver an enum is handled, we need to handle each probable type (usually with match)
enum IpAddr { V4(u8, u8, u8, u8), V6(String), } impl IpAddr { fn display(&self) { match self { IpAddr::V4(a, b, c, d) => println!("Standard IPv4: {}.{}.{}.{}", a, b, c, d), IpAddr::V6(s) => println!("Standard IPv6: {}", s), } } fn is_addr6(&self) -> bool { match self { IpAddr::V6(_) => true, _ => false, // W/O this, compile-time error. Must handle every pattern. } } } fn main() { let localhost_v4 = IpAddr::V4(127, 0, 0, 1); let localhost_v6 = IpAddr::V6(String::from("::1")); localhost_v4.display(); println!("Is lcaolhost V6? {}", lcaolhost_v6.is_addr6()); // true }
-
Rust doesn't use null, instead it has:
// Option<T> is a Functor/Monad? enum Option<T> { None, // No Value Some(T), // Value Exists }
-
if letvsmatch:match config_max { Some(max) => println!("Max is {max}"), _ => (), } // Same functionality, but this does not handle config_max = None case. if let Some(max) = config_max { println!("Max is {max}"); }
- TBD
- Define new Vector with
let v: Vec<T> = Vec::new(); - If we have a reference to any element, we cannot push a new number to the end of the vector. Why?
- Assuning
let n = v.len();andlet m = v.capacity();:- If n+1 < m: Pushing a new element to
vwill work fine without affecting the reference. - If n+1 >= m: Vector
vmight have to be reallocated to a different contiguous location in memory. MAKING THE REFERENCE INVALID. So, Rust's borrow checker does not allow pushing new elements while there is a reference.
- If n+1 < m: Pushing a new element to
- Assuning
-
panic!is used for unrecoverable errors. -
By default, Rust will unwind the stack, running destructors as it walks up. This is costly, so you can set
panic = "abort"inCargo.toml(makes binary smaller and skips cleanup) -
Setting
RUST_BACKTRACE=1shows a backtrace of panic. -
Recoverable Errors
enum Result<T, E> { Ok(T), Err(E) } // How this looks for opening files: let f = match File::open("hello.txt") { Ok(file) => file, Err(error) => panic!("Problem: {error:?}"), }; // If you want to get more specific on the error block: let f = match File::open("hello.txt") { Ok(file) => file, Err(error) => match e.kind() { ErrorKind::NotFound => ..., _ => panic!("Other error: {e:?}"); }, };
-
unwrap()/unwrap_or_else()/expect():
// WITH Option<T>
let val = Some(3).unwrap(); // 3
let val = None.unwrap(); // panic: "called `Option::unwrap()`..."
let val = None.expect("value missing"); // panic: "value missing"
let val = None.unwrap_or_else(|| 10); // 10 (NO ARGUMENT TAKEN BECAUSE OPTION DOES NOT HAVE ERROR TYPE)
// WITH Result<T, E>
let x = Ok(3).unwrap(); // 3
let x = Err("err").unwrap(); // panic with generic error message
let x = Err("err").expect("Failed to compute"); // panic: "Failed to compute: err"
let x = Err("err").unwrap_or_else(|e| {
eprintln!("Got error: {e}");
10
}); // -> 10 (closure receives the error)-
THE
?OPERATOR- With
?operator, we can propogate any errors caught back to the caller.
fn read_username_from_file() -> Result<String, io::Error> { let mut f = File::open("hello.txt")?; let mut s = String::new(); f.read_to_string(&mut s)?; Ok(s) }
- You can use
?onResultONLY IN FUNCTIONS THAT RETURNResult - You can use
?onOptionONLY IN FUNCTIONS THAT RETURNOption
- With
-
Box<dyn Error>is a catch-all for all kinds of errors.
-
whereclause (readibility improvement)- Meant to visually separate the shape of function & constraints necessary for function to be valid.
pub fn some_function<T: Display + Clone, U: Clone + Debug>( t: &T, u: &U, ) -> String { // ... } pub fn some_function<T, U>(t: &T, u: &U) -> String where T: Display + Clone, U: Clone + Debug, { // ... }
-
Lifetimes
- Each reference has a lifetime
- Borrow Checker validates relationships between lifetimes
- Lifetimes DO NOT AFFECT RUNTIME
- IT IS A COMPILER CONSTRUCT
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str // THE RETURN VALUE MUST BE VALID FOR ATLEAST AS LONG AS BOTH INPUT REFERENCES
-
Lifetime Epsilon Rules
- Each reference parameter gets its own lifetime parameter
- If one input lifetime, it is assigned to all output lifetimes
- If a method has
&selfor&mut self, the lifetime of self is assigned to all output lifetimes.
-
Static Lifetime
- Refers to data that lives for the entire duration of the program.
- Data is stored in program's binary.
let s: &'static str = "I have a static lifetime.";
-
Combining Generics + Traits + Lifetimes
fn longest_with_an_announcement<'a, T>(x: &'a str, y: &'a str, ann: T) -> &'a str where T: Display, { ... } // T is a generic type parameter that requires implementation of Display // Function uses lifetime parameter 'a // ann must implement traits specified by T // Return reference is bound by 'a, independent of T lifetime.
- Using
Result<T, E>in Tests- Don't have to return anything, or they can return
Result<T,E> - If test returns
Ok(...)-> pass, returnsErr(...)-> fail.
- Don't have to return anything, or they can return
- Force Single Threaded Tests:
cargo test -- --test-threads=1 - Show Test Output:
cargo test -- --nocapture - Unit Tests live in same file as source code
- Integration Tests live at
my_project_root/tests
// ... source code beguins...
// This is a doc test that rust can compile and run when we do `cargo test`
/// Adds one.
/// ```
/// assert_eq!(my_crate::add_one(1), 2);
/// ```
// ... source code ends ...
#[cfg(test)]
mod tests {
fn test_...() {
}
}- To provide cmd line arguments, you can do -- arg1 arg2
arg1andarg2get ingested by your program instead ofcargo
0sStringcan be used for cases where input is invalid Unicode.
-
Closures:
- Like lambdas from Python/C++
- Stronger Ownership Semantics + Stricter Type Rules
- Closures can capture variables in 3 ways:
&TImmutable Borrow&mut T- Mutable BorrowT- Take Ownership
let v = vec![1,2,3]; let print_v = || println!("{:?}", v); // IMMUTABLE BORROW let mut push_v = || v.push(4); // MUTABLE BORROW thread::spawn(move || println!("{:?}", v)); // TAKE OWNERSHIP // Last one is required when there are multiple threads, prevents race conditions.
-
Iterators
- All iterators need to implement the
Iteratortrait:
fn next(&mut self) -> Option<Self::Item> {}
- Rust iterators do nothing until consumed.
- Categories:
- Consumers:
sum(),collect(),for - Adapters :
map,filter,enumerate, transform but do not consume data.
- Consumers:
- Adapters will create a pipeline of actions that need to be done when consumed.
- Iterator chains will perform same/better than hand-written loops.
- Example:
v.iter().map(|x| x + 1).filter(|x| x % 2 == 0).collect::<Vec<_>>();
- All iterators need to implement the
- TBD
-
2 important traits for Smart Pointers:
Deref: makes smart pointers act like references, enabling*Drop: runs custom logic when smart pointer goes out of scope
-
Common Smart Pointers in Rust std lib:
Box<T>- Heap AllocationRc<T>- Reference Counting + Shared OwnershipRef<T>/RefMut<T>- Runtime checked borrows enabling interior mutability
-
Box<T>: Heap Allocation-
Tneeds to be a type with a known compile-time size. -
fn main() { let b = Box::new(5); println!("b = {b}"); }
-
b stores a pointer on stack, 5 lives on heap. When dropped heap and pointer is freed.
-
Recursion +
Box<T>-
enum List { Cons(i32, List), Nil, } // This will not compile, because Rust Compiler will try to figure out size of List and do something like: // List::Cons = size_of::<i32> + size_of::<List> which endlessly loops // Instead we could do: enum List { Cons(i32, Box<List>), Nil, } // The size of Box<List> is known, so now // List::Cons = size_of::<i32> + size_of::<Box<List>> <- size_of::<pointer>
-
-
-
Deref-
Needed to make smart pointers behave like references
-
struct MyBox<T>(T); impl<T> MyBox<T> { fn new(x: T) -> MyBox<T> { MyBox(x) } } // Deref will return a reference, not the owned value impl<T> Deref for MyBox<T> { type Target = T; fn deref(&self) -> &Self::Target { &self.0 } } // Then we can do something like *mybox, which internally does: *(mybox.deref()) // Additionally, deref coercion, will automatically convert `&T` to `&U` if `T: Deref<Target = U>` fn hello(name: &str) { println!("Hello, {name}!"); } let m = MyBox::new(String::from("Rust")); hello(&m); // Finally, for cleanup: // We cannot explicitly call the destructor: m.drop(); // Instead, we rely on this to consume the value and force cleanup: std::mem::drop(m);
-
-
RC<T>= Reference Counter Smart Pointer (shared_ptr<T>C++ equivalent)- Rust default ownership is one owner per value, but sometimes you need multiple owners of the same heap value
- ONLY USE IN SINGLE-THREADED (NOT ATOMIC)
- CORE PROPERTIES:
- Internal Value = Strong Count (rc)
- If rc = 0, data is dropped.
- If
clone()called on pointer, rc++ - If
drop()called on pointer, rc--
-
RefCell<T>- Allows mutation through an immutable reference
- Borrow-Checking happens at runtime (failure will cause panic), instead of compile time (failure will cause compile-time error)
RefCell<T>key functions:borrow()-> Immtable, returnsRef<T>borrow_mut()-> Mutable, returnsRefMut<T>
Rc<RefCell<T>>can cause runtime panic, because reference counting allows multiple references and ref cell allows for mutability. SoRefCell<T>resolves to a mutable reference withborrow_mut(), thenRc<T>can create multiple mutable references to the same spot in the heap.Weak<T>:- To prevent cycles, we can define weak refs
Rc<T>clones will upgrade strong count and denote ownershipWeak<T>clones will NOT incrememnt strong count and denote non-owning references.
-
The Rust standard library uses a 1:1 model of thread implementation, whereby a program uses one operating system thread per one language thread.
-
Don't communicate between threads, by sharing memory, communicate by channels. Each channel has two parts: transmitter and receiver
use std::sync::mpsc; use std::thread; fn main() { let (tx, rx) = mpsc::channel(); thread::spawn(move || { let val = String::from("hi"); tx.send(val).unwrap(); // Send takes ownership of val // So if we try doing something like: // println!("Value: {val}") // it will not compile }); let received = rx.recv().unwrap(); println!("Got: {received}"); }
-
recv()is blocking,try_recv()is a non-blocking call that returnsResult<T, E> -
For Mutexs:
use std::sync::Mutex; fn main() { let m = Mutex::new(5); { let mut num = m.lock().unwrap(); // blocks until lock acquired *num = 6; // num: MutexGuard<i32> (IMPLEMENTS DEREF) } // lock released here println!("{:?}", m); }
-
Arc<T>: Atomic Reference Counting-
Updates to this pointer are atomic and instantly reflected across threads.
-
use std::sync::{Arc, Mutex}; use std::thread; fn main() { let counter = Arc::new(Mutex::new(0)); let mut handles = vec![]; for _ in 0..10 { let counter = Arc::clone(&counter); // cheap atomic increment let h = thread::spawn(move || { let mut num = counter.lock().unwrap(); *num += 1; }); handles.push(h); } for h in handles { h.join().unwrap(); } println!("Result: {}", *counter.lock().unwrap()); }
-
-
Rust Concurrency relies on data types implementing the two following marker traits:
SendandSyncSend: Can I move a value into a new thread, is that move safe?Sync: Is&Tsafe to share across multiple threads at the same time?
-
Parallelism: Multiple cores doing work simultaneously. Hardware.
-
Concurrency: Switching between multiple tasks logically at once. Software.
-
Async Rust is concurrency first.
-
Rust Futures are:
- Lazy: they don’t run until you .await.
- State machines: the compiler transforms async code into an enum-like type with states.
- Polled: the runtime repeatedly asks the future “are you ready yet?”
-
async fncompiles into normal function returningimpl Future<Output = T> -
awaitsuspends future, yields control to the runtime and later.asyncfunction is a state machine with a "resume here" every time you useawait. -
Examples:
async fn page_title(url: &str) -> Option<String> { let response = trpl::get(url).await?; // async HTTP GET let body = response.text().await?; // await body download // Parse HTML and find <title> let doc = Html::parse_document(&body); let selector = Selector::parse("title").ok()?; let el = doc.select(&selector).next()?; Some(el.inner_html()) } async fn fastest_title(url1: &str, url2: &str) -> Option<String> { let f1 = page_title(url1); let f2 = page_title(url2); match trpl::race(f1, f2).await { Either::Left(title) => title, // url1 Either::Right(title) => title, // url2 } } let fut = async { let x = compute().await; x + 1 }; // fut is a Future. Not running until awaited: let result = fut.await;
-
An async runtime is a queue of futures where each future is polled to see if it is
ReadyorPending. -
Example of Async Tasks:
let handle = trpl::spawn_task(async { /* work */ }); handle.await.unwrap();
-
Joining Futures:
let fut1 = async { ... }; let fut2 = async { ... }; trpl::join(fut1, fut2).await; // Deterministic Interleaving
- Joining Futures:
trpl::join!(a, b, c); // If # of futures known at compile-time // If # of futures unknwon at compile-time use std::pin::Pin; let futures: Vec<Pin<Box<dyn Future<Output = ()>>>> = vec![ Box::pin(f1), // Pinning prevents a value from being moved in memory once it's been pinned. Box::pin(f2), ]; trpl::join_all!(futures);
-
Racing Futures:
- poll multiple futures and return whoever is
Readyfirst, cancel the others:trpl::race(fut1, fut2).await;
- poll multiple futures and return whoever is
-
Streams = Async Iterators
Iterator::next()-> Sync,StreamExt::next()-> Async- Streams are used when data arrives over time
- Example:
use trpl::StreamExt; fn main() { trpl::run(async { let values = [1,2,3,4,5,6,7,8,9,10]; let iter = values.iter().map(|n| n * 2); let mut stream = trpl::stream_from_iter(iter); while let Some(value) = stream.next().await { println!("The value was: {value}"); } }); }
- Static Dispatch: Compile-Time with generics (
T: Trait) - Dynamic Dispatch: Vtable pointer at runtime (
dyn Trait) - Example:
trait Shape {
fn area(&self) -> f64;
}
// Static Dispatch
fn print_area<T: Shape>(shape: &T) {
println!("{}", shape.area());
}
// Dynamic Dispatch
fn print_area_dyn(shape: &dyn Shape) {
println!("{}", shape.area());
}
fn main() {
// Static Dispatch
print_area(&Circle { r: 10.0 });
print_area(&Square { s: 3.0 });
// Rust will generate two calls internally at compile-time:
// print_area_for_Circle(...)
// print_area_for_Square(...)
// Dynamic Dispatch
print_area_dyn(Box::new(Circle { r: 10.0 }));
print_area_dyn(Box::new(Square { s: 3.0 }));
// In this case, shape is a FAT POINTER
// FAT POINTERS = (data_ptr, vtable_ptr)
// Steps for dispatch:
// 1. load vtable pointer
// 2. Lookup offset of 'area'
// 3. jump to function impl
}Article 1: To panic or not to panic [Link]
- "You cannot, and should not pretend to, write completely panic-free Rust. But you must always design for panics consciously"
- Panics represent programmer bugs or broken invariants or undefined behavior, not runtime recoverable conditions.
Resultfor recoverable casespanic!orunwrap()for impossible logic paths
- Can intercept/catch panics at multiple levels:
- At thread level, we can use:
std::panic::catch_unwind( potential_panic_func() ) - At process level, add
catch_unwind(rest_of_code()), so any future code called by process is covered. - Q: What happens to
catch_unwind(...)when you call fork() later in the code?- The Parent Process and Child Process are completely isolated. So, the child will have it's own version of the panic and the child process crashing/panicing will never make it's way back to the parent
catch_unwind()
- The Parent Process and Child Process are completely isolated. So, the child will have it's own version of the panic and the child process crashing/panicing will never make it's way back to the parent
- At thread level, we can use:
- Example of panic catching code:
use std::panic;
fn start_application() {
println!("Application initializing...");
// Simulate subsystem that may panic
perform_critical_task();
println!("Application shutdown cleanly.");
}
fn perform_critical_task() {
// Unexpected internal bug
panic!("Subsystem failure detected!");
}
fn main() {
// Process-level panic guard: top-level boundary
let result = panic::catch_unwind(|| {
start_application();
});
match result {
Ok(_) => println!("Program exited normally."),
Err(_) => {
eprintln!("Panic captured at process level — shutting down gracefully.");
// Optional recovery actions (log, cleanup, restart logic)
}
}
println!("Main process recovered — exiting cleanly.");
}-
In
cargo.tomlwe can define two different panic behaviors.panic = unwindorpanic = abortpanic = unwind:- iteratively unwinds stack
- calls destructors for all in-scope objects (Look into
Drop) - Optionally lets upper layers catch panic via:
std::panic::catch_unwind() - Leads to larger binaries (because we include unwinding tables and destructors)
panic = abort:- catastrophic failure
- process terminates immediately
catch_unwind()will not work, even if included- Ideally used, when process is monitored externally (eg: Docker, Kubernetes)
- Smaller Binaries
-
Future Reading:
- [Panic Recovery in Rust-based
Embedded Systems]
- Q: What are Landing Pads in the context of unwinding panics.
- [Panics in Rust and How to Track Them]
- Q: How does the linker fit in the context of panics.
- [Panic Recovery in Rust-based
Embedded Systems]