learn.sol

Rust Ownership System Explained (Borrowing and References)

Master Rust’s ownership model with borrowing and references, and learn how stack vs heap, move semantics, and slices deliver memory safety without a garbage collector.

Welcome to Day 2, where we dive into Rust's most distinctive feature: the ownership system. This is what sets Rust apart from every other programming language and enables it to be both memory-safe and performant without a garbage collector.

Understanding ownership is crucial for Solana development because blockchain programs must be extremely efficient and secure. Rust's ownership system prevents entire classes of bugs that could be catastrophic in financial applications.

Why Ownership Matters for Solana: In blockchain development, programs must be deterministic and efficient. Memory bugs can lead to security vulnerabilities or unpredictable behavior. Rust's ownership system prevents these issues at compile time.


What You'll Learn Today

By the end of Day 2, you'll understand:

  • The three fundamental ownership rules
  • How Rust manages memory without garbage collection
  • The difference between stack and heap memory
  • Move semantics and when values are transferred
  • Borrowing with immutable and mutable references
  • The borrowing rules that prevent data races
  • How to work with slices for efficient data access
  • When to clone vs. when to borrow data

The Problem Ownership Solves

Before diving into Rust's solution, let's understand the memory management problems that plague other languages:

Languages like C/C++:

// C code - manual memory management
char* data = malloc(100);  // Allocate memory
// ... use data ...
free(data);               // Must remember to free
// data is now a dangling pointer!

Problems:

  • Memory leaks (forget to free)
  • Double-free errors (free twice)
  • Use-after-free bugs (access freed memory)
  • Buffer overflows

Languages like Java/JavaScript:

// Java code - garbage collected
String data = new String("Hello");  // Allocate
// ... use data ...
// GC automatically frees when no longer referenced

Problems:

  • Unpredictable performance (GC pauses)
  • Memory overhead for tracking
  • Not suitable for real-time systems
  • Can't control when cleanup happens

Rust - Ownership System:

// Rust code - ownership tracking
let data = String::from("Hello");  // Allocate
// ... use data ...
// Automatically freed when 'data' goes out of scope
// Compiler ensures no use-after-free

Benefits:

  • Zero-cost abstraction (no runtime overhead)
  • Memory safety guaranteed at compile time
  • Predictable performance
  • No garbage collector needed

Understanding Memory: Stack vs Heap

Before we explore ownership, it's crucial to understand how Rust manages different types of memory.

Mental Model: Think of the stack like a stack of plates (fast to add/remove from top) and the heap like a library where you need to find an open shelf (slower but more flexible).


The Three Ownership Rules

Rust's ownership system is built on three simple rules that the compiler enforces:

Rule 1: Each value has a single owner

Every value in Rust has exactly one variable that owns it.

single_owner.rs
fn main() {
    let s = String::from("Hello");  // s owns the String
    // Only s can modify or control this String's memory
}

Rule 2: Only one owner at a time

There can only be one owner of a value at any given time.

one_owner.rs
fn main() {
    let s1 = String::from("Hello");
    let s2 = s1;  // s1 is no longer valid! Ownership moved to s2
    
    // println!("{}", s1);  // ❌ Error: s1 is no longer valid
    println!("{}", s2);     // ✅ Works: s2 owns the String
}

Rule 3: Owner cleanup on scope exit

When the owner goes out of scope, the value is automatically cleaned up.

scope_cleanup.rs
fn main() {
    {
        let s = String::from("Hello");  // s owns the String
        // ... use s ...
    }  // s goes out of scope, String is automatically freed
    
    // s is no longer accessible here
}

Move Semantics

When we assign a heap-allocated value to another variable, Rust moves the ownership instead of copying the data:

move_semantics.rs
fn main() {
    // Example 1: Stack data (Copy types)
    let x = 5;
    let y = x;  // x is copied (i32 implements Copy trait)
    println!("x: {}, y: {}", x, y);  // Both x and y are valid
    
    // Example 2: Heap data (Move types)
    let s1 = String::from("Hello");
    let s2 = s1;  // s1 is moved to s2 (String doesn't implement Copy)
    
    // println!("{}", s1);  // ❌ Error! s1 is no longer valid
    println!("{}", s2);     // ✅ Only s2 is valid
    
    // Example 3: Function calls also move ownership
    let s3 = String::from("World");
    take_ownership(s3);     // s3 is moved into the function
    // println!("{}", s3);  // ❌ Error! s3 is no longer valid
}

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

