Aussie AI

Chapter 23. Smart Pointers

  • Book Excerpt from "Advanced C++ Memory Techniques: Efficiency and Safety"
  • by David Spuler, Ph.D.

Chapter 23. Smart Pointers

Overview of Smart Pointers

Smart pointers are a major addition to the C++ language in C++11. The three main types of smart pointers are:

  • std::unique_ptr — exclusive ownership of an object.
  • std::shared_ptr — reference-counted multiple owners.
  • std::weak_ptr — not an owner, but keeping an eye on things.

These classes are defined in the <memory> header file, and are templated by the type of the object. There was also std::auto_ptr in C++98, but that was deprecated in C++11 and finally removed in C++17.

The main features of the smart pointer library include:

  • Use the smart pointer like a raw pointer via the overloaded *, [] and -> operators.
  • Automatic deallocation of the object when the smart pointer disappears.
  • Destructors called for the underlying object.
  • Automatically chooses between delete for objects and delete[] for arrays.

And some of the advanced features include:

  • Thread-safety of the smart pointers library.
  • Custom deleters can be defined for other actions on smart pointer destruction.

But we’re getting ahead of ourselves there.

Basic Smart Pointer Usage

Here’s a couple of empty smart pointer declarations that are managing nothing yet:

    std::unique_ptr<Object> up1;
    std::shared_ptr<Object> sp1;
    std::weak_ptr<Object> wp1;

However, those declarations are smart pointers without a pointer! The normal scheme of things is to use the new operator in the initialization of the smart pointer:

    std::unique_ptr<Object> up1(new Object);

Here’s the key point: auto-deallocation! The destructor for the unique_ptr type will automatically call the delete operator on the object that was created with the new operator. In fact, it’s even smarter and will know whether to call delete or delete[], depending on whether you called new or new[] in the initializer.

Copying smart pointers. Note that you can “copy” a smart pointer in a constructor or an assignment. This not only copies the address being managed to the new shared pointer, but increases the reference count by one in the control block. But copying a unique pointer makes no sense, because it’s the exclusive owner. Hence, copy construction or copy assignment has these effects:

  • Shared pointer — increases the reference count with the copy now also managing the object.
  • Unique pointer — copying is explicitly disallowed via the “=delete” syntax!

Hence, the unique pointer class has deleted copy constructor and copy assignment declarations. You can only move a unique pointer, not copy it.

Moving smart pointers. There are move constructors and move assignment operators for all types of smart pointer objects. The smart pointer being moved to will first unmanage its object, if any, which could call its destructor (i.e., always for a unique pointer, or based on the reference count for shared pointers, but never for a weak pointer). The details of the underlying object are then moved to the left operand smart pointer, and the smart pointer on the right becomes an empty smart pointer (managing nothing).

Take care with choosing between copying and moving, which have very different semantics for shared pointers, and copying is disallowed for unique pointers. Use of the std::move() type cast is helpful to ensure move semantics.

Details of auto-deallocation. The idea of auto-deallocation is to avoid memory leaks. It also allows smart pointers to embody the RAII idiom, provided your initializer does allocate the object.

The call to delete is made in the destructor of the smart pointer object, if it hasn’t already been destroyed. So, there are a few ways for the destructor to run:

  • The destructor at the end of scope.
  • The reset() member function to deallocate earlier.

Note that the deallocation differs for unique pointers with exclusive object control versus shared pointers with non-exclusive control:

  • Unique pointers — always deallocates its fully-controlled object.
  • Shared pointers — only deallocated when the reference count says it’s the last shared pointer managing this object.

Note the exceptions to deallocation in the destructor:

  • Weak pointers don’t deallocate.
  • You can define “custom deleters” in the declaration of a smart pointer.

Hence, technically, you can override the calls to delete in the destructor. The idea is mainly when using custom allocators such as memory pools (as an optimization), but it means you can workaround some limitations if you really need to. For example, you could define a “do-nothing” deleter to manage addresses of stack or global objects. Or you could define a custom deleter that calls free() if you want to manage malloc() blocks.

Pitfalls. There is no magic whereby the unique_ptr object knows that new was called in its initializer. It just assumes that you’ve given it an allocated pointer to manage. Hence, you can do this:

    Object *objptr = new Object;
    std::unique_ptr<Object> up1(objptr);  // Dangerous

