🔐Ownership

How ownership works in Rust?

Memory in Rust

There are 2 main types of memory in Rust: Stack and Heap.

Stack & Heap

💡 Stack memory is faster to access than heap memory due to its fixed location and automatic management.

Stack is used to store:

  • Primitive data types (integers, floats, booleans)

  • Small fixed-size structs and arrays

  • Function arguments (usually)

  • Local variables within a function

💡 Heap memory is suitable for data that may not be known at compile time or needs to persist beyond a function's lifetime.

Heap is used to store:

  • Large data structures (e.g., dynamically resizing vectors, binary trees)

  • Data that needs to live longer than the function that created it (e.g., global variables)

  • Data allocated with Box<T>, Rc<T>, or Arc<T>

Example of How Stack & Heap are stored data:

fn main() {
    // Stack allocation
    let x = 42;
    let y = 3.14;

    // Heap allocation
    let s = Box::new(String::from("Hello, heap!"));
    let v = Box::new(vec![1, 2, 3, 4, 5]);

    // Nested scope
    {
        let z = Box::new(99);
        println!("z: {}", z); // Using z in the nested scope
    } // z goes out of scope here and memory is freed

    println!("x: {}, y: {}, s: {}, v: {:?}", x, y, s, v);
}

Static

In Rust, there is a special region of memory to store data is static which refers to a specific way to declare items with a global lifetime and allocate memory for them.

💡 Static memory is ideal for constant data that won't change throughout the program and requires thread-safe access. They are stored in a special region of memory separate from the stack and heap. Using static items judiciously help avoid unnecessary memory usage and complexity.

Some of the key characteristics of static are:

  • Immutable by default: Once initialized, the value of an static item cannot be changed.

  • Thread-safe: Multiple threads can access the same static item concurrently without data races (assuming proper synchronization if needed).

  • Zero-cost abstraction: Accessing an static item is as fast as accessing a normal variable, unlike some heap allocations.

To be able to create a static type, we use a static keyword.

  • NAME: Identifier for your static item.

  • TYPE: Data type of the static item.

  • VALUE: Constant expression used to initialize the static item during compilation.

static NAME: TYPE = VALUE;
// For example
static PI: f64 = 3.14159;

fn main() {
  println!("Value of PI: {}", PI);
}

Since static items are allocated outside the stack and heap, they cannot be dynamically allocated or deallocated during program execution. Therefore, overusing static items can increase the memory footprint of your program.

If you need to modify the value of an static item, you can use the lazy_static crate, which provides mechanisms for lazy initialization and potential mutability.

Both static and const are used for constant values. However, const items are evaluated at compile time and generally limited to primitive data types and simple expressions. On the other hand, the static items are evaluated at program startup and can hold more complex data types.

ConceptMemory AllocationLifetimeMutabilityUse Cases

static items

Special memory region

Entire program execution

Immutable by default (can use lazy_static for mutability)

Global constants, configuration values

const items

Resolved during compilation

Entire program execution (implicitly)

Immutable

Simple constants, compile-time calculations

When to Use static

  • Defining constants that won't change throughout the program (e.g., mathematical constants, configuration values).

  • Sharing data across different modules or functions without dynamic allocation overhead.

The Differences Between Stack, Heap & Static

AspectStackHeapStatic

Contents

Primitive data types (integers, floats, booleans), small fixed-size structs and arrays, function arguments, local variables

Large data structures (vectors, binary trees), data that needs to live longer than a function

Constants, global variables

Size

Limited (typically a few megabytes)

Dynamically expandable

Limited (depends on program size)

Lifetime

Function call (data is deallocated when the function exits)

Entire program execution (or until manually deallocated)

Entire program execution

Memory allocation

Automatic by the compiler

Manual using Box<T>, vec!, or similar smart pointers

Automatic by the compiler during program startup

Clean up processing

Automatic when the function exits

Manual (memory leak possible if not deallocated properly)

Not required (automatically cleaned up at program termination)

Mutability

Can be mutable or immutable

Can be mutable or immutable

Immutable by default (use lazy_static for mutability)

Use cases

Local variables, function arguments, temporary data

Dynamically sized data structures, data shared across modules, long-lived objects

Constants, configuration values, global data that needs to be thread-safe

What is Ownership?

💡 Rust's ownership system is closely tied to memory management. Ownership ensures that there's always a single owner responsible for a piece of data, preventing dangling pointers and memory leaks.

Ownership Based Resource Management (OBRM)

💡 OBRM stands for Ownership Based Resource Management. It's a core concept in Rust that governs how memory and other resources are managed within your program.

