Rust Basics: A Comprehensive Guide to Rust Fundamentals
Rust is a systems programming language known for its focus on memory safety, zero-cost abstractions, and concurrency without data races. In this guide, we’ll cover the core building blocks of Rust programming—from managing memory with ownership and borrowing to leveraging smart pointers, traits, strings, concurrency, metaprogramming, iterators, closures, and the standard library collections.
Table of Contents
- Ownership, Borrowing, and Lifetimes
- Smart Pointers
- Traits and Trait Objects
- Demystifying Strings
- Concurrency with
std::thread
- Metaprogramming and Macros
- Iterators and Closures
- Standard Library Collections
Ownership, Borrowing, and Lifetimes
Rust’s unique approach to memory safety is built on three interrelated concepts:
- Ownership: Each piece of data has a single owner. When the owner goes out of scope, the data is dropped.
- Borrowing: Instead of transferring ownership, you can lend references to data. There are immutable borrows (
&T
) that allow multiple readers, and mutable borrows (&mut T
) that allow one writer at a time. - Lifetimes: Lifetimes ensure that references remain valid as long as needed and no longer.
Example: Ownership and Moving
Imagine a treasure chest that only one pirate can own at a time.
fn main() {
let treasure = String::from("Gold Coins"); // treasure owns the data
let captain_treasure = treasure; // Ownership moves to captain_treasure
// println!("{}", treasure); // Error! The original owner no longer has access.
println!("Captain's treasure: {}", captain_treasure);
}
Example: Borrowing and Lifetimes
A pirate can lend a map without giving up the treasure.
fn main() {
let treasure = String::from("Gold Coins");
{
let map = &treasure; // Borrowing the treasure as an immutable reference
println!("Reading the treasure map: {}", map);
// `map` is valid only within this block.
}
// Now, treasure is free of borrows.
println!("Treasure is still safe: {}", treasure);
}
Example: Mutable Borrowing
A single pirate can modify the treasure, but only one mutable reference is allowed at a time.
fn main() {
let mut treasure = String::from("Gold Coins");
// Borrow immutably for a read
println!("Initial treasure: {}", &treasure);
{
let mut mutable_ref = &mut treasure; // Unique mutable borrow
mutable_ref.push_str(" and Silver Coins");
println!("Modified treasure: {}", mutable_ref);
} // mutable_ref goes out of scope here
// Now we can borrow again
println!("Final treasure: {}", treasure);
}
Smart Pointers
Smart pointers extend regular pointers with extra capabilities like heap allocation, shared ownership, and interior mutability.
Example: Box
– Heap Allocation
A Box<T>
moves data to the heap. Think of it as storing a heavy treasure off the ship’s deck.
fn main() {
let boxed_treasure = Box::new("Ancient Artifact");
println!("The treasure stored on the heap: {}", boxed_treasure);
}
Example: Rc
– Shared Ownership
Rc<T>
allows multiple owners. Imagine several pirates sharing the same treasure map without making copies.
use std::rc::Rc;
fn main() {
let shared_map = Rc::new("X marks the spot");
let pirate1 = Rc::clone(&shared_map);
let pirate2 = Rc::clone(&shared_map);
println!("Pirate 1 sees: {}", pirate1);
println!("Pirate 2 sees: {}", pirate2);
println!("Reference count: {}", Rc::strong_count(&shared_map));
}
Example: RefCell
– Interior Mutability
RefCell<T>
enables mutation even when data is borrowed immutably at compile time, with runtime checks. It’s like a locked chest that you can open (at runtime) to adjust its contents.
use std::cell::RefCell;
fn main() {
let treasure_map = RefCell::new("X marks the spot");
// Borrow mutably at runtime to update the map.
*treasure_map.borrow_mut() = "X marks the spot near the old oak tree";
println!("Updated treasure map: {}", treasure_map.borrow());
}
Example: Combining Rc
and RefCell
When multiple owners need to mutate shared data, combine Rc
and RefCell
.
use std::rc::Rc;
use std::cell::RefCell;
fn main() {
let shared_treasure = Rc::new(RefCell::new("Ancient Coins"));
let pirate1 = Rc::clone(&shared_treasure);
let pirate2 = Rc::clone(&shared_treasure);
*pirate1.borrow_mut() = "Ancient Coins with a Secret Mark";
println!("Pirate 2 sees: {}", pirate2.borrow());
}
Traits and Trait Objects
Traits define shared behavior, similar to interfaces in other languages. They enable polymorphism and code reuse.
Example: A Treasure Trait
Imagine different kinds of treasures that all can describe themselves and reveal their value.
trait Treasure {
fn description(&self) -> String;
fn value(&self) -> u32;
}
// Implementing the Treasure trait for a String.
impl Treasure for String {
fn description(&self) -> String {
format!("A shiny treasure: {}", self)
}
fn value(&self) -> u32 {
100 // Fixed value for this example.
}
}
// A struct representing a treasure map.
struct Map {
location: String,
multiplier: u32,
}
impl Treasure for Map {
fn description(&self) -> String {
format!("A map leading to: {}", self.location)
}
fn value(&self) -> u32 {
self.multiplier * 10
}
}
fn main() {
let treasures: Vec<Box<dyn Treasure>> = vec![
Box::new(String::from("Gold Coins")),
Box::new(Map { location: String::from("Hidden Cave"), multiplier: 5 }),
];
for treasure in treasures.iter() {
println!("Description: {}", treasure.description());
println!("Value: {}", treasure.value());
println!("---");
}
}
In this example, we use a trait object (Box
Working with Strings
Rust uses two primary string types:
String
: An owned, mutable, heap-allocated string.&str
: A borrowed, immutable string slice.
Example: Creating and Modifying a String
fn main() {
let mut treasure = String::from("Gold Coins");
treasure.push_str(" and Diamonds");
println!("The treasure chest contains: {}", treasure);
}
Example: Borrowing a String Slice (&str
)
fn main() {
let treasure_map: &str = "Ancient Map";
println!("The treasure map reads: {}", treasure_map);
}
Example: Converting Between String
and &str
fn main() {
let owned_treasure = String::from("Emerald");
// Borrowing as a slice
print_treasure(&owned_treasure);
// Converting a borrowed string to an owned string
let borrowed_map = "Ruby";
let owned_map = borrowed_map.to_string();
println!("Owned map: {}", owned_map);
}
fn print_treasure(treasure: &str) {
println!("Treasure: {}", treasure);
}
These examples emphasize that you own data with a String
while &str
lets you borrow data safely.
Concurrency with std::thread
Rust makes concurrency safe by enforcing ownership rules even across threads.
Example: Spawning a Thread
use std::thread;
fn main() {
let handle = thread::spawn(|| {
println!("Thread: Searching for treasure in the forest!");
});
println!("Main: Searching for treasure in the cave!");
handle.join().unwrap();
}
Example: Moving Data into a Thread
use std::thread;
fn main() {
let treasure_map = String::from("X marks the spot");
// Use `move` to transfer ownership of `treasure_map` into the closure.
let handle = thread::spawn(move || {
println!("Thread: Using the treasure map: {}", treasure_map);
});
handle.join().unwrap();
}
Example: Sharing Data with Arc
and Mutex
For sharing mutable data between threads, use Arc
(Atomic Reference Counted pointer) with a Mutex
.
use std::sync::{Arc, Mutex};
use std::thread;
fn main() {
let treasure_chest = Arc::new(Mutex::new(vec!["Gold Coins"]));
let mut handles = vec![];
for i in 0..4 {
let chest = Arc::clone(&treasure_chest);
let handle = thread::spawn(move || {
let mut chest_guard = chest.lock().unwrap();
chest_guard.push(format!("Treasure from pirate {}", i));
println!("Pirate {} added treasure!", i);
});
handles.push(handle);
}
for handle in handles {
handle.join().unwrap();
}
println!("Final treasure chest: {:?}", *treasure_chest.lock().unwrap());
}
In this example, each thread safely modifies the shared treasure chest.
Metaprogramming with Macros
Macros generate code at compile time, reducing boilerplate and allowing flexible syntax.
Example: A Declarative Macro with macro_rules!
macro_rules! filter_treasures {
($list:expr, $threshold:expr) => {
$list.iter()
.cloned()
.filter(|&value| value > $threshold)
.collect::<Vec<_>>()
};
}
fn main() {
let treasures = vec![100, 200, 300, 50];
let valuable = filter_treasures!(treasures, 150);
println!("Valuable treasures: {:?}", valuable);
}
Example: Using Built-In Derive Macros
Rust provides built-in procedural macros to automatically implement common traits.
#[derive(Debug, Clone, PartialEq, Eq)]
struct Treasure {
name: String,
value: u32,
}
fn main() {
let t1 = Treasure { name: "Golden Crown".to_string(), value: 500 };
let t2 = t1.clone();
println!("Treasure: {:?}\nAre they equal? {}", t1, t1 == t2);
}
Iterators and Closures
Rust’s iterators and closures offer a powerful and concise way to work with collections.
Example: Using Iterators
Instead of manually looping, you can use iterator chains:
fn main() {
let treasures = vec![100, 200, 300, 50];
let doubled: Vec<_> = treasures.iter()
.filter(|&&t| t > 100)
.map(|&t| t * 2)
.collect();
println!("Doubled valuable treasures: {:?}", doubled);
}
Example: Closures Capturing Environment
Closures can capture surrounding variables, making them flexible for filtering and transformations.
fn main() {
let threshold = 150;
let treasures = vec![100, 200, 300, 50];
let filtered: Vec<_> = treasures.iter()
.filter(|&&t| t > threshold)
.cloned()
.collect();
println!("Treasures over {}: {:?}", threshold, filtered);
}
Example: Destructuring in Closures
Closures can destructure complex data types like tuples:
fn main() {
let treasure_ranks = vec![(1, "gold"), (2, "silver"), (3, "bronze")];
let descriptions: Vec<_> = treasure_ranks.iter()
.map(|(rank, kind)| format!("Rank {}: {}", rank, kind))
.collect();
println!("Treasure descriptions: {:?}", descriptions);
}
Standard Library Collections
Rust’s collections let you organize data efficiently. Each collection type has characteristics suited for different tasks.
Vec
– Dynamic Array
fn main() {
let mut treasure_vault = vec!["gold coin", "silver coin", "diamond"];
treasure_vault.push("emerald");
for treasure in &treasure_vault {
println!("Found: {}", treasure);
}
}
VecDeque
– Double-Ended Queue
use std::collections::VecDeque;
fn main() {
let mut queue = VecDeque::new();
queue.push_back("first treasure");
queue.push_front("urgent treasure");
while let Some(treasure) = queue.pop_front() {
println!("Processing: {}", treasure);
}
}
HashMap
– Key-Value Store
use std::collections::HashMap;
fn main() {
let mut inventory = HashMap::new();
inventory.insert("gold coin", 10);
inventory.insert("diamond", 2);
for (item, count) in &inventory {
println!("{}: {} found", item, count);
}
}
HashSet
– Unique Items
use std::collections::HashSet;
fn main() {
let mut unique_treasures = HashSet::new();
unique_treasures.insert("gold coin");
unique_treasures.insert("gold coin"); // Duplicate is ignored.
for treasure in &unique_treasures {
println!("Unique treasure: {}", treasure);
}
}
BinaryHeap
– Priority Queue
use std::collections::BinaryHeap;
fn main() {
let mut heap = BinaryHeap::new();
heap.push(10);
heap.push(50);
heap.push(30);
if let Some(top) = heap.peek() {
println!("Most valuable: {}", top);
}
}
BTreeMap
and BTreeSet
– Sorted Collections
use std::collections::{BTreeMap, BTreeSet};
fn main() {
// BTreeMap: keys are stored in sorted order.
let mut sorted_inventory = BTreeMap::new();
sorted_inventory.insert("diamond", 150);
sorted_inventory.insert("gold coin", 100);
sorted_inventory.insert("silver coin", 20);
println!("Sorted Inventory: {:?}", sorted_inventory);
// BTreeSet: a sorted set of unique items.
let mut sorted_treasures = BTreeSet::new();
sorted_treasures.insert("emerald");
sorted_treasures.insert("ruby");
sorted_treasures.insert("sapphire");
println!("Sorted Treasures: {:?}", sorted_treasures);
}
Key Takeaways
- Ownership, Borrowing, and Lifetimes: These rules ensure that each piece of data has a single owner, borrowed data never outlives its owner, and references remain valid.
- Smart Pointers: Use
Box
,Rc
, andRefCell
to manage heap data, share ownership, and allow controlled mutation. - Traits and Trait Objects: Define shared behavior with traits and use dynamic dispatch with trait objects when needed.
- Strings: Understand the difference between an owned
String
and a borrowed&str
; converting between them is straightforward. - Concurrency: Use threads with safe ownership transfer (
move
), and share data safely usingArc
andMutex
. - Macros: Write macros to eliminate boilerplate and generate code at compile time.
- Iterators and Closures: Use iterator chains and closures to work with collections in a clear, functional style.
- Standard Collections: Choose from various collections (
Vec
,HashMap
,BinaryHeap
, etc.) based on your data organization and performance needs.