DEV Community

Cover image for Mastering Rust's Ownership: The Key to Memory Safety and Efficiency
Son DotCom 🥑💙
Son DotCom 🥑💙

Posted on

Mastering Rust's Ownership: The Key to Memory Safety and Efficiency

The ownership mechanism in Rust is a unique feature that sets it apart from other mainstream programming languages. It's what makes Rust both memory-safe and efficient while avoiding the need for a garbage collector, a common feature in languages like Java, Go, and Python. Understanding ownership is crucial for becoming proficient in Rust. In this article, we will explore the following topics:

  1. Stack and Heap
  2. Variable Scope
  3. Ownership
  4. Borrowing (Referencing)
  5. Mutable Reference

Note: This article is beginner-friendly. Familiarity with Rust's basic syntax, variable declaration within functions, and basic data types is assumed. If you need a quick refresher, you can check out this guide.

Stack AND Heap

Memory in Rust can be divided into two main parts: the stack and the heap. Both of these are available during both compile time and runtime and serve different purposes. Understanding their mechanics is crucial for writing efficient Rust code.

Stack

The stack is used to store data with known sizes at compile time. The size of data stored on the stack is fixed and cannot be changed during runtime.

The stack operates using a First In, Last Out (FILO) mechanism, similar to stacking plates. The first plate added is the last one removed.

Rust automatically adds primitive types to the stack:

fn main() {

    let num: i32 = 14;
    let array = [1, 2, 4]; 
    let character = 'a'; 
    let tp = (1, true); 

    println!("number = {}, array = {:?}, character = {}, tp = {:?}", num, array, character, tp); 
}

Enter fullscreen mode Exit fullscreen mode

The stack layout for the main method would be as follows:

Address Name Value
3 tp (1, true)
2 character 'a'
1 array [1,2,4]
0 num 14

In this table, the num variable, being the first added, will be the last removed when the function scope ends.

Note: Address column uses numbers as representation, ideally addresses in computers are not just numbers.

Heap

The heap is used for data with sizes unknown at compile time. Types like Vec, String, and Box are stored on the heap automatically.

fn main() {

    let num: i32 = 14;
    let new_str = String::new(); 
}
Enter fullscreen mode Exit fullscreen mode

The memory mapping for data on the heap is as follows:

STACK MEM

Address Name Value
1 new_str ptr1
0 num 14

HEAP MEM

Address Name Value
ptr1 ""

When storing data on the heap, a pointer (like ptr1) is first stored on the stack. This pointer then references the actual data on the heap. This additional step makes heap access slower compared to stack access.

With stack and heap mechanisms understood, let's delve into variable scope.

Variable Scope

Variable scope defines the range in code where a variable is valid. In Rust, curly braces create blocks, and variables within these blocks are scoped to them.

fn main() {
// everything here is scoped to this block
 let hello = "Hello World";
// ...
}
Enter fullscreen mode Exit fullscreen mode

Variables within a block cannot be accessed outside that block:

fn main() {
 let hello = "Hello World"; 

  // inner block
  {
    let num = 20;
  }

 // This will fail to compile
 println!("{} {}", hello, num);
}
Enter fullscreen mode Exit fullscreen mode

From the above you can't use num outside the inner block because it is scoped only to the inner block.

Rust runs functions from top to bottom, allocating stack and heap memory as needed. When the scope ends, Rust calls the drop method internally to deallocate memory, a crucial concept for understanding Rust's approach to memory management.

Ownership

Here we come to the heart of the article, where we explore ownership and its impact on Rust programming. Remember these ownership rules as we proceed:

Ownership Rules

  1. There can only be one owner of a value at a time.
  2. Each value has an owner.
  3. If the owner goes out of scope the value is dropped.

Lets see what this mean below:

fn main() {
    let array = vec![1,2,3]; 
    let title = String::new(); 

    let new_array = array;
    let new_title = title; 

    println!("array {:?} title {} new_array {:?} new_title {}", array, title, new_array, new_title);
 }
