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:

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 like shared_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.
  • 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:

1

In reality, there are more categories but these two are the most relevant to us.

Author: Mehmet Emre

Created:

The material for this class is based on Prof. Richert Wang's material for CS 32