Advanced Rust Series Part 1: The Ownership Model Revisited – Beyond the Basics

Advanced Rust Series Part 1: The Ownership Model Revisited – Beyond the Basics

If you have been writing Rust for a while, you probably survived the ownership chapter in the official book. You know that values have a single owner, that assignment moves values, and that the borrow checker will reject your code if you try to use something after it has been moved. That much is the foundation.

But there is a big gap between knowing the rules and understanding the model well enough to write production-grade Rust confidently. The developers who fight the borrow checker every day are usually not violating the rules – they are missing the mental model behind them.

This is Part 1 of the Advanced Rust: Ownership, Borrowing and Lifetimes series. We are going to dig into ownership at a level that actually changes how you write code – not just how you survive the compiler.

What Ownership Actually Means

Ownership in Rust is not just a memory management strategy. It is a way of expressing resource lifetimes through the type system. When a value has an owner, the compiler knows exactly when that value will be cleaned up – at the end of the owner’s scope. No runtime garbage collector, no reference counting by default, no destructor calls you have to remember to make.

The rule is simple: every value has exactly one owner at any point in time. When the owner goes out of scope, the value is dropped. This is deterministic, zero-cost, and happens at compile time.

The key insight that most beginner resources skip is that ownership is about resource management in the general sense, not just heap memory. A file handle owns a file descriptor. A mutex guard owns a lock. A network connection owns a socket. When any of these go out of scope, the resource is released automatically. This is Rust’s version of RAII (Resource Acquisition Is Initialization), borrowed from C++ but enforced at the language level.

Move Semantics in Real Code

In beginner examples, move semantics look like this:

let s1 = String::from("hello");
let s2 = s1; // s1 is moved into s2
// println!("{}", s1); // compile error: value borrowed after move

Simple enough. But in production code, moves are happening constantly in ways that are less obvious. Consider this:

fn process(config: Config) {
    // config is moved into this function
    // the caller no longer owns it
}

fn main() {
    let config = Config::new("production");
    process(config);
    // process(config); // compile error: use of moved value
}

Every time you pass a non-Copy value to a function, you are moving it. The function takes ownership. The caller loses it. This is not a bug – it is the language being explicit about resource lifetimes. When process returns, it drops config.

This becomes significant at scale. If you have a value that multiple functions need, you have four options:

  • Pass a reference (borrow it)
  • Clone it before passing
  • Return it from the function after use
  • Redesign so only one function needs it

Experienced Rust developers default to option one – borrowing – almost every time. Cloning has a cost and is a design smell if overused. Returning values works well for builder patterns. Redesigning is sometimes the right answer when ownership fights signal a structural problem.

The Ownership Transfer Flow

Here is how ownership moves through a typical Rust program:

flowchart TD
    A[Value Created\non Stack or Heap] --> B[Owned by Variable]
    B --> C{Assigned or Passed?}
    C -->|Move - non-Copy type| D[New Owner]
    C -->|Copy - Copy type| E[Both Valid\nBitwise Copy]
    C -->|Borrow| F[Temporary Reference\nOwnership Stays]
    D --> G{Function Returns?}
    G -->|Returned| H[Caller Takes Ownership]
    G -->|Not Returned| I[Dropped at End of Scope]
    F --> J[Reference Expires\nOwner Regains Full Control]
    E --> K[Original + Copy Both Dropped\nat Their Own Scopes]

Copy Types: What They Are and Why They Exist

Not every type in Rust uses move semantics. Types that implement the Copy trait are duplicated automatically on assignment rather than moved. The original stays valid.

let x: i32 = 42;
let y = x; // x is NOT moved - it is copied
println!("x={}, y={}", x, y); // both work fine

The rule for what can be Copy is strict: a type can implement Copy only if a bitwise copy of its bytes creates a fully independent, valid value. Integers, booleans, floats, and tuples or arrays of Copy types all qualify. String, Vec, and any type that owns heap memory do not – because a bitwise copy would create two owners pointing at the same heap allocation, and when both try to drop it, you get a double-free error.

