Strongly Typed, Squared: How I Think About Rust's Memory

If someone asked me "Matheus, what's Rust's main feature?", I wouldn't say speed or the borrow checker. I'd say the type system.

Yes, Rust is strongly typed, like C and C++. But it goes further. It's almost strongly typed, squared. You still have to say exactly what type you want, and on top of that Rust forces you to answer questions other languages never ask.

Who owns this value? How long does it live? Who's allowed to change it right now?

In C you can skip those questions and your code will probably compile. It might even run. Rust won't let you skip them.

The cost

That makes Rust harder to write. You can't just express what you want. You have to justify it to the compiler first.

I've lost count of how many times I knew exactly what I wanted my code to do and still spent twenty minutes negotiating with the borrow checker.

The payoff

In exchange, you get guarantees. If it compiles, the code runs the way you designed it. No undefined behavior hiding in a small detail you'd only catch in production, if you catch it at all.

For a long time I understood that tradeoff without really feeling why it worked. Then I started reading Rust for Rustaceans by Jon Gjengset.

Two models of memory

Early in the book, Jon splits Rust's memory model into two mental models, and that idea stuck with me.

The high-level model is how the compiler sees your code. It isn't about bytes or addresses. It's about relationships: which value depends on what, who holds a reference to whom, and when that reference stops being valid. This is the world of ownership and borrowing.

The low-level model is what you'd think about in any systems language. Where data actually lives in memory, when space is allocated, when it's freed, and how much it costs to move, copy, or clone something.

Here's a tiny example that shows both at once:

let mut names = vec![String::from("Jay")];
let first = &names[0];

names.push(String::from("Bia")); // error: cannot borrow `names` as mutable
println!("{first}");

From the high-level view, it's simple: first borrows from names, so you can't mutate names while that borrow is alive. Rule broken, code rejected.

From the low-level view, you see why the rule exists. push might need more capacity, which means allocating a new buffer and moving everything there. first would then point to freed memory, a classic use-after-free.

The high-level rule is a compact way of protecting you from the low-level disaster.

Thinking in both

This is what clicked for me. In C or C++, you mostly think in the low-level model. You manage memory, and you keep the rest in your head (or you don't, and you find out later).

In Rust you think in both at the same time. You still care about allocations and the cost of a .clone(). But you also have to think about how the compiler will see your data, so that what you write is actually "safe", in the Rust sense of the word.

And that high-level model is just ownership and borrowing. Those rules exist to prevent a specific set of problems:

It's worth being honest about what they don't cover too. Memory leaks are considered safe in Rust: Box::leak, mem::forget, or a cycle of Rcs will happily leak. And Rust prevents data races, not race conditions in general. Deadlocks and logic bugs are still yours.

The guarantees are strong, and they're also precise. Knowing the edges is part of trusting them.

What changed for me

Splitting memory into these two models made the borrow checker stop feeling like an opponent.

When it rejects my code now, I try to ask two questions. What relationship did I break in the high-level model? And what would actually go wrong in the low-level one if Rust let it through?

Most of the time, the second answer is something I'd never want to debug at 2 a.m.

That's the deal with being strongly typed, squared. You answer harder questions up front, and you stop having to answer them later.