A Wild West Tour of Smart Pointers - I

Reading time ~21 minutes

Overview

Smart Pointers are a construct in C++ to aid programmers in tackling memory management. Beyond motivation and basics, the aim of this post is to understand potential use cases, performance tradeoffs and standard protocols to follow for each smart pointer type.
The first part introduces smart pointers and discusses single ownership constructs in detail. If you have some background, feel free to jump to the topic of interest from the table of contents below.


Motivation: System vs. Garbage

As depicted in the historical picture, there is an ever-ongoing conflict between the systems written by programmers and the garbage they generate. This is especially true for C++ which has given the reins of memory management to the programmers with the promise of efficiency in return. Failure to correctly do so leads to either memory leaks or dangling references. As a result, in the pre-smart pointer era, C++ programs looked something like the image on the left:

Modern C++(image on right) via smart pointers allows us to get rid of the complete Cleanup step. So programmers can now allocate necessary dynamic objects and operate with them without having to worry about cleaning them up. The best part of this is that these smart pointers have minimum(zero in many cases) overhead of performing the cleanup.


Categorization: The Taxonomy of Pointers

  1. Single Owner: A given object has ONLY one owner. Once the owner goes out of scope the object will be deleted. We will discuss: boost::scoped_ptr, std::unique_ptr.
  2. Multiple Owner: A single object having sharing/multiple owners. Once all the owners go out of scope the object will be deleted. A Control Block is the logical component of such a smart pointer which helps determine when all the owners have gone out of scope so the object can be cleaned. This can be further categorized into:
    1. Intrusive Control Block: The control block is inside the object pointed to. We will discuss boost::intrusive_ptr.
    2. Non Intrusive Control Block: The control block is external to the object pointed to. It can be laid side by side(cache-friendly) with the object or at a completely different location. We will discuss std::shared_ptr.

Which to Choose?

Surely, you must be scratching your head about which is the right one to choose. Fortunately, Herb Sutter gave an excellent talk on this topic “Leak Freedom in C++ by Default” accompanied with a poster shown below:

I’ve augmented the poster with two additional pointer types not covered in the talk at the preference order where I feel they appropriately belong. In summary the order would be:
locals/members > scoped pointer > unique pointer > shared_ptr ~= intrusive_ptr
A few comments on the above ordering:

  • On paper, Intrusive pointers seem like they should be preferred. But its usage involves the inconvenience of having to define the embedded refcount and doing so correctly. This it is not ideal/possible to use to use it in many instances.
  • If unsure about whether to go for single or shared ownership, always start with unique_ptr. If the situation for shared ownership comes up, directly replacing unique_ptr->shared_ptr should automatically work in most cases.

As a matter of convenience in the future, I’ll be using a sample class:

class WildWestGang{
    // Note, that std::cout's will be disabled during performance testing.
    public:
        explicit WildWestGang(std::vector<std::string> members): m_members(members) { 
          std::cout << "Birth of the Gang!" << std::endl;
        };
        void Print() {
            for(const auto& mem: m_members) {
                std::cout << mem << " ";
            }
            std::cout << std::endl;
        }
        ~WildWestGang() { std::cout << "Gang is dead!" << std::endl; }
    private:
        std::vector<std::string> m_members;
};

Scoped Pointers

Scoped Pointers are meant for Single Fixed Ownership - an object can only have a single owner which cannot be changed. As soon as this owner goes out of scope, its Game Over for both you and the object you owned! Let’s see an example(Try Online):

auto my_gang = new WildWestGang({"Dutch", "Arthur", "John"}); // Birth of the Gang!
{
    auto p1 = scoped_ptr<WildWestGang>(my_gang);
    p1->Print(); // Dutch Arthur John
} // Gang is dead!
std::cout << "The End" << std::endl;

Now let’s try to change ownership - We can try either copying or moving it(Try Online):

auto p = scoped_ptr<WildWestGang>(new WildWestGang({"Dutch", "Arthur", "John"}));
p->Print(); // Dutch Arthur John 
auto p2 = p; // error
auto p3 = std::move(p); // error

The error message depends on the scoped_ptr variant - for example with boost::scoped_ptr you would get something like error: scoped_ptr(scoped_ptr const &); delared private here.
That said, while the owner can’t be changed, the object owned/pointed to can be after deletion of the original object. Let’s see an example(Try Online):

{
    auto p = scoped_ptr<WildWestGang>(new WildWestGang({"Dutch", "Arthur", "John"})); // Birth of...
    std::cout << "p currently owns the Wild West Gang: " << std::endl;
    p->Print(); // Dutch Arthur John
    p.reset(new WildWestGang({"Micah", "Bill", "Javier"})); // delete original, point to new
    std::cout << "p now owns the Wild West Gang: " << std::endl;
    p->Print(); // Micah Bill Javier
} // Gang is dead!

