In the previous article, we learned about the concept of ownership in Rust. Thanks to ownership, Rust knows when a variable is no longer in use and can free up memory. By using &
before the variable name, we declare to Rust that we're just "borrowing" the value temporarily without transferring ownership, so the borrowed variable still exists after borrowing, but with some limitations.
Today's article will delve into how Rust deals with data borrowing behavior and how it prevents "non-standard" behaviors with borrowed and mutable data during compile time, not runtime.
First, let's take a look at how Rust handles memory in this example program.
let mut v: Vec<i32> = vec![1, 2, 3];
v.push(4);
At line L2, adding an element to a list causes a mutation, but instead of adding 4
to the next memory location, Rust copies the entire data to another memory location, causing the data in the heap of v at L1 to be freed, and v
now points to a different memory location.
What would happen with the following program?
let mut v: Vec<i32> = vec![1, 2, 3];
let num: &i32 = &v[2];
v.push(4);
println!("Third element is {}", *num);
Here, num
is borrowing the value at position 2 of v through &v[2]
. But after the push
operation, the original heap data of v
has been deallocated. So, where does num
point to? You guessed it right; this program would result in an error.
Therefore, Rust enforces the safety principle of pointers: data can never be both borrowed and mutated at the same time.
To introduce borrowing, Rust has established rules to ensure the "Pointer Safety Principle," which is enforced by the borrow checker tool.
The core idea behind Rust is the concept of three ownership rights for a variable's data.
These rights only exist at compile time, meaning the compiler "independently derives" these rights to check whether your program is valid before building it into binary.
By default, a variable has R and O rights over its data. If a variable is declared as mut
, it gains W rights.
Let's consider how Rust checks data rights in the following program.
First, v
is declared as mut
, so it has R, O, and W rights. Then, num
borrows the value of v
, and now v
loses 2 rights: W and O, while num
gains R and O rights over the borrowed data. *num
is a variable reading the value at the location it borrowed, so it only has R rights.
Right after the println!
statement, num
is released, restoring all rights to v
, but num and *num
lose all rights to the borrowed data.
After the push
statement, v
is no longer in use, so it also loses all rights to its data.
Rust calls everything to the left of the assignment (=) as paths
. Thus, ownership rights are determined on paths
, not just on variables. Paths include:
a
.*a
.a[0]
.a.0
of tuples or a.field
of structs.*((*a)[0].1)!?
.So, can a variable borrow data from another variable and modify that data? Remember the concept: "Data can never be both borrowed and mutated at the same time."
let mut v: Vec<i32> = vec![1, 2, 3];
let num: &mut i32 = &mut v[2];
*num += 1;
println!("Third element is {}", *num);
println!("Vector is now {:?}", v);
Instead of &v[2]
, we now declare &mut v[2]
to indicate that num has ownership rights (O) over the borrowed data. The variable *num
directly reads data from the heap and increments it by 1
. In the end, v
is also modified according to num
.
Compared to the initial example, v
loses all rights when num
borrows data with O rights. This ensures data safety and adheres to the principle of "Data can never be both borrowed and mutated at the same time."
Let's start with an example:
let s = String::from("Hello world");
let s_ref = &s;
drop(s);
println!("{}", s_ref);
The code is attempting to drop s
while s_ref
is borrowing its value. Remember the concept mentioned earlier: "Data can never be both borrowed and mutated at the same time." So, drop cannot proceed because it requires O rights. This is why the program results in an error.
Both we and Rust can quickly identify the lifetime of s
. However, programs are not always straightforward, and Rust needs a way to determine the lifetime of a variable in the program to prevent situations like the one above.
Consider this function:
fn first_or(strings: &Vec<String>, default: &String) -> &String {
if strings.len() > 0 {
&strings[0]
} else {
default
}
}
The function can return either the first String
from the strings parameter or the default parameter. What would happen if we call the function like this?
fn main() {
let strings = vec![];
let default = String::from("default");
let s = first_or(&strings, &default);
drop(default);
println!("{}", s);
}
Since strings is empty, s
will borrow from default
. However, a drop(default)
statement occurs before printing s. Therefore, this program is not safe and will result in an error.
Another example of an unsafe program:
fn return_a_string() -> &String {
let s = String::from("Hello world");
let s_ref = &s;
s_ref
}
s_ref
is attempting to borrow the value of s
, but when the function ends, s
will be deallocated, making returning s_ref
meaningless. Hence, this program will result in an error.
This concludes the article on ownership rights and how Rust checks borrowing data rights. In my opinion, this is one of the most challenging concepts in Rust, but it's crucial for writing and debugging programs. Readers can continue reading the article Fixing Ownership Errors to learn how to resolve some common ownership-related errors.
Hello, my name is Hoai - a developer who tells stories through writing ✍️ and creating products 🚀. With many years of programming experience, I have contributed to various products that bring value to users at my workplace as well as to myself. My hobbies include reading, writing, and researching... I created this blog with the mission of delivering quality articles to the readers of 2coffee.dev.Follow me through these channels LinkedIn, Facebook, Instagram, Telegram.
Comments (2)