Understanding Rust Ownership and Borrowing with Practical Examples
This guide demystifies Rust's core memory management concepts: ownership and borrowing. Through practical examples in Rust and comparisons with Java, it covers move semantics, cloning, copying, and mutable references to help developers grasp how Rust ensures memory safety.
Understanding ownership and borrowing is fundamental to mastering Rust. While numerous resources cover these crucial memory management concepts, this article provides practical examples to demystify them, aiming to offer clear insights.
Ownership is a core concept in Rust, defining how the language manages memory. As stated in the official documentation:
Ownership is a set of rules that govern how a Rust program manages memory. All programs have to manage the way they use a computer’s memory while running. Some languages have garbage collection that regularly looks for no-longer-used memory as the program runs; in other languages, the programmer must explicitly allocate and free the memory. Rust uses a third approach: memory is managed through a system of ownership with a set of rules that the compiler checks. If any of the rules are violated, the program won’t compile. None of the features of ownership will slow down your program while it’s running.
First Taste of Ownership
Let's begin with a simple Java example:
public void own(String text) {}
public static void main(String[] args) {
var text = "my text";
own(text);
System.out.println(text);
}
Even with basic Java knowledge, this code is clear: the text variable remains accessible after being passed to the own method.
Translating this concept to Rust, we get:
fn own(_: String) {}
fn main() {
let text: String = String::from("my text");
own(text); // Value moved here
println!("{}", text); // Error: value borrowed here after move
}
Rust's approach is quite different. The compiler immediately issues an error:
error[E0382]: borrow of moved value: `text`
--> src/main.rs:11:20
|
9 | let text: String = String::from("my text");
| ---- move occurs because `text` has type `String`, which does not implement the `Copy` trait
10 | own(text);
| ---- value moved here
11 | println!("{}", text);
| ^^^^ value borrowed here after move
|
note: consider changing this parameter type in function `own` to borrow instead if owning the value isn't necessary
--> src/main.rs:6:14
|
6 | fn own(text: String) {}
| --- ^^^^^^ this parameter takes ownership of the value
| |
| in this function
= note: this error originates in the macro `$crate::format_args_nl` which comes from the expansion of the macro `println` (in Nightly builds, run with -Z macro-backtrace for more info)
help: consider cloning the value if the performance cost is acceptable
|
10 | own(text.clone());
| ++++++++
The Rust compiler is remarkably helpful, pinpointing the issue: when text is passed to own(), its ownership is transferred. Consequently, text can no longer be used by println!() afterward.
Clone and Copy
Following the compiler's suggestion, we can explicitly call clone(). The String type implements the Clone trait, meaning clone() performs a deep copy of the data. While this resolves the compilation error and allows the program to run, it creates two distinct String structures, consuming double the memory.
It's important to note that if a type implements the Copy trait in addition to Clone, you don't need to call clone() explicitly; the value will be implicitly copied on assignment or function call. Let's define a custom type to illustrate this:
#[derive(Debug, Copy, Clone)]
struct Dummy {}
fn own(_: Dummy) {}
fn main() {
let dummy = Dummy {};
own(dummy);
println!("{:?}", dummy);
}
When a structure implements Copy, its value is implicitly duplicated every time it's moved. This behavior is inherent and cannot be overridden on a per-call basis. For large structures, frequent copying can lead to significant memory overhead, a trade-off to consider carefully.
Passing by Reference
Our previous examples passed structures by value, transferring ownership to the called function. Rust offers an alternative: borrowing. With borrowing, a function can temporarily access a structure without taking ownership, typically by passing it by reference using the & operator. Let's refactor our code to demonstrate this:
#[derive(Debug)]
struct Dummy {}
fn borrow(_: &Dummy) {}
fn main() {
let dummy = Dummy {};
borrow(&dummy);
println!("{:?}", dummy);
}
This code compiles and runs successfully. Borrowing enables functions to read the state of a structure without owning it.
Consider a Dummy struct with a field:
#[derive(Debug)]
struct Dummy {
foo: String,
}
fn borrow(dummy: &Dummy) {
println!("{:?}", dummy.foo);
}
fn main() {
let dummy = Dummy {
foo: String::from("Foo")
};
borrow(&dummy);
println!("{:?}", dummy); // Prints Dummy { foo: "Foo" }
}
The println! macro in main confirms that the dummy structure, including its foo field, remains accessible after borrow has used its reference.
Mutating Data
While borrowing allows reading, what if we need to modify the data?
#[derive(Debug)]
struct Dummy {
foo: String,
}
fn main() {
let dummy = Dummy {
foo: String::from("Foo")
};
dummy.foo = String::from("Bar");
}
Attempting to run this code results in an error:
error[E0594]: cannot assign to `dummy.foo`, as `dummy` is not declared as mutable
--> src/main.rs:8:5
|
8 | dummy.foo = String::from("Bar");
| ^^^^^^^^^ cannot assign
|
help: consider changing this to be mutable
|
7 | let mut dummy = Dummy { foo: String::from("Foo") };
| +++
In Rust, modifying data is called mutating. To enable mutations, a variable must be explicitly declared as mutable using the mut keyword.
#[derive(Debug)]
struct Dummy {
foo: String,
}
fn main() {
let mut dummy = Dummy {
foo: String::from("Foo")
};
dummy.foo = String::from("Bar");
}
To allow a function to modify a parameter it owns (i.e., passed by value), that parameter must also be marked as mut:
#[derive(Debug)]
struct Dummy {
foo: String,
}
fn mutate(mut dummy: Dummy) { // Parameter marked as mutable
dummy.foo = String::from("Bar");
println!("{:?}", dummy.foo);
}
fn main() {
let dummy = Dummy {
foo: String::from("Foo")
};
mutate(dummy);
}
Here, marking dummy as mut within the function signature allows its foo field to be updated.
Rust also supports mutable borrowing, allowing a function to modify data through a reference. This requires both the variable and the reference parameter to be explicitly marked as mut.
#[derive(Debug)]
struct Dummy {
foo: String,
}
fn mutate(dummy: &mut Dummy) { // Parameter marked as mutable reference
dummy.foo = String::from("Bar");
println!("Inside: {:?}", dummy.foo);
}
fn main() {
let mut dummy = Dummy { // Variable marked as mutable
foo: String::from("Foo")
};
mutate(&mut dummy); // Pass a mutable reference
println!("Outside: {:?}", dummy.foo);
}
Summary
Here's a quick reference for common ownership and borrowing behaviors in Rust:
| Behavior | How? |
|---|---|
| Take ownership | Pass by value |
| Duplicate the value (explicit) | Implement Clone and call clone() explicitly |
| Duplicate the value (implicit) | Implement Clone and Copy |
| Borrow (read-only) | Pass by reference (&) |
| Mutate an owned value | Use mut (for the variable and function parameter) |
| Mutate behind a reference | Use &mut (for the variable and function parameter) |