Lecture 14: Smart Pointers, Move Semantics, and Automated Memory Management
\( \newcommand\bigO{\mathrm{O}} \)
The notes for this lecture are rather sparse and there is no information about this on our textbooks, so I am attaching some relevant resources:
- The lvalue and rvalue categories on this page.
- Smart pointers category on this page (specifically, the three pointers under
"Pointer categories", ignore
auto_ptr
). - First 50 minutes of this talk.
So far, we had to manually manage the objects we allocated on the heap using
new
and delete
. This created a lot of headache for lab 3 because of
forgetting to delete objects and leaking memory, or not leaking them. Other
languages like Python, C#, and Java manage memory automatically. In these
languages, whenever the programmer calls new
, if there isn't enough memory
around (or for other reasons), the language runtime finds all inaccessible
objects and frees them (this is a form of garbage collection), so the
programmer never needs to call delete
. There are certain performance
trade-offs with a scheme like this, and C++ has the maxim "if you don't use it,
you don't pay for it" to make sure the programs that don't need a feature like
automatic memory management or virtual methods don't pay the runtime cost, so it
has some opt-in rudimentary support for automatic memory management, including
a different form of garbage collection called reference counting.
C++ achieves automatic memory management through some standard library classes
called smart pointers. Currently, there are three kinds of smart pointers in
C++: shared_ptr
, weak_ptr
and unique_ptr
. All three of them are defined in
the <memory>
header.
1. Reference counting and shared_ptr
shared_ptr<T>
is similar to T*
:
- You can get the value in a
shared_ptr<T> * p
using*p
- You can check if it is null by casting it to a Boolean (like using
if (p) { ... }
)
We create a shared pointer using std::make_shared<T>(arguments to the constructor);
. This is similar to calling new T(...);
.
shared_ptr<int> p = make_shared<int>(5);
// update the value of p
*p = 42;
// check if p is null, if not print the value
if (p) {
cout << *p << endl;
}
The crucial difference is that shared_ptr
also maintains a count of how
many sharedptrs point to that object (this is called a "reference
count"). So, when we copy a sharedptr, we increment the count, and when a
sharedptr is destroyed, the count is decremented. If there are 0 sharedptrs
pointing to the object, the runtime knows it is inaccessible, and it frees the
object at that point.
shared_ptr<int> p = make_shared<int>(5);
// new scope
{
shared_ptr<int> q = p; // copying p increases its count by 1: 1+1=2
*q = 42; // this updates the same object as *p
} // q is destroyed here, so decrement the count: 2-1=1
// check if p is null, if not print the value
if (p) {
cout << *p << endl; // prints 42
}
// when this scope ends, p is destroyed, so the count is now 1-1=0
// the count is 0, so the object is freed
This memory management scheme is called reference counting, and it works effectively most of the time.
Sometimes, we want to have an old-school "raw" pointer T*
(for example, when
calling another function). The get()
method returns this pointer. Note that
the pointer returned by p.get()
doesn't know anything about reference
counting, so if we try to use it after the original shared_ptr
is destroyed,
we can still have a use-after-free error.
1.1. Handling cycles
- In some cases, we want to create cyclic references (pointers between objects
that point each other, maybe indirectly):
- Building a circular linked list for a work queue
- Adding pointers between child and parent nodes in a tree so that we can find the parent and the children easily
- Implementing a state machine by having pointers to next states from each state. A state machine can have cycles.
However, cycles are problematic with reference counting. Example:
#include <memory> #include <iostream> #include <string> using namespace std; struct Node { shared_ptr<Node> next; string value; ~Node() { cout << "destroyed object with value " << value << endl; } }; int main(int argc, char *argv[]) { // all 3 of these are shared_ptr<Node> // and, they all have a reference count of 1 auto a = make_shared<Node>(); auto b = make_shared<Node>(); auto c = make_shared<Node>(); a->value = "a"; b->value = "b"; c->value = "c"; a->next = b; // ref count of b is now 2 b->next = c; // ref count of c is now 2 c->next = a; // ref count of a is now 2 // at this point, we created a cycle a -> b -> c -> a. return 0; }// now that the main function exited, all 3 shared pointers are destroyed // - c is destroyed so the count of *c goes down to 1 // - b is destroyed so the count of *b goes down to 1 // - a is destroyed so the count of *a goes down to 1 // // No Node object is freed!
- The solution is to use
weak_ptr
, which is likeshared_ptr
but does not increment/decrement the reference count. So, it is a "weak link" in the reference cycle. That reduces the count by 1, enough to destroy the cycle. - However,
weak_ptr
may point to a freed object (because it doesn't increment the count), so we need to be careful when using it.
2. Value categories & Move semantics
- C++ actually has 2 kinds of references (each one is related to a value
category):In reality, there are more categories but these two are the
most relevant to us.
- lvalue references (
T&
) - these are the references we have seen so far. They point to values that are not necessarily temporary in nature (so, they can be assigned to). They are called Lvalue references because they can appear on the left-hand side of an assignment.
- rvalue references (
T&&
) - these references usually point to temporary
objects (like the result of
1 + 2
). These objects can be "emptied out" safely by moving their contents. They are called Rvalue references because they appear only on right-hand side of assignments normally.
- lvalue references (
Example
// Let's look at these two lines of code: Node x = Node{} /* here, there is a temporary object, so the result is an rvalue reference */; x->value /* left-hand side of assignment, so lvalue reference */ = "x" /* this creates a temporary string, so rvalue reference */;
So, C++ has move constructors, just like copy constructors. However, these take a value by rvalue reference:
struct Node { // ... // copy constructor Node(Node& that) { ... } // move constructor Node(Node&& that) { ... } }
The usual way of using these is that a copy constructor copies over the values on the right-hand side, and the move constructor takes over the values inside
that
(like a large string buffer) by moving them out of the object. Since,that
is probably going to be destroyed soon (or the programmer doesn't care about what it holds), moving the value out is fine in this case.
2.1. std::move
I used move
in previous lectures, it does the following: it converts a T&
to a T&&
. That is it. It is just a type cast like (T&&)(value)
. So,
move
doesn't do anything by itself. However, now that the result is a
T&&
, the compiler will choose the move assignment operator/move constructor
over the copy versions, which will actually move the values out later.
3. unique_ptr
There is a small problem with shared_ptr
besides potentially leaking memory:
incrementing/decrementing reference counts is costly because they need to be
thread-safe. Recall the maxim: if you don't use it, you don't pay for it. So,
C++ also provides us with a smart pointer that doesn't need reference
counting. What's the catch? This pointer cannot be copied.
In the error handling lecture, we talked about optional
and how it was like a
pointer without memory management headaches. However, the optional copies the
associated value whenever it is copied. unique_ptr
has the same convenience as
optional
, except the following two important bits:
- It does not have a copy constructor and copy assignment operator, but has move constructor, and move assignment operator.
Footnotes:
In reality, there are more categories but these two are the most relevant to us.