Scala to Rust: How Hard Could It Be?Part 2

Nobody's coming to clean up after you

In Part 1, most of Rust felt like Scala with different keywords. match, Option, trait, generics — familiar enough to wonder what all the fuss was about. But I left one thing unexplained: the & in fn area(s: &Shape). I pointed out that a Rust dev would write it that way, and moved on.

What I didn’t mention is that for my first few weeks with Rust, I’d read about ownership and borrowing, I’d seen the rules — but I wasn’t confident I was applying them right. I’d add a & here, a .clone() there, and the code would compile, but I couldn’t always tell you why it worked. At some point something clicked — and behind all that syntax noise, there was really just one concept Scala had never required me to think about: ownership. Who holds this value right now? What happens to it when I pass it to a function? And with no garbage collector to fall back on — who’s responsible for cleaning it up?

The one fact everything hangs from

Every language that allocates memory has to pick a strategy for cleaning it up:

StrategyWho decides?When freed?Cost
Manual (C)YouWhen you say soZero overhead, but use-after-free and double-free bugs
GC (JVM, Go)RuntimeWhen unreachablePauses, memory overhead, non-deterministic
Ownership (Rust)CompilerWhen owner’s scope endsZero runtime overhead, but the compiler rejects some valid programs, some data structures are hard to express, and refactoring ripples through signatures

In Scala, the garbage collector handles it. You allocate objects, pass them around, and at some point — eventually — the GC notices nobody’s holding a reference anymore and cleans up. It works. You pay for it in pauses, in unpredictable latency, in memory overhead, but you never think about when something gets freed.

Rust doesn’t have a garbage collector. So that question needs an answer. And the answer fits in three rules:

The thing that made this click for me was thinking about it in terms of Cats Effect’s Resource:

// Rule 1: exactly one bracket manages each resource
val program = for {
pool <- Resource.make(createPool)(_.shutdown)
// Rule 3: flatMap transfers the resource to the next bracket
client <- Resource.make(httpClient(pool))(_.close)
} yield client
// Rule 2: when the scope exits, resources are released
// in reverse order — deterministically, not "eventually"
{
// Rule 1: exactly one owner per value
let pool = create_pool(4);
// Rule 3: lend to functions (borrow) or give away (move)
let client = http_client(&pool);
// use client...
}
// Rule 2: dropped in reverse order — deterministically, not "eventually"
// client dropped, then pool dropped

Except in Rust, every value works this way — not just the ones you remembered to wrap in Resource. It’s baked in, without opting-in.

Three rules, one owner, deterministic cleanup. Sounds simple enough. But then you write let b = a and discover what “exactly one owner” actually means in practice.

Move: you used up the shape just to measure it

In Scala, val b = a gives you a second handle to the same object. Both stay live. The GC sorts it out. In Rust, let b = a is an ownership transfera is gone.

val a = List(1, 2, 3)
val b = a
// b and a point to the same object — both still usable
println(a.length) // 3
println(b.length) // 3
// GC frees when neither is reachable
let a = vec![1, 2, 3];
let b = a;
// ownership MOVES to b — a is now invalid
println!("{}", a.len()); // value used after move
println!("{}", b.len()); // 3

The why is straightforward: without a garbage collector, two owners of one Vec means two attempts to free it when they go out of scope. A double-free. Rust’s fix is blunt — one owner, period. After let b = a, there’s only b.

Remember area from Part 1? When the function takes a Shape by value, it takes ownership:

fn area(s: Shape) -> f64 {
match s {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
}
} // s is dropped here
let shape = Shape::Circle { radius: 5.0 };
let a = area(shape);
println!("{:?}", shape); // shape was consumed

You passed the shape to area, and now it’s gone. You used it up just to measure it.

Not everything moves, though. Simple types that don’t own heap resources — integers, floats, booleans — implement the Copy trait, so let b = a copies the bits and both stay valid. Our Circle { radius: f64 } is all copyable data. But imagine adding a Polygon { vertices: Vec<Point> }Vec owns heap memory, so the whole enum can’t be Copy anymore. Same syntax, different behavior, decided entirely by what the type owns.

When clone feels like the wrong answer

So you can’t use a value after moving it. The first thing you reach for, and the one I actually reached for — is .clone():

let shape = Shape::Polygon {
vertices: vec![
Point { x: 0.0, y: 0.0 },
Point { x: 4.0, y: 0.0 },
Point { x: 4.0, y: 3.0 },
// ...imagine a thousand more
],
};
let a = area(shape.clone()); // clone the shape, move the clone
println!("{:?}", shape); // original is still here

It compiles. But think about what just happened: you deep-copied an entire Vec<Point> — potentially thousands of heap-allocated vertices — just to read the shape’s area once.

