Home  /  Lessons  /  In Strategy  /  Rust Fundamentals: Ownership, Borrowing, and the OOP Power You Think You're Losing
11 Rustminute read

Rust Fundamentals: Ownership, Borrowing, and the OOP Power You Think You're Losing

I kept a tally for a quarter while I was relearning everything I thought I knew about programming. The number I want you to sit with is zero.

An abstract conceptual still life on a polished slate surface: three interlocking metal gears…

I kept a tally for a quarter while I was relearning everything I thought I knew about programming. The number I want you to sit with is zero. Across every line of safe Rust I shipped in that stretch — and I was deliberately writing the kind of pointer-juggling, shared-state code that used to wreck me — I hit zero null-dereference panics, zero use-after-free bugs, and zero data races. Not "fewer." Zero. That is not a personal-discipline number. It is a property of the language. And understanding why that zero is even possible is, I think, the real entry point into Rust fundamentals — more than any syntax tour will give you.

Here's the verdict up front, because I know why you're reading this: you are not losing OOP's power by moving to Rust. You are getting the same guarantees — encapsulation, polymorphism, safe shared state — moved from runtime to compile time, delivered through ownership and traits instead of classes and inheritance. The panic you feel — "how do I do polymorphism without classes?" — is real and reasonable, and it has a concrete answer. Let me earn that claim slowly.

What the zero actually measured

When I first saw Rust people throw around "memory safe without a garbage collector," I rolled my eyes a little. Every language community claims its thing is the safe one. So let me be precise about what that zero was, because precision is the whole point of this language and the whole point of this piece.

The zero did not mean my code was correct. I shipped plenty of logic bugs. I had off-by-one errors, I had a function that summed the wrong column, I had a state machine that deadlocked because I designed it badly. Rust did not save me from being wrong about the problem.

What the zero measured is narrower and, honestly, more interesting. It measured a class of bug that the compiler simply refused to let me compile. In safe Rust, the borrow checker proves — before your program ever runs — that:

That is what got me to zero. Not testing. Not code review. Not me being careful. The compiler did the work that, in Python, I was doing in my head every time I asked "wait, does anyone else have a reference to this list?" — and in Java, the garbage collector was quietly doing for me while hiding the aliasing bugs underneath.

So the zero is a compile-time guarantee, not a benchmark. Hold onto that distinction. It's the hinge the whole language swings on. Java moves these problems to runtime and pays with a GC and the occasional ConcurrentModificationException. Python moves them to runtime and pays with the GIL and a lot of defensive copying. Rust moves them to compile time and pays with a learning curve — the one you're standing at the bottom of right now.

What the zero doesn't measure: your confusion

Here's what that number conveniently leaves out. It doesn't measure the three weeks I spent feeling stupid.

Because the first real wall I hit wasn't memory safety. It was the moment I went to build something with structure — a system with different kinds of things that all needed to behave a little differently — and reached for the tool I'd reached for my entire career. A base class. An extends. An abstract method. And Rust just... didn't have it. No class. No inheritance. I stared at the screen genuinely thinking: this language is missing a feature, and everyone is pretending that's fine.

If you came from Python or Java, you know this feeling. You've internalized that the way you model a problem is: find the nouns, make them classes, find what they have in common, push it up into a parent, override what differs. That instinct is so deep it doesn't feel like a choice. It feels like programming. And Rust takes the parent class away and says, calmly, do it differently.

I want to validate that this is disorienting before I tell you it's fine. People who say "oh, composition over inheritance, it's better, you'll see" are usually skipping the part where you feel like you've lost a limb. You haven't lost anything. But it takes building a couple of real things before you believe that. Let me show you the pieces that got me there.

Ownership is the thing your classes were quietly doing

Start with encapsulation, because that's the OOP guarantee you're most afraid of losing, even if you wouldn't phrase it that way.

What does encapsulation actually buy you? Strip away the textbook definition. The real value is this: it controls who is allowed to change your data, so that your invariants can't be broken from across the codebase. A private field is a promise that nothing outside this class will reach in and corrupt it.

Now look at what that promise is actually worth in Python:

class Account:
    def __init__(self, balance):
        self._balance = balance  # "private" by convention