Why Move Instead of Copy?

Moving prevents expensive deep copies and ensures clear ownership. If Rust copied every String assignment, programs would be slow and memory usage would explode.

// If Rust did shallow copies (like some languages)
let s1 = String::from("Hello");
let s2 = s1;  // Both point to same memory

// Problem: When both go out of scope, 
// both try to free the same memory = crash!
// If Rust did deep copies by default
let s1 = String::from("Hello");
let s2 = s1;  // Expensive copy of all string data

// Problem: Performance killer for large data
// Memory usage doubles
// Rust's move solution
let s1 = String::from("Hello");
let s2 = s1;  // Ownership transferred, s1 invalidated

// Benefits:
// - No expensive copying
// - No double-free errors
// - Clear ownership at compile time

References and Borrowing

Moving ownership every time we want to use a value would be impractical. Rust provides references that let us "borrow" values without taking ownership:

Immutable References

immutable_references.rs
fn main() {
    let s1 = String::from("Hello");
    
    let len = calculate_length(&s1);  // Borrow s1 with &
    
    println!("The length of '{}' is {}.", s1, len);  // s1 still valid!
}

fn calculate_length(s: &String) -> usize {  // s is a reference
    s.len()
}  // s goes out of scope, but it doesn't own the data, so nothing happens

Reference Syntax: & creates a reference, * dereferences (though often not needed due to automatic dereferencing).

Mutable References

To modify borrowed data, we need a mutable reference:

mutable_references.rs
fn main() {
    let mut s = String::from("Hello");  // s must be mutable
    
    change(&mut s);  // Borrow s mutably with &mut
    
    println!("{}", s);  // s has been modified
}

fn change(some_string: &mut String) {
    some_string.push_str(", World!");
}

The Borrowing Rules

Rust enforces strict borrowing rules to prevent data races:

Rule 1: Multiple immutable references OR one mutable reference

You can have either:

  • Any number of immutable references
  • Exactly one mutable reference

But not both at the same time.

borrowing_rules.rs
fn main() {
    let mut s = String::from("Hello");
    
    // ✅ Multiple immutable references are fine
    let r1 = &s;
    let r2 = &s;
    println!("{} and {}", r1, r2);
    
    // ✅ One mutable reference is fine (after immutable ones are done)
    let r3 = &mut s;
    println!("{}", r3);
    
    // ❌ This would be an error:
    // let r4 = &s;     // Immutable reference
    // let r5 = &mut s; // Mutable reference
    // println!("{} {}", r4, r5);  // Using both = error!
}

Rule 2: References must always be valid

References cannot outlive the data they point to.

reference_lifetime.rs
fn main() {
    let reference_to_nothing = dangle();  // ❌ This won't compile
}

fn dangle() -> &String {  // ❌ Error: missing lifetime specifier
    let s = String::from("Hello");
    &s  // s is freed when function ends, but we're returning a reference to it
}  // s goes out of scope and is freed

// ✅ Correct version: return the String itself
fn no_dangle() -> String {
    let s = String::from("Hello");
    s  // Ownership is moved out, so no dangling reference
}

Slices: References to Parts of Data

Slices let us reference a contiguous sequence of elements without taking ownership:

String Slices

string_slices.rs
fn main() {
    let s = String::from("Hello, World!");
    
    // String slice syntax: [start..end]
    let hello = &s[0..5];    // "Hello"
    let world = &s[7..12];   // "World"
    let whole = &s[..];      // Entire string
    
    println!("hello: {}", hello);
    println!("world: {}", world);
    println!("whole: {}", whole);
    
    // Practical example: finding the first word
    let first = first_word(&s);
    println!("First word: {}", first);
}

fn first_word(s: &String) -> &str {  // Returns a string slice
    let bytes = s.as_bytes();
    
    for (i, &item) in bytes.iter().enumerate() {
        if item == b' ' {  // b' ' is a byte literal for space
            return &s[0..i];
        }
    }
    
    &s[..]  // Return the whole string if no space found
}

