Create your own
Lesson illustration

Understanding Ownership: Move Semantics and the Copy Trait

Hello! Welcome back to our journey into Rust.

In our last lesson, we covered control flow, learning how to use if, loop, while, and for to direct the logic of our programs. We ended with a crucial question: when we pass complex, dynamic data like a String into functions or loops, how does Rust manage its memory without a garbage collector?

Today, we will answer that question by exploring Rust's most unique and powerful feature: the Ownership system. This is a fundamental concept in Rust that enables the language to provide compile-time memory safety guarantees without the performance overhead of garbage collection. For your goal of Solana development, mastering ownership is non-negotiable, as it is the key to writing secure and highly-performant on-chain programs.

This lesson will explain the ownership model, its rules, the concept of move semantics, and how the Copy trait provides an exception for certain data types.

Why Ownership? The Core Problem of Memory Management

Before diving into the rules, it's essential to understand the problem Rust is trying to solve. Programming languages typically manage memory in one of two ways:

  1. Garbage Collection (GC): Used by languages like JavaScript, Python, and Java. A runtime process (the garbage collector) periodically scans for memory that is no longer being used and frees it. This is convenient for the developer but introduces runtime overhead, which can be unpredictable and is undesirable for high-performance systems like Solana.
  2. Manual Memory Management: Used by languages like C and C++. The programmer is responsible for explicitly allocating and freeing memory. This offers maximum control and performance but is notoriously error-prone, leading to bugs like memory leaks, dangling pointers, and double-free errors.

Rust introduces a third approach: Ownership. It's a set of rules, checked by the compiler at compile time, that manage memory. This system enforces memory safety without needing a garbage collector, resulting in code that is both safe and performant.

The following video gives an excellent overview of this trade-off and introduces how Rust's borrow checker uses ownership rules to solve it.

The Rust Survival Guide

Watch this introductory segment from 'The Rust Survival Guide' by Let's Get Rusty. It sets the stage by explaining the problems with GC and manual memory management and positions Rust's ownership model as the solution.

Watch from the beginning (00:42) to 02:20. Focus on the 'why'—the core reasons Rust needs a system like ownership.

The Three Rules of Ownership

At its heart, the ownership system is governed by three simple rules that the compiler enforces. We will spend the rest of the lesson unpacking what these rules mean in practice.

The best place to start is the official Rust book, which clearly lays out the fundamentals.

What is Ownership? - The Rust Programming Language

Please read the introductory sections from 'What Is Ownership?' in The Rust Programming Language book. This will cover the core ownership rules and the crucial distinction between the stack and the heap.

Read the following sections: 'What Is Ownership?', 'The Stack and the Heap', 'Ownership Rules', and 'Variable Scope'. Understanding the stack vs. heap is critical for grasping why some types are moved and others are copied.

To summarize the key points from your reading:

  • The Three Rules:

    1. Each value in Rust has a variable that’s called its owner.
    2. There can only be one owner at a time.
    3. When the owner goes out of scope, the value is dropped (i.e., its memory is freed).
  • Stack vs. Heap:

    • Stack: Fast, organized (last-in, first-out). For data with a known, fixed size at compile time (e.g., integers, booleans, pointers).
    • Heap: Less organized, slower. For data that can grow or whose size is unknown at compile time (e.g., a String). The ownership system is primarily concerned with managing heap data.

Move Semantics: Transferring Ownership

Now let's see these rules in action. The most significant consequence of the ownership rules is how Rust handles assignment and function calls for heap-allocated data.

Consider this example with String, a heap-allocated type:

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

    // The line below will cause a compile error!
    // println!("s1 is: {}", s1); 
}

Coming from JavaScript, you might expect s1 and s2 to both refer to the same string data, similar to how object references work. While they initially point to the same data, Rust's rules change things.

Because there can only be one owner, when we assign s1 to s2, Rust considers ownership to have been moved. The String data (the pointer, length, and capacity on the stack) is copied, but s1 is now invalidated. s2 is the new sole owner. When s2 goes out of scope, it will free the memory. This prevents the "double-free" error that would occur if both s1 and s2 tried to free the same memory.

This process is called a move.

The Rust book provides a fantastic, detailed explanation with diagrams.

What is Ownership? - The Rust Programming Language

Let's continue in the Rust book. This section explains move semantics using the String example and contrasts it with stack-only data.

Please read the sections 'Memory and Allocation', 'Variables and Data Interacting with Move', and 'Ownership and Functions'. Pay close attention to Figures 4-1 through 4-4, which visually explain how a move works in memory.