There is also a hard rule: a type that implements Drop cannot implement Copy. If your type needs custom cleanup logic, it cannot be trivially copied.

Here is a quick reference for common types:

TypeCopy?Reason
i8, i16, i32, i64, i128, isizeYesFixed size, stack only
u8, u16, u32, u64, u128, usizeYesFixed size, stack only
f32, f64YesFixed size, stack only
bool, charYesFixed size, stack only
&T (shared reference)YesPointer is cheap to copy
StringNoOwns heap memory
Vec<T>NoOwns heap memory
Box<T>NoSingle ownership semantics
(T, U) where T and U are CopyYesAll parts are Copy
[T; N] where T is CopyYesAll elements are Copy

Implementing Copy on Your Own Types

You can derive Copy on your own structs and enums as long as all fields are also Copy:

#[derive(Debug, Copy, Clone)]
struct Point {
    x: f64,
    y: f64,
}

fn translate(p: Point, dx: f64, dy: f64) -> Point {
    Point { x: p.x + dx, y: p.y + dy }
}

fn main() {
    let origin = Point { x: 0.0, y: 0.0 };
    let moved = translate(origin, 3.0, 4.0);
    
    // origin is still valid because Point is Copy
    println!("origin: {:?}", origin);
    println!("moved: {:?}", moved);
}

Notice that Copy requires Clone to also be implemented. This is because Copy is a subtrait of Clone – anything that can be bitwise copied can also be explicitly cloned (cloning just does the same thing). You almost always derive both together.

A practical guideline: derive Copy when your type is small, represents a value (coordinates, colors, identifiers), and has no heap allocations. Avoid it on types that are meant to represent ownership of a resource.

Clone: Explicit Deep Copying

Clone is the explicit counterpart to Copy. Calling .clone() produces a new, fully independent copy of the value. For types like String or Vec, this means allocating new heap memory and copying all the data into it.

let original = vec![1, 2, 3, 4, 5];
let cloned = original.clone(); // new heap allocation, full copy

// both are independently owned
println!("{:?}", original);
println!("{:?}", cloned);

The .clone() call is intentionally verbose. It is a visual signal that potentially expensive work is happening. When you see a .clone() in a code review, it is worth asking: is this necessary, or can we restructure to use a borrow instead?

You can implement Clone manually when you need custom duplication logic:

#[derive(Debug)]
struct CacheEntry {
    key: String,
    value: Vec,
    hit_count: u64,
}

impl Clone for CacheEntry {
    fn clone(&self) -> Self {
        CacheEntry {
            key: self.key.clone(),
            value: self.value.clone(),
            hit_count: 0, // reset hit count on clone - intentional design choice
        }
    }
}

fn main() {
    let entry = CacheEntry {
        key: String::from("user:42"),
        value: vec![1, 2, 3],
        hit_count: 150,
    };
    
    let cloned = entry.clone();
    println!("Original hit count: {}", entry.hit_count); // 150
    println!("Cloned hit count: {}", cloned.hit_count);  // 0
}

Ownership Through Functions: The Patterns That Actually Matter

Here is where things get practical. These are the patterns you will use constantly in real Rust code.

Pattern 1: Take Ownership When You Need to Store the Value

struct Server {
    config: Config, // Server owns the Config
}

impl Server {
    // Takes ownership of config - caller gives it up
    fn new(config: Config) -> Self {
        Server { config }
    }
}

When a struct needs to store a value for its lifetime, take ownership in the constructor. The caller no longer needs it, so taking it is the right design.

Pattern 2: Borrow When You Only Need to Read

fn log_config(config: &Config) {
    // borrows config, does not take ownership
    println!("Config: {:?}", config);
}

fn main() {
    let config = Config::new("production");
    log_config(&config);
    // config is still valid here
    start_server(config); // now we move it
}

Pattern 3: Return Ownership to Thread It Through

fn with_timeout(mut config: Config, timeout_ms: u64) -> Config {
    config.timeout = timeout_ms;
    config // return ownership back to caller
}