Let’s go over some of its properties now:

  1. General Use Case: Stating intent that there is a single owner with no intention to change the owner.(For more, read here).
  2. Performance(size): See below for how a scoped pointer physically looks like(with no custom deleters - we’ll come back to this in the unique_ptr discussion). There are no extra data members introduced on the end of scoped object, which means that no additional overhead is incurred in terms of size. In case its unclear why the addition of the extra scoped pointer logic/methods is zero size overhead, you can refer to determining size of a class and why methods dont add to size of a class:

    And here is a simple sizeof output(Try Online):

    auto my_gang = new WildWestGang({"Dutch", "Arthur", "John"});
    std::cout << sizeof(my_gang) << std::endl; // 8
    auto p = scoped_ptr<WildWestGang>(my_gang);
    std::cout << sizeof(p) << std::endl; // 8
    
  3. Performance(speed): It is a Zero Overhead Abstraction. As long as there are no custom deleters involved, the performance in terms of speed is equal to that of a raw pointer. Here is the Quick-Bench output for Efficiency(Try Online):
  4. Thread Safety: There are 2 aspects to consider here:
    1. Thread safety when accessing the owned object: A scoped_ptr can be thought of as similar to a raw pointer - the dereferencing operators dont add additional mutexes or thread safe mechanisms. Thus access to the owned object will not be thread safe if the same object owned by a scoped_ptr is written to by multiple threads.
    2. Thread safety when writing to the scoped_ptr: i.e. the reset() call is also not thread safe. i.e. if the same scoped_ptr is written to by different threads, it is undefined behavior.
  5. Passing them around: The diagram below represents the different ways of passing around scoped pointers:

    Note that the Factory will not work for scoped pointers unless we use C++17 since we need guaranteed copy elision. Also note that Reseat also destroys the originally pointed to object. Here are some sample functions to help explain the above choices(Try Online):

    scoped_ptr<WildWestGang> createWildWestGang() {
      return scoped_ptr<WildWestGang>(new WildWestGang({"Dutch", "Arthur", "John"}));
    }
    void useWildWestGang(WildWestGang& g) {
      g.Print();
    }
    void reseatWildWestGang(scoped_ptr<WildWestGang>& gang_ptr) {
      gang_ptr.reset(new WildWestGang({"Bill", "Lenny", "Charles"}));
    }
    

    There could be alternative ways to pass around scoped pointers, but the methods mentioned here adhere strongly to the CPP Guidelines.

  6. Standard Variants: boost::scoped_ptr(pointer type), const std::unique_ptr(pointer/dynamic array type) and boost::scoped_array(dynamic array type). I’ve discussed the dynamic array vs. pointer type later. Here is how const std::unique_ptr can be correctly applied as a scoped_ptr:
    template<typename T>
    using scoped_ptr = const std::unique_ptr<T>;
    

    If you have the freedom to use boost::scoped_ptr, I would recommend preferring it as the unique_ptr variant needs to be correctly qualified with const or aliased.

Note: Well, there is one way to transfer ownership - the use of the scoped_ptr<T>.swap(scoped_ptr<T> &) member function which swaps the contents of two scoped pointers. So essentially a barter system. That said, the rationale of using scoped pointers to denote Single Fixed Ownership still applies.


Unique Pointers

A unique_ptr follows the norm of Single Fixed Ownership: Yes! I’ve cut the Fixed. It is only guaranteed that the object has a single owner. We don’t care who the owner is and can transfer the ownership as need be. However when the current owner goes out of scope, the object will be deleted. Essentially, it is a scoped pointer with three additional properties:

  1. Ownership can be transferred using std::move.
  2. A Custom deleter can be allocated. We will understand custom deleters in detail in another section.
  3. A convenience function std::make_unique<Obj>(params) to avoid the repeating Obj in smart_ptr<Obj>(new Obj(params)). This is purely a cosmetic change with no performance implications.

Here, is a code snippet to summarize the above(Try Online):

auto names = std::vector<std::string>{"Arthur", "Dutch", "John"};
{ // Demonstrate automatic destruction(similar to scoped_ptr)
  auto p = std::unique_ptr<WildWestGang>(new WildWestGang(names)); // constructor
  p->Print(); // Arthur Dutch John
} // automatic destructor call
{ // New unique_ptr features
  auto p = std::make_unique<WildWestGang>(names); // shorter std::make_unique. Yay!
  p->Print(); // Arthur Dutch John
  auto p2 = std::move(p); // Transfer ownership by move ONLY!
  p2->Print();  // Arthur Dutch John
  std::cout << "Is p NULL: " << (p == nullptr) << std::endl; // Yep!
} // automatic destructor call on p2