Benefits of OBRM

Unlike languages with garbage collection, where memory is automatically reclaimed, Rust's OBRM requires you to be explicit about ownership.

This might seem more complex initially, but it gives you fine-grained control over memory usage and avoids the unpredictable behavior of garbage collectors.

  • Memory safety: OBRM prevents memory leaks and dangling pointers, common issues in languages with manual memory management.

  • Improved performance: By avoiding unnecessary copying and by ensuring proper deallocation, OBRM contributes to efficient memory usage and program performance.

  • Code clarity: Ownership rules enforce clear data ownership relationships, making code easier to understand and reason about.

Ownership System

The foundation of OBRM is Rust's ownership system. It ensures that there's always a single owner responsible for a piece of data at any given time.

This ownership is transferred or moved between variables and functions, preventing dangling pointers and memory leaks.

The ownership rules:

  • Every value in Rust has a single owner. This owner is responsible for the lifetime and deallocation of the value's memory.

  • Ownership is moved when a value is assigned or passed by value to a function. The original variable loses ownership, and the new variable becomes the owner.

  • Ownership is dropped (deallocated) when the owner goes out of scope. This happens when a variable goes out of scope or a function returns.

Ownership and Data Types

Primitive data types (integers, floats): Don't have ownership themselves. When passed around, they are copied by value, so ownership isn't a concern for them. When a primitive value is assigned to a variable, the value itself is copied onto the stack. This means, both the original variable and the new variable point to independent copies of the data. The memory on the stack gets automatically deallocated when the function that created the variable exits.

Complex data types (structs, strings): Have ownership. When ownership is moved, the data itself is also moved in memory. When ownership is dropped, the memory is automatically deallocated. Because the complex data types like structs and strings can be larger and more expensive to copy. Rust's ownership system ensures efficient memory management by preventing unnecessary copying. It also helps avoid dangling pointers (where a variable points to memory that has already been deallocated).

Examples

Complex Data Passed into Functions (Ownership)

fn reverse_string(s: String) -> String {
  let mut reversed = String::new();
  for c in s.chars().rev() {
    reversed.push(c);
  }
  return reversed;
}

fn main() {
  let greeting = "Hello, world!".to_string();
  let reversed_greeting = reverse_string(greeting);

  println!("Original greeting: {}", greeting);  // Error: cannot access borrowed value
  println!("Reversed greeting: {}", reversed_greeting);
}

When you call reverse_string(greeting), the ownership of the string "Hello, world!" (stored in greeting) is moved into the function.

The function creates a new String (reversed) and populates it with reversed characters from the original string.

Since ownership of greeting moved out, trying to access it in main after the call will lead to a compile error, as it's no longer valid in that scope.

Borrowing with References

If you only need to read the string contents within the function without modifying it, you can use references.

In this case, reverse_string takes a reference (&str) to the string, allowing it to borrow the data without taking ownership. This keeps the original string intact in main.

fn reverse_string(s: String) -> String {
  let mut reversed = String::new();
  for c in s.chars().rev() {
    reversed.push(c);
  }
  return reversed;
}

fn main() {
  let greeting = "Hello, world!";
  let reversed_greeting = reverse_string(greeting);

  println!("Original greeting: {}", greeting); // Now accessible
  println!("Reversed greeting: {}", reversed_greeting);
}

Variable Passed into Function with Primitive Types (Copy)

fn double(x: i32) -> i32 {
  return x * 2;
}

fn main() {
  let num = 5;
  let double_num = double(num); // Pass by value (copy)

  println!("Original number: {}", num); // Still accessible (original value)
  println!("Doubled number: {}", double_num);
}

The double function takes an i32 argument x.

When you call double(num), the value of num (5) is copied into x within the function. This is because i32 is a primitive type and copying is efficient.

Inside the function, x is modified, but the original num in main remains unchanged and accessible.

Ownership Moves Out of Function with Primitive Types (Return Value)

fn get_sum(x: i32, y: i32) -> i32 {
  return x + y;
}

fn main() {
  let num1 = 3;
  let num2 = 7;

  let sum = get_sum(num1, num2); // Ownership is not moved

  println!("num1: {}", num1); // Still accessible
  println!("num2: {}", num2); // Still accessible
  println!("Sum: {}", sum); // The ownership moves out and being assiged to sum variable
}

In this case, the get_sum takes two i32 arguments and returns their sum.

When you call get_sum(num1, num2), the values of num1 and num2 are copied into the function's arguments (x and y). The function performs the calculation and returns the sum.

Since it's a copy, the original values of num1 and num2 in main remain unchanged and accessible.

