DEV Community

Cover image for πŸ¦€ Rust Foundations β€” The Stuff That Finally Made Things Click
Utkarsh Mishra
Utkarsh Mishra

Posted on

πŸ¦€ Rust Foundations β€” The Stuff That Finally Made Things Click

"Rust compiler and Clippy are the biggest tsunderes β€” they'll shout at you for every small mistake, but in the end… they just want your code to be perfect."


Why I Even Started Rust

I didn't pick Rust out of curiosity or hype.

I had to.

I'm working as a Rust dev at Garden Finance, where I built part of a Wallet-as-a-Service infrastructure. Along with an Axum backend, we had this core Rust crate (standard-rs) handling signing and broadcasting transactions across:

  • Bitcoin
  • EVM chains
  • Sui
  • Solana
  • Starknet

And suddenly… memory safety wasn't "nice to have" anymore.

It was everything.

Rust wasn't just a language β€” it was a guarantee.

But yeah… in the beginning?

It felt like the compiler hated me :(

So I'm writing this to explain Rust foundations in the simplest way possible β€” from my personal notes while reading "Rust for Rustaceans".


What is a "value" in Rust?

A value in Rust is:

Type + actual data from that type's domain

let x = 42;
Enter fullscreen mode Exit fullscreen mode

This isn't just 42. It's:

  • Type: i32
  • Value: 42

Rust always tracks both. That's why it feels stricter than dynamically typed languages like Python or JavaScript β€” there's no "figure it out at runtime." Rust knows everything at compile time.


Borrowing β€” The part that scares everyone

Let's look at this:

fn main() {
    let mut x = 42;

    let y = &x;        // immutable borrow
    let z = &mut x;    // mutable borrow

    println!("{}", z);
    println!("{}", y);
}
Enter fullscreen mode Exit fullscreen mode

Your brain immediately says:

"You can't have a mutable and immutable borrow at the same time. This should fail."

And classically, you'd be right.

But here's the thing that changed everything for me:

Non-Lexical Lifetimes (NLL)

Before NLL, Rust kept a borrow alive until the end of the scope block {}. That made the code above illegal β€” y would still be "alive" when z tried to take a mutable borrow.

After NLL (which is now the default), Rust is smarter. It keeps a borrow alive only until its last actual use.

Let's trace what the compiler sees:

y is created   (immutable borrow of x)
y is never used again
β†’ borrow ends immediately

z is created   (mutable borrow of x)
β†’ no active immutable borrows exist
β†’ βœ… Allowed
Enter fullscreen mode Exit fullscreen mode

Rust isn't being lenient. It's being precise.

NLL made Rust go from "technically correct but infuriating" to "actually ergonomic."


Memory β€” Stack vs Heap vs Static

This is the part where things start clicking.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  Static/Data    β”‚ ← Binary code + static variables + string literals
β”‚  Segment        β”‚   (baked into your executable)
β”œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€
β”‚  Heap           β”‚ ← Box, Vec, String (grows upward β†’)
β”‚       ↕         β”‚
β”‚       ↕         β”‚
β”‚  Stack          β”‚ ← Local variables, function calls (grows downward ←)
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

Stack

  • Fastest read/write memory
  • Each function call creates a stack frame β€” a contiguous chunk holding all local variables and arguments for that call
  • Stack frames are the physical basis of lifetimes β€” when a function returns, its frame is popped, and everything in it is gone

Heap

  • A pool of memory not tied to the call stack
  • Values here can live past the function that created them
  • Access via pointers β€” you allocate, get back a pointer, pass it around
  • Easiest way to heap-allocate: Box::new(value)
  • Want something to live for the entire program? Use Box::leak():
// This gives you a 'static reference β€” lives forever
let config: &'static Config = Box::leak(Box::new(load_config()));
Enter fullscreen mode Exit fullscreen mode

Static Memory

  • Part of your actual binary on disk
  • static variables and string literals ("hello") are baked directly into the executable
  • Loaded into memory when the OS runs your program

&str vs String β€” The Confusion Killer

These two look similar but live in totally different places.

&str β€” a borrowed view into existing bytes

Stack:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ s: &str     β”‚ ← pointer + length (that's it)
β”‚ ptr: 0x1000 β”‚ ───┐
β”‚ len: 5      β”‚    β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
                   β”‚
Static Memory:     β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚ 0x1000:     β”‚ β†β”€β”€β”˜
β”‚ "hello"     β”‚   ← baked into your binary
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

The reference (ptr + len) lives on the stack. The actual bytes live in static memory, embedded in your binary at compile time.

&str is immutable and borrowed β€” you don't own the data.

String β€” owned, heap-allocated bytes

Stack:
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚ s: String   β”‚
β”‚ ptr: 0x2000 β”‚ ───┐
β”‚ len: 5      β”‚    β”‚
β”‚ cap: 5      β”‚    β”‚   ← also tracks capacity for growing
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜    β”‚
                   β”‚
Heap:              β”‚
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”    β”‚
β”‚ 0x2000:     β”‚ β†β”€β”€β”˜
β”‚ "hello"     β”‚   ← allocated at runtime, can grow
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
Enter fullscreen mode Exit fullscreen mode

String owns its data. It can grow, shrink, be mutated.

When to use which:

Use &str when... Use String when...
You just need to read/pass text You need to own, build, or modify text
Taking function arguments (prefer &str) Returning text from a function
Working with string literals Reading user input or building dynamic strings

Quick rule of thumb: Function parameters? Use &str. Owned data you return or store in a struct? Use String.


const vs static

These look similar. They're not.

const β€” compile-time copy-paste

const MAX_RETRIES: u32 = 3;
Enter fullscreen mode Exit fullscreen mode
  • No memory address. The compiler literally copy-pastes the value everywhere it's used
  • Every usage creates a fresh copy
  • Must be known at compile time (must implement Copy)
  • Think of it like a find-and-replace the compiler does before running anything

static β€” single memory location, lives forever

static MAX_POINTS: u32 = 100;
Enter fullscreen mode Exit fullscreen mode
  • Has a real memory address β€” same one every time
  • Lives for the entire program ('static lifetime)
  • All references point to the same place
  • Exists in your binary's static memory section

You can actually see the difference:

const X: u32 = 5;
static Y: u32 = 5;

fn main() {
    println!("{:p}", &X);  // may print different addresses each time
    println!("{:p}", &X);  // copy-pasted, so may differ

    println!("{:p}", &Y);  // always the same address
    println!("{:p}", &Y);  // always the same address
}
Enter fullscreen mode Exit fullscreen mode

Quick rule: Use const for magic numbers and fixed values. Use static when you need a single global memory location (like a config that multiple places reference).


Ownership β€” Rust's Core Rule

A value has exactly one owner. Always.

When the owner goes out of scope, the value is dropped. Memory is freed. No garbage collector needed.

let s1 = String::from("hello");
let s2 = s1;  // ownership MOVES to s2

println!("{}", s1);  // ❌ compile error: s1 is moved
Enter fullscreen mode Exit fullscreen mode

Two important behaviors:

  • Primitive types (i32, f64, bool, etc.) implement Copy β€” they're duplicated on assignment, not moved
  • Heap data (String, Vec, Box) is moved β€” ownership transfers, the original is invalid

This is enforced by the borrow checker at compile time. Zero runtime cost.


Drop Order β€” Underrated but Powerful

Rust drops variables in reverse declaration order.

Why? Because a variable declared later might reference one declared earlier. Dropping in forward order could leave dangling references.

struct HasDrop(&'static str);

impl Drop for HasDrop {
    fn drop(&mut self) {
        println!("Dropping: {}", self.0);
    }
}

fn main() {
    let var1 = HasDrop("Variable 1");
    let var2 = HasDrop("Variable 2");

    let tuple = (
        HasDrop("Tuple elem 0"),
        HasDrop("Tuple elem 1"),
        HasDrop("Tuple elem 2"),
    );

    let var3 = HasDrop("Variable 3");

    println!("End of scope!");
}
Enter fullscreen mode Exit fullscreen mode

Output:

End of scope!
Dropping: Variable 3      ← last declared, drops first
Dropping: Tuple elem 0    ← tuple drops, elements in SOURCE order
Dropping: Tuple elem 1
Dropping: Tuple elem 2
Dropping: Variable 2      ← variables drop in reverse
Dropping: Variable 1
Enter fullscreen mode Exit fullscreen mode

Notice the split behavior:

What Drop order Why
Variables Reverse Later vars may reference earlier ones
Nested (tuple/struct fields) Source order Rust doesn't allow self-references within a single value

The &mut move problem

Here's a footgun related to ownership + mutable references:

// This is ILLEGAL β€” and for good reason:
fn broken(s: &mut String) {
    let stolen = *s;  // trying to move OUT of a mutable reference
    // s now points to... nothing? πŸ’€
}
Enter fullscreen mode Exit fullscreen mode

If you move a value out of a &mut reference, the original owner still exists and will try to drop it β€” causing a double free. Undefined behavior. Chaos.

The fix: use mem::replace() or mem::take() to safely swap in a new value:

use std::mem;

fn take_it(s: &mut String) -> String {
    mem::take(s)  // moves out the String, leaves an empty String in its place
}
Enter fullscreen mode Exit fullscreen mode

Interior Mutability β€” Rust Bending Its Own Rules

Normally Rust's rule is:

Either many immutable references OR one mutable reference. Never both.

But sometimes you genuinely need to mutate something through a shared (&T) reference. That's where interior mutability comes in β€” it moves the borrow check from compile time to runtime.

For single-threaded code:

Cell<T> β€” for types that implement Copy:

use std::cell::Cell;

let x = Cell::new(42);
x.set(100);               // mutate through shared reference
println!("{}", x.get()); // 100
Enter fullscreen mode Exit fullscreen mode

RefCell<T> β€” for types that don't implement Copy:

use std::cell::RefCell;

let v = RefCell::new(vec![1, 2, 3]);
v.borrow_mut().push(4);   // runtime borrow check
Enter fullscreen mode Exit fullscreen mode

⚠️ The footgun with RefCell: The borrow check happens at runtime. If you violate the rules (two mutable borrows), it doesn't fail at compile time β€” it panics at runtime:

let v = RefCell::new(vec![1, 2, 3]);
let b1 = v.borrow_mut();
let b2 = v.borrow_mut(); // πŸ’₯ panics: already mutably borrowed
Enter fullscreen mode Exit fullscreen mode

Use RefCell sparingly β€” it trades compile-time safety for runtime flexibility. If it panics in production, that's on you.

For multi-threaded code:

// Share AND mutate across threads:
let x = Arc::new(Mutex::new(vec![1, 2, 3]));

// Just need a fast counter across threads:
let x = Arc::new(AtomicU32::new(0));

// Read-only sharing across threads:
let x = Arc::new(vec![1, 2, 3]);
Enter fullscreen mode Exit fullscreen mode

Note: Arc alone only gives you shared ownership β€” it does not give you mutability. You need Mutex (or RwLock) for that.

Quick cheat sheet:

Scenario Use
Single thread, Copy type Cell<T>
Single thread, non-Copy type RefCell<T>
Multi-thread, any type Arc<Mutex<T>>
Multi-thread, fast counter Arc<AtomicU32>
Multi-thread, read-mostly Arc<RwLock<T>>

Lifetimes β€” Don't Overcomplicate This

A lifetime ensures a reference doesn't outlive the data it points to.

Lifetimes are compile-time only β€” they're erased after compilation and have zero runtime cost.

The compiler infers most lifetimes automatically. You only write them explicitly when the compiler can't figure it out on its own.

// The compiler needs help here β€” which input does the output reference?
fn longest<'a>(x: &'a str, y: &'a str) -> &'a str {
    if x.len() > y.len() { x } else { y }
}
Enter fullscreen mode Exit fullscreen mode

The 'a annotation says: "the output reference lives at least as long as the shorter of x and y."

Without it, the compiler has no idea whether the returned reference points to x or y β€” and therefore can't verify it's safe.

The dangling reference problem lifetimes solve:

fn dangle() -> &String {    // ❌ what lifetime does this have?
    let s = String::from("hello");
    &s                       // s drops here, reference would dangle
}
Enter fullscreen mode Exit fullscreen mode

The compiler rejects this because the reference would outlive the data it points to. Lifetimes make this impossible to sneak through.


Final Thought

Rust isn't hard.

It just forces you to think clearly about:

  • who owns what
  • who borrows what
  • how long things live

The compiler isn't your enemy. It's a tsundere β€” it yells at you precisely because it refuses to let your code betray you in production.

Once that mental model clicks?

You stop fighting Rust… and start trusting it.


All notes from "Rust for Rustaceans" by Jon Gjengset. Highly recommend.

Top comments (0)