LCM Logo
Programming

Rust Fundamentals

Rust is a compiled, statically typed systems language whose defining feature is a compile-time ownership model: it guarantees memory safety and data-race freedom without a garbage collector. This guide assumes you already program in another language and focuses on what makes Rust different. For installing the toolchain and creating a project, see the Rust scaffolding guide. New projects default to edition 2024.

The canonical reference is The Rust Programming Language — "the book" — which is free online and far more thorough than this page. Each section below links to the chapter that covers it in depth; see Resources at the end for more, including an interactive edition with quizzes.

A first program

cargo new hello generates a src/main.rs with an entry point. main takes no arguments and returns nothing; println! is a macro (the ! marks it), and cargo run compiles and runs.

src/main.rs
fn main() {
    println!("Hello, world!");
}

Variables and mutability

Bindings are created with let and are immutable by default — once bound, the value cannot change. This is the opposite of most languages and is a deliberate nudge toward predictable code. Opt into mutation with mut (book ch. 3.1).

rust
let x = 5;
// x = 6;        // error: cannot assign twice to immutable variable
let mut y = 5;
y = 6;           // ok

Shadowing re-binds a name with a new let, which can change the type. It is distinct from mutation: you are creating a new variable that happens to reuse the name.

rust
let spaces = "   ";        // &str
let spaces = spaces.len(); // usize — a new binding shadows the old

A const is a compile-time constant. It requires a type annotation, is always immutable (no mut), and can be declared in any scope including the module top level.

rust
const MAX_POINTS: u32 = 100_000;

Scalar and compound types

Rust infers most types, but every value has one. The scalar types are integers (i8i128, u8u128, and the pointer-sized isize/usize), floats (f32, f64), bool, and char (a 4-byte Unicode scalar, written with single quotes). Integer literals default to i32, floats to f64 (book ch. 3.2).

rust
let count: i64 = 42;
let ratio = 2.5;          // f64
let ready: bool = true;
let grade = 'A';          // char, not String

The two primitive compound types are tuples (fixed-length, mixed types) and arrays (fixed-length, single type). A growable list is a Vec, covered below.

rust
let point: (i32, i32) = (3, 7);
let (px, py) = point;      // destructuring
let first = point.0;       // index access

let week: [&str; 2] = ["Mon", "Tue"];
let zeros = [0; 5];        // [0, 0, 0, 0, 0]

Functions

Functions are declared with fn. Parameter types are always required; the return type follows ->. The body is a block, and a block's final expression without a semicolon is its value — so the idiomatic return is simply to omit the semicolon on the last line (book ch. 3.3).

rust
fn add(a: i32, b: i32) -> i32 {
    a + b               // no semicolon: this is the return value
}

if and blocks are expressions too, so they can produce values directly:

rust
fn classify(n: i32) -> &'static str {
    if n < 0 { "negative" } else { "non-negative" }
}

Control flow

if conditions must be bool — there is no truthiness, so if x only works when x is already a boolean. Rust has three loops: loop (infinite, often paired with break value to return a result), while, and for over an iterator or range (book ch. 3.5).

rust
let mut total = 0;
for i in 1..=5 {          // 1..=5 is inclusive; 1..5 is exclusive
    total += i;
}

let mut n = 0;
let doubled = loop {
    n += 1;
    if n == 10 { break n * 2; }   // break can carry a value out of loop
};

Ownership

Ownership is the rule set the borrow checker enforces, and it is what lets Rust free memory without a garbage collector. Three rules: every value has exactly one owner; there is only one owner at a time; when the owner goes out of scope, the value is dropped (freed). Ownership gets a full chapter in the book — ch. 4.1 — and it is the one concept most worth reading in full.

For types stored on the heap, such as String, assigning or passing a value moves it. The original binding becomes invalid, which prevents two owners from freeing the same memory.

rust
let s1 = String::from("hello");
let s2 = s1;            // s1 is MOVED into s2
// println!("{s1}");   // error: borrow of moved value: `s1`
println!("{s2}");      // ok

To keep using the original, .clone() makes a deep copy. Small stack-only types (integers, bool, char, and tuples of them) implement the Copy trait and are duplicated instead of moved, so the original stays valid.

rust
let a = String::from("hi");
let b = a.clone();     // explicit deep copy; both a and b are valid

let x = 5;
let y = x;             // i32 is Copy; x is still usable
println!("{x} {y}");

Passing a value to a function moves it too — unless you pass a reference, described next.

References and borrowing

Borrowing lets a function read or modify a value without taking ownership, so the caller keeps it. A reference is written &value; a mutable reference is &mut value. The borrow checker enforces one rule that eliminates data races at compile time: at any given time you may have either any number of shared (&) references or exactly one mutable (&mut) reference — never both (book ch. 4.2).

rust
fn len(s: &String) -> usize {   // borrows, does not take ownership
    s.len()
}

fn push_bang(s: &mut String) {  // mutable borrow
    s.push('!');
}

let mut greeting = String::from("hi");
let n = len(&greeting);         // shared borrow
push_bang(&mut greeting);       // mutable borrow
println!("{greeting} ({n})");   // greeting still owned here -> "hi! (2)"

A &str (string slice) is a borrowed view into string data; a String is an owned, growable buffer. Prefer &str for function parameters that only read text — it accepts both String and string literals.

Structs