The same move semantics apply when passing a value to a function. If a function takes ownership of a value, you cannot use that value again in the calling scope.

fn main() {
    let s = String::from("hello");
    takes_ownership(s); // `s`'s ownership is moved into the function

    // The next line would cause an error because `s`'s value has been moved.
    // println!("{}", s);
}

fn takes_ownership(some_string: String) { // `some_string` takes ownership
    println!("{}", some_string);
} // `some_string` goes out of scope, `drop` is called, and the memory is freed.

The Exception: The Copy Trait

You might be thinking, "Wait, I've done this with numbers and it worked fine!"

fn main() {
    let x = 5;
    let y = x;

    println!("x = {}, y = {}", x, y); // This works perfectly!
}

Why was x not invalidated after being assigned to y?

This is where the Copy trait comes in. Types like integers, booleans, and floats live entirely on the stack. Copying them is trivial and cheap. For these types, Rust doesn't perform a move. Instead, it performs a simple bitwise copy of the value.

A type can implement the Copy trait if all of its parts can be copied trivially. You can't implement Copy on a type that needs to do something special when it's dropped (like String, which needs to free heap memory).

Here are some common types that implement Copy:

  • All integer types (u32, i64, etc.)
  • The boolean type (bool)
  • All floating-point types (f32, f64)
  • The character type (char)
  • Tuples, if they only contain types that also implement Copy. For example, (i32, i32) is Copy, but (i32, String) is not.

This distinction between move and copy behavior is a common source of confusion for newcomers. This forum post explains it in a very direct and memorable way.

Rust Mutability, Moving and Borrowing - The Straight Dope

Read this section from a forum post on rust-lang.org. It captures the 'aha!' moment many developers have when they realize why some types move and others copy.

Read the sections 'BUT WAIT - YOU WILL PULL YOUR HAIR OUT OVER THIS!!!!' and the 'Moving and Borrowing summary'. It provides an excellent mental model: think 'pass by move' vs. 'pass by borrow', not 'pass by value' vs. 'pass by reference'.

Test your understanding!

For each of the following code snippets, predict whether it will compile or not. If it won't compile, explain why.

  1. let s1 = String::from("solana");
    let s2 = s1;
    println!("{}", s1);
    
  2. let x: u64 = 100;
    let y = x;
    println!("{}", x);
    
  3. let t1 = (10, String::from("anchor"));
    let t2 = t1;
    println!("{:?}", t1);
    
Show answer
  1. Will not compile. String does not implement the Copy trait. When s1 is assigned to s2, ownership is moved. s1 is no longer valid and cannot be used.
  2. Will compile. u64 is an integer type that implements the Copy trait. When x is assigned to y, the value is copied. x remains valid.
  3. Will not compile. The tuple contains a String, which is not Copy. Therefore, the tuple as a whole does not implement the Copy trait. When t1 is assigned to t2, ownership is moved. t1 is no longer valid.

What About Deep Copies? The clone Method

If you do want to create a deep copy of heap data (like a String), you can call the clone() method. This explicitly creates a new, independent instance of the data on the heap.

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

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

Using clone() is a clear signal that you are performing a potentially expensive operation.

Rust’s Most Unique Feature

This short segment from ArjanCodes demonstrates clone() as the explicit way to create a deep copy.

Watch from 05:26 to 06:08. This clearly shows how clone() creates a separate copy of the heap data.

Conclusion

Congratulations on getting through one of the most challenging but rewarding concepts in Rust! Understanding ownership is like gaining a superpower: it allows you to write code with the performance of C++ but with the safety guarantees of a higher-level language.

Here are the key takeaways from today's lesson:

  • Ownership Rules: A value has one owner. When the owner goes out of scope, the value is dropped.
  • Move Semantics: By default, assigning a heap-allocated value to a new variable moves it, transferring ownership and invalidating the original variable. This also applies when passing values to functions.
  • Copy Trait: Simple, stack-only data types implement the Copy trait. Assigning these values creates a copy, and the original variable remains valid.
  • clone(): To explicitly create a deep copy of heap data, you can use the .clone() method.

The ownership system might seem restrictive at first. You might be wondering, "What if I just want a function to read a value without taking ownership and forcing me to return it?" That's a great question, and it leads directly to our next topic.

In the next lesson, we will learn about references and borrowing, which is Rust's mechanism for allowing code to access data without taking ownership. This is the final piece of the ownership puzzle and will complete your core understanding of how Rust manages memory.

Can't find a good explanation? Sign up and we'll make it for you

Sign up