25 January 2023
We’re getting to the heart of what makes Rust Rust: ownership is the concept that lets Rust programmers write safe code (e.g. no use-after-free errors) without compromising efficiency (e.g. no garbage collector required).
Ownership is a way to think about which part of your program “owns” a value; in other words, who is allowed to access it at any given point in time. Part of that is making sure that a value will be deallocated when no-one owns it anymore, and making sure no-one can access the value once it has been deallocated.
Before we get into the details, let’s take a look at one case where we don’t need to worry about ownership. Rust types are divided into two groups: those with copy semantics and those with move semantics. Types with copy semantics are the ones that can easily and cheaply be copied. Ownership isn’t really an issue for them; in any situation where it would be the compiler will insert instructions to make a copy instead. Types with copy semantics include primitive types (booleans, characters, integers, floats), tuples whose components all have copy semantics, and structs whose components have copy semantics. Structs also need to be annotated with the Copy trait.
Here’s a simple example:
let x = 42; // assign an integer to x
let y = x; // make a copy and assign it to y
println!("{} {}", x, y); // use x and y
Types that implement the Clone trait can be used in
the same way, but you need to explicitly call the clone
method to make a copy:
let x = String::from("hello"); // create a new String
let y = x.clone(); // make a copy
println!("{} {}", x, y); // use both
For types with move semantics, variable assignment means the value gets “moved” and the original variable can’t be used anymore:
let x = String::from("bonjour"); // create a new String
let y = x; // move the String to y
println!("{}", y); // use y
println!("{}", x); // not allowed!
If we try to compile this, we’ll get an error message complaining about trying to use a “moved value”. The assignment on the second line moves the String from x to y. After that, we can’t use x anymore.
Ownership is necessary to make the rules for deallocation that I described in Rust: The Stack and the Heap work correctly. When x and y go out of scope, the values they refer to are deallocated. If they were allowed to refer to the same value, that value would be deallocated twice! Ownership avoid this problem. Since the String has been moved from x, when x goes out of scope no deallocation happens:
fn f1() {
let x = String::from("hello");
let y = x;
println!("{}", y);
// y goes out of scope; the String is deallocated
// x goes out of scope
}
Passing an argument to a function works like binding a value to a variable. Let’s look at a function that takes an integer and a String as arguments:
fn f2(i: i32, s: String) {
println!("f2 got: {} {}", i, s);
}
fn main() {
let x = 4;
let y = String::from("hello");
f2(x, y); // copy x and move y
println!("{}", x); // fine
println!("{}", y); // not allowed!
}
When we pass x and y to the function, x is copied because i32 has copy semantics and y is moved because String has move semantics. After the call we still have our copy of x, but y is gone and the compiler will stop us if we try to use it.
The same logic applies to method calls. If you have a method defined like this:
struct Foo(String);
impl Foo {
fn my_method(self) { ... }
}
then a call foo.my_method()
implies moving foo and the caller won’t be able to use it
afterwards.
Function return values also work like variable bindings:
fn f3() -> (i32, String) {
let i = 5;
let s = String::from("hola");
(i, s)
}
fn main() {
let (x, y) = f3();
println!("{} {}", x, y);
}
That may seem obvious, but it does highlight the connection between ownership and deallocations once more. When f3 returns, s goes out of scope, but just before that happens the String it refers to is moved to the return value, so s going out of scope doesn’t cause any deallocation.