Rust โ Complete Tutorial
6 modules ยท 12 examples ยท Click โถ Run Code to see the console output
๐ก How to use this tutorial
Read each explanation, study the code, then click โถ Run Code to see the output.
Variables & Types
Rust is statically typed โ every variable has a type known at compile time. Unlike most languages, variables in Rust are immutable by default. To allow a variable to change you must add the mut keyword. Rust infers types automatically so you rarely need to write them out. The compiler enforces these rules strictly, which is why Rust programs are so reliable.
Variables and Primitive Types
Declare variables with let. Add mut to make them mutable. Rust has signed integers (i8โi128), unsigned integers (u8โu128), floats (f32, f64), booleans, and chars. The default integer type is i32 and the default float is f64. Underscores in numbers improve readability without affecting the value.
fn main() {
// Immutable by default
let x = 5;
// x = 6; โ compile error!
// Mutable variable
let mut count = 0;
count += 1;
count += 1;
println!("count = {count}");
// Type annotations (usually inferred)
let age: u32 = 28;
let pi: f64 = 3.14159;
let flag: bool = true;
let letter: char = 'R';
println!("age={age}, pi={pi:.2}, flag={flag}, letter={letter}");
// Shadowing โ reuse a name with a new binding
let spaces = " ";
let spaces = spaces.len(); // now an integer!
println!("spaces = {spaces}");
// Constants โ must be type-annotated
const MAX_POINTS: u32 = 100_000;
println!("max = {MAX_POINTS}");
// Tuple
let tup: (i32, f64, bool) = (500, 6.4, true);
let (a, b, c) = tup;
println!("tuple: {a}, {b}, {c}");
// Array โ fixed size
let arr = [1, 2, 3, 4, 5];
println!("arr[2] = {}", arr[2]);
println!("arr len = {}", arr.len());
}Shadowing (let x = x + 1) is different from mutation (mut x; x = x + 1). Shadowing creates a completely new binding and can even change the type. This is useful when transforming a value through multiple steps โ each step can shadow the previous one with the same name.
Strings โ &str vs String
Rust has two string types. &str (string slice) is a borrowed reference to string data โ immutable, stored in the binary or stack. String is a heap-allocated, growable, owned string. You will use both constantly. &str for read-only views, String when you need to build or modify a string.
fn main() {
// &str โ string slice (borrowed, immutable)
let hello: &str = "Hello, World!";
println!("{hello}");
println!("length = {}", hello.len());
println!("contains 'World' = {}", hello.contains("World"));
// String โ heap-allocated, growable
let mut owned = String::from("Hello");
owned.push_str(", Rust!"); // append a string slice
owned.push('!'); // append a char
println!("{owned}");
// String::new() โ empty string
let mut s = String::new();
s.push_str("building");
s.push(' ');
s.push_str("strings");
println!("{s}");
// format! โ combine strings (no ownership issues)
let name = "Alice";
let greeting = format!("Hello, {}! You are learning Rust.", name);
println!("{greeting}");
// Useful methods
let sentence = " rust is awesome ";
println!("trimmed: '{}'", sentence.trim());
println!("upper: {}", sentence.trim().to_uppercase());
println!("replace: {}", sentence.trim().replace("awesome", "great"));
// Convert &str to String and back
let s1: String = "hello".to_string();
let s2: &str = &s1; // borrow as &str
println!("s1={s1}, s2={s2}");
}The rule of thumb: use &str for function parameters (it accepts both &str and &String), use String when you need to own or build a string. The & in &str means "reference" โ you are borrowing the string data without taking ownership.
Ownership & Borrowing
Ownership is Rust's most unique and important feature โ it is the mechanism that allows Rust to be memory-safe without a garbage collector. The rules are: (1) each value has exactly one owner, (2) when the owner goes out of scope the value is dropped (freed), (3) ownership can be transferred (moved) or borrowed (referenced). These rules are enforced at compile time โ zero runtime cost.
Move Semantics and Cloning
When you assign a heap-allocated value to another variable, the first variable is "moved" โ it no longer owns the data. To keep both variables valid, call .clone() for a deep copy. Primitive types (i32, bool, f64, char) implement the Copy trait and are duplicated automatically โ no move needed.
fn main() {
// Primitives are COPIED automatically
let x = 5;
let y = x; // y gets a copy โ x is still valid
println!("x={x}, y={y}");
// String is MOVED โ x2 is no longer valid after move
let s1 = String::from("hello");
let s2 = s1; // s1 is moved into s2
// println!("{s1}"); โ compile error: value used after move
println!("s2 = {s2}");
// Clone โ explicit deep copy
let s3 = String::from("world");
let s4 = s3.clone();
println!("s3={s3}, s4={s4}"); // both valid
// Move into a function โ ownership transferred
let s5 = String::from("Rust");
print_string(s5);
// println!("{s5}"); โ compile error: s5 was moved
// Return ownership from a function
let s6 = give_ownership();
println!("got: {s6}");
// Takes and gives back ownership
let s7 = String::from("hello");
let s8 = takes_and_returns(s7);
println!("s8 = {s8}");
}
fn print_string(s: String) {
println!("inside: {s}");
} // s is dropped here
fn give_ownership() -> String {
String::from("owned string")
}
fn takes_and_returns(s: String) -> String {
s // return ownership to caller
}Moving ownership everywhere gets tedious. That is why Rust has borrowing โ instead of moving ownership into a function, you pass a reference (&). The function borrows the value temporarily without taking ownership. This is by far the most common pattern in real Rust code.
References and Borrowing
A reference (&T) lets you refer to a value without taking ownership. This is called borrowing. You can have many immutable references (&T) at the same time, OR exactly one mutable reference (&mut T) โ never both at once. This rule prevents data races at compile time.
fn main() {
// Immutable borrow โ just reading
let s1 = String::from("hello");
let len = calculate_length(&s1); // pass reference
println!("'{}' has {} chars", s1, len); // s1 still valid
// Multiple immutable borrows โ all OK
let r1 = &s1;
let r2 = &s1;
println!("r1={r1}, r2={r2}");
// Mutable borrow โ one at a time, exclusive
let mut s2 = String::from("hello");
change(&mut s2);
println!("changed: {s2}");
// Slices โ borrow a portion of a collection
let numbers = [10, 20, 30, 40, 50];
let slice = &numbers[1..4]; // indices 1, 2, 3
println!("slice: {:?}", slice); // [20, 30, 40]
// String slice
let sentence = String::from("hello world");
let first = first_word(&sentence);
println!("first word: {first}");
}
fn calculate_length(s: &String) -> usize {
s.len() // borrowing โ does not take ownership
} // s goes out of scope but value is NOT dropped
fn change(s: &mut String) {
s.push_str(", world");
}
fn first_word(s: &String) -> &str {
let bytes = s.as_bytes();
for (i, &item) in bytes.iter().enumerate() {
if item == b' ' { return &s[0..i]; }
}
&s[..]
}The borrow checker rule โ many readers OR one writer, never both โ is the same rule database systems use for read/write locks. Rust enforces this at compile time with zero overhead. If your code compiles, it is free of data races.
Structs & Enums
Structs let you group related data under a named type. Enums define a type that can be one of several variants โ and each variant can carry different data. Rust's enums are algebraic data types, making them far more powerful than enums in C, Java, or TypeScript. The match expression exhaustively handles every possible variant.
Structs and impl Blocks
Define a struct with field names and types. Create an instance by filling in all fields. Add methods and associated functions (like constructors) in an impl block. &self means the method borrows the struct for reading. &mut self means it borrows for writing. Self (capital S) refers to the type itself.
#[derive(Debug)]
struct Rectangle {
width: f64,
height: f64,
}
impl Rectangle {
// Associated function โ constructor
fn new(width: f64, height: f64) -> Self {
Self { width, height }
}
fn square(side: f64) -> Self {
Self { width: side, height: side }
}
// Method โ borrows self immutably
fn area(&self) -> f64 {
self.width * self.height
}
fn perimeter(&self) -> f64 {
2.0 * (self.width + self.height)
}
fn is_square(&self) -> bool {
(self.width - self.height).abs() < f64::EPSILON
}
fn can_hold(&self, other: &Rectangle) -> bool {
self.width > other.width && self.height > other.height
}
// Method โ borrows self mutably
fn scale(&mut self, factor: f64) {
self.width *= factor;
self.height *= factor;
}
}
fn main() {
let mut r1 = Rectangle::new(10.0, 5.0);
let r2 = Rectangle::new(4.0, 3.0);
let sq = Rectangle::square(6.0);
println!("r1 = {:?}", r1);
println!("area = {:.1}", r1.area());
println!("perimeter = {:.1}", r1.perimeter());
println!("is_square = {}", r1.is_square());
println!("can_hold r2? {}", r1.can_hold(&r2));
r1.scale(2.0);
println!("after scale(2): {:?}", r1);
println!("sq is_square = {}", sq.is_square());
}#[derive(Debug)] automatically generates the {:?} formatter so you can print your struct with println!("{:?}", value). You can also use {:#?} for pretty-printed output with indentation. This is one of Rust's procedural macros โ it generates code for you at compile time.
Enums and Pattern Matching
Enums in Rust are algebraic data types โ each variant can hold different types of data. The match expression must handle every variant (exhaustive matching). Option<T> is Rust's replacement for null โ Some(value) when data exists, None when it does not. The ? operator propagates None automatically.
#[derive(Debug)]
enum Shape {
Circle { radius: f64 },
Rectangle { width: f64, height: f64 },
Triangle { base: f64, height: f64 },
}
impl Shape {
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,
}
}
fn name(&self) -> &str {
match self {
Shape::Circle { .. } => "Circle",
Shape::Rectangle { .. } => "Rectangle",
Shape::Triangle { .. } => "Triangle",
}
}
}
fn divide(a: f64, b: f64) -> Option<f64> {
if b == 0.0 { None } else { Some(a / b) }
}
fn main() {
let shapes = vec![
Shape::Circle { radius: 5.0 },
Shape::Rectangle { width: 4.0, height: 6.0 },
Shape::Triangle { base: 3.0, height: 8.0 },
];
for shape in &shapes {
println!("{}: area = {:.2}", shape.name(), shape.area());
}
// Option โ no null pointers!
for (a, b) in [(10.0, 2.0), (7.0, 0.0), (9.0, 3.0)] {
match divide(a, b) {
Some(r) => println!("{a}/{b} = {r:.2}"),
None => println!("{a}/{b} = undefined"),
}
}
// if let โ concise single-variant match
if let Some(result) = divide(100.0, 4.0) {
println!("100/4 = {result}");
}
// unwrap_or โ provide a default
let val = divide(5.0, 0.0).unwrap_or(0.0);
println!("fallback = {val}");
}match must be exhaustive โ you must handle every possible variant. The underscore _ is a catch-all: _ => println!("something else"). This exhaustiveness guarantee means the compiler will tell you if you add a new enum variant and forget to handle it somewhere โ impossible to forget in Rust.
Traits & Generics
Traits define shared behaviour โ like interfaces in Java or Go. A type implements a trait by providing the required methods. Generics let you write code that works for any type that satisfies certain trait bounds. Rust resolves generics at compile time (monomorphisation) โ the compiled code is as fast as if you had written a specific version for each type.
Defining and Implementing Traits
Define a trait with the trait keyword โ it lists method signatures. Implement it for a type with impl TraitName for TypeName. Traits can have default method implementations that types can optionally override. The impl Trait syntax in function parameters is shorthand for a generic with a trait bound.
trait Describe {
fn describe(&self) -> String;
// Default implementation
fn short(&self) -> String {
let d = self.describe();
if d.len() > 30 {
format!("{}...", &d[..30])
} else {
d
}
}
}
struct Book {
title: String,
author: String,
pages: u32,
}
struct Movie {
title: String,
director: String,
year: u16,
}
impl Describe for Book {
fn describe(&self) -> String {
format!("'{}' by {} ({} pages)",
self.title, self.author, self.pages)
}
}
impl Describe for Movie {
fn describe(&self) -> String {
format!("'{}' directed by {} ({})",
self.title, self.director, self.year)
}
}
// Generic function โ works for any type that implements Describe
fn print_info(item: &impl Describe) {
println!("Full: {}", item.describe());
println!("Short: {}", item.short());
}
fn main() {
let book = Book {
title: "The Rust Programming Language".to_string(),
author: "Steve Klabnik".to_string(),
pages: 526,
};
let movie = Movie {
title: "Inception".to_string(),
director: "Christopher Nolan".to_string(),
year: 2010,
};
print_info(&book);
println!();
print_info(&movie);
// Vec of trait objects โ dynamic dispatch
let items: Vec<Box<dyn Describe>> = vec![
Box::new(Book { title: "1984".into(), author: "Orwell".into(), pages: 328 }),
Box::new(Movie { title: "Matrix".into(), director: "Wachowski".into(), year: 1999 }),
];
println!();
for item in &items {
println!("- {}", item.short());
}
}impl Trait (static dispatch) and Box<dyn Trait> (dynamic dispatch) are two ways to use traits in functions. impl Trait is faster โ the compiler generates specific code for each type at compile time. Box<dyn Trait> allows storing different types in one Vec but has a small runtime cost for the virtual dispatch.
Generic Functions and Structs
Generic functions use type parameters (T, U, etc.) that become concrete types when the function is called. Add trait bounds with T: TraitName to specify what the type must be able to do. Multiple bounds use +. The where clause keeps complex bounds readable.
use std::fmt::Display;
// Generic function with a bound โ T must implement PartialOrd
fn largest<T: PartialOrd>(list: &[T]) -> &T {
let mut largest = &list[0];
for item in list {
if item > largest { largest = item; }
}
largest
}
// Multiple bounds with Display and PartialOrd
fn print_largest<T: Display + PartialOrd>(list: &[T]) {
let result = largest(list);
println!("largest = {result}");
}
// Generic struct
#[derive(Debug)]
struct Pair<T> {
first: T,
second: T,
}
impl<T: Display + PartialOrd> Pair<T> {
fn new(first: T, second: T) -> Self {
Self { first, second }
}
fn larger(&self) -> &T {
if self.first >= self.second { &self.first } else { &self.second }
}
fn show(&self) {
println!("({}, {}) โ larger: {}", self.first, self.second, self.larger());
}
}
fn main() {
let numbers = vec![34, 50, 25, 100, 65];
print_largest(&numbers);
let chars = vec!['y', 'm', 'a', 'q'];
print_largest(&chars);
let p1 = Pair::new(5, 10);
let p2 = Pair::new("hello", "world");
let p3 = Pair::new(3.14, 2.71);
p1.show();
p2.show();
p3.show();
// The compiler generates specific code for each type used
// โ zero runtime overhead compared to hand-written versions
}Rust generics are zero-cost because of monomorphisation โ the compiler creates a separate version of the function for each concrete type used. So Pair<i32> and Pair<&str> become two distinct compiled functions. This is why generic Rust code is as fast as non-generic code.
Error Handling
Rust has no exceptions. Errors are values โ either Option<T> (something or nothing) or Result<T, E> (success or failure). This forces you to handle errors explicitly, which is why Rust programs rarely crash unexpectedly. The ? operator is syntactic sugar that propagates errors up the call stack โ it makes error handling concise without hiding it.
Result<T, E> and the ? Operator
Result<T, E> is an enum with two variants: Ok(T) for success and Err(E) for failure. Use match to handle both cases. The ? operator extracts the Ok value or returns the Err immediately from the current function โ it is shorthand for the common "if error, return error" pattern.
use std::num::ParseIntError;
// Custom error type
#[derive(Debug)]
enum AppError {
ParseError(ParseIntError),
NegativeNumber(i32),
TooBig(i32),
}
impl std::fmt::Display for AppError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
match self {
AppError::ParseError(e) => write!(f, "parse error: {e}"),
AppError::NegativeNumber(n) => write!(f, "negative: {n}"),
AppError::TooBig(n) => write!(f, "too big: {n} (max 100)"),
}
}
}
impl From<ParseIntError> for AppError {
fn from(e: ParseIntError) -> Self { AppError::ParseError(e) }
}
fn parse_score(s: &str) -> Result<i32, AppError> {
let n: i32 = s.trim().parse()?; // ? converts ParseIntError via From
if n < 0 { return Err(AppError::NegativeNumber(n)); }
if n > 100 { return Err(AppError::TooBig(n)); }
Ok(n)
}
fn main() {
let inputs = ["85", "-5", "110", "abc", "0", "100"];
for input in inputs {
match parse_score(input) {
Ok(score) => println!("OK '{input}' -> score = {score}"),
Err(e) => println!("ERR '{input}' -> {e}"),
}
}
// Chaining with map and and_then
let doubled = parse_score("42")
.map(|n| n * 2)
.unwrap_or(0);
println!("doubled = {doubled}");
// unwrap_or_else
let safe = parse_score("bad")
.unwrap_or_else(|_| -1);
println!("safe = {safe}");
}The ? operator can only be used in functions that return Result or Option. In main() you can write fn main() -> Result<(), Box<dyn std::error::Error>> to use ? there too. The Box<dyn Error> is a trait object that can hold any error type โ useful for mixing different error types.
Option<T> โ Handling Missing Values
Option<T> replaces null. Some(value) means data exists. None means it does not. This forces you to handle the "no value" case explicitly โ the compiler will not let you use an Option<T> as if it were a T. Methods like map, and_then, unwrap_or, and filter let you transform Options without unwrapping.
fn find_user(id: u32) -> Option<String> {
match id {
1 => Some("Alice".to_string()),
2 => Some("Bob".to_string()),
3 => Some("Carol".to_string()),
_ => None,
}
}
fn get_email(name: &str) -> Option<String> {
match name {
"Alice" => Some("alice@example.com".to_string()),
"Bob" => Some("bob@example.com".to_string()),
_ => None,
}
}
fn main() {
// Basic match
for id in [1, 2, 3, 99] {
match find_user(id) {
Some(name) => println!("User {id}: {name}"),
None => println!("User {id}: not found"),
}
}
println!();
// if let โ cleaner for single-arm match
if let Some(name) = find_user(1) {
println!("Found: {name}");
}
// map โ transform the inner value
let upper = find_user(2).map(|n| n.to_uppercase());
println!("upper = {:?}", upper);
// and_then โ chain Options (flatMap)
let email = find_user(1).and_then(|name| get_email(&name));
println!("email = {:?}", email);
let no_email = find_user(3).and_then(|name| get_email(&name));
println!("no_email = {:?}", no_email);
// unwrap_or โ provide a default
let name = find_user(99).unwrap_or_else(|| "Guest".to_string());
println!("name = {name}");
// ? in a function returning Option
fn user_email(id: u32) -> Option<String> {
let name = find_user(id)?; // returns None if no user
let email = get_email(&name)?; // returns None if no email
Some(email)
}
println!("user 1 email = {:?}", user_email(1));
println!("user 3 email = {:?}", user_email(3));
println!("user 99 email = {:?}", user_email(99));
}and_then (also called flatMap in other languages) is the key to chaining Options. Each step produces an Option โ and_then unwraps the Some to pass into the next function, or short-circuits to None if any step returns None. The ? operator inside a function that returns Option does the same thing automatically.
Iterators & Closures
Closures are anonymous functions that can capture values from their environment. Iterators let you process sequences of values one at a time โ and because they are lazy, the entire chain does no work until a consuming method like collect() or sum() is called. Combining closures with iterators gives you expressive, efficient data transformation pipelines with zero extra allocation.
Closures โ Capturing Environment
Closures are defined with |params| body. They can capture variables from the surrounding scope โ by reference by default, or by value with the move keyword. Rust has three closure traits: Fn (immutable reference capture), FnMut (mutable reference capture), FnOnce (takes ownership, can only be called once).
fn main() {
// Basic closures
let add = |a: i32, b: i32| a + b;
let sq = |x: i32| x * x;
let greet = |name: &str| format!("Hello, {name}! ๐ฆ");
println!("{}", add(3, 4));
println!("{}", sq(9));
println!("{}", greet("Rust"));
// Capture by reference (default)
let base = 100;
let add_base = |x: i32| x + base; // borrows base
println!("add_base(5) = {}", add_base(5));
println!("base is still {base}");
// Capture by value (move)
let prefix = String::from("LOG: ");
let log = move |msg: &str| format!("{prefix}{msg}");
println!("{}", log("server started"));
// prefix is moved into log โ cannot use it here
// FnMut โ mutates captured variable
let mut count = 0;
let mut increment = || { count += 1; count };
println!("{} {} {}", increment(), increment(), increment());
// Higher-order functions
fn apply<F: Fn(i32) -> i32>(f: F, val: i32) -> i32 { f(val) }
fn apply_twice<F: Fn(i32) -> i32>(f: F, val: i32) -> i32 { f(f(val)) }
println!("apply sq(5) = {}", apply(sq, 5));
println!("apply_twice +3 (1) = {}", apply_twice(|x| x + 3, 1));
// Returning a closure from a function
fn make_adder(n: i32) -> impl Fn(i32) -> i32 {
move |x| x + n
}
let add5 = make_adder(5);
let add10 = make_adder(10);
println!("add5(3) = {}, add10(3) = {}", add5(3), add10(3));
}The move keyword is essential when you spawn threads or return closures โ it ensures the closure owns its captured data so it can live beyond the current scope. Without move, a closure that captures a reference cannot outlive the referenced value.
Iterator Adaptors โ Lazy Pipelines
Iterator adaptors like map, filter, flat_map, and take are lazy โ they do nothing until a consuming method (collect, sum, for_each, count, find) is called. This means you can chain many operations and Rust will execute them in a single pass, as efficiently as a hand-written loop.
fn main() {
let numbers = vec![1, 2, 3, 4, 5, 6, 7, 8, 9, 10];
// map โ transform each element
let squares: Vec<i32> = numbers.iter().map(|&x| x * x).collect();
println!("squares: {:?}", squares);
// filter โ keep matching elements
let evens: Vec<&i32> = numbers.iter().filter(|&&x| x % 2 == 0).collect();
println!("evens: {:?}", evens);
// chain them โ odd squares
let odd_sq: Vec<i32> = numbers.iter()
.filter(|&&x| x % 2 != 0)
.map(|&x| x * x)
.collect();
println!("odd_sq: {:?}", odd_sq);
// fold โ combine all into one value
let sum = numbers.iter().fold(0, |acc, &x| acc + x);
println!("sum = {sum}");
// sum, product, min, max shortcuts
let total: i32 = numbers.iter().sum();
let product: i32 = (1..=5).product();
println!("total={total}, 5!={product}");
// zip โ combine two iterators
let langs = vec!["Rust", "Go", "C++"];
let years = vec![2015, 2009, 1985 ];
let zipped: Vec<_> = langs.iter().zip(years.iter()).collect();
for (lang, year) in &zipped {
println!(" {lang} ({year})");
}
// flat_map โ flatten nested iterators
let words = vec!["hello world", "foo bar"];
let all: Vec<&str> = words.iter()
.flat_map(|s| s.split_whitespace())
.collect();
println!("words: {:?}", all);
// Infinite iterator with take
let first5_sq: Vec<i32> = (1..).map(|x| x * x).take(5).collect();
println!("first 5 squares: {:?}", first5_sq);
// any, all, find
println!("any > 9? {}", numbers.iter().any(|&x| x > 9));
println!("all > 0? {}", numbers.iter().all(|&x| x > 0));
println!("first > 7: {:?}", numbers.iter().find(|&&x| x > 7));
}Iterator chains are compiled into a single tight loop โ there is no intermediate allocation for each step. This is the zero-cost abstraction in action: high-level functional code with the same machine-code output as a hand-written for loop. Use iterators freely โ they are not slower than loops.
You finished the Rust tutorial!
You now understand Rust's ownership model, structs, enums, traits, generics, error handling, and iterators โ the foundation of real-world Rust development.