String Literals Are Slices

string_literals.rs
fn main() {
    // String literals have type &str (string slice)
    let s = "Hello, World!";  // Type: &str
    
    // This function works with both String and &str
    let len1 = string_length(&String::from("Hello"));  // String
    let len2 = string_length("Hello");                  // &str literal
    
    println!("Lengths: {}, {}", len1, len2);
}

// Best practice: take &str instead of &String for flexibility
fn string_length(s: &str) -> usize {
    s.len()
}

Array Slices

array_slices.rs
fn main() {
    let numbers = [1, 2, 3, 4, 5];
    
    let slice = &numbers[1..4];  // [2, 3, 4]
    
    println!("Original: {:?}", numbers);
    println!("Slice: {:?}", slice);
    
    // Works with vectors too
    let vec = vec![10, 20, 30, 40, 50];
    let vec_slice = &vec[2..];  // [30, 40, 50]
    
    println!("Vector slice: {:?}", vec_slice);
}

Slice Syntax Summary:

  • &s[start..end]: Elements from start to end-1
  • &s[start..]: Elements from start to the end
  • &s[..end]: Elements from beginning to end-1
  • &s[..]: All elements

Practical Examples: Blockchain Concepts

Let's apply ownership concepts to blockchain-relevant scenarios:

Transaction Processing

transaction_example.rs
#[derive(Debug)]
struct Transaction {
    from: String,
    to: String,
    amount: u64,
    signature: String,
}

impl Transaction {
    fn new(from: String, to: String, amount: u64) -> Self {
        Transaction {
            from,
            to,
            amount,
            signature: String::from("unsigned"),
        }
    }
    
    fn sign(&mut self, private_key: &str) {
        // Simulate signing process
        self.signature = format!("signed_with_{}", private_key);
    }
    
    fn verify(&self) -> bool {
        // Simulate verification
        !self.signature.starts_with("unsigned")
    }
}

fn main() {
    // Create transaction
    let mut tx = Transaction::new(
        String::from("Alice"),
        String::from("Bob"),
        1000,
    );
    
    println!("Created transaction: {:?}", tx);
    
    // Sign transaction (needs mutable reference)
    sign_transaction(&mut tx, "alice_private_key");
    
    // Verify transaction (immutable reference is fine)
    if verify_transaction(&tx) {
        // Process transaction (ownership transfer)
        process_transaction(tx);
        // tx is no longer accessible here - it was moved
    }
}

fn sign_transaction(tx: &mut Transaction, private_key: &str) {
    tx.sign(private_key);
    println!("Transaction signed");
}

fn verify_transaction(tx: &Transaction) -> bool {
    let is_valid = tx.verify();
    println!("Transaction valid: {}", is_valid);
    is_valid
}

fn process_transaction(tx: Transaction) {  // Takes ownership
    println!("Processing transaction: {:?}", tx);
    // Transaction is consumed here
}

Account Management

account_example.rs
#[derive(Debug, Clone)]
struct Account {
    address: String,
    balance: u64,
    nonce: u32,
}

impl Account {
    fn new(address: String, initial_balance: u64) -> Self {
        Account {
            address,
            balance: initial_balance,
            nonce: 0,
        }
    }
    
    fn transfer(&mut self, amount: u64) -> Result<(), String> {
        if self.balance >= amount {
            self.balance -= amount;
            self.nonce += 1;
            Ok(())
        } else {
            Err("Insufficient funds".to_string())
        }
    }
    
    fn receive(&mut self, amount: u64) {
        self.balance += amount;
    }
}

fn main() {
    let mut alice = Account::new("Alice123".to_string(), 5000);
    let mut bob = Account::new("Bob456".to_string(), 1000);
    
    println!("Initial state:");
    print_account_summary(&alice);
    print_account_summary(&bob);
    
    // Transfer funds
    let transfer_amount = 1500;
    match execute_transfer(&mut alice, &mut bob, transfer_amount) {
        Ok(()) => println!("Transfer successful!"),
        Err(e) => println!("Transfer failed: {}", e),
    }
    
    println!("\nFinal state:");
    print_account_summary(&alice);
    print_account_summary(&bob);
}

