2024-07-06
HSPACE Rust Lectures #1 — Key Features of Rust
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:
- Every value has exactly one owner.
- When the owner goes out of scope, the value is dropped.
- 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 —
pubcontrols 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 ofCopytypes). 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.