Rust Fundamentals Learning

Rust Key Concepts — The Mental Models That Matter

A crash course for C++ and Python developers who want to get past the learning curve without drowning in theory.

Hokwang Choi March 2026 18 min read
TL;DR
Why Rust? The performance of C++ with the safety of a garbage-collected language — without a garbage collector.
Core concepts covered:
· Ownership & Borrowing — Rust's memory model · Lifetimes — how Rust tracks reference validity · Traits & Generics — polymorphism without inheritance · Enums & Pattern Matching — algebraic data types · Result & Option — error handling without exceptions

Why Rust? An Honest Comparison

If you come from C++, you already live with the power — and the pain — of manual memory control. Use-after-free, dangling pointers, data races, segfaults that only surface in production at 3 AM. You've been there.

If you come from Python, you've probably hit the performance wall at some point. The GIL, 100x slower than native, type errors that hide until runtime. The tradeoff for that nice syntax.

Rust sits in a weird and interesting spot between these two worlds. You get C++-level performance with compile-time memory safety — no garbage collector, no runtime overhead, no segfaults. The tradeoff is that you need to learn a few new mental models, and the compiler is strict about enforcing them. But once these models click, you'll wonder why other languages don't work this way.

Where Rust Fits
Performance → Safety → Python safe, slow Go/Java GC-safe, decent perf C/C++ fast, unsafe Rust fast + safe
Aspect C++ Python Rust
Memory safety Manual — you can break it GC handles it Compiler enforces it
Null pointers Yes, segfaults None + runtime crash No null — Option<T>
Data races Possible, hard to detect GIL hides them Compile-time prevented
Error handling Exceptions / error codes Exceptions Result<T, E> — explicit
Performance Excellent 100x slower (typical) On par with C++
Compilation Fast (incremental) Interpreted Slower, but catches more bugs

1. Ownership — Rust's Big Idea

This is the thing you'll hear about the most when people talk about Rust. In C++, you manage memory yourself (or throw smart pointers at the problem and hope). In Python, the garbage collector handles it. In Rust, the compiler manages memory through a set of ownership rules that it checks at compile time. No runtime cost.

Three rules, that's it:

Rule 1: Every value has exactly one owner.
Rule 2: When the owner goes out of scope, the value is dropped (freed).
Rule 3: Ownership can be moved, but not implicitly copied (for heap data).

Here's what that looks like compared to C++ and Python:

fn main() {
    let s1 = String::from("hello");
    let s2 = s1;        // Ownership MOVES to s2. s1 is now invalid.

    // println!("{s1}");  // ← Compile error! s1 was moved.
    println!("{s2}");     // ✓ Works. s2 owns the string.
}

In C++, s2 = s1 would copy the string by default (expensive!) or, with std::move, you'd transfer it — but nothing stops you from accidentally using s1 afterwards (undefined behavior). In Python, both s1 and s2 would point to the same object, and the garbage collector would free it whenever both go out of scope.

Rust does something different: move by default, compile error if you touch the old variable. No runtime cost, no undefined behavior, no GC. You find out before the code ever runs.

Ownership Move
s1 owner "hello" heap data move ownership s2 new owner

If you do want a copy, Rust makes you explicit about it:

let s1 = String::from("hello");
let s2 = s1.clone();  // Explicit deep copy — you're paying for it intentionally.
println!("{s1} {s2}"); // Both valid.

For simple types like integers and floats, Rust copies automatically (they implement the Copy trait). This makes sense — copying a u64 is cheap. Copying a String is not, so Rust makes you opt in.

2. Borrowing — References Without the Danger

If ownership always moved, you'd have to pass data into a function and then return it just to keep using it. That would be annoying. Rust solves this with borrowing — you take a reference to a value without taking ownership of it.

fn calculate_length(s: &String) -> usize {   // s is a reference (borrow)
    s.len()
}   // s goes out of scope, but since it doesn't own the String, nothing is freed.

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);  // Lend s1, don't move it
    println!("{s1} has length {len}");  // s1 is still valid!
}

There are two kinds of borrows, and the compiler is strict about mixing them:

Shared references &T — you can have many at the same time (read-only).
Mutable references &mut T — you can have exactly one (exclusive write access).