This will also be auto-deallocated correctly. But you have to be careful that the scope of objptr does not outlast the up1 smart pointer object. Because objptr will point to an already-deallocated pointer after the destructor for up1 runs. Here’s an example:

    Object *objptr = new Object;
    {
       std::unique_ptr<Object> up1(objptr);  // Dangerous
    } // Destructor calls delete
    // ....
    obj->my_method();  // Kaboom!

There are also various other pitfalls whereby you can get a double-delete error on a pointer managed by a smart pointer:

  • Declaring two smart pointers on the same raw pointer.
  • Calling delete on your raw pointer (before or after the smart pointer destructor).

There also pitfalls whereby delete is called on the wrong type of address:

  • Using smart pointers with a stack or global/static address.
  • Using malloc addresses with smart pointers.

Note that the nullptr is not a crash with the smart pointer classes. It simply means an “empty” smart pointer that isn’t managing anything yet.

These memory address problems don’t arise with std::weak_ptr, which doesn’t ever do any deallocation of the managed pointer. Also, if you were desperate, you could use a custom deleter that doesn’t call delete. But the main defence is to stick to the idiom whereby the new operator is expressly called in the initializer of your smart pointer.

Pointer Templating. Note that templating with a pointer type “Object*” rather than “Object” is a misunderstanding, and will get a compilation error for this declaration:

        std::unique_ptr<Object*> up1(new Object);   // Compiler error!

You can use that type of templating if you’re really wanting your smart pointers to manage other raw pointers, but why would you want to? Anyway, this would compile:

        std::unique_ptr<Object*> up1(new Object*);   // Strange!

Weak Pointers

Weak pointers are used much less than unique or shared pointers. They are “observers” that do not change the lifetime of the managed object. In fact, the object can be destroyed or deallocated before the weak pointer has finished watching. The main features of weak pointers:

  • Observer idiom without control.
  • Does not destroy the object ever.
  • Can be “upgraded” to a shared pointer.

Weak pointers cannot be initialized with a raw pointer. They can only be initialized with nothing (or nullptr), or via another shared pointer.

Weak pointers can stop watching in two ways: they can go out-of-scope, in which case their destructor reduces the reference count (of weak pointers) on any object they’ve been watching. Or you can expressly call the reset() member function for an early release, which releases the object, and causes the weak pointer object to be empty thereafter.

One misunderstanding about weak pointers is that you might have read something that implies the objects managed by shared pointers are not deallocated if the number of weak pointers is not zero. And yes, there’s a separate reference count of weak pointers that’s used in a shared pointer. However, this is only that the control block is not deallocated until the weak pointer reference count is zero (and also the shared pointer reference count). This is an internal allocated block that contains the reference counters and other stuff. As mentioned above, the object being managed by a shared pointer is destroyed when zero shared pointers are managing it (ignoring the weak pointer reference count), so a weak pointer can be pointing to a deallocated object if all the shared pointers have disappeared. There is the expired() member function to test whether the weak pointer still has a valid non-destroyed object.

Limitations of Smart Pointers

The smart pointer library has many advanced features, but there are still some things you cannot do. Some of the limitations of smart pointers include:

  • Only work with heap pointers via new — not addresses of stack objects or global objects.
  • Cannot be used with old-style malloc or calloc objects.
  • Weak pointers cannot deallocate the pointer — whereas unique pointers and shared pointers do deallocate (and must!).
  • No way to avoid deallocation in shared pointers — you can deallocate early with reset(), but cannot specify that a shared pointer destructor shouldn’t do so (except by adding a custom “do-nothing” deleter at the declaration of the smart pointer, and unique pointers have the release() function).
  • Smart pointers don’t know about anything you do with its raw pointer — e.g., if you extract the raw pointer using the get() method.

Okay, so I’m wrong. I’ve written that you cannot do these things, but really you can work around most of these by defining a custom deleter. Your custom deleter might simply do nothing, rather than deallocating memory. If you really had to use malloc addresses, your custom deleter could call free.

Note that there’s a reason that shared_ptr does not have a release() function, whereas unique_ptr does. The idea with shared pointers is that they are reference counted and a sharing ownership of an item. Hence, it makes less sense for a shared pointer to “release” an object to the wild, whereas unique pointer is the only manager of an object.

Furthermore, the last point about the shared pointer not knowing what you’re doing if you call get(), this means that if you use a shared or unique pointer, it will 100% be deallocated. You cannot return your pointer to the wild, except that, again, you could use a do-nothing custom deleter.

Smart Pointer Safety

The proper use of smart pointers can significantly improve the safety of pointer-related code. Some of the errors avoided include:

  • Wild pointer addresses — the smart pointer object is safe within its scope.
  • Memory leaks — instead, delete is automatically called.

