ICS 46 Spring 2022
Notes and Examples: Smart Pointers


On the complexities of cleanup

When we write programs in any programming language, one problem we're faced with is what you might call cleanup: making sure that our programs don't leave a mess behind them and, more importantly, that they don't leave a gradually-growing mess as they run for a long period of time. This is not just a matter of polishing one's program when it's mostly done, and it doesn't affect only programs that run for a long time. Some cleanup-related issues affect the correctness of a program's results, such as being sure that you've flushed buffers (e.g., by closing any open files) before a program ends, so that anything you've asked to be written will actually get written as you requested, rather than being lost when the buffers are destroyed at program end.

Some programming languages are more aggressive than others about performing at least some of this cleanup on your behalf automatically, and you are likely to have seen different perspectives on this, assuming you've programmed in at least one language other than C++ previously. For example, languages whose runtimes include garbage collectors will generally deallocate memory for you when it can determine that you're no longer using it. While this is a handy tool to have, it's worth noting that garbage collectors have some limitations:

C++ keeps this issue squarely in view, as it is ultimately a program's responsibility to clean up all of its own resources, since there is no automatic garbage collector. Objects that are allocated by a program must also be deallocated by that program; files opened must be closed; network connections created must be terminated; and so on. That isn't to say that C++ provides no tools to assist, but these tools require us to be more attentive to finer-grained details than in a lot of other languages. We're compelled, as C++ programmers, to design our programs more carefully, and consider — whenever we allocate some resource — precisely how and where that resource will be deallocated.

The good news, though, is that many uses of memory and other resources fit certain well-known patterns, and C++ provides tools that automate these patterns for us. Once we learn how to recognize these patterns, much of the heavy lifting can be done by the language runtime and the C++ Standard Library.


Resource acquisition is initialization (RAII)

In our previous example, we saw that C++ provides a feature called exceptions, which explicitly separates the notion of failure from the notion of success, by providing an alternative mechanism for functions to indicate that they've failed. Rather than returning a value, functions are said to throw exceptions when they fail. Calling functions then have the option to either catch those exceptions, or to step aside and let them propagate outward (i.e., the called function's failure is also the calling function's failure) to whoever called them. Ultimately, either an active function on the run-time stack catches the exception and completes its own task, or none of them catches the exception and the program terminates.

At first blush, exceptions are a relatively straightforward concept: Functions either succeed (returning a value) or fail (throwing an exception), and how you handle each kind of outcome is separate. However, we quickly discovered that things are more complicated in C++ than they are in some other programming languages that offer a similar feature. Of particular concern in C++ is the problem of manual management of memory and resources. For example, consider the following straightforward function that dynamically allocates a couple of objects, uses them, and then deallocates them.

void doTheJob()
{
    A* a = buildA();   // Assume this dynamically allocates an A
    B* b = buildB();   // Assume this dynamically allocates a B

    doThings(a, b);
    doMoreThings(a);
    doYetMoreThings(b);

    delete b;
    delete a;
}

An initial reading through this function makes it appear that memory leaks aren't possible, since there only appears to be one path through the code; there are no if statements or loops, so control flow appears to be straight-line. Of course, having learned about exceptions already has tuned you into additional possibilities; this function has anything but straight-line control flow! Consider these scenarios:

Let's assume that the doTheJob function can't be considered to have succeeded if any of the functions it calls fail; a failure of any of these functions means that doTheJob has failed, as well. (More often than not in real designs, this is the case. We generally catch exceptions in many fewer places than you might imagine.) In that case, we don't want this function to catch exceptions and fully handle them, yet we also have to ensure that we don't leak memory here. These needs lead to the following, rather contorted-looking logic:

void doTheJob()
{
    A* a = nullptr;
    B* b = nullptr;

    try
    {
        a = buildA();
        b = buildB();

        doThings(a, b);
        doMoreThings(a);
        doYetMoreThings(b);

        delete b;
        delete a;
    } 
    catch (...)
    {
        // It's safe to pass nullptr to delete, so we don't technically
        // need to check for nullptr here.
        delete b;
        delete a;

        // Re-throw the exception, since we haven't handled it; we've just made
        // sure to do some cleaning up before our function fails.
        throw;
    }
}

Even leaving aside the comments that I added to explain the design, look how much noise was introduced into what was previously a much simpler example! Consider, too, how error-prone this kind of code is. What do you think the odds are that you could write a 10,000 line program using techniques like these without making mistakes?

Memory isn't the only resource that leaks

There are resources that we manage in C++ other than just memory. Just as there is a "matched pair" of operations to acquire and release memory (e.g., new and delete, to acquire and release dynamically-allocated memory), it's quite common for us to have a "matched pair" of operations to acquire other kinds of resources:

Without some technique for managing resources like these, we end up having to write logic every bit as contorted as the catch-and-rethrow version of the doTheJob function above, in case an exception is thrown that prevents us from releasing a resource when we're finished with it. And if we need more than one kind of resource, that can get quite messy indeed. Consider this hypothetical function that orchestrates the download of a file, ensuring that only one thread downloads at a time. (None of the "library" functions demonstrated below actually exist, but imagine they do.)

void downloadFile(const std::string& host, unsigned int port, const std::string& filename)
{
    Lock* lock = acquireLock();
    Connection* connection = connect(host, port);
    File* file = openFile(filename);

    while (connection->hasMoreData())
    {
        file->writeLine(connection->readLine());
    }

    file->close();
    delete file;

    connection->close();
    delete connection;

    lock->release();
    delete lock;
}

Let's suppose that any of the functions called above can throw an exception except the close()/release() member functions and destructors. Again, let's assume that any failure along the way indicates that the downloadFile function has also failed. If so, then we've got some work to do; the version of this function above will fail to close files, close connections, and release locks. To fix the problem, we'll need to introduce some additional noise:

void downloadFile(const std::string& host, unsigned int port, const std::string& filename)
{
    Lock* lock = nullptr;
    Connection* connection = nullptr;
    File* file = nullptr;

    try
    {
        lock = acquireLock();
        connection = connect(host, port);
        file = openFile(filename);

        while (connection->hasMoreData())
        {
            file->writeLine(connection->readLine());
        }

        if (file != nullptr)
        {
            file->close();
            delete file;
        }

        if (connection != nullptr)
        {
            connection->close();
            delete connection;
        }

        if (lock != nullptr)
        {
            lock->release();
            delete lock;
        }
    }
    catch (...)
    {
        if (file != nullptr)
        {
            file->close();
            delete file;
        }

        if (connection != nullptr)
        {
            connection->close();
            delete connection;
        }

        if (lock != nullptr)
        {
            lock->release();
            delete lock;
        }

        throw;
    }
}

As if this isn't bad enough, imagine we instead wanted to handle partial results, recover and continue on some kinds of failures, and so on. Suddenly, what was once simple, straight-line logic will turn into a mess.

All hope isn't lost, but we need to consider the fact that exceptions introduce a major issue into our designs: We may end up bailing out of functions in arbitrary places, even when we don't expect it, so we need to figure out a design that doesn't require us to consider every possibility and then manually write code to handle it. Fortunately, this idea has been well-considered over the years, and a pattern has emerged for solving it.

What is RAII?

It's not always the case that resource management is problematic in the presence of exceptions. For example, consider this short function that stores data in a std::vector and returns it.

std::vector<int> getFunctionValues(int n, std::function<int(int)> f)
{
    std::vector<int> v;

    for (int i = 0; i < n; i++)
    {
        v.push_back(f(i));
    }

    return v;
}

There are a few ways that this function could fail with an exception:

Yet there's actually no risk here of a memory leak, even if one of those functions throws an exception. And we don't have to add any additional code to handle these scenarios; the right thing will happen automatically.