You cannot have a &mut T and a &T to the same data at the same time. This is how Rust prevents data races at compile time.

let mut s = String::from("hello");

let r1 = &s;      // ✓ Shared borrow
let r2 = &s;      // ✓ Another shared borrow — fine!

// let r3 = &mut s; // ✗ Compile error: can't mutably borrow while shared borrows exist

println!("{r1} {r2}"); // After this, r1 and r2 are no longer used

let r3 = &mut s;   // ✓ Now ok — no active shared borrows
r3.push_str(" world");
println!("{r3}");

For C++ people: think of &T as const& — except the compiler actually guarantees nobody else is mutating the data while you hold the reference. No const_cast escape hatch. C++'s const is more of a polite suggestion; Rust's borrow checker is a wall.

3. Lifetimes — The Compiler's Proof That References Are Valid

Lifetimes have a reputation for being confusing, but the actual idea is small: a reference must never outlive the data it points to. That's it. Rust tracks this at compile time.

Most of the time, lifetimes are inferred automatically. You only need to annotate them when the compiler can't figure out the relationship on its own — usually in function signatures that return references.

// The lifetime 'a means: the returned reference lives at least as long
// as the shorter-lived input reference.
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}

fn main() {
    let s1 = String::from("long string");
    let result;
    {
        let s2 = String::from("hi");
        result = longest(&s1, &s2);
        println!("{result}"); // ✓ s2 still alive here
    }
    // println!("{result}"); // ✗ s2 dropped — result might dangle
}

In C++, the equivalent code compiles fine and gives you a dangling reference — a bug that might only blow up weeks later under the right conditions. In Rust, the compiler rejects it on the spot and tells you why.

Pro tip: Don't try to learn lifetimes in isolation. Write your code, let the compiler complain, and read the error. Rust's error messages for lifetime issues are weirdly good — they usually tell you exactly what annotation to add and where.

4. Traits — Polymorphism Without Inheritance

If you know C++, traits are roughly analogous to pure virtual interfaces — but without inheritance hierarchies or vtable headaches. In Python terms, think of typing.Protocol or abc.ABC. A trait says "any type that implements me can do X."

trait Summarizable {
    fn summary(&self) -> String;

    // Default implementation — types can override or keep it
    fn preview(&self) -> String {
        format!("{}...", &self.summary()[..50.min(self.summary().len())])
    }
}

struct Article {
    title: String,
    content: String,
}

struct Tweet {
    username: String,
    text: String,
}

impl Summarizable for Article {
    fn summary(&self) -> String {
        format!("{}: {}", self.title, &self.content[..100.min(self.content.len())])
    }
}

impl Summarizable for Tweet {
    fn summary(&self) -> String {
        format!("@{}: {}", self.username, self.text)
    }
}

// Use trait bounds to accept any type that implements Summarizable
fn print_summary(item: &impl Summarizable) {
    println!("{}", item.summary());
}

The nice thing compared to C++ inheritance: a type can implement as many traits as it wants. No diamond problem, no vtable mess, no fragile base class issues. You think about what a type can do instead of what it inherits from.

Generics use trait bounds to say "this function works for any type that can do X" — you get the flexibility of generic code with the safety of knowing exactly which operations are available:

// This function works for any type that can be displayed and compared
fn print_largest<T: std::fmt::Display + PartialOrd>(a: T, b: T) {
    if a >= b {
        println!("Largest: {a}");
    } else {
        println!("Largest: {b}");
    }
}

If you've used C++ templates, you know the horror of getting a 200-line error message because a type didn't have the right method. Rust's trait bounds catch that at the function signature — you know immediately what's expected.

5. Enums & Pattern Matching — The Power of Algebraic Types

Rust's enum is nothing like a C++ enum. It's closer to a tagged union — each variant can hold different data. Paired with pattern matching, this ends up being how you model a lot of real-world problems in Rust.

enum Shape {
    Circle { radius: f64 },
    Rectangle { width: f64, height: f64 },
    Triangle { base: f64, height: f64 },
}

fn area(shape: &Shape) -> f64 {
    match shape {
        Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
        Shape::Rectangle { width, height } => width * height,
        Shape::Triangle { base, height } => 0.5 * base * height,
    }
}