a = Account(100)
b = a            # b and a are the same object
b._balance = -9999   # nothing stops me
A dramatic close-up of a single illuminated computer monitor in a dim home office…

The underscore is a polite request. b = a didn't copy anything — a and b now both point at the same object, and either of them can quietly mutate it. You've almost certainly been bitten by this: you pass a list into a function, the function mutates it, and a value changes somewhere you didn't expect. Python's encapsulation is a social contract. It holds because people are nice, until they aren't, or until you are tired at 2 a.m.

Rust's answer is ownership, and it's the same encapsulation guarantee with the politeness removed and the compiler installed in its place:

struct Account {
    balance: i64,
}

let a = Account { balance: 100 };
let b = a;              // ownership MOVES from a to b
// println!("{}", a.balance);  // compile error: a is gone

That let b = a does not make b an alias for the same object the way Python does. It moves ownership. After that line, a is no longer valid, and trying to use it is a compile error, not a runtime surprise. There is exactly one owner of that data at a time, and the compiler knows who it is.

That single rule — one owner — is doing the work your private fields were trying to do, plus more. It's not just "who can see this field." It's "who is responsible for this data, and when does it get cleaned up." When the owner goes out of scope, the data is freed. Automatically. No GC pause, no free() you can forget, no double-free. The encapsulation and the memory management turn out to be the same idea viewed from two angles, and that's the first thing that made me stop missing classes.

Borrowing: sharing without surrendering

The obvious objection: if there's only one owner and moving invalidates the original, how does anything get shared? You can't move your data into every function that wants to read it.

This is where borrowing comes in, and it's the most elegant idea in the language once it clicks. You can hand out references without giving up ownership. But Rust enforces one rule that quietly eliminates an entire category of bug:

At any given time, you can have either one mutable reference or any number of immutable references. Never both.

fn print_balance(acc: &Account) {        // borrows, read-only
    println!("{}", acc.balance);
}

fn deposit(acc: &mut Account, amt: i64) { // borrows, exclusive write
    acc.balance += amt;
}

let mut a = Account { balance: 100 };
print_balance(&a);       // shared, immutable borrow — fine
deposit(&mut a, 50);     // exclusive, mutable borrow — fine

&Account is a read-only borrow; you can have a hundred of those at once. &mut Account is an exclusive borrow; while it exists, nothing else — not even a reader — can touch that data. The compiler enforces this. Try to mutate something while someone else is reading it and your program does not compile.

Think about the Java bug you never saw because the GC hid the corpse. You pass an object to a method, the method stashes a reference to it, later something mutates the original from another thread, and now two parts of your program disagree about the state of one object. In Java that's a runtime race, the kind that passes a thousand test runs and fails in production on a Tuesday. The garbage collector kept the object alive, so you never got a crash — you got something worse, a silent wrong answer.

Rust's borrow rule makes that structurally impossible in safe code. The reason my tally hit zero data races isn't that I was careful about locking. It's that the same rule that governs single-threaded borrowing also governs threads, and the compiler applies it everywhere. The aliasing problem and the data-race problem are, again, the same problem — and ownership solves both at once.

Polymorphism without inheritance: traits

Now the question that probably drove you to this article. You want different types to share behavior. In Java you'd write an interface, or a base class with overridable methods. In Python you'd duck-type, or use a Protocol. Rust gives you traits, and a trait is close enough to both of those that you can build a mental bridge today.

A trait is a set of behaviors a type can declare it supports. Like a Java interface. Like a Python Protocol. Here's one:

trait Notify {
    fn send(&self, message: &str);
}

struct Email { address: String }
struct Sms   { number: String }

impl Notify for Email {
    fn send(&self, message: &str) {
        println!("emailing {}: {}", self.address, message);
    }
}

impl Notify for Sms {
    fn send(&self, message: &str) {
        println!("texting {}: {}", self.number, message);
    }
}

Notice the shape of this. The data (Email, Sms) is declared separately from the behavior (Notify). You don't have a Notification base class with Email and Sms reaching up into it. You have two independent structs, each of which implements a shared contract. The behavior is bolted on from the outside.

