Understanding Rust Ownership: The Essentials - 2

Memory management is a crucial aspect of programming that ensures the efficient use of resources and prevents memory leaks. In Rust, memory management is handled through a unique system of ownership, which comes with a set of rules checked by the compiler. This approach eliminates the need for garbage collection or explicit memory allocation and deallocation, making Rust both safe and efficient.

The Stack and Heap

Understanding the stack and heap is essential in Rust. The stack stores values in a Last-In-First-Out (LIFO) manner, meaning values are pushed and popped in a specific order. The heap, on the other hand, is more flexible but less organized, requiring explicit memory allocation and deallocation.

  • Stack: Fast and efficient for storing values of known size. It follows a strict order for data storage and retrieval, making access times predictable and quick.

  • Heap: Used for dynamically sized data, but accessing it is slower due to the need to follow pointers. Allocation here is more complex, involving searching for available memory space.

When a function is called, its parameters and local variables are pushed onto the stack. Once the function completes, these values are popped off the stack, maintaining the stack's LIFO structure.

Ownership Rules

Rust's ownership system is built on three main rules:

  1. Each value in Rust has a single owner.

  2. There can only be one owner at a time.

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

These rules ensure memory safety and prevent data races, making Rust a robust choice for system-level programming.

Variable Scope

Variables in Rust are only valid within a specific scope, which is determined by curly brackets {}.

{
    let s = "hello"; // s is valid from this point forward
    // do stuff with s
} // s is no longer valid here

Scope-based management ensures that memory is automatically cleaned up when it is no longer needed, reducing the risk of memory leaks.

String Type

Strings in Rust are stored on the heap, allowing them to be mutable and growable. This necessitates dynamic memory allocation, which is managed safely by Rust's ownership rules.

let s = String::from("hello");

let mut s = String::from("hello");
s.push_str(", world!"); // Appends a literal to a String
println!("{s}"); // Prints `hello, world!`

Memory and Allocation

Rust automatically deallocates memory when a variable goes out of scope using a function called drop. This eliminates the need for manual memory management and ensures that memory is reclaimed efficiently.

let s1 = String::from("hello");
let s2 = s1; // s1 is no longer valid
let s1 = String::from("hello");
let s2 = s1;

println!("{s1}, world!"); // This will cause a compile-time error

Variables and Data Interacting with Clone

To deeply copy heap data, use the clone method. This is necessary when you want to create an exact duplicate of a value, including the data on the heap.

let s1 = String::from("hello");
let s2 = s1.clone();

println!("s1 = {s1}, s2 = {s2}");

Stack-Only Data: Copy

Simple scalar values like integers are stored on the stack and can be copied quickly. These types implement the Copy trait, allowing for fast and efficient duplication.

let x = 5;
let y = x;

println!("x = {x}, y = {y}"); // x=5, y=5

Ownership and Functions

Passing variables to functions follows the same rules as variable assignments: ownership is moved or copied. This ensures that memory is managed correctly even when data is passed between functions.

fn main() {
    let s = String::from("hello");
    takes_ownership(s); // s is no longer valid here

    let x = 5;
    makes_copy(x); // x is still valid here
}

fn takes_ownership(some_string: String) {
    println!("{some_string}");
}

fn makes_copy(some_integer: i32) {
    println!("{some_integer}");
}

Rust allows returning multiple values using a tuple, providing a flexible way to return data from functions without sacrificing ownership rules.

fn main() {
    let s1 = String::from("hello");
    let (s2, len) = calculate_length(s1);

    println!("The length of '{s2}' is {len}.");
}

fn calculate_length(s: String) -> (String, usize) {
    let length = s.len();
    (s, length)
}

References

References allow you to use values without taking ownership. They are immutable by default, meaning you cannot modify the value they point to. This enables safe borrowing of data.

fn main() {
    let s1 = String::from("hello");
    let len = calculate_length(&s1);

    println!("The length of '{s1}' is {len}.");
}

fn calculate_length(s: &String) -> usize {
    s.len()
}

Mutable References

Mutable references allow modifying the borrowed value but only one mutable reference to a particular piece of data is allowed at a time. This prevents data races and ensures thread safety.

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

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

let r1 = &mut s;
let r2 = &mut s; // This will cause a compile-time error

To resolve this, create a new scope.

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

{
    let r1 = &mut s;
} // r1 goes out of scope here

let r2 = &mut s; // This is now allowed

The Rules of References

  • You can have either one mutable reference or any number of immutable references at any given time.

  • References must always be valid.

These rules ensure that data can be safely accessed and modified without causing undefined behavior.

The Slice Type

Slices let you reference a contiguous sequence of elements in a collection without owning them. They provide a way to work with parts of data structures efficiently.

fn first_word(s: &String) -> usize {
    let bytes = s.as_bytes();

    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {
            return i;
        }
    }

    s.len()
}

String Literals as Slices

String literals are stored inside the binary and their type is &str, which is an immutable reference. This makes them efficient and ensures that they can be used safely without additional memory allocation.

fn main() {
    let my_string = String::from("hello world");
    let word = first_word(&my_string[0..6]);
    let word = first_word(&my_string[..]);
    let word = first_word(&my_string);

    let my_string_literal = "hello world";
    let word = first_word(&my_string_literal[0..6]);
    let word = first_word(&my_string_literal[..]);
    let word = first_word(my_string_literal);
}

By understanding Rust's ownership, references, and slices, you can write safe and efficient code that makes the most of your system's memory. This foundational knowledge is crucial for mastering Rust and leveraging its powerful features for system-level programming.

1
Subscribe to my newsletter

Read articles from srinu madhav vysyaraju directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

srinu madhav vysyaraju
srinu madhav vysyaraju

I am a seasoned backend engineer with a passion for building robust, scalable, and efficient systems. With 3 years of experience in software development, I thrive on tackling complex challenges and crafting elegant solutions that power the backbone of modern applications.