DEV Community

Cover image for Rust chronicles #2 - ownership, the unprecedented memory safety guarantee without a garbage collector
SiliconCatalyst
SiliconCatalyst

Posted on

Rust chronicles #2 - ownership, the unprecedented memory safety guarantee without a garbage collector

I have heard about Rust's unique implementation of memory management before, but I have yet to dive deep into its mechanics, as it is the case with anybody who has never worked with Rust before, that's right, Rust's memory management is unique to it, although it combines principles known prior to Rust's development (those being Ownership, Borrowing, and Lifetimes), Rust packaged it into a practical, usable system that is efficient in actual production software.

understanding Rust's ownership system

Rust's ownership system is a compile-time memory management strategy that enforces three fundamental rules:

  1. every value has exactly one owner,
  2. ownership can be transferred (moved) or lent (borrowed) but not duplicated,
  3. when the owner goes out of scope, the value is automatically deallocated.

The compiler analyzes the program's memory usage during compilation and inserts deallocation code at precisely the points where values are no longer accessible. This approach eliminates entire classes of bugs, use-after-free, double-free, and memory leaks, that are synonym with C and C++ programs, while avoiding the runtime overhead and unpredictable pauses of garbage collection. Unlike languages that choose between safety and performance, Rust achieves both by moving memory safety checks from runtime to compile time. When your program compiles, the compiler has proven your memory usage is safe.

The system extends through "borrowing," which allows temporary access to data without transferring ownership. Rust enforces that you can have either unlimited immutable references or exactly one mutable reference at any time, but never both simultaneously. This restriction makes data races impossible, if two threads can't have mutable access to the same memory simultaneously, race conditions cannot occur. The compiler tracks the lifetime of every reference and ensures no reference outlives the data it points to, eliminating dangling pointers entirely. These ownership checks add zero runtime overhead, producing binaries that run as fast as hand-optimized C but without the memory corruption vulnerabilities that have plagued systems software for decades.

"What is this chump yappin' about here?", Do not worry my friend, I have been writing Rust code for a total of TWO DAYS, and I am here to explain it (I am competent enough I swear).

Let's take this simple code block:

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1; // ownership moved to `s2`, `s1` is marked as invalid

    println!("{}", s1): // ERROR: borrow of moved value: `s1`
    println!("{}", s2); // Hello
}
Enter fullscreen mode Exit fullscreen mode

Lets revise the 3 core principles of Rust's ownership system:

1. Each value has exactly one owner

let s1 = String::from("Hello");
Enter fullscreen mode Exit fullscreen mode

s1 owns the heap-allocated string data. It contains a pointer to the heap, length (5), and capacity (5).

2. Ownership can be transferred (moved) or lent (borrowed) but not duplicated

let s2 = s1;  // ownership moves from `s1` to `s2`
// s1 is now invalid - compiler won't let you use it
Enter fullscreen mode Exit fullscreen mode

The pointer is copied to s2, but s1 is marked as moved. Only s2 can access the heap data now.

3. When the owner goes out of scope, the value is automatically deallocated

{
    let s = String::from("Hello");
}  // `s` goes out of scope here, heap memory is freed automatically
Enter fullscreen mode Exit fullscreen mode

The compiler inserts a drop() call that deallocates the heap memory.

copy types

Some types implement the Copy trait, which makes them copy instead of move:

let x = 5;
let y = x;  // `x` is copied, both `x` and `y` are valid
Enter fullscreen mode Exit fullscreen mode

Copy types:

  • Integers (i32, u64, etc.)
  • Floats (f32, f64)
  • Booleans (bool)
  • Characters (char)
  • Tuples of Copy types

They're stored entirely on the stack with no heap allocation. Copying them is cheap (just copying bytes), and there's nothing to deallocate, so having multiple owners is safe.

functions can own values too!

fn main() {
    let s1 = String::from("Hello");
    take_ownership(s1); // ownership moves from `s1` to function body
    println!("{}", s1); // ERROR: borrow of moved value: `s1`
}

fn take_ownership(value: String) -> String {
    value // implicit return, having no semicolon is equivalent to
          // return value;
}
Enter fullscreen mode Exit fullscreen mode

s1 cannot be used after 3rd line because the function take_ownership now owns the information about the value Hello, same thing happens if you call take_ownership inside the print function:

fn main() {
    let s1 = String::from("Hello");
    println!("{}", take_ownership(s1)); // Hello
    println!("{}", s1); // ERROR: borrow of moved value: `s1`
}

fn take_ownership(value: String) -> String {
    value // implicit return, having no semicolon is equivalent to 
          // return value;
}
Enter fullscreen mode Exit fullscreen mode

