Rust Borrowing and References
In Rust, a reference is used to borrow a value from an owner. Borrowing allows us to ‘borrow‘ ownership using a reference to the original variable binding. This means that we can use an owned value without taking ownership by creating a copy or performing a move operation. The concepts of borrowing and references are closely tied to each other.
Borrowing describes what is happening – ownership is borrowed so that operations can be more easily performed without running into issues with the Rust borrow checker.
References are what we use to perform borrowing. References use an ampersand ‘&’ to indicate that a value is to be borrowed.
In this article, we will cover the details of borrowing and references in Rust – including shared and mutable borrows, borrowing with functions, and using borrows to slice arrays, vectors, and strings.
Let’s look at a simple case. In this example, we are able to use a vector even after it’s value has been reassigned. This is because instead of moving the vector, we are borrowing it from the original owner:
let vec1 = vec![1,2,42];
// We create a new binding using a reference to vec1:
let vec2 = &vec1;
println!("Vector 2 : {:?}",vec2);
println!("We can still access the original: {:?}",vec1);
Standard Output:
Vector 2 : [1, 2, 42]
We can still access the original: [1, 2, 42]
Without the reference ‘&‘, this would not have compiled because vec1 would have been moved and invalidated.
Introduction to Borrowing and References
Review of Copy and Move
In Rust, when we perform an operation that uses a value owned by a variable or binding, the compiler will automatically perform either a copy or a move operation. For example:
let x = 42;
let y = x;
A copy is created when the value is a primitive type (as is the case with the i32 integer ’42’ in the example above). in this case a new variable binding is created and stored on the stack.
A move is performed when the value is non-primitive. Non-primitives are stored partially on the stack and partially on the heap. When the move takes place, the stack data is copied and the original variable on the stack is invalidated.
This system is designed to promote memory safety but isn’t the easiest to work with and it doesn’t always produce the cleanest code.
This where borrowing comes in. Borrowing allows us to use a value bound to a variable without copying or moving. As a result, more complex operations are possible without requiring a ton of additional code or memory allocation.
Referencing and Dereferencing
Referencing is performed using an ampersand ‘&‘. The opposite of referencing is dereferencing, which is done using an asterisk ‘*‘. Dereferencing is introduced in the section on mutable borrows below.
Two Kinds of Borrows in Rust
There are two kinds of borrows in Rust: shared borrows (abbreviated ‘&T’) and mutable borrows (abbreviated &mut T).
Shared Borrows
With shared borrows, ownership is maintained entirely by the original variable. Shared borrows are abbreviated as ‘&T‘ where T represents a generic type.
A shared borrow can only read the data owned by the original variable. Since a shared borrow is a read-only operation, there can be any number of them. This means that a value can be borrowed infinitely using shared borrows.
let a = 42;
let b = &a;
let c = &a;
let d = &a;
In the example above, we are able to borrow the value of a multiple times because we are using shared borrows. The variables b, c, and d can only read the value bound to a ’42’, but they cannot change it.
Let’s look at a shared borrow of a vector, which is a non-primitive type:
let vec1 = vec![1,2,42];
let vec2 = &vec1;
println!("Vector 2 is : {:?}",vec2);
println!("We can still access the original vector: {:?}",vec1);
Standard Output:
Vector 2 is : [1, 2, 42]
We can still access the original vector: [1, 2, 42]
If we had performed a move operation by reassigning the value of vec1 to vec2 (i.e. let vec2 = vec1;) then we would no longer be able to access vec1. This is because the move operation invalidates the original stack data of vec1.
Mutable Borrows
Shared borrows can read an owned value, which allows us to work with owned values instead of having to copy or move them. But a shared borrow can’t mutate the value itself. This is where mutable borrows come in.
A mutable borrow allows us to borrow and mutate a value bound to a variable. Mutable borrows are abbreviated as ‘&mut T’ where T represents a generic type. In order to mutate the value, we need to dereference it using an asterisk ‘*‘. This tells the compiler to follow the pointer to the location of the memory address holding the data. Then we use the assignment operator ‘=‘ to change the value stored at that address.
Let’s see how this works:
let mut x = 1;
let y = &mut x;
*y = 4;
println!("The new value of x is: {}", x);
Standard Output:
The new value of x is: 4
The great thing about this is that we can still mutate the variable after the shared borrow:
let mut x = 1;
let y = &mut x;
*y = 4;
println!("The new value of x is: {}", x);
x = 3;
println!("The newer value of x is: {}", x);
Standard Output:
The new value of x is: 4
The newer value of x is: 3
However if we try to do this using *y instead of x, we will run into an error:
let mut x = 1;
let y = &mut x;
*y = 4;
println!("The new value of x is: {}", x);
*y = 3;
println!("The new value of x is: {}", x);
Standard Error:
|
4 | let y = &mut x;
| ------ mutable borrow occurs here
5 | *y = 4;
6 | println!("The new value of x is: {}", x);
| ^ immutable borrow occurs here
7 | *y = 3;
| ------ mutable borrow later used here
Limitations of Shared Borrows
Because mutable borrows allow the value to be mutated, there can be only one mutable borrow. Additionally, we can’t do a shared borrow and a mutable borrow in the same scope. Mutable borrows are more powerful than shared borrows, but they are also more limited as a result.
We can get around these limitations using code blocks (within curly braces {}) to define and manipulate scope.
For example, we can resolve our previous issue and perform an infinite number of shared borrows as long as they occur in an inner scoped code block:
let mut x = 1;
{
let y = &mut x;
*y = 4;
println!("The new value of x is: {}", x);
}
{
let y = &mut x;
*y = 3;
println!("The new value of x is: {}", x);
}
Standard Output:
The new value of x is: 4
The new value of x is: 3
Borrowing With Functions
We can also use borrowing and references to reduce ownership issues while working with functions. To do so, the borrow type needs to be specified in the function parameters. In this trivial example, the foo() function will take in a borrowed vector:
fn foo(v: &vec) {}
When the function gets called, we need to pass a borrowed type to match the parameter:
foo(&vec1);
Let’s look at an example where we use a mutable borrow to change the value of a variable outside of a function:
fn calc_sum(num1: i32, num2: i32, sum: &mut i32) {
println!("Initial sum: {}", sum);
*sum = num1 + num2;
}
let x = 1;
let y = 42;
let mut sum = 0;
calc_sum(x, y, &mut sum);
println!("New sum: {}", sum);
Standard Output:
Initial sum: 0
New sum: 43
Borrowing and Slicing
We can use borrowing to get a slice of an object without transferring ownership or creating a copy. The following example shows how we can use references to create slices of arrays, vectors, and strings by borrowing:
let my_arr:[i32;4] = [1, 2, 3, 4];
let my_vec = vec![1, 2, 3, 4, 5];
let my_str = String::from("Rust");
println!("Sliced array : {:?}", &my_arr[..2]);
println!("Sliced vector : {:?}", &my_vec[2..]);
println!("Sliced string : {:?}", &my_str[1..]);
Standard Output:
Sliced array : [1, 2]
Sliced vector : [3, 4, 5]
Sliced string : "ust"