Modern (Effective) C++ - std::shared_ptr

Here we discuss about workings, performance and characteristics of std::shared_ptr and end the article with a test program which compares the performance of std::shared_ptr vs std::unique_ptr.

This article is inspired by Item 19 of Effective Modern C++ by Scott Meyers.

Just like the std::unique_ptr , std::shared_ptr is used to manage the lifetime of an underlying raw pointer, and delete it when it's no longer needed. However shared_ptr (going to drop the std prefix from now on) can be copied to a new shared_ptr ( sp_new = sp_orig) or copy constructed ( shared_ptr sp2 = sp1 , thus resulting in two or more shared_ptrs pointing to the same raw pointer. These shared_ptrs now share the ownership of the underlying pointer. They co-operate with each other ( by using reference count ) to decide when to finally delete the raw pointer.

These are some important features of std::shared_ptr -
  • They are twice the size of raw pointer (or unique_ptr), because we need another pointer pointing to the control block which consists of the reference count object.
  • Increments and decrements on the reference count should be atomic, since shared_ptrs can be spread out in different threads. If two threads decrement the reference count, they should be ordered using atomic variables or mutex ( one after the other), otherwise we might lose one decrement. 

Shared_ptr supports copy assignment, copy construction, move assignment, move construction. Moving the shared_ptr to a new shared_ptr, works like move on any other variable type.
For instance -
shared_ptr sp2 = std::move(sp1);
Here the state of sp1 is transferred to sp2, along with raw pointer, reference count, deleters etc ( collectively known as control block). and sp1 is set to null. Also note that in move construction, reference count is unchanged.

Just like std::unique_ptr, std::shared_ptr also supports custom deleters. However the difference is, deleters is not a part of the shared_ptr itself.
For example -

#include <iostream>
#include <memory>
using namespace std;
int main() {
// your code goes here
auto deleterFunc = [](int* xp)
{
cout << "Deleting " << *xp << '\n';
delete xp;
};
// deleter function type is a part of unique_ptr.
std::unique_ptr<int, decltype(deleterFunc)> uPtr(new int(10), deleterFunc);
// deleter function type is NOT part of shared_ptr type.
// but deleter function is passed during shared_ptr construction
std::shared_ptr<int> sPtr(new int(11), deleterFunc);
cout << *uPtr << ' ' << *sPtr << '\n';
//(new int(10), deleterFunc);
return 0;
}
This enables different shared_ptrs with different deleters to be assigned (copy or move) to each other, and to co exist in a single collection like vector. We can also notice that deleter function with captured variables does not increase the size of the shared_ptr. This is because the captured variables is not part of shared_ptr, but is allocated in the free space within the control block.

#include <iostream>
#include <memory>
using namespace std;
int main() {
float ft = 9.8f;
auto deleterFunc = [ft](int* xp)
{
cout << ft << '\n';
cout << "Deleting " << *xp << '\n';
delete xp;
};
std::shared_ptr<int> sPtr1(new int(10));
std::shared_ptr<int> sPtr2(new int(11), deleterFunc);
std::unique_ptr<int> uPtr1(new int(12));
std::unique_ptr<int, decltype(deleterFunc)> uPtr2(new int(13), deleterFunc);
cout << "*sPtr1 = " << *sPtr1 << ". Size = " << sizeof(sPtr1) << '\n';
cout << "*sPtr2 = " << *sPtr2 << ". Size = " << sizeof(sPtr2) << "\n\n";
cout << "*uPtr1 = " << *uPtr1 << ". Size = " << sizeof(uPtr1) << '\n';
cout << "*uPtr2 = " << *uPtr2 << ". Size = " << sizeof(uPtr2) << '\n';
return 0;
}
/* Output in gcc 6.3
*sPtr1 = 10. Size = 16
*sPtr2 = 11. Size = 16
*uPtr1 = 12. Size = 8
*uPtr1 = 13. Size = 16
9.8
Deleting 13
9.8
Deleting 11
*/
Here are some rules for creation of control block -
  • Creating shared_ptr using make_shared always creates a control block.
  • It is created when a shared_ptr is constructed from unique_ptr. ( unique_ptr is then set to null).
  • When shared_ptr is created using raw pointer.