.clone() is a legitimate tool, but it has to be used wisely — not sprinkled everywhere the compiler yells at you. Before you clone, it’s worth asking: do I actually need a second copy, or do I just need to look at this?

Borrow: how to look without taking

Instead of giving area the shape, you lend it:

fn area(s: &Shape) -> f64 {
match s {
Shape::Circle { radius } => std::f64::consts::PI * radius * radius,
Shape::Rectangle { width, height } => width * height,
Shape::Polygon { vertices } => polygon_area(vertices),
}
}
let shape = Shape::Polygon {
vertices: vec![/* ...a thousand points... */],
};
let a = area(&shape); // borrow — area just looks
println!("{:?}", shape); // still ours — no clone needed

The & means “borrow”. The function can read the value but can’t own it or mutate it. When area returns, the borrow ends and the caller keeps the original. Nothing got copied, neither allocated. That & from Part 1 finally has a meaning.

Mutable borrows

So we can lend things out without losing them — but what if the borrower needs to change the value? A regular & won’t let you. For that, there’s &mut:

fn add_suffix(s: &mut String) {
s.push_str("!");
}

Three things must line up: the variable is let mut, the function accepts &mut T, and the call site passes &mut variable. Mutation is visible at every level — the declaration, the signature, and the call.

// Scala: mutation is hidden
val buf = ListBuffer(1, 2, 3)
addItem(buf)
// Did addItem change buf?
// You can't tell from the call site.
// Rust: mutation is explicit
let mut buf = vec![1, 2, 3];
add_item(&mut buf);
// &mut tells you: this call can change buf.
// You know just by looking at the call site.

The rule that makes it all work

Now here’s where borrowing gets clever. Rust enforces one strict constraint: at any given time, you can have either any number of &T (shared, read-only) or exactly one &mut T (exclusive, mutable). Never both at the same time.

Sounds familiar? It’s the same shape as a read-write lock — many readers or one writer. It’s not solving the same problem (Rust has RwLock and Mutex for actual thread synchronization), but you already know the mental model. The same pattern applied cleverly at compile time instead of at runtime.

let mut s = String::from("hello");
let r1 = &s; // shared borrow
let r2 = &s; // another shared borrow — fine
println!("{r1} {r2}");
// r1 and r2 are done here
let r3 = &mut s; // exclusive borrow — no conflict, r1 and r2 are finished
r3.push_str(" world");
let mut s = String::from("hello");
let r1 = &s; // shared borrow
let r2 = &mut s; // can't borrow as mutable while a shared borrow is live
println!("{r1}");

The question the compiler can’t answer alone

Ownership handles who frees the value. Borrowing handles who can access it. But there’s one question neither can answer on its own:

fn longest(a: &str, b: &str) -> &str {
if a.len() >= b.len() { a } else { b }
}

Two references come in, one comes out — but the compiler needs to know which one. Does the result follow a’s lifetime or b’s? If a gets dropped while something still holds the result pointing at it, that’s a dangling reference — exactly the kind of bug ownership exists to prevent.

The compiler can’t figure this out alone. It needs you to describe the relationship:

fn longest<'a>(a: &'a str, b: &'a str) -> &'a str {
if a.len() >= b.len() { a } else { b }
}

That 'a is a lifetime annotation. It says: the result lives only as long as both inputs are valid — constrained by whichever one dies first. It doesn’t extend anything. It describes what’s already true, so the compiler can verify it.

That 'a has been lurking since Part 1. Now at least it’s clear why it exists — figuring out how it actually works is a problem for next time.

So who’s coming to clean up?

Nobody. That’s the whole point.

There’s no free() to write and no GC cycle to wait for — you arrange ownership so the compiler can prove exactly when each value dies, and it inserts the cleanup at the closing brace.

You do pay for it. You change one function signature and suddenly ten others need updating too. Sometimes you just call .clone() because the borrow checker won’t budge and you have actual work to do. What you get back is no garbage collector, no pauses, and memory bugs that the compiler catches before they exist. Coming from Scala (which already has a great compiler) I appreciate that a lot, but it can bite you more than you’d expect, and you’ll have to decide for yourself if it’s a good tradeoff.

Move, borrow, and clone are how you tell the compiler what you intend — and then it holds you to it.

You writeWhat it meansCaller keeps the value?
fn f(s: Shape)Move or Copy (same rules as assignment)Depends on whether the type implements Copy
fn f(s: &Shape)Borrow, read-onlyYes
fn f(s: &mut Shape)Borrow, exclusive + mutableYes
let b = a;Move or Copy (depends on the type)Depends on whether the type implements Copy
let b = a.clone();Deep copy (cost depends on type)Yes — independent copy