Best practices for using smart pointers for safety include:

  • Choose carefully between unique pointers and shared pointers (occasionally also weak pointers).
  • Initialize smart pointers using new directly as the initializer (rather than an already-allocated raw pointer).
  • Use smart pointers with function-local scope (i.e., stack variables, not global or static smart pointer objects).
  • Use scope of the smart pointer to control when delete is called.

Some problematic styles to avoid with smart pointers include:

  • Avoid using the raw pointer via get() by using * and -> operators on the smart pointer object itself.
  • Avoid using smart pointers on raw pointers that already exit (i.e., prefer to allocate the objects in the smart pointer’s initializer).
  • Avoid allocating the smart pointer objects themselves via new (it gets very confusing!).

Smart Pointer Inefficiencies

Generally, smart pointers focus more on safety than speed, so that add some inefficiency to your code. However, they are relatively efficient with a limited amount of extra overhead for each smart pointer:

  • The smart pointer object itself, and
  • A “control block” with details about the managed object.

The control block is an internal data structure, which is not explicitly part of the smart pointer object (by default). The contents of the control block include:

  • Address of managed object (i.e., the raw pointer).
  • Reference count of shared pointers to the object.
  • Reference count of weak pointers to the object.
  • Deleter to be used (e.g., by default, it’s automatically chosen as the delete or delete[] operator).

Smart Pointer Optimizations

What can you do to reduce the inefficiencies of smart pointers? I mean, other than going back to the use of raw pointers, which is not ideal. Using “dumb pointers” would lose all the safety advantages of smart pointers.

Some of the optimizations include:

  • Avoid two separate objects per smart pointer (with the extra control block).
  • Minimize the scope of the smart pointer.
  • Call reset() to deallocate the memory for the object earlier.

Making Smart Pointers. By default, smart pointers have two separate objects: the smart pointer object itself, and an internal allocated object called the “control block.” One of the main ways to optimize smart pointer objects is to merge the smart pointer object with its control block. The way to do this is by calling either of the “make” methods for smart pointers:

  • std::make_unique() (C++14)
  • std::make_shared() (C++11)

Both of these standard functions create a single object with both the smart pointer and its control block. Note that there’s no “make_weak()” version of these functions.

Smart Pointer Bugs

Although the idea of smart pointers is to prevent common problems with raw pointers, such as wild pointers or memory leaks, there are also some new types of bugs that can occur due to misuses of smart pointer objects. Some of the possible bugs include:

  • Templating the smart pointer classes with a pointer type — usually a compilation error.
  • Smart pointer leaks — if you lose track of your smart pointers, their destructors never run, and the underlying objects are never cleaned up either.
  • Using the delete operator on a raw pointer used with a smart pointer — it’s also double-deallocated by the smart pointer’s destructor.
  • Creating two smart pointers from the same raw pointer — also causes a double delete memory error, since both destructors run.
  • Accessing the raw pointer from a smart pointer via get() — very risky, allowing various raw pointer problems.
  • Using smart pointers with malloc() blocks — this causes delete on a malloc() block (a bad error).
  • Using a non-heap pointer to initialize a smart pointer — will cause delete on a stack or static address in the destructor (crashing).
  • Weak pointer refers to an object that has “expired” (i.e., been destroyed) — the last shared_ptr or the single unique_ptr has already deallocated the object via reset() or its destructor, although this can be avoided by always testing the weak_ptr::expired() member function.

Note that a weak pointer does not ever deallocate the object, but only “observes” the object (or “refers” to it). Only unique pointers and shared pointers can actually delete the object from memory. Note also that you can also define “custom deleters” for your smart pointers, if you really need to avoid some of these problems.

Fortunately, there are quite a few bugs that smart pointers avoid. For example, the smart pointer destructor should automatically know whether to use delete or delete[], depending on whether it was initialized by a simple object pointer or an array type. This is important, because a call of delete on a new[] block will not properly run the destructors of all objects in the array.

 

Online: Table of Contents

PDF: Free PDF book download

Buy: Advanced C++ Memory Techniques: Efficiency and Safety

Advanced C++ Memory Techniques Advanced C++ Memory Techniques: Efficiency & Safety:
  • Memory optimization techniques
  • Memory-efficient data structures
  • DIY memory safety techniques
  • Intercepting memory primitives
  • Preventive memory safety
  • Memory reduction optimizations

Get your copy from Amazon: Advanced C++ Memory Techniques