Enter fullscreen mode Exit fullscreen mode

move bug
The compilation error "borrow of moved value" arises because both array and title are being owned by multiple variables simultaneously. This violates the ownership rules.

Rust's memory layout for the code above is as follows:

STACK MEM

Address Name Value
3 new_title ptr2Copy
2 new_array ptr1Copy
1 title ptr2
0 array ptr1

HEAP MEM

Address Name Value
ptr2 and ptr2Copy ""
ptr1 and ptr1Copy vec![1,2,3]

As seen from above because vectors and strings are non fixed size types Rust allocates them to the heap and can be accessed via pointer from the stack.

The ptr1Copy pointer is a copy of ptr1 pointer and the ptr2Copy pointer is a copy of ptr2 pointer.

When Rust is done it then calls the drop method, the drop method walks through the stack remember FILO we explained above? It starts by removing the last item added to the stack which is new_title then finds out it has a pointer(ptr2Copy) goes to the heap memory via that pointer and removes the string value. It continues to the next element on the stack new_array which also has a pointer ptr1Copy and removes the vector from the heap memory too. It again continues to the next which is title and has a pointer ptr2 but when it follows the pointer it realises that the data no longer exits as it has been removed via ptr2Copy this why it throws error for title the same thing goes for the array variable.

The Ownership rules apply only to Rust types on the heap. Primitive types on the stack don't follow these rules:

fn main() {
    let array = [1,2,3]; 
    let title = "hello"; 

    let new_array = array;
    let new_title = title; 

    println!("array {:?} title {} new_array {:?} new_title {}", array, title, new_array, new_title);
 }
Enter fullscreen mode Exit fullscreen mode

primitive copy types

For readers familiar with other languages, reassigning values to new variables while they're on the heap might seem intuitive. How do we achieve this in Rust?

We can use the .clone method, which duplicates the heap value and stores it in a new location on the heap:

See below:

fn main() {
    let array = vec![1,2,3]; 
    let title = String::new(); 

    let new_array = array.clone();
    let new_title = title.clone(); 

    println!("array {:?} num {} new_array {:?} new_num {}", array, title, new_array, new_title);
}
Enter fullscreen mode Exit fullscreen mode

From the above the code works now, let's see how the translation is done on the stack and heap

STACK MEM

Address Name Value
3 new_title ptr2Copy
2 new_array ptr1Copy
1 title ptr2
0 array ptr1

HEAP MEM

Address Name Value
ptr2Copy ""
ptr1Copy vec![1,2,3]
ptr2 ""
ptr1 vec![1,2,3]

While this approach works, it might result in unnecessary memory consumption and inefficiencies.

Borrowing

In Rust, borrowing (or referencing) allows us to use values without taking ownership. To reference a value, we use the & sign.

fn main() {
    let hello = String::from("hello");
    let another_hello = &hello;

    let arr = vec![1];
    let another_arr = &arr;

    println!("hello = {} another_hello = {} {:?} {:?}", hello, another_hello, another_arr, arr)
  }
Enter fullscreen mode Exit fullscreen mode

By referencing values, we can read data without copying it, avoiding unnecessary memory overhead.

This is also applicable for functions parameters. Function parameters have their own scope, passing a value to it will automatically move the ownership to the parameters.

fn main() {
    let name = String::from("John");
    say_hello(name);
    println!("{}", name)
}

fn say_hello(name: String) {
    println!("hello {}", name)
}
Enter fullscreen mode Exit fullscreen mode

ownership in function params
The above failed because the ownership of value "John" was moved to the function params.

We can resolve this error by borrowing from the name owner, see below

fn main() {
    let name = String::from("John");
    say_hello(&name);
    println!("{}", name)
}

fn say_hello(name: &String) {
    println!("hello {}", name)
}
Enter fullscreen mode Exit fullscreen mode

We don't have anymore errors, looks good so far.

All these while we've only read the value from the owner what if we want to update that value and still not take ownership?

Mutable References

