Understanding Ownership in Rust - Part 1

prajuwal singhprajuwal singh
6 min read

Concept of ownership in Rust is tricky, and has a decent steep learning curve. However, with practice and seeing enough errors in console, which MIT called ‘fighting with the borrow checker’, we will reach a point of understanding where we don't pull our hair out of the scalp every time we see the borrow checker throwing its punch.

Here we will be covering the ownership only, borrowing, and their associated feature ‘references’ and lifetimes, an advanced concept of borrowing will be covered in the later blogs.

I am assuming familiarity with rust basic syntax, variables and mutability. the control flow and data types.

Before we move on, let's understand scoping in Rust:

fn main() {
    {
        // s is not valid here, it’s not yet declared
        let s = "hello"; // s is valid from this point forward
                         // do stuff with s
    } // this scope is now over, and s is no longer valid
}

In other words, there are two important points here:

  • When s comes into scope, it is valid.

  • It remains valid until it goes out of scope.

Scoping is simple in rust, unlike JavaScript which has concepts like Hoisting, with variables defined as var, which are not restricted to block scope.

Before we move on, we must understand that the goal of rust is safety and speed.

Garbage collection(referred as GC for the rest of this blog), is one of the main features of rust which makes it unique and quite efficient.

The usual way of handling memory—manual allocation and freeing in C++, and automatic garbage collection in Java — is re-imagined by Rust. It adopts an ownership system, with compiler-enforced rules, to manage memory without traditional garbage collection.

The rules are as follows:

  • Each value in Rust has an owner.

  • There can only be one owner at a time.

  • When the owner goes out of scope, the value will be dropped.

Let's see them in action now:

Variable bindings have a property in Rust: they ‘have ownership’ of what they’re bound to. This means that when a binding goes out of scope, Rust will free the bound resources. For example:


#![allow(unused_variables)]
fn main() {
fn foo() {
    let v = vec![1, 2, 3];
}
}

When v comes into scope, a new vector is created on the stack, and it allocates space on the heap for its elements. When v goes out of scope at the end of foo(), Rust will clean up everything related to the vector, even the heap-allocated memory. This happens deterministically, at the end of the scope.

If you are unfamiliar with vectors, just think of them as arrays, except their size may change by push()ing more elements onto them, used here as an example of a type that allocates space on the heap at runtime.

Understand that the types which are simple (scalar) are of a known size, can be stored on the stack and popped off the stack when their scope is over, and can be quickly and trivially copied to make a new, independent instance if another part of code needs to use the same value in a different scope. But we want to look at data that is stored on the heap and explore how Rust knows when to clean up that data, and the vector type is a great example.

It does not behave same as an integer, or a string literal, which as explained above are stored on stack, it is rather stored on the heap as a stack, which comprises of a pointer to the memory that holds the contents of the vector, a length, and a capacity. This group of data is stored on the stack.

The length is how much memory, in bytes, the contents of the vector are currently using. The capacity is the total amount of memory, in bytes, that the vector has received from the allocator.

The difference between length and capacity matters, but to keep this simple, let's assume the difference between the length and capacity is minimal, so for now, it’s fine to ignore the capacity.

Move semantics

Here’s some more subtlety here, though: Rust ensures that there is exactly one binding to any given resource. For example, if we have a vector, we can assign it to another binding:


#![allow(unused_variables)]
fn main() {
let v = vec![1, 2, 3];

let v2 = v;
}

But, if we try to use v afterwards, we get an error:

let v = vec![1, 2, 3];

let v2 = v;

println!("v[0] is: {}", v[0]);

It looks like this:

A similar thing happens if we define a function which takes ownership, and try to use something after we’ve passed it as an argument:

fn take(v: Vec<i32>) {
    // What happens here isn’t important.
}

let v = vec![1, 2, 3];

take(v);

println!("v[0] is: {}", v[0]);

Same error: ‘use of moved value’. When we transfer ownership to something else, we say that we’ve ‘moved’ the thing we refer to. You don’t need some sort of special annotation here, it’s the default thing that Rust does.

Because Rust also invalidates the first variable, instead of being called a shallow copy like in JavaScript, it’s known as a move.

copy types

We’ve established that when ownership is transferred to another binding, you cannot use the original binding. However, there’s a trait that changes this behavior, and it’s called Copy. We haven’t discussed traits yet, but for now, you can think of them as an annotation to a particular type that adds extra behavior. For example:


#![allow(unused_variables)]
fn main() {
let v = 1;

let v2 = v;

println!("v is: {}", v);
}

In this case, v is an i32, which implements the Copy trait. This means that, just like a move, when we assign v to v2, a copy of the data is made. But, unlike a move, we can still use v afterward. This is because an i32 has no pointers to data somewhere else, copying it is a full copy.

All primitive types implement the Copy trait and their ownership is therefore not moved like one would assume, following the ‘ownership rules’. To give an example, the following snippet of code only compile because bool type implements the Copy trait.

fn main() {
    let a = true;

    let _y = change_truth(a);
    println!("{}", a);
}

fn change_truth(x: bool) -> bool {
    !x
}

If we had used a type that do not implement the Copy trait, we would have gotten a compile error because we tried to use a moved value.

More than ownership

Of course, if we had to hand ownership back with every function we wrote:


#![allow(unused_variables)]
fn main() {
fn foo(v: Vec<i32>) -> Vec<i32> {
    // Do stuff with `v`.

    // Hand back ownership.
    v
}
}

This would get very tedious. It gets worse the more things we want to take ownership of:


#![allow(unused_variables)]
fn main() {
fn foo(v1: Vec<i32>, v2: Vec<i32>) -> (Vec<i32>, Vec<i32>, i32) {
    // Do stuff with `v1` and `v2`.

    // Hand back ownership, and the result of our function.
    (v1, v2, 42)
}

let v1 = vec![1, 2, 3];
let v2 = vec![1, 2, 3];

let (v1, v2, answer) = foo(v1, v2);
}

The return type, return line, and calling the function gets way more complicated.

Luckily, Rust offers a feature which helps us solve this problem. It’s called borrowing and is the topic of the next blog.

Tune in for the next part, thanks for reading!

0
Subscribe to my newsletter

Read articles from prajuwal singh directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

prajuwal singh
prajuwal singh