Skip to Main Content

Learning Rust #6: Understanding ownership in Rust

Last December I finally started learning Rust and in January I built and published my first app with Rust: 235. Learning Rust is my monthly blog series that is defnitely not a tutorial but rather a place for me to keep track of my learning and write about things I've learned along the way.

Learning Rust series

Learning Rust talk in Rust Denver meetup

A presentation slide with Ferris the Rustacean mascot and text "Learning Rust - experiences from a Python/Javascript developer"

Before we jump into this month's topic, I wanted to let you know that I gave a talk last month in Rust Denver meetup. The talk was about basically the same things as this blog series: what I've learned in my ~7 months of Rust and especially how those things reflect against my background as a mainly Python and Javascript developer.

Go watch the talk in my Youtube channel where I also host codebase livestreams once a month and then come back to finish reading this blog post!

Garbage collection – and the lack of it

I've been programming mostly in languages like Python and Javascript, both which have automated garbage collection. What it means is that as a developer, I don't have to worry about whether a variable should stay in memory or not, the compiler or interpreter takes care of that.

And in very practical terms, what it means is that with Python or Javascript, I don't have to think about how to refer to variables. It's always just name. That is not the case with Rust.

My first touch to languages where I had to think about these things was a Systems Programming course that I took a decade ago in the university. That course focused on C and C++. Back then, I really struggled with gaining routine on when to use the variable as-is, like items and when to use a reference, like &items. Somehow, I ended up passing that course but also didn't do anything more with C and C++ since.

I feel like I understand the theory and when I read about the topic, I keep nodding. Sure, this seems simple, of course it's like that. And then I get back to code and I get told by the compiler that I did not, in fact, understand it.

Stack and Heap

To understand how memory management works in Rust, we need to take a look at two concepts: the stack and the heap. I won't go into it in-depth here but if you want to learn more, there's a good explanation in the Rust book and visualization in Technorage's blog.

In a nutshell, Rust stores memory into its stack and its heap. Stack is very fast to allocate to and access from and it's the default place where Rust stores your application's memory. Rust uses stack to store local variables with limited scope.

Heap is used to store values that are passed between functions or have longer lifetime than just the local scope. It's slower to access though which is why Rust uses stack by default.

Ownership in Rust

Ownership is Rust’s most unique feature, and it enables Rust to make memory safety guarantees without needing a garbage collector. - Rust Book

Rust is often praised for how it manages its memory through a system ownership and how its borrow checker helps the developers to not make mistakes where you'd be trying to access something that doesn't exist anymore.

This first example comes from the Rust Book:

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

  println!("{}, world!", s1);
}

If you try to run this code, it results in an error. That's because a move occured and on line 5, we're trying to access a value that has been moved on line 3.

When you do let s2 = s1, it does not copy the value nor the reference of s1. Instead, it moves the reference and causes s1 no longer to point to anything so we cannot access it anymore inside the println macro.

A move happens in assignment, passing into or returning from functions, when assigned to a member in struct or when explicitly called with functions that move ownership.

However, that's not always the case. In our above example, we used String which gets moved on assignment but if a type implements Copy trait, the value is copied, like with type u32:

fn main() {
    let num: u32 = 150;
    let num_copy = num;
    
    println!("{:?} == {:?}", num, num_copy);
}

HashRust has a nice blog post on the topic and goes a bit deeper than I did here.

Understanding this has been the first step for me. For someone coming from languages where this doesn't happen, it's been quite a challenge to internalize.

Borrow Checker

Rust comes with a borrow checker tool that helps you avoid these mistakes. It ensures that every access to memory is valid.

Let's take a look at a bit more involved example (this one will result in compiler error):

fn reverse(word: String) -> String {
    word.chars().rev().collect::<String>()
}

fn main() {
    let word = String::from("example");
    let reversed = reverse(word);
    
    println!("{:?} is reversed to {:?}", word, reversed);
}

Here we have a function reverse that accepts a String and returns another String.

Inside our main function, we pass word into reverse function which causes it to be moved and no longer accessible after line 7. We have multiple ways to fix this.

First one is to accept a reference (&String) and pass such reference (&word) into the function:

fn reverse(word: &String) -> String {
    word.chars().rev().collect::<String>()
}

fn main() {
    let word = String::from("example");
    
    let reversed = reverse(&word);
    
    println!("{:?} is reversed to {:?}", word, reversed);
}

Once we run this, we'll get output "example" is reversed to "elpmaxe". Sure, in Rust you could simplify this even further by using &str but since that's kinda a special case with strings, I wanted to keep this example more generically applicable. If you wanna learn more about those, I recommend checking out Tomas Buß's blog post Rust Slice Types (and Strings), explained.

Another way to fix the issue is to create a clone of the original variable. Cloning is available for all types that implement Clone trait. When cloning a variable, Rust creates a deep copy* so each of the variables point to a different point in memory.

* Kinda, at least. Developers are welcome to implement clone as they wish but deep copy is often the default behaviour implemented.

fn reverse(word: String) -> String {
    word.chars().rev().collect::<String>()
}

fn main() {
    let word = String::from("example");
    
    let reversed = reverse(word.clone());
    
    println!("{:?} is reversed to {:?}", word, reversed);
}

If you want to deep a bit deeper into understanding Borrow Checker system, I recommend watching Nell Shamrell-Harrington's talk The Rust Borrow Checker: a Deep Dive from RustLab. Tim McNamara also did a nice video on the topic last week. And finally, LogRocket has a blog post written by Thomas Heartman called Understanding the Rust borrow checker that has been very helpful for me.

What's so hard about this then?

As I was reading more on the topic and writing this blog post, I once again felt like I understand these concepts but when I actually write code, I still struggle quite a bit with it.

I believe one key reason for that is that I don't yet have an internalized mental model of thinking about these things since I've coded for a better part of a decade without ever thinking about them in other languages. So when for example I try to map over a vector of items, do operations on them and return something, things start to feel very complicated. Things I'm very comfortable doing in Python or Javascript suddenly become complicated in Rust.

I hope it's something that I'll get better at just by writing more Rust. As I'm fixing the final bugs and issues in 235, I'm currently planning on what project to pick up next to continue learning the language. I have a lot of interest for static site generators and building one in Rust is something that interests me for various reasons but I'm not sure if it forces me to use more complex features of the language enough to really push my learning to the next level.