the workaround: returning vs borrowing vs copying (cloning)

The way Rust is designed ensures no overhead or faulty memory exists at compile time, but the developer can opt-in to changing the status quo. When it comes to values, there are 3 ways you can opt-in to preserving the lifetime of your value:

return the data

This happens when the return value of a function is assigned to a variable:

fn main() {
    let s = String::from("Return Me!");

    let result = return_ownership(s); // `result` owns the string: "Return Me!"

    println!("{}", result); // Return Me!
    println!("{}", s); // ERROR: borrow of moved value: `s`
}

fn return_ownership(value: String) -> String {
    value
} // value leaves scope, but ownership was returned to `result`, so nothing is freed here
Enter fullscreen mode Exit fullscreen mode

In this example, the String value Return Me! moved from being owned by s, to the function return_ownership, marking s as invalid from the 4th line onward. But because we assign the returned value to result, ownership transfers to result. When the function goes out of scope (remember, leaving scope = death... or freeing the memory to be less dramatic), the value has already been moved out, so nothing is freed. result now owns the string Return Me!.

borrow the data

Borrowing uses a reference (pointer) to let code look at data without taking ownership. The borrower cannot free the memory because they don't own it:

fn main() {
    let my_string = String::from("Hello");

    print_string(&my_string); // lend it temporarily, the function merely looks at the data

    println!("{}", my_string); // still works, `my_string` is still the owner
}

fn print_string(s: &String) {
    println!("{}", s);
} // borrow ends once the function executes, data still owned by caller
Enter fullscreen mode Exit fullscreen mode

What about here? Is a return happening or a borrowing?:

fn main() {
    let my_string = String::from("Hello");
    let result = return_string(&my_string); 
    println!("{}", result);
    println!("{}", my_string); // works
}

fn return_string(s: &String) -> &String {
    s
}
Enter fullscreen mode Exit fullscreen mode

If you've answered both, then you'd be correct! return_string is borrowing a reference (pointer) to the allocated value Hello that is owned by my_string, then it returns the same borrowed reference to result. This means both my_string and result point to the same exact memory address that holds the string Hello, but only my_string has ownership over it. The data will not be freed until my_string goes out of scope or ownership is moved somewhere else.

copy (clone) the data

For types that don't implement Copy (integers, floats, booleans, characters, tuples of copy types), you can explicitly duplicate data using .clone():

fn main() {
    let s1 = String::from("Hello");
    let s2 = s1.clone(); // explicit copy, duplicates heap data

    println!("{} {}", s1, s2); // both work! Two independent copies
}
Enter fullscreen mode Exit fullscreen mode

For types that implement Copy (like integers), copying happens automatically:

fn main() {
    let x = 5;
    let y = x; // automatic copy, both valid

    println!("{} {}", x, y); // both work!
}
Enter fullscreen mode Exit fullscreen mode

Copying and Cloning produce independent copies that contain the same content, with the same length and capacity, but the memory address is different, which means each copy will be tracked and freed independently, modifying or freeing one does not affect the other:

fn main() {
    let my_string = String::from("Hi");
    let another_string = my_string.clone(); // new independent clone

    println!("{} {}", my_string, another_string); // both work

    take_owndership(my_string); // ownership moved, marking `my_string` as invalid

    println!("{}", another_string); // works, `my_string` was cloned prior to transfer (move)
    println!("{}", my_string); // ERROR: borrow of moved value: `my_string`
}

fn take_ownership(s: String) {
    println!("{}", s)
}
Enter fullscreen mode Exit fullscreen mode

Test yourself, will the last print statement execute correctly?:

fn main() {
    let x = 5;
    let y = x; // integers get automatically copied, no ownership change
    println!("{} {}", x, y); // both work

    take_owndership(x);

    println!("{}", y); // works fine
    println!("{}", x); // QUESTION: will this work?
}

fn take_ownership(v: i32) -> i32 {
    v
}
Enter fullscreen mode Exit fullscreen mode

The answer: YES! It does work. I've included a hint in the 3rd line that might help. Because x is of type i32, it automatically gets copied everywhere the variable x is used, including in the take_ownership function. This means the function isn't actually using the same memory location that x is using, instead, a copy is made that contains the same value as x. The copy exists independently on the stack, so the last print statement works normally since x still holds the value 5 and hasn't gone out of scope yet.

If you got it wrong, that's okay; Why'd you think I included this test? I got confused as to why x was working normally too!

End of log. Thanks for reading, see you in the next entry!

Top comments (0)