</>PatchNote
Back to posts

2024-07-06

HSPACE Rust Lectures #1 — Key Features of Rust

RustHSPACE 2024OwnershipTrait

The first session was a tour of Rust's major features. Each of them gets its own session later, so here I focused on the one question underneath all of them: what did this language's design actually choose?

What Rust picked

Memory management in programming languages splits roughly two ways:

  • Manual (C/C++) — fast, but use-after-free, double-free, and leaks are all on you.
  • Garbage collection (Java, C#, Go) — safe, but you pay for a runtime and GC pauses.

Rust takes a third path: it puts ownership rules in the type system and checks them at compile time, so deallocation points are decided with no runtime cost. No GC, no free(), and yet leaks and use-after-free are ruled out. That's the starting point for everything else.

Ownership

Three rules:

  1. Every value has exactly one owner.
  2. When the owner goes out of scope, the value is dropped.
  3. Ownership can be moved, and after a move the original variable is unusable.

Comparing with C++ makes the difference sharp. Passing a std::string by value in C++ makes a copy (or, after a move, leaves the original "valid but unspecified"). In Rust the original variable is simply invalidated.

fn f(mut s: String) {
    s.push_str(", world!");
}

fn main() {
    let mut s = String::from("Hello");

    f(s);   // ownership of s moves into f

    // println!("{s}");  // error: value used here after move
}

This felt restrictive at first, but flipped around it means the code always says who is responsible for a value right now. You never have to reason about when a destructor runs.

Borrowing

Moving ownership every time would get you nowhere, so values can be borrowed through references:

  • &T — shared reference. Many can exist at once; read-only.
  • &mut T — mutable reference. Only one at a time; can write.

And the two cannot coexist.

fn main() {
    let mut a = 10;
    let b = &a;

    {
        let c = &mut a; // error: cannot borrow `a` as mutable, it is also borrowed as immutable
        *c = 20;
    }

    println!("a : {a}");
    println!("b : {b}");   // b is used here, so its borrow must live this long
}

The important line is the last one. Because b is used at the end, its borrow is still alive where c is created — hence the conflict. Drop b from the println! and it compiles. That's where it clicked that a borrow's lifetime runs to its last use, not to the end of its declaration's scope.

This single rule blocks the definition of a data race — concurrent access where at least one side writes — at compile time.

Lifetimes

A reference must not outlive what it points to. For the compiler to check that, it needs to know which reference is tied to which value, and that's what lifetime parameters express.

#[derive(Debug)]
struct Number(i32);

// fn min(a: &Number, b: &Number) -> &Number  // error: can't infer the return lifetime
fn min<'a>(a: &'a Number, b: &'a Number) -> &'a Number {
    if a.0 < b.0 { a } else { b }
}

'a promises that the returned reference lives no longer than the shorter of a and b. It isn't a new constraint so much as writing down a fact that had to hold anyway. Lifetime elision means you rarely have to spell it out.

OOP in Rust

Rust has no classes and no inheritance. Instead you get structs + impl blocks + traits, which cover the parts of OOP that are actually load-bearing.

  • Encapsulation — pub controls visibility, and methods declare their receiver as &self / &mut self / self, so ownership shows up in the signature.
  • Polymorphism — traits.
  • Inheritance — absent. Composition and default trait methods take its place.

A trait is a contract saying "this type can do this."

trait Moveable {
    fn move_to(&mut self, x: i32, y: i32);
}

impl Moveable for Player {
    fn move_to(&mut self, x: i32, y: i32) {
        if self.stamina <= 0 {
            println!("Not enough stamina to move");
            return;
        }
        self.stamina -= 1;
        println!("Moving player to ({x}, {y})");
    }
}

To put different types in one container and drive them through one interface, you use a trait object:

let mut objects = vec![
    Box::new(Player { /* ... */ }) as Box<dyn Moveable>,
    Box::new(Pet    { /* ... */ }) as Box<dyn Moveable>,
];

for object in objects.iter_mut() {
    object.move_to(10, 20);
}

Box<dyn Moveable> is a fat pointer: a data pointer plus a vtable pointer. So the moment you write dyn, you're paying for dynamic dispatch. Generics (fn f<T: Moveable>(x: T)) go the other way — they're monomorphized into one copy per type, giving static dispatch and inlining. The same trait can be used either way, and the choice is mine, which I found genuinely interesting.

Enums, Option, Result

Rust's enum isn't C's enum — it's a tagged sum type, where each variant can carry its own data.

Two standard library types are built on it:

enum Option<T> { Some(T), None }
enum Result<T, E> { Ok(T), Err(E) }

Option replaces null pointers. "There might be no value" is written in the type, and getting the value out forces you to handle the empty case, so null dereferences disappear structurally.

let x: Option<i32> = Some(5);
let y: Option<i32> = None;

println!("{}", x.unwrap_or(0));

match y {
    Some(v) => println!("Value: {}", v),
    None    => println!("No value"),
}

Result replaces exceptions. A function that can fail says so in its return type.

fn square_if_even(num: i32) -> Result<i32, String> {
    if num % 2 == 0 {
        Ok(num * num)
    } else {
        Err(String::from("Not even"))
    }
}

Unlike exceptions, the caller can't ignore it. Dropping a Result on the floor produces a warning, and using the value means passing through the failure path somehow — match, ?, whatever.

The tool for taking these apart is pattern matching, and match is checked for exhaustiveness. Add a variant and every place that didn't handle it turns into a compile error.

Type conversion

Rust does no implicit numeric conversion.

let a = 10;
let b = 30.4;

// let c = a + b;      // error: mismatched types
let c = a as f64 + b;  // explicit

Every integer promotion and narrowing that used to happen silently in C/C++ is now visible. Annoying at first, but cheap compared to the hours I've spent debugging precision and signedness bugs.

Copy and Clone

There's an exception to the move rule: types implementing Copy stay usable after being passed.

  • Copy — types where a bitwise copy is enough (integers, bool, char, f64, tuples/arrays of Copy types). Assignment and passing copy instead of move.
  • Clone — an explicit .clone() deep copy. It may allocate, so the cost is visible at the call site.
// String implements Clone but not Copy
let name = String::from("Chris");
hello(name.clone());   // pass a copy
hello(name);           // pass the original (moves here)

// i32 is Copy + Clone
let num = 5;
println!("{}", square(num));
println!("{}", square(num + 5));  // num is still valid

Why isn't String Copy? It points at a heap buffer, so a bitwise copy would leave two pointers to it and a double free. The distinction "cheap copy → Copy, expensive copy → Clone" is baked into the language, which means every expensive copy shows up in the source as .clone().

What traits buy you: sorting f64

The example that made traits click for me:

let mut arr = vec![1.2, 4.5, 3.1, -5.7, 6.3];

// arr.sort();   // error: f64 doesn't implement Ord
arr.sort_by(|a, b| a.partial_cmp(b).unwrap());

sort() requires Ord (a total order), and f64 isn't totally ordered because of NaN — NaN isn't even equal to itself, which breaks the comparison laws. So f64 only implements the partial order PartialOrd, and sorting means going through partial_cmp and asserting via unwrap() that no NaN is present.

In another language this would surface at runtime as a weirdly sorted array. Here it surfaces first as a type that doesn't fit, so it doesn't compile.

A taste of closures and concurrency

A closure is a function that captures surrounding variables — and how it captures them changes its type.

fn call_twice<F>(closure: F) where F: Fn() {
    closure();
    closure();
}

fn main() {
    let name = String::from("Chris");
    let hello = || {
        println!("Hello, {name}");
        drop(name);          // consumes name → this closure is FnOnce
    };

    call_twice(hello);       // error: FnOnce doesn't satisfy the Fn bound
}

drop(name) consumes the captured value, so this closure can only be called once — it's FnOnce, not Fn, and "call it twice" becomes a compile error. The closure traits (Fn / FnMut / FnOnce) get a full session later.

Concurrency is caught by the same machinery:

// Rc can't move between threads (it isn't Send)
// thread::spawn(move || { rc2.clone(); });   // error

use std::sync::Arc;
let arc1 = Arc::new("Hyundai".to_string());
let arc2 = arc1.clone();

thread::spawn(move || {
    let _ = arc2.clone();   // OK
});

Rc bumps its reference count non-atomically, so sharing it across threads is forbidden at the type level (it isn't Send). Crossing a thread boundary requires Arc, whose counter is atomic.

That's what Rust's "fearless concurrency" actually is. There's no new theory of concurrency here — the ownership and trait checks simply extend across thread boundaries.

Takeaways

After one lap, what stuck was the sense that these features all branch from a single root: ownership.

  • ownership → borrowing → lifetimes: memory safety
  • the same rules extended across threads: no data races
  • Option/Result + pattern matching: null and exceptions replaced by types
  • traits: polymorphism without inheritance, with static or dynamic dispatch as a choice

Next up: fundamental types — fixed-size numerics, how overflow is handled, arrays/vectors/slices, and the relationship between String and &str.