Photo by Jay Heike on Unsplash

Ownership and Borrowing in Rust: A Comprehensive Guide to Memory Management

Aurora Poppyseed

--

Rust is a high-level programming language focused on performance and safety, especially safe concurrency. It incorporates a system of ownership with a set of rules that the compiler checks at compile time. Here, we’ll delve into how Rust manages memory, especially through the concepts of ownership, borrowing, and slices.

The Stack and the Heap

Understanding how Rust handles memory begins with understanding the concepts of the stack and the heap. These are parts of your computer’s memory where data is stored when a program runs.

However, before we dive deep into that let’s first take a step back and think about what happens when you run a program on your computer.

How computers work?

At a very high level, your computer has three key components involved in running programs:

  1. The CPU (Central Processing Unit): This is like the brain of the computer. It performs the operations that your program tells it to do.
  2. Memory (RAM — Random Access Memory): This is where your program and the data it’s working on are stored while the program is running. Memory is like a big, empty space where your program can store and retrieve data.
  3. Storage (Hard Disk, SSD): This is where your program and its data are stored when not running. Storage is like a big, filing cabinet where your program and its files live when they’re not currently being used.
For more info on How Computers Work checkout this video

Now, let’s zoom in on memory (RAM). There are two main parts of memory where your program can store data: the stack and the heap.

The stack and the heap are both parts of memory that can be used by your program to store data, but they’re used in different ways.

The Stack

Think of the stack like a stack of books. When you add a new book (some data), you put it on top of the stack. When you want to remove a book, you also take it from the top of the stack. You can’t remove a book from the bottom without removing all the ones on top first. This is why the stack is referred to as “Last-In, First-Out” or LIFO.

In Rust, data stored on the stack must have a known, fixed size. This is because Rust needs to know how much memory to allocate when it pushes onto the stack.

fn main() {
let x = 10; // x is stored on the stack
}

In the above example, x is an integer, which has a fixed size, so it's stored on the stack. When main finishes running, x goes out of scope and is popped off the stack.

The Heap

The heap, on the other hand, is more like a messy room. When you want to store something, you just find an empty spot where it fits and put it there. There’s no inherent order, and you can take things out in any order you want.

In Rust, data stored on the heap can have a dynamic size. But because the heap is disorganized, it’s slower to access. You have to follow a pointer to get to the data you want.

fn main() {
let x = Box::new(10); // x is stored on the heap
}

In the above example, x is a box, which is a type that allocates memory on the heap. The actual integer is stored on the heap, and a pointer to the integer is stored on the stack. When main finishes running, x goes out of scope, the memory on the heap is freed, and the pointer is popped off the stack.

For Image reference on how the Stack and the Heap works, I really like this simple image from the article Stack, Heap, Value Type, and Reference Type in C#

Hopefully, this clarifies the difference between the stack and the heap, and how they fit into the overall picture of running a program on a computer. In the next sections, we’ll discuss how Rust uses these concepts to enforce memory safety through its ownership rules.

Ownership Rules

The ownership rules in Rust are fundamental to the language and are as follows:

  1. Each value in Rust has a variable that’s its owner.
  2. There can only be one owner at a time.
  3. When the owner goes out of scope, the value will be dropped.

Let’s consider an example:

fn main() {
// s becomes the owner of the memory holding "hello"
let s = String::from("hello");
// s's value moves into the function and is no longer valid here
takes_ownership(s);
// x is a simple variable and comes into scope
let x = 5;
// x would move into the function, but i32 is Copy, so
// it's okay to still use x afterward
makes_copy(x);
}
// Here, x goes out of scope, then s. But because s's value was moved,
// nothing special happens.

fn takes_ownership(some_string: String) {
// some_string comes into scope
println!("{}", some_string);
}
// Here, some_string goes out of scope and `drop` is called.
// The backing memory is freed.
fn makes_copy(some_integer: i32) {
// some_integer comes into scope
println!("{}", some_integer);
} // Here, some_integer goes out of scope. Nothing special happens.

In this example, the string s is passed to the function takes_ownership, so s is no longer valid in the main function. This is because s has been moved. On the other hand, the integer x is still valid after being passed to the function makes_copy because integers have the Copy trait, meaning they are copied rather than moved.

Borrowing and References

