Rust
Ownership, lifetimes, traits, fearless concurrency, and when Rust is the right choice for your project
Rust
Rust makes a bold promise: memory safety without garbage collection, concurrency without data races, abstraction without runtime cost. It delivers on all three, but the price is a steep learning curve and a compiler that rejects code other languages would happily run (and crash at 3 AM).
Rust is not the right choice for every project. It is the right choice when correctness matters more than development speed, when performance matters at the system level, and when you cannot afford the unpredictable pauses of garbage collection. Infrastructure tooling, databases, game engines, blockchain, embedded systems -- these are Rust's sweet spots.
Ownership and Borrowing
Ownership is Rust's central innovation. Every value has exactly one owner. When the owner goes out of scope, the value is dropped. This is how Rust avoids garbage collection.
fn main() {
let s1 = String::from("hello"); // s1 owns the String
let s2 = s1; // ownership moves to s2
// println!("{}", s1); // ERROR: s1 no longer valid
let s3 = s2.clone(); // explicit deep copy
println!("{} {}", s2, s3); // both valid
}Borrowing Rules
Instead of transferring ownership, you can borrow a value. The rules are strict:
- You can have any number of immutable references (
&T), OR - Exactly one mutable reference (
&mut T), but not both at the same time.
fn calculate_stats(data: &[f64]) -> (f64, f64) {
let sum: f64 = data.iter().sum();
let mean = sum / data.len() as f64;
let variance = data.iter()
.map(|x| (x - mean).powi(2))
.sum::<f64>() / data.len() as f64;
(mean, variance)
}
fn normalize(data: &mut Vec<f64>) {
let (mean, var) = calculate_stats(data);
let std_dev = var.sqrt();
for x in data.iter_mut() {
*x = (*x - mean) / std_dev;
}
}
fn main() {
let mut values = vec![1.0, 2.0, 3.0, 4.0, 5.0];
let (mean, _) = calculate_stats(&values); // immutable borrow
println!("Mean: {mean}");
normalize(&mut values); // mutable borrow
println!("Normalized: {:?}", values);
}Lifetimes
Lifetimes tell the compiler how long references are valid. Most of the time, the compiler infers them. You need explicit lifetimes when the compiler cannot figure out which input reference the output reference is tied to:
// The compiler needs to know: does the returned &str live as
// long as `a` or as long as `b`?
fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}
// Lifetimes in structs: the struct cannot outlive the data it borrows
struct Config<'a> {
name: &'a str,
values: &'a [i32],
}
impl<'a> Config<'a> {
fn new(name: &'a str, values: &'a [i32]) -> Self {
Config { name, values }
}
fn summary(&self) -> String {
format!("{}: {} values", self.name, self.values.len())
}
}The mental model: lifetimes are not about how long something lives. They are about proving to the compiler that a reference will not outlive the data it points to. The compiler uses lifetimes to prevent dangling references at compile time.
Traits
Traits are Rust's mechanism for shared behavior -- similar to interfaces in Go or type classes in Haskell:
use std::fmt;
trait Summary {
fn summarize(&self) -> String;
// Default implementation
fn preview(&self) -> String {
format!("{}...", &self.summarize()[..50.min(self.summarize().len())])
}
}
struct Article {
title: String,
author: String,
content: String,
}
impl Summary for Article {
fn summarize(&self) -> String {
format!("{} by {}", self.title, self.author)
}
}
impl fmt::Display for Article {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{} (by {})", self.title, self.author)
}
}
// Trait bounds: accept anything that implements Summary
fn print_summary(item: &impl Summary) {
println!("{}", item.summarize());
}
// Multiple trait bounds
fn print_and_display(item: &(impl Summary + fmt::Display)) {
println!("Summary: {}", item.summarize());
println!("Display: {}", item);
}Error Handling
Rust uses Result<T, E> for recoverable errors and panic! for unrecoverable ones. The ? operator propagates errors concisely:
use std::fs;
use std::io;
use thiserror::Error;
// Define domain errors with thiserror
#[derive(Error, Debug)]
enum AppError {
#[error("configuration error: {0}")]
Config(String),
#[error("database error: {source}")]
Database { #[from] source: sqlx::Error },
#[error("I/O error: {source}")]
Io { #[from] source: io::Error },
#[error("not found: {entity} with id {id}")]
NotFound { entity: String, id: String },
}
fn read_config(path: &str) -> Result<Config, AppError> {
let content = fs::read_to_string(path)?; // io::Error -> AppError via From
let config: Config = toml::from_str(&content)
.map_err(|e| AppError::Config(e.to_string()))?;
Ok(config)
}
fn get_user(id: &str) -> Result<User, AppError> {
let user = db::find_user(id)?; // sqlx::Error -> AppError via From
user.ok_or_else(|| AppError::NotFound {
entity: "User".into(),
id: id.into(),
})
}Enums and Pattern Matching
Rust enums are algebraic data types -- far more powerful than enums in most languages:
enum Command {
Quit,
Echo(String),
Move { x: i32, y: i32 },
Color(u8, u8, u8),
}
fn execute(cmd: Command) {
match cmd {
Command::Quit => println!("Quitting"),
Command::Echo(msg) => println!("{msg}"),
Command::Move { x, y } => println!("Moving to ({x}, {y})"),
Command::Color(r, g, b) => println!("Color: #{r:02x}{g:02x}{b:02x}"),
}
}
// Option<T> is just an enum: Some(T) | None
fn find_user(users: &[User], name: &str) -> Option<&User> {
users.iter().find(|u| u.name == name)
}
// Chaining Option methods
fn get_user_email(users: &[User], name: &str) -> String {
find_user(users, name)
.filter(|u| u.active)
.map(|u| u.email.clone())
.unwrap_or_else(|| "unknown@example.com".into())
}Iterators
Rust iterators are zero-cost abstractions -- the compiler optimizes them into the same machine code as hand-written loops:
fn process_transactions(transactions: &[Transaction]) -> Summary {
let (total, count, max) = transactions.iter()
.filter(|t| t.status == Status::Completed)
.fold((0.0, 0u32, 0.0f64), |(sum, count, max), t| {
(sum + t.amount, count + 1, max.max(t.amount))
});
Summary { total, count, average: total / count as f64, max }
}
// Collecting into different types
fn group_by_category(items: &[Item]) -> HashMap<String, Vec<&Item>> {
items.iter()
.fold(HashMap::new(), |mut map, item| {
map.entry(item.category.clone())
.or_default()
.push(item);
map
})
}Smart Pointers
When stack allocation or single ownership is not enough:
use std::sync::{Arc, Mutex};
use std::rc::Rc;
// Box<T>: heap allocation with single ownership
let large_data: Box<[u8; 1_000_000]> = Box::new([0u8; 1_000_000]);
// Rc<T>: reference-counted, single-threaded shared ownership
let shared = Rc::new(vec![1, 2, 3]);
let clone1 = Rc::clone(&shared); // bumps reference count, no deep copy
let clone2 = Rc::clone(&shared);
println!("References: {}", Rc::strong_count(&shared)); // 3
// Arc<T>: atomic reference-counted, thread-safe shared ownership
// Mutex<T>: mutual exclusion for interior mutability
let counter = Arc::new(Mutex::new(0));
let handles: Vec<_> = (0..10).map(|_| {
let counter = Arc::clone(&counter);
std::thread::spawn(move || {
let mut num = counter.lock().unwrap();
*num += 1;
})
}).collect();
for handle in handles {
handle.join().unwrap();
}
println!("Counter: {}", *counter.lock().unwrap()); // 10Fearless Concurrency
Rust's type system prevents data races at compile time through Send and Sync traits:
Send: a type can be transferred to another threadSync: a type can be shared between threads (via&T)
use std::sync::mpsc;
use std::thread;
// Channel-based communication (like Go)
fn parallel_search(data: Vec<String>, query: &str) -> Vec<String> {
let query = query.to_string();
let (tx, rx) = mpsc::channel();
let chunk_size = (data.len() / num_cpus::get()).max(1);
let handles: Vec<_> = data.chunks(chunk_size)
.map(|chunk| chunk.to_vec())
.map(|chunk| {
let tx = tx.clone();
let query = query.clone();
thread::spawn(move || {
let results: Vec<String> = chunk.into_iter()
.filter(|item| item.contains(&query))
.collect();
tx.send(results).unwrap();
})
})
.collect();
drop(tx); // close sender so rx.iter() terminates
let results: Vec<String> = rx.iter().flatten().collect();
for handle in handles {
handle.join().unwrap();
}
results
}Async Rust with Tokio
Async Rust is powerful but has a steeper learning curve than sync Rust:
use tokio;
use reqwest;
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let urls = vec![
"https://api.example.com/users",
"https://api.example.com/orders",
"https://api.example.com/products",
];
// Concurrent requests
let results = futures::future::join_all(
urls.iter().map(|url| async move {
let resp = reqwest::get(*url).await?;
resp.text().await
})
).await;
for result in results {
match result {
Ok(body) => println!("Got {} bytes", body.len()),
Err(e) => eprintln!("Error: {e}"),
}
}
Ok(())
}
// A simple HTTP handler with axum
use axum::{Router, Json, extract::Path};
async fn get_user(Path(id): Path<String>) -> Json<User> {
let user = db::find_user(&id).await.unwrap();
Json(user)
}
fn app() -> Router {
Router::new()
.route("/users/:id", axum::routing::get(get_user))
}When Rust Makes Sense (and When It Does Not)
Use Rust when:
- Performance is a hard requirement (not just "we want it fast")
- Memory safety matters and you cannot afford GC pauses (embedded, real-time, infrastructure)
- Correctness is critical and you want the compiler to enforce it
- You are building a library or tool that will be used for years
- The domain is naturally systems-level: databases, networking, compilers, CLI tools
Do not use Rust when:
- Iteration speed matters more than correctness (early-stage startups, prototypes)
- Your team does not know Rust and cannot invest time to learn it
- The bottleneck is I/O, not CPU (a Go or Python service is often good enough)
- You are building a CRUD API with no unusual performance requirements
The honest assessment: Rust makes you 2-3x slower to write the first version, but the code you write is dramatically more reliable. The trade-off is worth it for infrastructure that runs for years. It is usually not worth it for a startup MVP.
Rust's Growing Demand
Rust is increasingly used in:
- Infrastructure tooling: ripgrep, fd, bat, delta, zoxide -- the Unix tool renaissance is Rust-powered
- Cloud infrastructure: parts of AWS (Firecracker), Cloudflare Workers
- Blockchain: Solana, Polkadot, and many other chains use Rust
- Databases: SurrealDB, TiKV, Qdrant
- Web backends: Axum, Actix-web for high-performance services
In NZ, Rust roles are still rare but growing. Blockchain companies, infrastructure teams, and performance-sensitive startups are the primary employers. Knowing Rust is less about immediate job prospects and more about signaling engineering depth -- it tells an interviewer that you understand systems programming, memory models, and concurrency at a level most candidates do not.