shared_ptr initialized with nullptr is null or empty?

Question | Apr 6, 2020 | hkumar 

Null vs. Empty shared_ptr

Initializing a shared_ptr with nullptr is as straightforward as anyone would expect it to be. There is absolutely no difference between the two shared_ptr instances shown below. Although they are created with different constructors, they are both holding nullptr, and therefore, both of them can be treated as null pointers:

//Uses default constructor: shared_ptr(); 
std::shared_ptr<int> p1;
//Uses constructor: shared_ptr(std::nullptr_t);
std::shared_ptr<int> p2(nullptr);

//They are both null
std::cout << std::boolalpha << !p1 
       << " " << !p2 << "\n"; //true true

And, the below shared_ptr initialized with a nullptr of the exact type (int*) also holds a nullptr. This shared_ptr is created using a different constructor, and it is same as p1 and p2 for the most apparent purposes:

//Uses constructor: template<class Y>
//                  explicit shared_ptr(Y* ptr);
int* iptr{nullptr};
std::shared_ptr<int> p3(iptr);
//null check
std::cout << std::boolalpha << !p3 
          << "\n"; //true

However, p3 is different from p1 and p2. Both p1 and p2 are null, but they are empty too because they don't have any control block associated with them. On the other hand, p3 is null but not empty because it has a control block with a managed nullptr and a reference count of 1. In brief, a control block is a data structure through which several shared_ptr instances share ownership of a managed object. Simply speaking, a control block keeps a pointer to the managed object and a reference counter, among other bookkeeping data. The nextptr article "shared_ptr - basics and internals with examples" has in-depth coverage of the control block.

It is easy to verify the reference count of these shared_ptr instances:

//ref count: 0
std::cout << p1.use_count() << "\n"; //0
//ref count: 0
std::cout << p2.use_count() << "\n"; //0
//ref count: 1
std::cout << p3.use_count() << "\n"; //1

Here is a pictorial representation of the memory layout of all the three shared_ptr instances:


null empty and non-empty shared_ptr

So, what happens when we copy these shared_ptr instances? Well, copying p1 (or p2) creates another shared_ptr that shares nothing with it because there is nothing to share. Whereas, copying p3 creates a shared_ptr that shares the ownership of the managed nullptr with it:

//p1's (or p2's) copy is null and empty
auto p1c = p1;
//ref count: 0
std::cout << p1c.use_count() << "\n"; //0

//p3 is different. ref count increases with copy.
auto p3c = p3;
//ref count: 2
std::cout << p3c.use_count() << "\n"; //2

Interestingly, it is also possible for a shared_ptr to be non-null but still be empty. That is achieved through the aliasing constructor. With aliasing constructor, we can create a shared_ptr that points to an object but shares the ownership of a completely unrelated object. Here is an example of how we create a shared_ptr that is empty but still pointing to an object:

 int x = 100;
 //'px' holds &x, but is empty.
 //A null and empty shared_ptr<void> is passed 
 //to aliasing constructor to initialize px 
 std::shared_ptr<int> px(std::shared_ptr<void>(), &x); 

Having established the difference between null and empty shared_ptr, let's look at an example where this knowledge is put to use.

Executing code on block exit

A null shared_ptr does serve the same purpose as a raw null pointer. It might indicate the non-availability of data. However, for the most part, there is no reason for a null shared_ptr to possess a control block or a managed nullptr. But we might utilize a non-empty shared_ptr's deleter to execute arbitrary cleanup code on block exit. In the following code, the function spam() acquires a few resources (an open file and a connection to some service) that it has to free on return. There are many conditional returns and the possibility of exceptions in the function. We utilize a null shared_ptr's deleter to clean up the resources in all conditions, as shown:

struct Connection {
 std::string read(); //Can throw
 void write(const std::string&); //Can throw
 //..more interface
};

struct Service {
 static Connection* getConnection();
 static void freeConnection(Connection* cp);
 //more...
};

void spam() {
 //Resource handles
 std::FILE* fp = nullptr; 
 Connection* cp = nullptr;

 //The guard's deleter always executes on return/exit
 //The shared_ptr is null but not empty
 std::shared_ptr<void>
 guard(nullptr, [&fp, &cp](void*){
    //Always runs. Releases resources.
    if(fp)
     std::fclose(fp);
    if(cp) 
     Service::freeConnection(cp);
 });

 /* There are conditional returns
   and the possibility of exceptions */

 //Open a log file
 fp = std::fopen("test.log","a");
 if(!fp)
  return;

 //Get a connection    
 cp = Service::getConnection();
 if(!cp)
  return;

 //Read from connection
 auto data = cp->read();
 if(data.empty())
  return;

 //Process data...

 //Write some data to file
 std::fputs("Some Data", fp);

 //Write to connection
 cp->write("Some Data");
 //...
}

A Question

Let's look at an easy question related to this subject. A shared_ptr, p4, is initialized with nullptr, as shown below. Later, p4 is copied to p4c, and then reset to nullptr again. You have to tell the reference count of the p4 and p4c:

std::shared_ptr<int> p4(static_cast<int*>(nullptr));

//make a copy
auto p4c = p4;

p4.reset<int>(nullptr);
//Print ref counts
std::cout << "p4: " << p4.use_count()
          << " p4c: " << p4c.use_count() << "\n";

Select below the output of the above (check Explanations for details):


shared_ptr to an object on the stack

Above, we talked about how a null and non-empty shared_ptr can be used to execute some arbitrary code on a block's end. Similarly, a non-null and empty shared_ptr also has a practical use. An empty shared_ptr can hold a pointer to an object on a function's stack and can be passed to a third party API that expects a shared_ptr:

//Third-party API
void api(std::shared_ptr<int> p) {
 //...
}

//This function uses aliasing constructor
//to create a shared_ptr that has no control block
//but holds a pointer to an object on the stack
void call_api_aliasing_cotr() {
 int y = 10;
 //Aliasing constructor takes a shared_ptr and a raw pointer 
 std::shared_ptr<int> p(std::shared_ptr<int>(), &y);
 api(p);
}

However, the above use case is far less common, and IMO a cleaner alternative is using a custom deleter that does not delete the managed pointer:

//This function uses a noop custom deleter
//with a shared_ptr that holds pointer to
//an object on the stack
void call_api_noop_deleter() {
 int y = 10;
 //Custom deleter does nothing.
 std::shared_ptr<int> p(&y, [](int*) {});
 api(p);
}

References

shared_ptr - cplusplus.com
shared_ptr - cppreference