The match is exhaustive — the compiler forces you to handle every variant. Add a new shape? Every match in the codebase will refuse to compile until you deal with it. Compare that to C++'s switch where a missing case just silently falls through.

Python added match in 3.10, which looks similar on the surface, but there's no exhaustiveness check and no type information backing it up.

6. Error Handling — No Exceptions, Just Types

Rust doesn't have exceptions. Instead, errors are just types — two enums from the standard library that make failure explicit and hard to ignore:

// Option — for values that might not exist (replaces null)
enum Option<T> {
    Some(T),
    None,
}

// Result — for operations that might fail (replaces exceptions)
enum Result<T, E> {
    Ok(T),
    Err(E),
}

Here's how that works in a real function:

use std::fs;
use std::io;

fn read_config(path: &str) -> Result<String, io::Error> {
    let content = fs::read_to_string(path)?;  // ? propagates error if it fails
    Ok(content)
}

fn main() {
    match read_config("config.toml") {
        Ok(config) => println!("Config loaded: {}", &config[..50.min(config.len())]),
        Err(e) => eprintln!("Failed to load config: {e}"),
    }
}

The ? operator is doing a lot of work here — it's shorthand for "if this failed, return the error to the caller." It replaces what would be a verbose match + early return, and keeps error handling lightweight without hiding it.

C++: An unhandled exception unwinds the stack and crashes. In Rust, if you have a Result and don't check it, the compiler warns you. If you unwrap() a None or Err, you get a panic with a clear message — not undefined behavior.

Python: You can silently swallow any exception with except: pass and nobody complains. Rust won't let you ignore a failure path — you have to at least acknowledge it exists.

7. Structs & impl — Methods Without Classes

No classes, no inheritance, no constructors. You define your data with struct and bolt on behavior with impl blocks:

struct Sensor {
    id: u32,
    readings: Vec<f64>,
}

impl Sensor {
    // Associated function (like a static method / constructor)
    fn new(id: u32) -> Self {
        Self { id, readings: Vec::new() }
    }

    // Method — takes &mut self because it modifies state
    fn record(&mut self, value: f64) {
        self.readings.push(value);
    }

    // Method — takes &self because it only reads
    fn average(&self) -> f64 {
        let sum: f64 = self.readings.iter().sum();
        sum / self.readings.len() as f64
    }
}

fn main() {
    let mut sensor = Sensor::new(42);
    sensor.record(23.5);
    sensor.record(24.1);
    println!("Average: {:.1}", sensor.average()); // 23.8
}

Notice &self vs &mut self in the method signatures — mutability is always explicit. If a method takes &self, you can tell at a glance that it won't change anything. And the compiler holds you to it.

Putting It All Together — A Real Example

Here's a small but realistic example that ties all of these concepts together — a log parser:

use std::collections::HashMap;

#[derive(Debug)]
enum ParseError {
    EmptyInput,
    InvalidFormat(String),
}

trait Parseable {
    fn parse_line(line: &str) -> Result<Self, ParseError> where Self: Sized;
}

#[derive(Debug)]
struct LogEntry {
    timestamp: String,
    level: String,
    message: String,
}

impl Parseable for LogEntry {
    fn parse_line(line: &str) -> Result<Self, ParseError> {
        if line.is_empty() {
            return Err(ParseError::EmptyInput);
        }
        let parts: Vec<&str> = line.splitn(3, ' ').collect();
        if parts.len() < 3 {
            return Err(ParseError::InvalidFormat(line.to_string()));
        }
        Ok(LogEntry {
            timestamp: parts[0].to_string(),
            level: parts[1].to_string(),
            message: parts[2].to_string(),
        })
    }
}

fn count_by_level(entries: &[LogEntry]) -> HashMap<&str, usize> {
    let mut counts = HashMap::new();
    for entry in entries {
        *counts.entry(entry.level.as_str()).or_insert(0) += 1;
    }
    counts
}