After the calculation, the ownership of get_num function is assigned to the sum variable.

Allocation Memory On The Heap

In Rust, we can allocate memory on the heap using the Box smart pointer.

fn main() {
    // Allocating an integer on the heap
    // The boxed_int is owner of this heap allocated integer
    let boxed_int = Box::new(10);
    println!("Boxed integer: {}", boxed_int);

    // Allocating a string on the heap
    // The boxed_string is owner of this heap allocated string
    let boxed_string = Box::new(String::from("Hello, world!"));
    println!("Boxed string: {}", boxed_string);

    // Allocating a vector on the heap
    // The boxed_vector is owner of this heap allocated vector
    let boxed_vector = Box::new(vec![1, 2, 3, 4, 5]);
    println!("Boxed vector: {:?}", boxed_vector);
}

// At the end of this function, boxed_int, boxed_string and boxed_vector will go out of scope, 
// meaning their values will be cleaned up

The Move Semantics

The difference between Rust and C++ is that move semantics are implicit

fn main() {
    // Example 1: Simple move
    let s1 = String::from("Hello");
    let s2 = s1; // s1 is moved to s2

    // Uncommenting the next line will cause a compile-time error because s1 has been moved
    // println!("s1: {}", s1);
    println!("s2: {}", s2);

    // Example 2: Move with functions
    let s3 = String::from("Rust");
    take_ownership(s3); // s3 is moved into the function

    // Uncommenting the next line will cause a compile-time error because s3 has been moved
    // println!("s3: {}", s3);

    // Example 3: Returning ownership
    let s4 = String::from("Ownership");
    let s5 = take_and_return_ownership(s4); // s4 is moved into the function and then returned to s5
    println!("s5: {}", s5);

    // Example 4: Move with non-Copy types and Copy types
    let x = 5; // i32 is a Copy type
    let y = x; // x is copied to y, because i32 implements the Copy trait
    println!("x: {}, y: {}", x, y);

    let v1 = vec![1, 2, 3]; // Vec is a non-Copy type
    let v2 = v1; // v1 is moved to v2
    // Uncommenting the next line will cause a compile-time error because v1 has been moved
    // println!("v1: {:?}", v1);
    println!("v2: {:?}", v2);

    // Example 5: Using clone to duplicate data
    let s6 = String::from("Clone me");
    let s7 = s6.clone(); // s6 is cloned to s7
    println!("s6: {}, s7: {}", s6, s7);
}

fn take_ownership(some_string: String) {
    println!("Taking ownership of: {}", some_string);
    // some_string goes out of scope here and is dropped
}

fn take_and_return_ownership(some_string: String) -> String {
    println!("Taking and returning ownership of: {}", some_string);
    some_string // Returning ownership
}

Shared Ownership

In Rust, shared ownership can be achieved using the Rc (Reference Counted) and Arc (Atomic Reference Counted) smart pointers. These types allow multiple ownership of the same data, with Rc being used for single-threaded scenarios and Arc for multi-threaded scenarios.

Using Rc<T> for Shared Ownership in Single-Threaded Contexts

Rc<T> enables multiple parts of your program to read from the same data without needing to make copies.

use std::rc::Rc;

fn main() {
    // Create an Rc instance
    let s = Rc::new(String::from("Hello, Rc!"));

    // Clone the Rc to create multiple references
    let s1 = Rc::clone(&s);
    let s2 = Rc::clone(&s);

    // Both s1 and s2 now share ownership of the data
    println!("s: {}, s1: {}, s2: {}", s, s1, s2);

    // Display the reference count
    println!("Reference count: {}", Rc::strong_count(&s));
}

Using Arc<T> for Shared Ownership in Multi-Threaded Contexts

Arc<T> is similar to Rc<T> but is thread-safe and can be used in multi-threaded environments.

use std::sync::Arc;
use std::thread;

fn main() {
    // Create an Arc instance
    let s = Arc::new(String::from("Hello, Arc!"));

    // Clone the Arc to create multiple references
    let s1 = Arc::clone(&s);
    let s2 = Arc::clone(&s);

    // Create a vector to hold the thread handles
    let mut handles = vec![];

    // Spawn threads that share ownership of the data
    let handle1 = thread::spawn(move || {
        println!("Thread 1: {}", s1);
    });

    let handle2 = thread::spawn(move || {
        println!("Thread 2: {}", s2);
    });

    // Add handles to the vector
    handles.push(handle1);
    handles.push(handle2);

    // Wait for all threads to finish
    for handle in handles {
        handle.join().unwrap();
    }
}

Last updated