fn main() {
    let config = Config::new("production");
    let config = with_timeout(config, 5000); // ownership threads through
    start_server(config);
}

This pattern – take, modify, return – is the basis of builder patterns in Rust. It is verbose compared to mutable references but makes the transformation explicit.

The Drop Order and Why It Matters

When multiple values are in scope, Rust drops them in reverse order of declaration. This is deterministic and important for resources that depend on each other:

fn main() {
    let connection = DatabaseConnection::new(); // created first
    let transaction = connection.begin();       // depends on connection
    
    // transaction is dropped first (last declared = first dropped)
    // connection is dropped second
    // this guarantees transaction is closed before connection is released
}

Fields within a struct are dropped in declaration order. This matters when you have fields that depend on each other – put the dependent field first so it is dropped first.

Ownership and the Drop Trait

The Drop trait is how you hook into the cleanup that happens when ownership ends. It is Rust’s destructor:

#[derive(Debug)]
struct TempFile {
    path: String,
}

impl TempFile {
    fn new(path: &str) -> Self {
        // create the file
        std::fs::File::create(path).unwrap();
        TempFile { path: path.to_string() }
    }
}

impl Drop for TempFile {
    fn drop(&mut self) {
        // guaranteed cleanup when TempFile goes out of scope
        if std::fs::remove_file(&self.path).is_err() {
            eprintln!("Warning: failed to remove temp file {}", self.path);
        }
    }
}

fn main() {
    {
        let tmp = TempFile::new("/tmp/workfile.tmp");
        // do work with tmp
        println!("Working with {:?}", tmp);
    } // tmp dropped here, file deleted automatically
    
    println!("Temp file has been cleaned up");
}

You can also force an early drop with std::mem::drop(value). This moves the value into drop, which immediately disposes of it. This is useful for releasing locks or file handles before the end of a scope.

Common Mistakes and How to Think About Them

Mistake 1: Cloning to Avoid Thinking About Ownership

Early-stage Rust code often sprinkles .clone() everywhere to make the compiler happy. It works, but it is masking a design issue. Every .clone() is a sign that you have not decided who owns what. Fix the design, not the symptoms.

Mistake 2: Using Arc When You Do Not Need Shared Ownership

Arc<T> is for shared ownership across threads. If you are not crossing thread boundaries and do not actually have multiple owners, you probably need a reference, not an Arc. Arc has reference-counting overhead and implies a design where no single owner is responsible – that is sometimes right, but often it is overkill.

Mistake 3: Partial Moves in Structs

struct User {
    name: String,
    email: String,
}

fn main() {
    let user = User {
        name: String::from("Alice"),
        email: String::from("alice@example.com"),
    };
    
    let name = user.name; // partial move - user.name is moved out
    // println!("{}", user.name); // error: value partially moved
    println!("{}", user.email); // this is still fine
    // println!("{:?}", user); // error: user is partially moved
}

Partial moves leave the struct in a partially-moved state. You can still use the non-moved fields individually, but you cannot use the struct as a whole. This is often surprising. The fix is usually to either take a reference, use .clone() on the field you want, or destructure with let User { name, email } = user to move both fields explicitly.

What Comes Next

Ownership gives Rust its memory safety guarantees. But ownership alone does not let you write flexible, reusable code – for that you need borrowing. In Part 2 of this series, we will go deep on the borrowing rules, look at how the borrow checker reasons about your code, and explore the patterns that let you work with the borrow checker rather than against it.

The concepts in this post – moves, Copy, Clone, Drop, partial moves – are the vocabulary you need to make sense of what the borrow checker is telling you. Get comfortable with them, and Part 2 will be significantly more productive.

References

Written by:

632 Posts

View All Posts
Follow Me :
How to whitelist website on AdBlocker?

How to whitelist website on AdBlocker?

  1. 1 Click on the AdBlock Plus icon on the top right corner of your browser
  2. 2 Click on "Enabled on this site" from the AdBlock Plus option
  3. 3 Refresh the page and start browsing the site