fn main() {
    let raw_logs = vec![
        "2026-03-27T10:00:00 INFO Server started",
        "2026-03-27T10:00:01 WARN High memory usage",
        "2026-03-27T10:00:02 ERROR Connection refused",
        "2026-03-27T10:00:03 INFO Request handled",
        "",  // This will fail parsing — and we handle it gracefully
    ];

    let entries: Vec<LogEntry> = raw_logs
        .iter()
        .filter_map(|line| LogEntry::parse_line(line).ok())
        .collect();

    let counts = count_by_level(&entries);
    for (level, count) in &counts {
        println!("{level}: {count}");
    }
}

Everything we talked about shows up in this one example: LogEntry owns its strings, count_by_level borrows the slice, Parseable is a trait, ParseError is an enum, and Result + filter_map handles errors — no try-catch, no null checks.

The Learning Curve — And How to Get Over It

Yes, there's a learning curve. But it's mostly front-loaded — the first couple of weeks are the hardest, while you're getting used to ownership and the borrow checker. After that, you and the compiler start working together instead of fighting.

The Rust Learning Curve (Realistic)
Time → Productivity Ownership clicks Lifetimes dip Compiler is your friend Productive!

A few things that helped me get through it faster:

  1. Don't fight the compiler. Read the error messages — they're genuinely good. They tell you what broke, why, and usually suggest the fix.
  2. Nail ownership and borrowing first. Everything else — lifetimes, traits, async — builds on this foundation. Once it clicks, the rest follows.
  3. Start small. CLI tools, file parsers, data processors. Don't jump straight to async web servers.
  4. Use clone() freely at the start. It's not idiomatic long-term, but it lets you focus on what your code does before worrying about how it borrows. You can tighten it up later.
  5. Treat compiler errors as learning moments. Every time the borrow checker stops you, it's pointing at something that would've been a real bug in C++. That reframe makes the friction feel productive.

Free Resources

Rust has some of the best free learning material of any language I've seen. Here's what I'd actually point people to:

Essential
The official intro, free online. I'd read the first 6 chapters to get the basics down, then treat the rest as a reference you come back to. There's also a Brown University interactive version with quizzes and memory visualizations that really helped me grok ownership.
Hands-on
Small exercises you run in your terminal. Each one targets a specific concept. I'd do these alongside The Book — read a chapter, then do the matching exercises. cargo install rustlings to get started.
Reference
If you learn better by reading code than prose, start here. Every concept gets a runnable example you can tweak in the browser.
Course
Google's internal Rust course for their Android team, open-sourced. It's dense but well-structured — covers the full language in about 4 days of material.
Exercises
By Luca Palmieri. More structured than Rustlings, and the exercises build on each other. Good complement if you've already read some of The Book.
Video
Deep dives into topics like iterators, smart pointers, async, and lifetimes. Not for day one — watch these after you've written a bit of Rust and have questions about how things work under the hood.

Recommended Books

If you want to go deeper, these are the books I'd actually point you to:

📘
Programming Rust, 2nd Ed.
Jim Blandy, Jason Orendorff & Leonora Tindall — O'Reilly
Goes deeper than "The Book" on basically every topic — the standard library, the ownership model, unsafe. If you're coming from C++ and want the full picture without hand-holding, this is it.
📙
Rust for Rustaceans
Jon Gjengset — No Starch Press
The "what's next" book once you've built a few things. Covers design patterns, unsafe, macros, async internals, and API design. Don't start with this — come back to it after a month or two.
📗
Rust in Action
Tim McNamara — Manning
You learn by building systems projects — networking, file systems, CPU emulation. If you're the type who needs a project to stay motivated (same), this one's great.
📕
Zero to Production in Rust
Luca Palmieri
Walks you through building a real backend API from scratch — testing, CI/CD, observability, deployment. If you're a backend developer looking to bring Rust into your stack, start here.

Final Thoughts

The first week or two with Rust can feel rough. The compiler will reject things you know should work, and you'll spend time figuring out why. But there's a turning point where the ownership model stops being an obstacle and starts being a tool — you begin to think in terms of who owns what, and the code just flows.

What I didn't expect is how much Rust changed the way I think about other languages too. I started being more deliberate about ownership in C++, more careful about error paths in Python. That's probably the most underrated thing about learning Rust — you take the mental models with you everywhere.

Good luck out there.

Back to all posts © 2026 Hokwang Choi