Mutating values in Rust requires the mut keyword, and to mutate a value via reference, we use the &mut keyword.

fn main() {
  let mut name = String::from("John");

  full_name(&mut name);

  println!("Your full name is: {}", name);
}

fn full_name(name: &mut String) {
  name.push_str(" Doe");
}
Enter fullscreen mode Exit fullscreen mode

From above the full_name function is able to update the name value without taking ownership of the value.

One thing to always remember is at any given time, you can have either one mutable reference or any number of immutable references.
So the following code will fail to compile:

fn main() {
    let mut s = String::from("hello");

    let r1 = &mut s;
    let r2 = &mut s;

    println!("{}, {}", r1, r2);
}
Enter fullscreen mode Exit fullscreen mode

multiple mutable reference fail

Even if we are just reading the value hello as far as we have a single mutable reference rust complains. The below still fails to compile

fn main() {
    let mut s = String::from("hello");

    let r1 = &s;
    let r2 = &s;
    let r3 = &mut s;

    println!("{}, {}, {}", r1, r2, r3);
}
Enter fullscreen mode Exit fullscreen mode

The reason for this is to avoid data inconsistencies because rust cannot guarantee at what point the reading or data change will happen.

We can make the above work with scope.

fn main() {
    let mut s = String::from("hello");

    // inner scope 
    {
     let r1 = &mut s;
     println!("{}", r1);
    }

    let r2 = &mut s;
    println!("{}", r2);

}
Enter fullscreen mode Exit fullscreen mode

Also we can read all read references and be done with them before mutating.

fn main() {
 let mut s = String::from("hello");

    let r1 = &s; // no problem
    let r2 = &s; // no problem
    println!("{} and {}", r1, r2);
    // variables r1 and r2 will not be used after this point

    let r3 = &mut s; // no problem
    println!("{}", r3);
}
Enter fullscreen mode Exit fullscreen mode

Rust enforces us to think of our code properly to avoid writing buggy code that are hard to fix.

Conclusion

In the world of programming languages, Rust stands out as a remarkable choice due to its ownership mechanism. This mechanism, while initially challenging to grasp, is the cornerstone of Rust's memory safety and efficiency. By strictly adhering to rules that enforce single ownership, Rust mitigates the risk of memory leaks, data inconsistencies, and other common programming pitfalls.

Through this article, we've explored the intricate workings of Rust's ownership concept. We began with a foundational understanding of the stack and heap, the pillars that support memory allocation. The stack, efficient for fixed-size data, and the heap, accommodating dynamic data, together enable Rust to balance performance and flexibility.

The significance of variable scope became evident as we delved into the importance of block-level scoping. Rust's rigorous scope management not only simplifies code but also assists in automating memory deallocation through the drop mechanism.

Ownership, the core of Rust's memory management philosophy, teaches us the invaluable lesson that only one entity can own a value at any given time. This principle may initially seem restrictive, but it's an essential safeguard against data races and memory issues.

We learned how to navigate these ownership rules by utilizing borrowing, a mechanism that allows controlled access to values without compromising ownership. We saw how references (&) and mutable references (&mut) facilitate this process, enabling both reading and manipulation without sacrificing data integrity.

In a landscape where memory safety is often sacrificed for convenience, Rust shines as a language that compels developers to adopt best practices. While the ownership model can be challenging, mastering it will make you a more disciplined and effective Rust programmer.

As you continue your journey with Rust, remember that ownership isn't just a technicality; it's a mindset. Embrace it, let it guide your coding decisions, and you'll unlock the true potential of this language. Rust's ownership is not merely a hurdle to overcome; it's a superpower to wield responsibly.

So, as you write your Rust code, keep these ownership principles in mind, and you'll be well on your way to creating robust, efficient, and reliable software that stands the test of time.

Top comments (2)

Collapse
 
cjsmocjsmo profile image
Charlie J Smotherman

How refreshing a dev.to article that actually taught me something ;)

Happy Coding

Collapse
 
charles_lukes profile image
Son DotCom 🥑💙

I'm glad it did