It should also be noted that we cannot use make_shared when supplying custom deleter.
De-referencing a shared_ptr costs the same as de-referencing a raw pointer.

If we want to create shared_ptr from this object, then our class must inherit from enable_shared_from_this<OurClass>.
To create shared_ptr of this (current object), from any of the member function, we should call shared_from_this() . Keep in mind that before calling shared_from_this(), shared_ptr for this object must already be present. Hence to enforce this, such classes make constructors private, and have a factory kind of method which returns shared_ptr.

We can create a shared_ptr from unique_ptr, but not vice versa. We cannot create (convert) unique_ptr from shared_ptr even when reference count is 1.

Here is a program to compare the performance of unique_ptr VS shared_ptr

#include <iostream>
#include <memory>
#include <chrono>
#include <string>
using chrono_clock = std::chrono::high_resolution_clock;
void compareSizes()
{
std::shared_ptr<int> sPtr = std::make_shared<int>(23);
std::unique_ptr<int> uPtr = std::make_unique<int>(23);
std::cout << "Shared ptr content = " << *sPtr << ". Size = " << sizeof(sPtr) << '\n';
std::cout << "Unique ptr content = " << *uPtr << ". Size = " << sizeof(uPtr) << '\n';
// Setting - MSVC 14.12 , x64 platform
// Results
// Shared ptr content = 23. Size = 16
// Unique ptr content = 23. Size = 8
// Setting - MSVC 14.12 , x86 platform
// Results
// Shared ptr content = 23. Size = 8
// Unique ptr content = 23. Size = 4
}
const int noOfPointers = 10000;
void displayStat(std::string message, std::chrono::steady_clock::time_point start , std::chrono::steady_clock::time_point finish)
{
std::chrono::duration<double> elapsed = finish - start;
std::cout << message << " : " << elapsed.count() << '\n';
}
void comparePerformanceConvenienceFunc()
{
std::cout << "\nUsing convenience functions" << '\n';
std::cout << "Time for creation and destruction of " << noOfPointers << " of each type\n";
auto start = chrono_clock::now();
for (int i = 0; i < noOfPointers; ++i)
{
std::unique_ptr<int> uPtrVec2 = std::make_unique<int>(i);
}
auto finish = chrono_clock::now();
displayStat("Unique Ptr", start, finish);
start = chrono_clock::now();
for (int i = 0; i < noOfPointers; ++i)
{
std::shared_ptr<int> sPtrVec = std::make_shared<int>(i);
}
finish = chrono_clock::now();
displayStat("Shared Ptr", start, finish);
}
void comparePerformanceUsingNew()
{
std::cout << "\nWithout using convenience functions" << '\n';
std::cout << "Time for creation and destruction of " << noOfPointers << " of each type\n";
auto start = chrono_clock::now();
for (int i = 0; i < noOfPointers; ++i)
{
std::unique_ptr<int> uPtrVec (new int(i));
}
auto finish = chrono_clock::now();
displayStat("Unique Ptr", start, finish);
start = chrono_clock::now();
for (int i = 0; i < noOfPointers; ++i)
{
std::shared_ptr<int> sPtrVec(new int(i));
}
finish = chrono_clock::now();
displayStat("Shared Ptr", start, finish);
}
int main()
{
compareSizes();
comparePerformanceUsingNew();
comparePerformanceConvenienceFunc();
// Setting - MSVC 14.12 , x64 platform
// Output
/*
Without using convenience functions
Time for creation and destruction of 10000 of each type
Unique Ptr : 0.00486729
Shared Ptr : 0.00962007
Using convenience functions
Time for creation and destruction of 10000 of each type
Unique Ptr : 0.00569801
Shared Ptr : 0.00827406
*/
// Obervations
// Unique ptr creation and destruction is almost 100% faster than shared ptr
// Using std::make_shared is considerably faster than using new when creating shared_ptr
// make_unique is very slightly slower compared to using new.
}

PS : Sean Parent on the C++ seasoning talk (Youtube) discourages using shared_ptr as it removes our ability to reason about the code locally, and makes the usage of shared_ptr almost similar to global variables.

Modern (Effective) C++ - std::shared_ptr Modern (Effective) C++ - std::shared_ptr Reviewed by zeroingTheDot on February 03, 2018 Rating: 5

No comments:

Powered by Blogger.