-
Notifications
You must be signed in to change notification settings - Fork 0
Smart Pointers
A pointer is a general concept for a variable that contains an address in memory. This address refers to, or “points at,” some other data. References are indicated by the & symbol and borrow the value they point to.
Smart pointers, on the other hand, are data structures that act like a pointer but also have additional metadata and capabilities. Rust has a variety of smart pointers defined in the standard library that provide functionality beyond that provided by references. One that is particularly interesting is a reference counting smart pointer type. This pointer enables you to allow data to have multiple owners by keeping track of the number of owners and, when no owners remain, cleaning up the data. Other examples of smart pointers are String and Vec<T>. These both own some memory and allow you to manipulate it, and also have some metadata and other capabilities.
Rust, with its concept of ownership and borrowing, has an additional difference between references and smart pointers: while references only borrow data, in many cases, smart pointers own the data they point to.
For full reference, see Ch.15 in the Rust Book
The most straightforward smart pointer is a box with type Box<T>. Boxes allow you to store data on the heap rather than the stack - or rather store a pointer to some heap-data on the stack.
Boxes do not have performance overhead, other than storing data on the heap instead of the stack. But then they also do not have that many capabilities either. We use them most often in these situations:
- When we have a type whose size cannot be known at compile time, and we want to use a value of that type in a context that requires an exact size
- When you want to own a value, and you care that it is a type that implements a particular trait rather than being of a specific type
- When we have a large amount of data, and we want to transfer ownership but ensure the data will not be copied when we do so
Transferring ownership of a large data amount can take a long time if the data needs to be copied around on the stack (stacks are usually LIFO for example). So we can then see how in the last case mentioned above, it would be a lot quicker if we could just store that data somewhere on the heap, and only copy the pointer- and metadata around on the stack.
The second case is known as a trait object and is documented in Ch.17 of the Rust Book and the first case we will get to in a bit (Enabling Recursive Types with Boxes), however Recursive Types are also covered in the Rust book of course.
fn main() {
let b = Box::new(5);
println!("b = {}", b); // b = 5
}The variable bhas a value of a Box pointing to the value of 5 - a value which in this case will be stored at some heap address. In this case, we can access the data in the box similar to how we would if this data were on the stack. And as with any owned value, when the box goes out of scope at the end of main, so does b. The deallocation happens for both the box stored on the stack, and the data it points to stored on the heap.
Putting a single value on the heap is not very useful, so this is not the best use-case for them. Having values like a single i32 on the stack, where they are stored by default is more appropriate in most cases.
A better use-case would for example be cases where we do not know the size of the data at compile time, or a recursive type.
A value of recursive type can have another value of the same type as part of itself Recursive types pose an issue at compile time because Rust needs to know how much space a type takes up. However, the nesting of values of recursive types could theoretically continue indefinitely, so Rust cannot know how much space the value requires.
// Note the use of Box<T>. Explanation in the text further down below
enum List {
Cons(i32, Box<List>), // Here, i32 is just an example. The type could be anything really
Nil,
}
use crate::List{Cons, Nil};
fn main() {
let list = Cons(1, Box::new(Cons(2, Box::new(Cons(3, Box::new(Nil))))));
}A cons list is a data type often seen in functional programming languages, originating from LISP. Its name comes from "construction function", a function that constructs a new pair from its two arguments. For example, here is some pseudocode if a cons list of 1,2,3 with each pair in the parentheses:
(1, (2, (3, Nil)))If we look at the above examples, and imagine that we did not wrap the recursive List enum in a Box<T> pointer - what would happen? Well, we would get an error - because there is no way Rust could figure out how much space to allocate for recursively defined types; It would start by looking at the Const variant holding a value of i32 and a value of List. To figure out how much memory the Listtype needs, the compiler looks at its variants, starting with the Const variant - holding a value of i32 and a value of List, and then this process would continue to infinity.
However, since Box<T> is a pointer Rust always knows how much space it needs because a pointer's size does not change based on the amount of data it is pointing to. So now the second argument only holds a pointer to the next List value on the heap, rather than trying to hold a List of indeterminable size inside the Cons variant. Conceptually, we still have a list, created with lists holding other lists, but this implementation is now more like placing the items next to one another rather than inside one another.
Note that the cons list is not a commonly used data structure in Rust, and in fact most of the time when we have a list of items in Rust, Vec<T> is a better choice. But it is useful to know that in some cases more complex recursive data types are useful. Also, cons serves as a good starting point for discussing how boxes let us define a recursive data type without much fuzz.
The Box<T> type is a smart pointer because it implements the Deref trait, which allows Box<T> values to be treated like references. When a Box<T> value goes out of scope, the heap data that the box is pointing to is cleaned up as well because of the Drop trait implementation. These two traits will be even more important to the functionality provided by the other smart pointer types.
By implementing Deref in such a way that a smart pointer can be treated like a regular reference, you can write code that operates on references and use that code with smart pointers too.
use std::ops::Deref;
struct MyBox<T>(T);
impl<T> MyBox<T> {
fn new(x: T) -> MyBox<T> {
MyBox(x)
}
}
impl<T> Deref for MyBox {
type Target = T;
fn deref(&self) -> &Self::Target {
&self.0
}
}Without the Deref trait, the compiler can only dereference & references. The deref method gives the compiler the ability to take a value of any type that implements Deref and call the deref method to get a & reference that it knows how to dereference. Remember, normally we would maybe dereference by using * on the value, looking something like this:
fn main() {
let x = 5;
let y = MyBox::new(x);
assert_eq!(5, x);
assert_eq!(5, *y);
}If we did not have the Deref trait, we would get an error message saying type "MyBox<{integer}>" cannot be dereferenced. Because what the compiler is really doing behind the scenes is:
*(y.deref())Similar to how you use the Deref trait to override the * operator on immutable references, you can use the DerefMut trait to override the * operator on mutable references.
Rust does deref coercion when it finds types and trait implementations in three cases:
- From
&Tto&UwhenT: Deref<Target=U> - From
&mut Tto&mut UwhenT: DerefMut<Target=U> - From
&mut Tto&UwhenT: Deref<Target=U>
The first two cases are the same as each other except that the second case implements mutability.
For more information on Smart Pointers and the Deref trait, see ch15.2 in the Rust Book.
Reference Counted Smart Pointer
Atomically Reference Counted Smart-Pointer
The Rc<T> smart-pointer is only for use in single-threaded scenarios. When we move into concurrency with multithreading, then we need to use the Arc<T> pointer instead.
Arc<T> is a type like Rc<T> that is safe to use in concurrent situations. The a stands for atomic, see the standard library documentation for std::sync::atomic for more details, but we could say that atomics work like primitive types but are safe to share across threads.
So why are not all primitive types atomic then, and why aren't standard library types implemented to use Arc<T> by default? The reason is that thread safety comes with a performance penalty that you only want to pay when you really need to.