std::make_shared vs. the Normal std::shared_ptr Constructor

Contents

There are two different ways to create a std::shared_ptr: via one of its constructors and via std::make_shared. Both have their merits and different tradeoffs.

First of all I’d like to thank my colleague Stefan Asbeck for a chat session where we brainstormed about the different aspects I’ll go into. Stefan is a software engineer at the Zühlke office in Munich.

shared_ptr and weak_ptr: a short overview

Let’s quickly recap how std::shared_ptr works: The underlying feature of shared_ptr is a reference count. When we copy a shared_ptr, the count increases. When a shared_ptr gets destroyed, the count decreases. When the count reaches zero, there are no more shared_ptrs to the object and the object gets destroyed.

std::weak_ptr is the companion of shared_ptr: it does not own the object, so it does not contribute to the reference count. It does not contain a pointer to the object itself, because that may become invalid after the object has been destroyed. Instead, there is another pointer to the object alongside the reference count.

weak_ptr refers to the reference count structure and can be converted to a shared_ptr if the count is not zero, i.e. the object still exists. For reasons we’ll see in a second, there has to be another counter for the number of weak_ptrs.

shared_ptr is non-intrusive, which means the count is not stored inside the object itself. This, in turn, means that the count has to be stored somewhere else, on the heap. When a shared_ptr is constructed from an existing pointer that is not another shared_ptr, the memory for the count structure has to be allocated.

The structure has to live as long as there are any shared_ptrs or weak_ptrs left, which may well be after the object has been destroyed. Therefore, the number of weak_ptrs needs to be counted as well.

Conceptually, we can think of the situation like this (the actual implementation details can differ):
graph showing weak_ptr, shared_ptr, the count structure, and the pointee object

std::make_shared

With the above picture, when we create an object managed by shared_ptr, the naive approach takes two memory allocations:

auto* ptr = new MyObject{/*args*/};   //allocates memory for MyObject
std::shared_ptr<MyObject> shptr{ptr}; //allocates memory for the ref count structure

The situation is the same whether we create the shared_ptr from a raw pointer, from a unique_ptr, or by creating an empty shared_ptr and later resetting it with a raw pointer.
As you may know, memory allocations and deallocations are amongst the slowest single operations. For that reason, there’s a way to optimize this into one single allocation:

auto shptr = std::make_shared<MyObject>(/*args*/);

std::make_shared allocates the memory for the reference count structure and the object itself in one block. The object is then constructed by perfectly forwarding the arguments to its constructor:
graph showing weak_ptr, shared_ptr, and the pointee object inside the count structure

Pros and cons of make_shared vs. normal shared_ptr construction

As always in life, nothing comes for free. Using make_shared entails some tradeoffs we should be aware of.

Pro make_shared

The big advantage of make_shared is, of course, the reduced number of separate allocations. When the other tradeoffs are not an issue, this is the single reason why we should use make_shared as a default.

Another advantage is cache locality: With make_shared, the count structure and the object are located right beside each other. Actions that work with both the count structure and the object itself will have only half the number of cache misses. That being said, when cache misses are an issue, we might want to avoid working with single object pointers altogether.

Order of execution and exception safety is another issue that has to be kept in mind, at least before C++17. Imagine this piece of code:

struct A {
  int i;
};

void foo(std::shared_ptr<A>, double d);
double bar_might_throw();

int main() {
  foo(std::shared_ptr<A>(new A{22}),
      bar_might_throw());
}

There are three things that have to be done before foo can be called: constructing and allocating the A, constructing the shared_ptr, and calling bar_might_throw. C++17 introduced more restrictive rules for the evaluation order of function parameters. Before that, that sequence could have looked like this:

  1. new A
  2. call bar_might_throw()
  3. construct shared_ptr<A>

If step 2 throws, step 3 is never reached, no smart pointer takes ownership of the A pointer, and we have a memory leak. make_shared takes care of that issue.

Contra make_shared

One of the regularly encountered drawbacks with make_shared is that it needs access to the constructor it has to call. Making make_shared a friend of our class is not guaranteed to work – the actual constructor call may be done inside a helper function. One possible workaround to this problem is the passkey idiom. This is a bit clumsy and might not be worth the effort if a second allocation is not an issue.

Another problem might be the lifetime of the object storage (not the object itself). While the pointee object is destroyed when the last shared_ptr releases its ownership, the ref count structure needs to live on until the last weak_ptr is gone. When we use make_shared this includes the storage for the pointee object. When we deal with large objects and long-lived weak_ptrs, that can mean that a considerable amount of empty memory is needlessly locked.
graph showing weak_ptr, and the count structure with empty space from the pointee object

Conclusion

While std::make_shared is a good default for creating shared_ptrs, we have to be aware of the implications. Every best practice has its exceptions, there are no absolute rules.

Previous Post
Next Post

7 Comments


  1. I cannot see why it is necessary for a shared_ptr object to contain a pointer to the objects it refers to since the ref count structure already has a pointer to the same object.

    Reply



  2. What about creating a shared poiter from weak_ptr using Lock() function ?

    Reply

    1. When lock succeeds, it means the pointee object still lives, as well as the ref count object. There’s no memory allocation involved – it’s little more than copying two pointers.

      Reply

  3. There is a little known problem with make_shared: It will suppress warnings. The first of the following two lines will compile with no warning or error, while the second will fail as expected:

    auto zero1 = std::make_shared<uint8_t>(256);
    std::shared_ptr<uint8_t> zero2{new uint8_t{256}};

    Here is the code in Compiler Explorer:

    https://godbolt.org/z/rqbg1a

    The problem is that the narrowing happens inside the STL, where all warnings are disabled. This can be a problem for security critical code, and one could argue that make_shared and its siblings should be prohibited there.

    Reply

    1. I agree that this is can be a security issue. Warnings being disabled in the library implementation is implementation specific, though, meaning it might differ between compilers/library implementations.
      I think I vaguely remember a defect report stating that the standard should require brace initialization for the implementation of make_shared. I might be wrong though

      Reply

Leave a Reply

Your email address will not be published. Required fields are marked *