This is the part that felt like losing a limb and then turned out to be a better limb. Because there's no parent class, there's no fragile base class problem, no diamond inheritance, no "I changed the parent and broke nine subclasses I forgot about." A type can implement as many traits as you like, and you can implement your own traits on types you didn't even write. That last one is genuinely impossible in Java — you cannot add an interface to String after the fact. In Rust you can.

A thoughtful programmer seated at a minimalist wooden desk, leaning back in a chair…

There are two ways to use that contract, and knowing which is which is a real part of Rust fundamentals.

Static dispatch — the compiler picks the concrete type at compile time and generates specialized code:

fn alert<T: Notify>(channel: &T, msg: &str) {
    channel.send(msg);
}

This is fast — there's no runtime lookup, it's as if you wrote a separate function for each type — but every call site is locked to one known type.

Dynamic dispatch — you defer the choice to runtime, the way a Java interface reference does:

fn alert_all(channels: &[Box<dyn Notify>], msg: &str) {
    for c in channels {
        c.send(msg);   // looked up at runtime
    }
}

dyn Notify means "some type that implements Notify, decided at runtime." This is how you put Email and Sms in the same list and treat them uniformly — the exact polymorphism you were afraid you'd lost. It costs a pointer indirection, the same cost Java's virtual method calls pay all the time. The difference is that in Rust you chose it and the dyn keyword says so out loud.

How I'd actually decide what to reach for

This is the practical replacement for "find the nouns, make classes." When you have a set of types that should behave alike, you've got three tools, and here's how I pick between them after a couple of years of getting it wrong first.

You want Reach for Cost Why
Behavior shared across known types, max speed Generic + trait bound (fn f<T: Trait>) None at runtime Monomorphized; compiler specializes each type
A mixed collection of "anything that does X" Box<dyn Trait> One pointer indirection Runtime dispatch, like a Java interface
A fixed, closed set of variants enum + match None Compiler forces you to handle every case

The one that has no OOP equivalent and that I now reach for constantly is the enum. When your set of types is closed — there are exactly four kinds of message and there will never be a fifth from outside — an enum is better than a trait, because the compiler forces you to handle every variant with a match. Add a fifth variant later and every match that forgot about it fails to compile. That's a guarantee inheritance never gave you: in Java, add a subclass and the if (x instanceof ...) chains keep compiling and silently fall through.

My rough rule: closed set of cases, use an enum. Open set of types that share behavior, use a trait. Generic where you can, dyn where you must put them in a collection together. That decision — which I used to make by reflex with "subclass it" — is now a real design choice, and making it consciously is most of what "thinking in Rust" means.

Who this is for, and who it isn't

This framing — ownership as encapsulation, traits as polymorphism — is for you if you're coming from Python or Java, you're past hello-world, and you've hit the wall where your OOP instincts stopped working. You don't need someone to tell you Rust is great. You need someone to translate your existing mental model into the new one so you stop fighting the compiler.

It is not for you if you're looking for a reason to be reassured that Rust is "basically OOP with extra steps." It isn't, and pretending otherwise will hurt you later. The struct/trait split is a genuinely different way to decompose a problem, and the people who struggle longest are the ones who try to mechanically translate their class hierarchies one-to-one. You will write a few designs that fight the language before you write one that flows with it. That's the tuition. There's no skipping it, and anyone selling you a way to skip it is selling you something.

And it's not the whole story, which is exactly where I'll leave you.

What this didn't answer

That zero I started with — zero memory bugs — I told you it was a compile-time guarantee. What I didn't explain is how the compiler knows a reference doesn't outlive its data. That's lifetimes, and lifetimes are the part of Rust fundamentals that everything in this article was quietly leaning on without naming. Every borrow I showed you had a lifetime the compiler was tracking; I just never had to write it down because the simple cases get inferred. The cases where you do have to write 'a annotations are the next wall, and they deserve their own piece, not a paragraph.

I also said nothing about ownership across threads and async code — where the same rules hold but the types (Arc, Mutex, Send, Sync) get more involved — and nothing about the escape hatch of unsafe, where you can opt out of the borrow checker and take the guarantees back into your own hands.

So here's the sentence to underline: ownership and traits aren't OOP with the power removed — they're the same guarantees you trusted, moved to a place where the compiler can prove them, and the next thing to learn is the mechanism that makes the proof possible, which is lifetimes.

Rust Traits Ownership