fn execute_transfer(
    from: &mut Account, 
    to: &mut Account, 
    amount: u64
) -> Result<(), String> {
    from.transfer(amount)?;  // ? operator propagates errors
    to.receive(amount);
    Ok(())
}

fn print_account_summary(account: &Account) {
    println!("{}: Balance={}, Nonce={}", 
             account.address, account.balance, account.nonce);
}

When to Clone vs. Borrow

Understanding when to clone data versus when to borrow is crucial for efficient Rust programming:

// ✅ Prefer borrowing for read-only access
fn analyze_data(data: &Vec<u64>) -> (u64, u64, f64) {
    let sum: u64 = data.iter().sum();
    let max = *data.iter().max().unwrap_or(&0);
    let avg = sum as f64 / data.len() as f64;
    (sum, max, avg)
}

// ✅ Borrow mutably when you need to modify
fn sort_data(data: &mut Vec<u64>) {
    data.sort();
}
// ✅ Clone when you need to store data beyond the lifetime
fn store_important_data(data: &String) -> String {
    // We need to own this data, so we clone
    data.clone()
}

// ✅ Clone when multiple ownership is needed
fn distribute_data(data: &Vec<u64>) -> (Vec<u64>, Vec<u64>) {
    (data.clone(), data.clone())  // Two separate copies
}
// ✅ Move when transferring ownership is natural
fn consume_and_transform(mut data: Vec<u64>) -> Vec<String> {
    data.iter().map(|x| x.to_string()).collect()
}

// ✅ Move when the original is no longer needed
fn main() {
    let data = vec![1, 2, 3, 4, 5];
    let result = consume_and_transform(data);
    // data is gone, but that's fine - we have result
}

Performance Guideline: Borrow > Move > Clone. Try borrowing first, move if ownership transfer makes sense, clone only when you truly need multiple copies.


Common Ownership Pitfalls

Trying to use moved values

let s1 = String::from("Hello");
let s2 = s1;  // s1 is moved
println!("{}", s1);  // ❌ Error: s1 is no longer valid

// Solution: use references or clone
let s1 = String::from("Hello");
let s2 = &s1;  // Borrow instead of move
println!("{} {}", s1, s2);  // ✅ Both valid

Violating borrowing rules

let mut s = String::from("Hello");
let r1 = &s;
let r2 = &mut s;  // ❌ Error: can't have mutable ref while immutable ref exists
println!("{} {}", r1, r2);

// Solution: separate the borrows
let mut s = String::from("Hello");
let r1 = &s;
println!("{}", r1);  // r1 scope ends here
let r2 = &mut s;     // ✅ Now we can borrow mutably
r2.push_str(", World!");

Creating dangling references

fn dangle() -> &String {  // ❌ Error: missing lifetime specifier
    let s = String::from("Hello");
    &s  // s is freed when function ends
}

// Solution: return the owned value
fn no_dangle() -> String {  // ✅ Return ownership
    let s = String::from("Hello");
    s
}

Summary

Today you've mastered Rust's ownership system - the foundation that makes Rust both safe and fast:

Key Concepts Learned:

  • Three ownership rules: single owner, one at a time, cleanup on scope exit
  • Memory management: stack vs. heap, automatic cleanup
  • Move semantics: ownership transfer prevents expensive copies
  • Borrowing: references allow data access without ownership transfer
  • Borrowing rules: prevent data races at compile time
  • Slices: efficient references to data subsequences

Why This Matters for Solana:

  • Memory safety prevents security vulnerabilities
  • Zero-cost abstractions ensure optimal performance
  • Predictable resource management for blockchain constraints
  • Compile-time guarantees eliminate runtime errors

Tomorrow Preview: Day 3 will build upon ownership to explore structs and methods - how to create custom data types and organize code in a way that's perfect for modeling blockchain entities like accounts, transactions, and programs.


Practice Time!

Ready to test your ownership understanding? Head over to Day 2 Challenges to practice with string analysis, array manipulation, and memory management exercises!

Solana Assistant

AI-powered documentation helper

Welcome to Solana Assistant

Ask specific questions about Solana development:

Ask specific questions for better results400px
    Rust Ownership System Explained (Borrowing and References) | learn.sol