A struct groups related fields under a name. Methods live in a separate impl block. A method's first parameter is &self (read), &mut self (mutate), or self (take ownership); an associated function omits self and is called with ::, commonly as a constructor named new (book ch. 5).

rust
struct Rectangle {
    width: u32,
    height: u32,
}

impl Rectangle {
    fn new(width: u32, height: u32) -> Self {
        Self { width, height }      // field init shorthand
    }
    fn area(&self) -> u32 {
        self.width * self.height
    }
}

let rect = Rectangle::new(3, 4);
println!("{}", rect.area());        // 12

Enums and pattern matching

An enum is a type that is exactly one of several variants, and each variant can carry its own data. This makes enums far more expressive than in most languages — they model "one of these shapes" precisely (book ch. 6).

rust
enum Shape {
    Circle(f64),             // radius
    Rectangle(f64, f64),     // width, height
}

match inspects a value against patterns and must be exhaustive — the compiler rejects it if any variant is unhandled, so adding a variant later forces you to update every match. Each arm can destructure the variant's data.

rust
fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle(r) => std::f64::consts::PI * r * r,
        Shape::Rectangle(w, h) => w * h,
    }
}

When you only care about one pattern, if let is a concise alternative to a full match:

rust
let maybe = Some(7);
if let Some(n) = maybe {
    println!("got {n}");
}

Collections

The standard collections live on the heap and grow at runtime. The three you reach for constantly are Vec<T> (a growable array), String (growable UTF-8 text), and HashMap<K, V> (key-value lookup) (book ch. 8).

rust
use std::collections::HashMap;

let mut scores: Vec<i32> = Vec::new();
scores.push(10);
scores.push(20);
let total: i32 = scores.iter().sum();   // 30

let mut counts: HashMap<&str, i32> = HashMap::new();
*counts.entry("apple").or_insert(0) += 1;   // insert-or-update idiom

for (fruit, n) in &counts {
    println!("{fruit}: {n}");
}

Indexing a Vec with [i] panics if i is out of bounds; .get(i) returns an Option instead, which leads to error handling.

Error handling

Rust has no null and no exceptions. Absence and failure are ordinary values you handle explicitly, which means the type signature tells you what can go wrong (book ch. 9).

Option<T> represents a value that may be absent: Some(value) or None. Result<T, E> represents an operation that may fail: Ok(value) or Err(error). You unwrap both by matching, or with helpers like unwrap_or.

rust
fn first_word(s: &str) -> Option<&str> {
    s.split_whitespace().next()
}

match first_word("hello world") {
    Some(w) => println!("first: {w}"),
    None => println!("empty"),
}

The ? operator is the workhorse: applied to a Result (or Option), it returns the value on success, or short-circuits and returns the Err/None from the enclosing function. It replaces pages of manual matching.

rust
use std::num::ParseIntError;

fn sum_strings(a: &str, b: &str) -> Result<i32, ParseIntError> {
    let x: i32 = a.parse()?;   // returns early if parse fails
    let y: i32 = b.parse()?;
    Ok(x + y)
}

Reserve panic! (and .unwrap() / .expect(), which panic on None/Err) for unrecoverable bugs or quick prototypes — for anything a caller might reasonably handle, return a Result.

.unwrap() is fine in examples and tests but a code smell in production paths: it converts a recoverable error into a crash. Prefer ? to propagate, or match/unwrap_or to handle.

Putting it together

This program ties the pieces together: a struct with a method, a Vec, iteration, and a fallible function that uses ? and returns a Result. Parsing the prices can fail, so main itself returns a Result and uses ?.

src/main.rs
struct Order {
    item: String,
    price: f64,
}

impl Order {
    fn new(item: &str, price: f64) -> Self {
        Self { item: item.to_string(), price }
    }
}

fn parse_order(line: &str) -> Result<Order, std::num::ParseFloatError> {
    let (item, price) = line.split_once(',').unwrap_or((line, "0"));
    Ok(Order::new(item.trim(), price.trim().parse()?))
}

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let lines = ["coffee, 3.50", "bagel, 2.25", "tea, 2.00"];

    let mut orders: Vec<Order> = Vec::new();
    for line in lines {
        orders.push(parse_order(line)?);   // ? propagates a parse error
    }

    let total: f64 = orders.iter().map(|o| o.price).sum();
    for o in &orders {
        println!("{:<8} ${:.2}", o.item, o.price);
    }
    println!("total    ${total:.2}");

    Ok(())
}

Box<dyn std::error::Error> is a catch-all error type that any concrete error can convert into via ?, which is why main can return it directly. From here, the next steps are traits (shared behavior across types), generics, and iterators — the tools that make the ownership model productive at scale. The book covers these in ch. 10 and ch. 13; working through it front to back is the single best way to learn Rust. To apply these fundamentals to a real program, see Rust CLIs with clap.

Resources

The Rust Programming Language ("the book") is the canonical, free, and continuously updated introduction — the sections above link to its individual chapters.

For learning, the Brown CS interactive edition is the same book with inline quizzes, highlight-to-annotate, and a substantially rewritten Understanding Ownership chapter whose visualizations were shown by research to improve comprehension. It's an excellent way to check your understanding as you read.

A few more references worth bookmarking: Rust by Example for runnable, annotated snippets; the standard library docs for every type and method; Rustlings for small hands-on exercises; and the official playground for running code in the browser without a local install.

On this page