Now, let’s have a look at its properties:

  1. General Use Case: Single Transferrable Ownership
  2. Performance(size): Below is a diagram showing the physical layout of a unique_ptr.

    As can be seen, without a custom deleter, the unique_pointer physically looks exactly like a scoped_ptr. So as expected:

    std::cout << sizeof(int*) << std::endl;                 // 8
    std::cout << sizeof(std::unique_ptr<int>) << std::endl; // 8
    
  3. Performance(speed):It is a Zero Overhead Abstraction. As long as there are no custom deleters involved, the performance in terms of speed is equal to that of a raw pointer. Here is the Quick-Bench output for Efficiency(Try Online):
  4. Thread Safety: Same rules as scoped_ptr - Refer here. The std::move ownership transfer op is also not thread safe.
  5. Passing uniq_ptr around: The diagram below represents the different ways of passing around unique pointers as provided by the CPP Guidelines:

    Here is sample code demonstrating the same(Try Online):

    std::unique_ptr<WildWestGang> createWildWestGang() {
     auto names = std::vector<std::string>{"Dutch", "Arthur", "John"};
     return std::make_unique<WildWestGang>(names);
    }
    void useWildWestGang(WildWestGang& g) {
     g.Print();
    }
    void reseatWildWestGang(std::unique_ptr<WildWestGang>& gang_ptr) {
     gang_ptr.reset(new WildWestGang({"Bill", "Lenny", "Charles"}));
    }
    void sink(std::unique_ptr<WildWestGang> gang_ptr) {
     // do additional cleanup ops here
     std::cout << "Time for cleanup!" << std::endl;
     gang_ptr.reset();
    }
    
  6. Object variants: std::unique_ptr can be applied to either raw pointer(T*) or dynamic array(T[]). The cool part of this is that the set of operators supported for both are different appropriate to the type used. For example, dereference(->) op isnt provided for arrays and index access([]) op isnt provided for pointer types. Here is sample code:(Try Online):
      auto smart_ptr = std::make_unique<int>(5);
      std::cout << *smart_ptr << std::endl; // 5
      auto smart_array = std::make_unique<int[]>(5);
      std::cout << smart_array[0] << std::endl; // 0
    
  7. Custom Deleters: Yes, we are allowed to supply a custom deleter for the owned object. The std::unique_ptr type template actually looks like std::unique_ptr<T,D=std::default_delete> so the custom deleter defaults to calling delete obj. But if needed we can create a custom deleter variant too. Its important to note though that depending on the custom deleter variant used, the “zero” cost abstraction no longer stays applicable since the custom deleter occupies some size. First a visualization for how these unique_ptr variants look like:

    Let’s assume we have the following utility functions(Try Online!):

    template <typename T, typename D>
    auto create_custom_deleter(T val, D deleter) {
     std::cout << "creating ptr with value " << val << std::endl;
     return std::unique_ptr<T, decltype(deleter)>(new T(val), deleter);
    }
    
    • Captureless Lambda: The reason this ends up occupying the same size as a regular unique_ptr is Empty Base Optimization explained in the context of unique pointers in this stackoverflow answer in reasonable detail.
      {
       auto captureless_lambda_deleter = [](int *ptr) {
        std::cout << "Killing ptr with value " << *ptr << std::endl;
        delete ptr;
       };
       auto ptr = create_custom_deleter<int, decltype(captureless_lambda_deleter)>(5, captureless_lambda_deleter);
       std::cout << sizeof(ptr) << " " << sizeof(captureless_lambda_deleter) << std::endl; // 8
      } // "Killing ptr with value 5"
      
    • Captured Lambda: The final size of the unique_ptr depends on what’s captured.
      {
       auto v = std::vector<int>{1, 2, 3, 4, 5};
       auto capturefull_lambda_deleter = [v](int *ptr) {
        std::cout << "Scaled ptr value: " << (*ptr + v.size()) << std::endl;
        delete ptr;
       };
       auto ptr = create_custom_deleter<int, decltype(capturefull_lambda_deleter)>(5, capturefull_lambda_deleter);
       std::cout << sizeof(ptr) << " " <<  sizeof(capturefull_lambda_deleter) << " " << sizeof(v) << std::endl; // 8 bytes(raw ptr size) + 24 bytes(v)
      } // Scaled ptr value: 10
      
    • Function Pointer: == Size of Two pointers
      {
       auto* fn = +[](int *ptr) { // function pointer
        std::cout << "Killing ptr with value " << *ptr << std::endl;
        delete ptr;
       };
       auto ptr = create_custom_deleter<int, decltype(fn)>(5, fn);
       std::cout << sizeof(ptr) << std::endl; // 16(2 pointers)
      }
      

That concludes the discussion of both unique_ptr and this post. We’ll move on to multi-owner constructs in the next post.

A Wild West Tour of Smart Pointers - II

Sample Smart Pointer Implementation, Shared Pointer Brainstorming, Non-Intrusive and Intrusive shared Pointers including Performance and Microbenchmarks, Thread-Safety, Passing them around and more, References and Further Reading. Continue reading

C++ Lambdas In-Depth

Published on November 04, 2018

Apache Spark: A Learning Path

Published on June 30, 2017