Rust Dereferencing vs Destructuring — For the Kids 1/2

Thanks Chat GPT
TL;DR
- Dereferencing: accessing the value behind a reference or smart pointer (e.g.,
*x
, or implicit viaDeref
). Used to read or mutate the underlying data, respecting Rust’s borrowing rules (&T
,&mut T
). - Destructuring: breaking apart composite values (tuples, structs, enums) using pattern matching syntax. Can move or borrow parts depending on context.
The Post is in 2 Parts
- The introduction is the same
- Rust Dereferencing vs Destructuring — For the Kids 1/2
- Rust Dereferencing vs Destructuring — For the Kids 2/2
Table of Contents
- Introduction
- Dereferencing: A smooth start
- Dereferencing: Mutability
- Dereferencing: Reference as argument
- Dereferencing: Allocations on the heap
- Dereferencing:
Rc<T>
and Reference-counted smart pointers - Dereferencing:
Rc<RefCell<T>>
for shared mutation (single-thread) - Rust Gotchas: Dereferencing Edition
Introduction
If you’re learning Rust and the concepts of ownership, borrowing, and references still feel unfamiliar or intimidating — you’re not alone.
Coming from languages like Python or C++, it’s easy to assume that Rust’s &
, *
, and smart pointers behave the same way. But Rust has its own philosophy, built around memory safety and enforced by strict compile-time rules.
This article aims to clarify the difference between dereferencing and destructuring — two concepts that are often confused, especially outside of match
expressions.
Why the Confusion?
At first glance, dereferencing (using *
) and destructuring (in let
, if let
, or match
patterns) can look similar when working with references. Consider the following lines:
let r = &Some(5);
if let Some(val) = r {
println!("val = {val}");
}
No explicit *r
— yet the pattern matches. How?
Now look at this one-liner:
let Some(x) = &Some(42);
Is this dereferencing, destructuring, or both?
And then:
let b = Box::new((42, "hello"));
let (x, y) = *b;
let (x, y) = b; // Doesn't compile
All three examples seem simple — but do you really understand why they behave this way?
If you already know the answers, maybe this article isn’t for you. But if you’ve ever hesitated, been surprised by a compilation error, or struggled to explain why one line works and another doesn’t… then you’re in the right place.
This article won’t just define dereferencing and destructuring — it will show you how Rust treats them, how the compiler helps (or confuses) you, and when the distinction truly matters.
What This Post in 2 Parts Covers
- Dereferencing: Part 1/2. How to access values through references and smart pointers (Box, Rc, RefCell), and how mutability affects this.
- Destructuring: Part 2/2. How to unpack values in let, match, and function or closure parameters — including when working with references.
No multithreading knowledge required. For a threaded use case, see this post on Multithreaded Mandelbrot sets (in French).
Whether you’re just starting with Rust or adjusting your mental model, this post is for you.
Dereferencing: A smooth start
Copy and paste the code below in the Rust Playground then hit CTRL+ ENTER
With the others sample code I will only show the function of interest, not the main()
function.
fn dereferencing01() {
println!("\nDereferencing 01 : 101");
let my_value = 5; // => my_value: i32
println!("my_value : {}", my_value);
let addr_of_my_value = &my_value; // => addr_of_my_value: &i32
println!("addr_of_my_value : {:p}", addr_of_my_value);
let content_at_addr_of_my_value = *addr_of_my_value; // content_at_addr_of_my_value => i32
println!("content_at_addr_of_my_value : {}", content_at_addr_of_my_value);
}
fn main(){
dereferencing01();
}
Expected output
Dereferencing 01 : 101
my_value : 5
addr_of_my_value : 0x7ffeac669fa4
content_at_addr_of_my_value : 5
Explanations
my_value
is a binding (a variable) of typei32
and initialized with the value 5. If the difference between initialization and assignment is not clear, read this page (US). Same thing… If you are not sure about the difference between binding and variable keep the term variable in mind for now, but, stand up, put your right hand on your heart and swear you will read this page (US).- Next come the interesting part.
addr_of_my_value
is a binding whose value is a reference tomy_value
, that is, it holds the memory address ofmy_value
. Its type is&i32
becausemy_value
is of typei32
. You should already know that every variable, data structure, code… Are somewhere in the memory of the PC. So, they all have a memory address. The syntax is what it is and the&my_value
means, in plain English : “address ofmy_value
”. content_at_addr_of_my_value
is a binding of typei32
. It is initialized by dereferencing the referenceaddr_of_my_value
, i.e., accessing the value stored at the memory address it points to.
Just to make sure…
- addr_of_my_value is a binding, just like my_value. It is initialized by referencing
my_value
(&my_value
). The term reference describes the value that this binding holds: in this case, a memory address produced by the&
operator. - Saying “
addr_of_my_value
is a reference” is an acceptable simplification, but more precisely, we should say “addr_of_my_value
is a binding whose value is a reference tomy_value
.”
Ah OK. So a reference is a pointer. Right? Almost because they are both used to indirectly access and manipulate objects but no because they have key differences. Since there is no pointer in Rust (yes, I know *const T
and *mut T
exist but let’s avoid them for today), let’s illustrate de differences between pointers and references using C++. No worries, the syntax is very similar.
References and Pointers comparaison in C++
1. Syntax and Declaration
- Pointer: Declared with
*
, can be reassigned to point to different objects (ornullptr
).int x = 10; int* ptr = &x; // ptr is a pointer pointing to x int y = 20; ptr = &y; // Now pointer is pointing to y
-
Reference: Declared with
&
, must be initialized upon declaration and cannot be reassigned.int x = 10; int& ref = x; // ref is an alias, another name, for x int y = 20; ref = y; // Doesn't make ref point to y; instead, assigns y's value to x // because ref is an alias of x
2. Nullability
- Pointer: Can be
nullptr
(uninitialized or explicitly set to null).int* ptr = nullptr; // Valid
- Reference: Must always refer to a valid object (no null references).
int& ref; // Does'nt compile: Must be initialized
3. Memory Address Handling
- Pointer: Stores the memory address of an object. You can perform arithmetic (e.g.,
ptr++
).int arr[3] = {1, 2, 3}; int* ptr = arr; ptr++; // Moves to next element
- Reference: Acts as an alias (another name) for an existing variable. No arithmetic.
int x = 10; int& ref = x; // ref is just another name for x; no address manipulation.
4. Indirection & Dereferencing
- Pointer: Requires explicit dereferencing (
*
) to access the value.int x = 10; int* ptr = &x; cout << *ptr; // Outputs 10
- Reference: Automatically dereferenced (no need for
*
).int x = 10; int& ref = x; cout << ref; // no * needed. ref acts as an alias
5. Safety
- Pointer: Risk of dangling pointers (pointing to freed memory).
- Reference: Safer (cannot be null, always bound to an object).
Summary Table
Feature | Pointer (* ) | Reference (& ) |
---|---|---|
Syntax | int* ptr = &x; | int& ref = x; |
Reassignable? | Yes | No |
Can be null? | Yes | No |
Memory arithmetic | Yes | No |
Dereferencing | Explicit dereferencing cout << *ptr; | No cout << ref; |
To keep in mind in a Rust context
- Syntax
let ref_to_value = &my_value;
let content = *ref_to_value;
- Where
&my_value
is a reference tomy_value
ref_to_value
is a binding (immutable here) initialized with the reference tomy_value
. AKA a “reference” tomy_value
.content
is a binding (immutable here) initialized by dereferencingref_to_value
.
- Not dangling. A “reference” is always bound to a variable (
&variable
) - Mutable. If the binding which receive the reference is mutable (
let mut ref_to_value...
), it can be assigned another reference (of same datatype) - No arithmetic on a “reference” (binding whose value is a reference to a variable)
Dereferencing: Mutability
Just to make sure we are on the same page. There are 2 kinds of mutability to consider here :
- Mutability of the reference target controls whether we can modify the value through the reference.
- Mutability of the binding controls whether we can assign a new reference to the variable.
If you don’t feel confident enough to explain this concept to your kids, read this page about Mutability.
In the the code below we focus on the mutability of the reference. Let’s run the code!
fn dereferencing02_1() {
println!("\nDereferencing 02_1 : Mutability of the reference\n");
let my_value = 5; // immutable variable
println!("my_value : {}", my_value);
let ref_to_my_value = &my_value; // immutable reference to immutable variable
println!("ref_to_my_value : {}", ref_to_my_value);
println!();
// *ref_to_my_value = 24; // => does not compile
// ref_to_my_value is a `&` ref so the data it refers to cannot be written
let mut my_mutable_value = 55; // mutable variable
println!("my_mutable_value : {}", my_mutable_value);
let ref_to_my_mutable_value = &mut my_mutable_value; // mutable reference to mutable variable
// &mut my_mutable_value creates a mutable reference
// which can be used to modify the pointed value
// my_mutable_value is already a mutable variable
println!("ref_to_my_mutable_value : {}", ref_to_my_mutable_value);
println!();
*ref_to_my_mutable_value += 1;
println!("ref_to_my_mutable_value : {}", ref_to_my_mutable_value);
println!("my_mutable_value : {}", my_mutable_value);
println!();
}
Expected output
Dereferencing 02_1 : Mutability of the reference
my_value : 5
ref_to_my_value : 5
my_mutable_value : 55
ref_to_my_mutable_value : 55
ref_to_my_mutable_value : 56
my_mutable_value : 56
Explanations
- Like in the very first code snippet, we create a variable (immutable variable,
let my_value = 5;
) and print its content - Then we create an immutable binding to the immutable reference (
let ref_to_my_value = &my_value;
) and print its content - It is important to realize that
&my_value
is an immutable reference (this should not be a big issue) while on left hand side, no matter its name,ref_to_my_value
is an immutable variable, an immutable binding (a name associated to the state of an instance of a datatype + properties of mutability, ownership, borrowing and lifetime. Again, you can read this post if this is not crystal clear.) - The commented line does not compile (
*ref_to_my_value = 24;
). With this setup we cannot mutate the content of the reference (and the variable)
Here’s how we can address this issue
- We create and print a mutable variable (
let mut my_mutable_value = 55;
) - Then we create (
let ref_to_my_mutable_value = &mut my_mutable_value;
) and print the content of a mutable reference to a mutable variable. The point I did’nt get at the beginning is that this is the&mut my_mutable_value
that creates a mutable reference which is assigned to a non mutable variable namedref_to_my_mutable_value
(let ref_to_my_mutable_value...
). - We mutate the content of the reference (
*ref_to_my_mutable_value += 1;
) - Finally we print both, the content of the reference and the variable
Now, let’s run this code :
fn dereferencing02_2() {
println!("\nDereferencing 02_2 : Mutability of the binding\n");
let my_value = 5; // immutable variable
println!("my_value : {}", my_value);
let other_value = 42;
println!("other_value : {}", other_value);
println!();
let ref_to_my_value = &my_value; // immutable reference to immutable variable
println!("ref_to_my_value : {}", ref_to_my_value);
println!();
// ref_to_my_value = &other_value; // => does not compile
// cannot assign twice to immutable variable `ref_to_my_value`
let ref_to_my_value = &other_value; // => shadowing. Does compile
println!("ref_to_my_value : {}", ref_to_my_value); // => ref_to_my_value: &i32
println!();
let mut mut_ref_to_my_value = &my_value; // mutable reference to immutable variable
println!("mut_ref_to_my_value : {}", mut_ref_to_my_value);
mut_ref_to_my_value = &other_value; // mut_ref_to_my_value now reference other_value
println!("mut_ref_to_my_value : {}", mut_ref_to_my_value);
let other_value = std::f64::consts::PI; // => other_value: f64
println!("other_value : {}", other_value);
// mut_ref_to_my_value = &other_value; // => does not compile: expected `&{integer}`, found `&f64`
}
Expected output
Dereferencing 02_2 : Mutability of the binding
my_value : 5
other_value : 42
ref_to_my_value : 5
ref_to_my_value : 42
mut_ref_to_my_value : 5
mut_ref_to_my_value : 42
other_value : 3.141592653589793
Explanations
In the code we look at the mutability of the binding. We want the “reference” being able to “point to” different variables.
Just to make sure… It must be clear that there is no “reference” pointing to a different variable. There will be mutable binding which links a name (mut_ref_to_my_value
) to a reference (&my_value
) at one time then to another (&other_value
) in a second time. It is really like a mutable binding linking a name to an i32
value, then to another i32
value. Here instead of i32
we deal with &i32
. Nothing more.
This said let’s read the code :
- We create 2 immutable variables (
let my_value = 5;
,let other_value = 42;
) - We create an immutable reference to an immutable variable (
let ref_to_my_value = &my_value;
) - The commented line does not compile (
ref_to_my_value = &other_value;
). Indeed the reference is immutable. It cannot mutate, it cannot “point to” another variable.
Here’s what we can do
- We could “write over” (shadowing) the previous reference (
let ref_to_my_value = &other_value;
)
Here’s another option
- We create a mutable binding to an immutable variable (
let mut mut_ref_to_my_value = &my_value;
). - The binding
mut_ref_to_my_value
can then be mutated and “point to” another variable (let mut mut_ref_to_my_value = &my_value;
first thenmut_ref_to_my_value = &other_value;
) - Like a binding to an
i32
can only hosti32
, our mutable binding can only points to variable of the same type. The first version ofother_value
was ani32
. Now if we create anf64
version ofother_value
(let other_value = std::f64::consts::PI;
),mut_ref_to_my_value
cannot point to it. The very last line does not compile.
That is fine but why should I care? I mean, what is the purpose? One of the key usage of references is to pass efficiently arguments to functions. Let’s see how it works now.
Dereferencing: Reference as argument
fn dereferencing03() {
println!("\nDereferencing 03 : ref as argument\n");
fn my_function01(v: Vec<i32>) {
println!("{:?}", v);
}
fn my_function02(v: &Vec<i32>) {
println!("{:?}", *v);
println!("{:?}", v); // deref coercion in action
}
fn my_function03(v: &[i32]) { // accept reference to vectors or arrays
// println!("{:?}", *v); // Does not compile because *v is of type [i32] with no Sized trait
// Sized trait expected by println!()
// Only references like `&[i32]` implement the `Debug` trait;
// `[i32]` alone doesn't, as it's dynamically sized)
println!("{:?}", &*v); // Overkill ?
println!("{:?}", v);
}
let my_vector = vec![42, 43, 44];
my_function01(my_vector); // after the call my_vector disappears
// println!("{:?}", my_vector); // Does not compile
let my_vector = vec![42, 43, 44]; // must recreate my_vector
my_function02(&my_vector); // pass a reference
my_function03(&my_vector);
let my_array = [142, 143, 144]; // an array on the stack
my_function03(&my_array);
}
Expected output
Dereferencing 03 : ref as argument
[42, 43, 44]
[42, 43, 44]
[42, 43, 44]
[42, 43, 44]
[42, 43, 44]
[142, 143, 144]
[142, 143, 144]
Explanations
- Yes we can! Yes we can have function definitions inside function definition
my_function01
has a unique parameter of type vector ofi32
my_function02
has a unique parameter of type reference to a vector ofi32
my_function03
has a unique parameter of type slice (&[T]
) ofi32
Pass by value
Then we create my_vector
(let my_vector = vec![42, 43, 44];
), a vector of i32
and we give it as an argument to my_function01
- The argument is passed by value
- So it is given to the function and we lost it (RIP)
- After the call to
my_function01
we cannot printmy_vector
Before to move forward we must recreate my_vector
(let my_vector = vec![42, 43, 44];
)
Pass by reference
The 2 calls to my_function02
and my_function03
are much more interesting
- In both calls
my_vector
is passed by reference (my_function02(&my_vector);
) my_vector
is borrowed to the functions- It is not given, it does not disappear after the call
- This is why
my_vector
is still available after the first call and can be reused in the second (my_function03(&my_vector);
)
This is all good… But why do we do this? What I will say here is not 100% accurate but stick with me because the idea is to get the concept first, then I’ll tell you the truth. I promise.
- In the call to
my_function01
we passed the vector by value. It is like passing the 3 values. We push them on the stack. Call the function. In the function, we pop the values from the stack, then work on them. It was OK because we only had 3i32
. What if we had 1_000_000? This would be inefficient. - In the calls to
my_function02
andmy_function03
we pass a reference, the address of themy_vector
. No matter the length of the vector we will always use 8 bytes (on a 64 bit OS, a memory address is on 8 bytes). This is much smarter and much faster.
OK… But what is going on in my_function02
?
- In
my_function02
,v
is a parameter of type reference to a vector ofi32
(&Vec<i32>
). If we want to get access to its content, we must dereference it. This is why the firstprintln!
has*v
as an argument (println!("{:?}", *v);
). - However, like in C++, in the second call to
println!
we can usev
as a parameter (println!("{:?}", v);
). The Rust compiler will then apply deref coercion, to transform, at compile time, the&Vec<i32>
(v
) into aVec<i32>
(*v
). How does this work here ?println!
expects a type which implement Debug trait but&Vec<i32>
doesn’t- The compiler looks for Deref trait implementations on
&Vec<i32>
Vec<T>
implementsDeref<Target = [T]>
, meaning&Vec<i32>
can auto-convert to&[i32]
(a slice ofi32
)- And
&[i32]
implements the Debug trait, so the reference to a vector ofi32
is printed as a slice ofi32
.
And what about my_function03
?
- In
my_function03
,v
parameter is a slice ofi32
(&[i32]
) - The commented
println!
does not compile (println!("{:?}", *v);
). Indeed,*v
is of type[i32]
and this type does not implement the Sized trait expected byprintln!
- If we really want to dereference the parameter then we can use the second
println!
but it looks weird (println!("{:?}", &*v);
) -
The third
println!
is the way to go (println!("{:?}", v);
) - Last but not least, we create an array (
my_array
) and pass a reference to it as an argument tomy_function03
(my_function03(&my_array);
)- This demonstrates that
my_function03
works fine when it receives as a parameter either a reference to vector or an array (thanks to deref coercion).
- This demonstrates that
You promised to tell the truth about what happens when you pass a vector by value… In fact, the content of a vector is allocated on the heap ([42, 43, 44]
) and you can view a vector as a struct with 3 fields (PLC) :
- address of the data on the heap (P, pointer)
- the len of the vector (L, len)
- the capacity of the vector (C, capacity >=len)
So, yes, I lied. When we pass by value a vector of 100 i32
we do not passe 100 values. We only pass 3 i64
(address, len and capacity). Nevertheless what is false for a vector is true for an array. An array is just a set of contiguous memory addresses on the stack and if we pass an array by value we pass its content by value.
You mentioned data on the heap. How to dereference this kind of data ? You read my mind, this is what we will focus on now.
Dereferencing: Allocations on the heap
fn main() {
println!("\nDereferencing 04 : Box, Rc and Deref\n");
// Function that takes a value by reference
fn print_ref(v: &i32) {
println!("Value: {}", *v);
println!("Value: {}", v);
}
// Function that takes a Box<i32>
fn print_box(v: Box<i32>) {
println!("Boxed value: {}", v);
}
// Create a value on the heap using Box
let b = Box::new(123);
println!("Address of the heap in the Box : {:p}", b);
println!("Address of b on the stack : {:p}", &b);
println!("Dereferenced Box: {}", *b); // explicit deref
println!("Dereferenced Box: {}", b); // works, thanks to deref coercion
// The function expects &i32, but we give it &Box<i32>
// Thanks to deref coercion, this works
print_ref(&b);
// Can also pass the Box directly if signature matches
print_box(b); // b is moved here
}
Expected output
Dereferencing 04 : Box, Rc and Deref
Address of the heap in the Box : 0x5dcf88fe1b10
Address of b on the stack : 0x7ffe596c76d8
Dereferenced Box: 123
Dereferenced Box: 123
Value: 123
Value: 123
Boxed value: 123
Explanations
- We first define 2 functions
print_ref
andprint_box
- Then, in order to allocate memory on the heap we use
Box::new()
(let b = Box::new(123);
) - Let’s keep in mind this creates, in a single-threaded context, a unique pointer that own the pointed area.
- Here the required space to store the value 123 (an
i32
, 4 bytes) is allocated on the heap b
a variable of typeBox<i32>
remains on the stack.b
is a smart pointer which implements RAII- RAII = Ressource Acquisition Is Initialisation. This term is pretty well known in C++. This creates a wrapper around the allocated memory and warranty that the memory will be automatically freed when
b
goes out of scope (even if apanic
or an earlyreturn
happens) - Once the variable
b
exists, using a regularprintln!
we print, the address on the heap which is in the Box (println!("Address of the heap in the Box : {:p}", b);
). - Just to make sure we understand that the address of
b
(data typeBox<i32>
) on the stack has nothing to do with the address in the boxe (pointing to the heap) we print the address ofb
so that we can compare (println!("Address of b on the stack : {:p}", &b);
). - Next we dereference the boxe
b
and print the value it points to (println!("Dereferenced Box: {}", *b);
). This is really cool because once the boxe is created, we useb
has a regular reference to ani32
. - Even cooler and more idiomatic we can print the value pointed to with
println!("Dereferenced Box: {}", b);
- In the call to
print_ref
we pass a reference to the box (print_ref(&b);
). The box is borrowed and it remains available after the call. - In the
print_ref
function the firstprintln!("Value: {}", *v);
is what we should write but the deref coercion allow us to write the second (println!("Value: {}", v);
) - In the last call, make sure to understand that
b
(a variable on the stack) is moved and no longer available right after the call toprint_box
. This is at this point that RAII mechanism will work behind the scene and deallocate the memory on the heap.
Ok. Now I know how to safely allocate memory on the heap. But can I have 2 boxes pointing to the same area? I know what you mean. The heap allocated memory could be a picture of your brand new Aprilia RSV4 and you would like to make sure your friends can look at it without modifying it. This is not possible with a box directly. So let’s find out another solution…
Dereferencing: Rc<T>
and Reference-counted smart pointers
Indeed, in order to manage memory efficiently we need to be smarter than a Box
and to include a counter in order to know how many people are currently watching the picture of your motorbike. Let’s look at the code below :
use std::rc::Rc;
fn dereferencing05() {
println!("\nDereferencing 05 : Rc<T> and Reference Count\n");
// Function that takes Rc<i32>
fn print_rc(v: &Rc<i32>) {
println!("From print_rc : {}", v);
}
// Create an Rc pointing to a value on the heap
let rc1 = Rc::new(999);
println!("Initial value: {}", rc1);
println!("Address in Rc: {:p}", Rc::as_ptr(&rc1));
println!("Reference count after creation: {}", Rc::strong_count(&rc1)); // 1
print_rc(&rc1);
// Create a clone of rc1 — this does not copy the value
let rc2 = Rc::clone(&rc1);
println!("\nAfter cloning rc1 into rc2:");
println!("rc1 points to: {}", rc1);
println!("rc2 points to: {}", rc2);
println!("Reference count (rc1): {}", Rc::strong_count(&rc1)); // 2
println!("Reference count (rc2): {}", Rc::strong_count(&rc2)); // 2
{
// Introduce a new scope
let rc3 = Rc::clone(&rc2);
println!("\nInside inner scope with rc3:");
println!("rc3 points to: {}", rc3);
println!("Reference count: {}", Rc::strong_count(&rc3)); // 3
} // rc3 goes out of scope here
println!("\nAfter rc3 is dropped:");
println!("Reference count (rc1): {}", Rc::strong_count(&rc1)); // 2
}
Expected output
Dereferencing 05 : Rc<T> and Reference Count
Initial value: 999
Address in Rc: 0x56d9e7fceb20
Reference count after creation: 1
From print_rc : 999
After cloning rc1 into rc2:
rc1 points to: 999
rc2 points to: 999
Reference count (rc1): 2
Reference count (rc2): 2
Inside inner scope with rc3:
rc3 points to: 999
Reference count: 3
After rc3 is dropped:
Reference count (rc1): 2
Explanations
- We first create a reference-counted smart pointer, pointing to the memory on the heap (
let rc1 = Rc::new(999);
) - Again, just to make sure we are in sync :
Rc
is for single-threaded only - This said, we print the value and the address on the heap. I really don’t like the fact that
Box
andRc
don’t have similar API. For example why I can’t write :
use std::rc::Rc;
fn main(){
let b = Box::new(999);
println!("Dereferenced Box: {}", b); // 999
println!("Dereferenced Box: {}", *b); // 999
println!("Address in the Box : {:p}", b); // 0x5eca24b8eb10
// Does'nt compile use of unstable library feature `box_as_ptr`
// println!("Address in the Box : {:p}", Box::as_ptr(&b));
println!();
let rc1 = Rc::new(999);
println!("Initial value: {}", rc1); // 999
println!("Initial value: {}", *rc1); // 999
println!("Initial value: {:p}", rc1); // 0x5eca24b8eb40
println!("Address in Rc: {:p}", Rc::as_ptr(&rc1)); // 0x5eca24b8eb40
}
- Finally we demonstrate how to dereference an
Rc
when we callprint_rc(&rc1);
. Nothing fundamentally new here.
Then it becomes interesting…
- We print the value of the counter of the smart pointer (
println!("Reference count after creation: {}", Rc::strong_count(&rc1)); // 1
). At this point, only one pointer is pointing to area where999
is stored
Then it becomes really interesting…
- Indeed we
.clone()
the smart pointer we had (let rc2 = Rc::clone(&rc1);
). It is important to keep in mind that no copy or worst, no deep copy happens. When applied to a reference-counted smart pointer,.clone()
simply add 1 to the counter. The.clone()
operation is fast and cheap. - Once rc2 is created we print the value it points to and the current value of the counter (
println!("Reference count (rc2): {}", Rc::strong_count(&rc2)); // 2
). This should not be a surprise but the counter ofrc1
andrc2
are equal (otherwise we would be in be trouble)
In a last experiment we create a scope ({
and }
) where we create another clone (let rc3 = Rc::clone(&rc2);
).
- Before the end of the scope we display the counter of one of the clones (
println!("Reference count: {}", Rc::strong_count(&rc3)); // 3
) - After the scope we display the counter of one of the clones (
println!("Reference count (rc1): {}", Rc::strong_count(&rc1)); // 2
). It goes from 3 to 2 sincerc3
no longer exists.
Well, well, well… I know you will NOT talk about references in a multithreaded context but… What if I need to mutate the heap allocated memory ? This could represent allocated memory acting as your bank account—where your company deposits your salary and you check your available balance. To do so, we need to be even smarter than before…
Dereferencing: Rc<RefCell<T>>
for shared mutation (single-thread)
However, the good news it that instead of learning a new smart pointer, we will reuse what we already know about Rc
(reference-counted smart pointer) and add interior mutability to the heap allocated memory. This is done using RefCell
. First thing first, let’s run the code below :
use std::cell::RefCell;
use std::rc::Rc;
fn dereferencing06() {
println!("\nDereferencing 06 : Rc<RefCell<T>> for shared mutation (single-thread)\n");
// Rc enables multiple ownership, RefCell enables interior mutability
let shared_vec = Rc::new(RefCell::new(vec![1, 2, 3]));
println!("shared_vec: {:?}", shared_vec);
println!("Reference count: {}", Rc::strong_count(&shared_vec));
// Clone the Rc to get multiple owners
let a = Rc::clone(&shared_vec);
let b = Rc::clone(&shared_vec);
println!("Reference count: {}", Rc::strong_count(&shared_vec)); // 3
// Mutate the shared vector from owner `a`
{
let mut vec_ref = a.borrow_mut(); // borrow `a` as mutable
vec_ref.push(4);
println!("a pushed 4: {:?}", vec_ref);
}
// Read from the shared vector via owner `b`
{
let vec_ref = b.borrow(); // borrow `b` as immutable
println!("b sees the vector: {:?}", vec_ref);
}
// Shows that the compiler doesn't see borrow conflicts, but the runtime does.
{
let _first = shared_vec.borrow_mut();
// let _second = shared_vec.borrow_mut(); // panics at runtime
}
// Reference count stays at 3 until `a`, `b`, and `shared_vec` go out of scope
println!("Reference count: {}", Rc::strong_count(&shared_vec)); // 3
}
Expected output
Dereferencing 06 : Rc<RefCell<T>> for shared mutation (single-thread)
shared_vec: RefCell { value: [1, 2, 3] }
Reference count: 1
Reference count: 3
a pushed 4: [1, 2, 3, 4]
b sees the vector: [1, 2, 3, 4]
Reference count: 3
Explanations
- Let’s say we want to share on the heap a vector
shared_vec
ofi32
- We would write :
let shared_vec = Rc::new(vec![1, 2, 3]);
- Since we want to add interior mutability (ability to mutate the content of the vector) we write :
let shared_vec = Rc::new(RefCell::new(vec![1, 2, 3]));
- Everthing we said about cloning and inspecting the counter remains valid. After all,
shared_vec
is aRc<Vec<i32>>
- This is true but
shared_vec
is aRc<RefCell<Vec<i32>>>
and this is what allow us to mutate the heap allocated memory safely (almost)
Then we create the first scope (more on this in few lines)
- Inside the scope, we create
vec_ref
(let mut vec_ref = a.borrow_mut();
). To make a long story short it borrow the wrapped object (vec![1, 2, 3]
) with the ability to mutate it (.borrow_mut()
). - Then we push a value in the borrowed vector (
vec_ref.push(4);
) and print it - And the scope ends right after the
}
,vec_ref
goes out of scope and no one is borrowing the heap allocated vector
We then create a second scope
- We create a new version
vec_ref
(let vec_ref = b.borrow();
). This time it borrowsb
without the ability to mutate it (.borrow()
not.borrow_mut()
). - The print shows that the mutably shared vector has been mutated
Why do we need scopes here ? This is the one million dollars question baby! And you should know the answer. Make a test. Remove the scope and the code will panic on line let vec_ref = b.borrow();
. Can you say why?
Without the scopes, when we reach the line let vec_ref = b.borrow();
, on stage we have one borrower with mutability capabilities and we would like to add a borrower with read-only capabilities. No way. You know the rule : Only one writer or multiple readers.
One thing to keep in mind however. The conflict among borrowers happen at runtime not at compile time.
This is what is demonstrated in the last scope.
- If you uncomment the second line
// let _second = shared_vec...
- The code panic at runtime
Any tips and tricks to share ? Here are a few common traps and surprises you might encounter (I did)
Rust Gotchas: Dereferencing Edition
-
References are not pointers (exactly)
They behave similarly but are not the same. You can’t do pointer arithmetic, and they must always be valid and non-null. - **
&mut
vsmut
**mut x
: you’re allowed to modifyx
.&mut x
: you’re allowed to modify the valuex
points to (&mut is a compound operator in Rust, it is a single “logical keyword”, which reads “mutable reference to”)- But
let mut x = &y;
only means thatx
(the reference) can change to point elsewhere — not thaty
is mutable!
-
Shadowing vs reassignment
You can “reassign” an immutable reference via shadowing (let x = &y;
again), but trying to reassign withoutlet
won’t compile. -
Boxing isn’t cloning
Box::new(value)
allocatesvalue
on the heap. It does not create a deep copy when passed by value — it moves ownership unless you explicitly.clone()
the inner value. -
Rc<T>
cloning doesn’t clone the value
It just increments the reference counter. Great for sharing read-only access — but not safe across threads or for mutation withoutRefCell
. -
Deref coercion looks like magic
But it’s not: it follows well-definedDeref
rules. Still, don’t rely on it blindly — sometimes explicit*
helps readability and prevents surprises. - Borrow checker checks borrows at compile time… until
RefCell
enters the game
RefCell<T>
defers checks to runtime. That means your code compiles, but can still panic if you violate borrow rules (e.g., two mutable borrows).