Borrowing is the mechanism that allows a function to use a value without taking ownership of it. Borrowing is done using references, which are created using the & symbol.

Consider the following example:

fn main() {
let s = String::from("hello");
let len = calculate_length(&s);
println!("The length of '{}' is {}.", s, len);
}
fn calculate_length(s: &String) -> usize {
s.len()
}

In this example, we pass a reference to s into calculate_length. Because calculate_length does not own s, the string will not be dropped when calculate_length goes out of scope.

Rust also has mutable references for cases where you need to modify the borrowed value. But there is a big restriction: you can have only one mutable reference to a particular piece of data in a particular scope. This prevents data races at compile time. Here’s an example:

fn main() {
let mut s = String::from("hello");
change(&mut s);
}
fn change(some_string: &mut String) {
some_string.push_str(", world");
}

Here, s is a mutable string. We pass a mutable reference to s into change, which appends ", world" to the string.

These concepts of ownership, borrowing, and references are fundamental to Rust, allowing it to make powerful safety guarantees without sacrificing performance.

The Slice Type

A slice is a view into a block of memory represented as a pointer and a length. Slices allow you to work with a portion of a collection of items rather than the whole collection. The collection can be an array, a string, a vector, or any other data structure that holds a sequence of elements.

Consider slices as a two-word object, the first word is a pointer to the data, and the second word is the length of the slice. The data pointed to by a slice is guaranteed to be valid for the length specified. You can take a slice of an array, a string, a Vec, and other collections using the range syntax.

Let’s delve deeper into slices in the context of strings and arrays:

String Slices

A string slice is a reference to part of a String. We create a string slice by specifying a range within brackets, [starting_index..ending_index]. The starting index is inclusive, while the ending index is exclusive.

let s = String::from("Hello, world!");
let hello = &s[0..5];  // 'Hello'
let world = &s[7..13]; // 'world'

In this example, hello and world are slices of s. They don't contain any string data themselves. Instead, they are references to the original String data.

Array Slices

Similarly, we can also create a slice from an array:

let arr = [1, 2, 3, 4, 5];
let slice = &arr[1..3]; // [2, 3]

In this case, slice is a slice of arr. It is a window into arr from index 1 to 2. Like with string slices, slice does not own the data it points to.

Lifetimes and Slices

An important concept to understand with slices is Rust’s ‘lifetime’. Since a slice contains a reference to an element, it’s bound by Rust’s borrowing rules. The lifetime of the slice cannot exceed the lifetime of the thing it references. When that data goes out of scope, your slice is no longer valid.

{
let s = String::from("hello");
let t = &s[0..2];
} // <- s goes out of scope here, so t is no longer valid

In this code, when s goes out of scope, t also becomes invalid. This is because t holds a reference to s, and s is no longer valid.

Understanding and using slices effectively can help improve your programs’ memory efficiency by enabling more precise data access without unnecessary ownership transfers.In conclusion, Rust offers a powerful and unique system for managing memory. Through ownership, Rust ensures that there is a clear owner for each piece of data, and that this data is properly cleaned up when it is no longer needed. This significantly reduces the risk of memory leaks.

Conclusion

Borrowing allows for flexibility in accessing and using data without taking ownership, while the borrowing rules prevent data races. Immutable references allow multiple reads simultaneously, and mutable references allow for a single write without any reads happening concurrently.

Finally, slice types are a flexible and powerful tool that allows you to reference a range of elements in a collection without taking ownership of the entire collection. This can be particularly useful for working with subsets of data.

Understanding these concepts is key to mastering Rust, as they are not only essential for memory management, but they also have deep implications for the design and structure of your programs. Rust’s commitment to zero-cost abstractions means that, when used properly, these features can create code that is both efficient and safe, providing the best of both worlds.

Thanks for reading this article!

In our collective journey into the future, your thoughts and contributions are invaluable. For more engaging talks about blockchain, AI, programming, and biohacking, let’s stay connected across these platforms:

  • Read more of my articles on Medium 📚
  • Follow me on Twitter 🐦 for thoughts and discussions on AI, biohacking, and blockchain.
  • Check out my projects and contribute on GitHub 👩‍💻.
  • And join me on YouTube 🎥 for more in-depth conversations.

Let’s continue shaping the future together, one creation at a time. 🚀

Aurora Poppyseed

--

--