As we've seen, C++ provides a natural mechanism to handle "matched pairs" of operations where we acquire some resource (such as dynamically-allocated memory) and then subsequently need to release it. When objects are allocated statically, their constructors are called; when they fall out of scope (even if an exception has been thrown), their destructors are guaranteed to be called. In fact, this is guaranteed even in cases where exceptions are thrown: If a statically-allocated object is constructed successfully before an exception is thrown, it will be destroyed automatically in every case; if not, it won't (and it won't need to be).

This leads to a very important idea that underlies C++ design: Resource acquisition is initialization, an idea so important that C++ programmers often just refer to it by an acronym (RAII). The basic idea is this:

What makes the std::vector example so much simpler than the others is that std::vector adheres to this principle. It acquires a dynamic resource (dynamically-allocated memory) in its constructor, and it automatically deletes it in its destructor. If we successfully finish the line where we initialize v, we're guaranteed that v's destructor will be called, whether because we reached the return statement or because an exception was thrown at any point. It's all automatic, so we don't have to clutter our code with additional logic to ensure this behavior. A std::vector, in short, is a dynamically-allocated array that supports RAII, which makes it easy to use in scenarios where exceptions might be thrown.

The same principle applies within constructors. The first thing a constructor does is to construct an object's member variables, one by one. If one of those constructors throws an exception, we're guaranteed that the destructors will be called only on the member variables that were constructed successfully. So if those member variables automatically clean up any resources they need, we won't have to worry about the constructor leaking those resources when an exception is thrown.

So, in short, we should prefer to move dynamic resource acquisition of all kinds — dynamic memory allocation, files, network connections, locks, and so on — into constructors, and then release them in the corresponding destructors. That's it. As is often the case, there are some details that can be tricky, but the idea is quite simple.


The problem with pointers

Pointers in C++ are a very thinly-veiled abstraction for a location in memory. While they have their uses in C++, it's important to be aware of their limitations. Being a simple tool — keep track of where something is located, so it can be found again later — they do little else on our behalf. All they know is a location of an object and a (compatible) type for that object. It's up to us to use pointers correctly, with sometimes catastrophic consequences, such as program crashes or unexpected behavior, if we make a mistake.

Simple tools have their place, but sometimes we need richer ones to help us tame the complexity of the programs we write. When we think about pointers in C++, a few limitations come to the fore:

Suppose, then, that you have a pointer. How do you know what your rights and responsibilities are? Should you feel free to access the object whenever you need it? Should you feel free to index into it, assuming it's an array? Should you make sure it's not nullptr? How can you be sure the object at that location is really of the right type, or that it hasn't been deallocated already? Is the object dynamically allocated and, if so, should you delete it when you don't need it anymore? Seeing a simple pointer declaration like int* p doesn't tell you any of these things. The pointer type implies only that you have a location in memory and a clue about what type of object you might find there; everything else is something that would need to be communicated in some other way that's not a part of the language's syntax or semantics (e.g., via comments, or just details that we talk to each other about, keep in our heads, and hope not to forget later).

So how can we do better than that? Part of the answer lies in preferring static allocation whenever possible, because statically-allocated objects naturally do the right thing — they're deallocated and are told to clean themselves up by having their destructors called — when they fall out of scope, even in cases when exceptions are thrown or return statements take us out of functions early. (RAII is essentially the idea of centralizing the "hard stuff" in places like constructors and destructors, so we can rely on automatic C++ mechanisms in many cases to clean things up for us.) Generally, we should think long and hard about whether it's necessary for us to say new and delete ourselves; in a lot of cases, it's not.

Sometimes, though, we really do need pointers to dynamically-allocated objects. How can we make that easier to get right? The answer to that lies in first establishing a mental model for how dynamically-allocated objects are typically managed. If we learn to recognize some of the common patterns, we might be able to automate them. And, better yet, since some of those patterns are so well-known, we'll find that the C++ Standard Library implements some of them for us, in the form of smart pointers that automate some of the things that we might otherwise have to painstakingly implement ourselves.


Ownership

The key concept behind pointers, no matter what kind of pointer you use, is what you might call ownership, which refers to whether that pointer holds responsibility over the object it points to. The word "responsibility" here mainly means that when the pointer dies, the object it points to should also be deleted. When you use a pointer, one of the things you need to be thinking about is this notion of ownership. The kinds of pointers you've likely learned about previously, which are sometimes called raw pointers, don't describe any of these details, but you still have to know, for example, whether to expect a pointer already to point to an object, as well as whether (and when) you need to use delete, so that you're sure that every dynamically-allocated object exists before you use it and is deallocated exactly once when you're finished with it.

Unique ownership and std::unique_ptr

One of the ownership patterns that arises in typical C++ programs is what you might call unique ownership, which means that a particular pointer is intended to be uniquely responsible, before its death, for destroying the object it points to. This sounds simple, but it's more common than it sounds. For example, if you're implementing a class A with a member variable that points to a dynamically-allocated object, and you want that dynamically-allocated object to die when the A object dies, what you want is unique ownership. (The std::vector class template in the C++ Standard Library does this with the dynamically-allocated array it uses behind the scenes; so does std::string.) In that case, you might see a pattern like this one:

class A
{
public:
    A();
    ~A();

    // other member functions would be here

private:
    B* b;
    // other member variables would be here
};


A::A()
    : b{new B}, ... // other member variable initializers would be here
{
    // other code to be run after the initializers would be here
}


A::~A()
{
    // necessary cleanup other than deleting the object pointed to by b
    // would be here

    delete b;
}

And, as long as you've taken care to use delete in the constructor as we've done, you might feel that you're in pretty good shape. But, as is often the case in C++, the devil is in the details.

Generally speaking, this kind of thing is hard to get right. But if we could tell the compiler "This is a pointer that uniquely owns the object it points to," then better code could be generated. In particular, the compiler could see to it that such a pointer's death could automatically trigger the death of the object it points to, since that would be a perfectly sensible default if it was known that the pointer has unique ownership of the object.

The C++ Standard Library provides a solution to this problem, in the form of a class template called std::unique_ptr. A std::unique_ptr is a pointer that holds unique ownership of the object it points to, which means that it guarantees that its death will trigger the automatic deletion of the object it points to. Using a std::unique_ptr is quite simple:

#include <memory>    // We need this if we want to use smart pointers

void foo()
{
    // Creates a unique_ptr called p and points it to a newly-allocated int.
    // p holds unique ownership of that int.
    std::unique_ptr<int> p{new int};

    // We can dereference the pointer with the * operator, just as you'd
    // expect with a raw pointer.
    *p = 3;
    std::cout << *p << std::endl;

    // When p falls out of scope -- either because the function ends normally
    // or because the function threw an exception -- it will destroy the
    // int it points to automatically, so no need for delete!  Similarly, if
    // we re-point p to a new integer later, or we point it to null, a
    // deletion will also be triggered.
}

Given that we have std::unique_ptr in our arsenal now, let's think about that A class again, but using a std::unique_ptr in place of the raw pointer:

#include <memory>

class A
{
public:
    A();
    ~A();

    // other member functions would go here

private:
    std::unique_ptr<B> b;
    // other member variables would go here
};


A::A()
    : b{new B}, ... // other member variable initializers would go here
{
    // other code to be run after the initializers would go here
}


A::~A()
{
    // necessary cleanup other than deleting the object pointed to by b
    // would go here, while there is no need to clean up anything relating to
    // b, because that will be done automatically; if there is no other
    // code needed in the destructor, we wouldn't even need to write one!
}

The problems we had previously with exceptions would be handled automatically; for example, if A's constructor failed after constructing a new B and pointing b to it, b would still be guaranteed to be destroyed, and that would trigger the automatic deletion of the B object.

It's certainly true that we would still need to worry about a copy constructor and an assignment operator here, since there is still the problem that unique ownership implies that every A needs its own B object, but even those will be simpler to write, because if they fail partway through and we're using smart pointers to point to those B's, they'll be cleaned up automatically even in cases when exceptions are thrown.

One important thing to note is that the unique ownership properties of a std::unique_ptr makes it unsafe to copy one. For example, imagine you had these two functions:

void foo(std::unique_ptr<int> q)
{
    *q = 4;
}

void bar()
{
    std::unique_ptr<int> p{new int};
    *p = 3;
    foo(p);
    std::cout << *p << std::endl;
}

You might first conclude that the output you'd see if you called bar() would be 4. But look at it more closely, and consider what's really happening here:

The moral of this story is actually that copying a std::unique_ptr is simply not allowed; its copy constructor and assignment operator have been deleted, so that you can't call them. The reason is simple: If it was allowed, it would be 100% certain to cause problems.

In short, a std::unique_ptr applies some of the principles of RAII — most notably, the automation of cleanup in a destructor — to pointers. This is a potentially handy tool, indeed, but obviously not all pointers imply unique ownership, so there's still more to the story.

Shared ownership and std::shared_ptr

In other cases, what you actually want is for ownership to be shared by many pointers, as opposed to in a single one, so that as long as any one of those pointers still points to the object, the object will continue to exist, but as soon as the last one is destroyed, the object will be destroyed automatically. For example, you might store an object in one of several different data structures (depending on the situation), and as long as it's in any one of those data structures, you still want the object to exist. This is what we call shared ownership, and the C++ Standard Library provides a class template std::shared_ptr that implements it.

A quick example follows:

void foo()
{
    std::shared_ptr<int> p1{new int};     // p1 points to a new integer
    std::shared_ptr<int> p2 = p1;         // p2 points to the same integer
    *p1 = 4;                              // 4 is stored in that integer
    std::cout << *p2 << std::endl;        // prints 4
    p1 = nullptr;                         // p1 no longer points to the integer, but p2 still does
}                                         // when the function ends, the integer is destroyed

Notice in the example above that, unlike std::unique_ptrs, std::shared_ptrs can be copied safely. This is because they share ownership of the objects they point to, so the death of any one of them doesn't cause the owned object to die unless no one else is left to share that ownership.

When first confronted with an idea like a std::shared_ptr, it seems like a form of magic that will simply make all of your memory management problems disappear forever. However, it's important to note that there is still a fair amount of caution required. For example, in a tree implementation in which every node points to its children and its parent, if every one of those pointers was a std::shared_ptr, even if you no longer had a pointer pointing to any of the nodes, the nodes would still be pointing to each other, so none of them would ever be destroyed, leading to a potentially large memory leak.

Accessing objects without the implication of ownership

Even when you have a dynamically-allocated object, it's not always the case that all pointers to objects imply some kind of ownership. Sometimes you want to "loan" an object to a function by, say, passing it to the function as a parameter so that the function can use it, but with no expectation that the function will clean it up, since you still want it to live afterward. Other times, you might want a data structure that is cyclic, such as a tree in which every node points to its children and its parent, in which case not every pointer can imply ownership or there will be problems with things being deleted more than once or not at all.

The question is what to do in cases like these. While, as usual, there is nuance involved in thinking these things through, there are some guidelines that can help.


Additional, in-depth information

For a more complete explanation of how the various smart pointers in C++11 work, you might also want to check out the very nicely-written paper linked below. The paper goes into more depth than we'll likely need in this course, but it's a good place to go if you want to see some of the details that I haven't covered here.