What is shared pointer up to?

In the last section, we saw that std::shared_ptr will hang on to our destructor for us (and even let us provide our own), but we haven’t gone over how yet. So lets do that and also go over what else is hiding in a std::shared_ptr.

#include <string>
#include <memory>
#include <iostream>

struct Y
{
    Y(int32_t in) : local(in) { std::cout << "ctor y - " << local << std::endl; }
    ~Y() { std::cout << "dtor y - " << local << std::endl; }
    
    int32_t local;
};

struct X
{
    X(int32_t value) : _y{value} { std::cout << "ctor x - " << _y.local << std::endl; }
    ~X() { std::cout << "dtor x - " << _y.local << std::endl; }
    
    Y _y;
};

In the above section, we define two types X and Y. X happens to have an instance of Y locally, but aside from that, nothing else of interest in going on. Just some logging so we can tell when we enter the ctors and dtors.

If you don’t already know this, shared_ptr has a reference counter that keeps track of how many instances of shared_ptr are in use. What makes this a smart pointer is that it knows to clean up after itself when there are no more references to this object. Which is neat. Lets see what that looks like via code:

So lets use:

{
    auto x1 = std::make_shared<X>(123);
    std::cout << "shared pointer ref count initially: " << x1.use_count() << std::endl;
    auto x2 = x1;
    std::cout << "x1 is the same as x2: " << (((bool) (x1 == x2)) ? "yup" : "nope") << std::endl;
    std::cout << "shared pointer ref count after first copy: " << x1.use_count() << std::endl;

    {
        auto x3 = x2;
        auto x4 = x3;
        std::cout << "shared pointer ref count inside sub scope: " << x1.use_count() << std::endl;
    }

    std::cout << "shared pointer ref count towards the end: " << x1.use_count() << std::endl;
}
ctor y - 123
ctor x - 123
shared pointer ref count initially: 1
x1 is the same as x2: yup
shared pointer ref count after first copy: 2
shared pointer ref count inside sub scope: 4
shared pointer ref count towards the end: 2
dtor x - 123
dtor y - 123

So, it seems like we start off at 1. Then go up to 2 when we do auto x2 = x1;. Then go up to 4 in the mini scope. Once we get out of the scope, all the objects in there get deleted, so we see the ref count go back down to 2. It eventually goes down to 0 at the end and cleans up after itself.

Lets inpect this a bit more though. How big is our object anyway?

{
    auto x1 = std::make_shared<X>(123);
    
    std::cout << "Size of just the X object by itself: " << sizeof(X{123}) << " bits" << std::endl;
    std::cout << "Size of shared_ptr<X>: " << sizeof(x1) << " bits" << std::endl;
    std::cout << "Size of a pointer: " << sizeof(void*) << " bits" << std::endl;
}
ctor y - 123
ctor x - 123
Size of just the X object by itself: 4 bits
Size of shared_ptr<X>: 16 bits
Size of a pointer: 8 bits
dtor x - 123
dtor y - 123

So that’s a bit interesting, isn’t it? Our std::shared_ptr<X> is much bigger than just an instance of X (16 bits vs 4 bits). Seems like we are using a lot more space here. So what is being used here? We can see that a pointer uses 8 bits.

We know that a std::unique_ptr is just a single pointer.

It turns out that a std::shared_ptr has 2 pointers instead. Why the heck does it need 2 pointers? Well, lets go over what the two pointers are for first:

  • The stored pointer

  • A pointer to a control block

There are no surprised with the stored pointer, but what’s a control block you might ask. The control block looks something like this: Control block:

  • reference counter

  • weak counter

  • custom alloc / deleter

The reason it is laid out this way is because a std::shared_ptr can actually point to something other than what it owns (alias) and tie its lifetime to that. Lets look at a normal example first:

{
    std::shared_ptr<X> p{new X{456}};

    std::cout << "local block -- start" << std::endl;
    {
        std::shared_ptr<Y> q(new Y{123});
    }
    std::cout << "local block -- end" << std::endl;

}
ctor y - 456
ctor x - 456
local block -- start
ctor y - 123
dtor y - 123
local block -- end
dtor x - 456
dtor y - 456

The above code shows us nothing suprising. If we create an object in a local scope, it gets cleaned up once that scope completes. Hence we see 123 go in and out of existance immediately while 456 lives longer. But this can be wonky if we tie lifetimes together. Let me show you what I mean:

{
    std::shared_ptr<X> p{new X{456}};

    std::cout << "local block -- start" << std::endl;
    {
        std::shared_ptr<Y> q(p, &(p->_y));
    }
    std::cout << "local block -- end" << std::endl;
}
ctor y - 456
ctor x - 456
local block -- start
local block -- end
dtor x - 456
dtor y - 456

In the previous case, we saw 123 immediately go out of existance. But not here. We don’t see anything logged here. Isn’t that neat?