C++ - Smart Pointers



C++ smart pointers let us get rid of the explicit deletion of dynamic memory. In this post, we want to see what are smart pointers and how we can properly use them. 

The new C++ library provides three types of smart pointers defined in memory header file:
  1. Shared pointer
  2. Unique pointer
  3. Weak pointer. We don't cover weak pointers in this post. 

Shared Pointer

C++ provides a class template called shared_ptr. We specify the type of object that our pointer refers to as a template parameter. 

std::shared_ptr<MyClassmyPointer

The shared_ptr class overrides * and -> operators to make a shared pointer object looks like a normal (raw) pointer. Thus, using * we can deference the pointer, and using -> we can access members of the underlying object. ->mem() function is the same as *. 

When we define a shared pointer like above, the shared pointer holds a null pointer. The safer way is to use the make_shared template function. This function constructs an actual object and returns a shared pointer to it. Thus, we are sure that our smart pointer is referring to an actual object instead of a null pointer. 

std::shared_ptr<MyClassmyPointer = std::make_shared<MyClass>(/* constructor parameters */);
or we can take advantage of auto:

auto myPointer = std::make_shared<MyClass>(/* constructor parameters */);

We can also initialize a shared pointer with new: 

std::shared_ptr<MyClassmyPointer (new MyClass(/* constructor parameters */)); 

Reference Counting 

Each shared pointer keeps track of the number of other shared pointers referring to the same object that it is referring to. You can think of it as ALL shared pointers referring to the same object share this number called the reference count. Note that the reference count is assigned to the underlying object. When a new pointer joins the group of shared pointer referring to this object, the reference counter increases, and when one pointer leaves the group this number decreases. When the last shared pointer leaves the group, i.e., there is no shared pointer referring to the underlying object anymore, the object is destroyed. 

When does the reference count increase?
  1. When a shared pointer is used to initialize another shared pointer.
  2. When a shared pointer is used on the right-hand-side of an assignment.
  3. When a shared pointer is passed by value to a function.
  4. When a shared pointer is returned by value from a function.
When does the reference count decrease?
  1. When a shared pointer is used on the left-hand-side of an assignment.
  2. When a shared pointer goes out of its scope. 
Is shared_ptr the same as Java objects? 
Although thinking of shared_ptr as Java objects might be useful to understand and work with it, C++ shared pointers and Java objects are different. Java is a managed language. The out-of-scope objects are deleted by the garbage collector periodically. Thus, Java classes cannot have destructors, and there is no way for the program to determine when its object must be destroyed. On the other hand, when using shared pointers in C++, we know for sure, that the destructor of the underlying class is called immediately when the last shared_ptr is destroyed. 

No mixed access!

Although we can initialize a shared_pointer from a raw pointer, it is very important to avoid using the raw pointer after using it to initialize a shared pointer, because as soon as we create a shared pointer out of a raw pointer, we have given up our control over the lifetime of the object, so accessing the raw pointer is dangerous. 

For example, if I have the following code:

MyClassmyRawPointer = new MyClass(); 
std::shared_ptr<MyClassmyPointer (myRawPointer);
myPointer  = std::make_shared<MyClass>(); 

The object is deleted at the third line and myRawPointer is undefined now. 

get() Function 

The smart pointers have a get function that returns a raw pointer to the underlying object. the get function must be only used to give access to the raw pointer to a code that only works with the raw pointers (e.g. a function that receives a raw pointer as argument). We may face this situation while working with a legacy code that does not use smart pointers. 

There are 2 important things we have to check when we use the get() function: 
  1. No deletion: We must not delete the pointer that get() function returns. Thus, if we give the raw pointer to a function, we must make sure that the function does not delete this raw pointer. 
  2. No initialization: We must use the raw pointer return by the get() function to initialize another smart pointer, as this will mess up the reference counting for the underlying object and may cause deleting it at the wrong time. 

Exceptions 

One of the reasons that we don't use exceptions in C++ as much as we use in Java is the memory leak due to exceptions. If we create a dynamic object using a new operator, we know that we have to delete it. However, if we leave the scope of the object due to an exception, we may not delete it and there might be no way to delete it at all.  Using smart pointers we can use exceptions safely because smart pointers manage their memory correctly even when we leave the scope prematurely due to an exception. 

Deleter

By default, a shared pointer assumes the object that it is referring to a created dynamically--using the new operator. Thus, it executes the delete operator on the underlying raw pointer when it is the last shared pointer for that object and it is being destroyed. Now suppose we create a shared pointer out of a statically allocated object. For example:

MyClass obj
std::shared_ptr<MyClassmyPointer (&obj); 

Here, the shared pointer cannot call delete on the underlying pointer, as the delete can only be called for dynamically allocated objects created by the new operator. 

Why would I even need to create a shared_ptr out of a statically allocated object? 
Sometimes, we have classes that do not have proper destructors or require the user to explicitly free resources. We want to make sure we call a routine when the object is destroyed. For that, we can use shared pointers with a deleter. 

First, we define a deleter. The deleter must receive a pointer to the underlying object: 

void myClassDeleter(MyClass* obj) {
    //free resources here
}

Then, we register the deleter at the initialization of the shared pointer. 
{
MyClass obj
std::shared_ptr<MyClassmyPointer (&objmyClassDeleter); 
//use obj here
}

An Example: A vector of shared pointers

#include <iostream> 
#include<memory>
#include <vector>
class MyClass {
private: 
    int id
public:
MyClass (int id_) : id(id_) {}
    ~MyClass () {
        std::cout << "Destructor for " << id << std::endl
    }
    int getId () {return id;}
};
int main (int argcchar** argv) {
    std::vector<std::shared_ptr<MyClass>> vec
//Different ways to push this vector. 
vec.push_back(std::shared_ptr<MyClass>(new MyClass(1))); 
vec.push_back(std::make_shared<MyClass>(2)); 
vec.emplace_back(new MyClass(3));
    auto second = vec[1];  //reference count for 2 will become 2
    vec.clear(); //reference count for the 1 and 3 become 0 
    //so they will be deleted here. 
    std::cout<<"Right before exit" << std::endl;
    return 0;
}
The output will be:
Destructor for 1
Destructor for 3
Right before exit
Destructor for 2

Other Operations

Swapping underlying pointers:

swap(sharedPtr1, sharedPtr2)
smartPtr1.swap(sharedPtr2)
Note: You CANNOT swap a shared pointer with a unique pointer.  

Return the number of the shared pointer pointing to the same object as sharedPtr:
sharedPtr.use_count()

Return true if sharedPtr is the only shared pointer pointing to the object:
sharedPtr.unqiue()

Reset the shared pointer to null or a different raw pointer:
sharedPtr.reset() //sets sharedPtr to null
sharedPtr.reset(rawPtr) 
sharedPtr.reset(rawPtr, deleterFunction) 

In summary, shared pointers are easy to understand and use. Just remember: 
  • Don't use raw and shared pointers to the same object at the same time. Once you created a shared pointer out of a raw pointer, don't access the raw pointer anymore.  
  • Be careful when using get() function; don't delete the returned raw pointer and don't use it to initialize another shared pointer. 

Unique Pointer

A unique pointer, as the name suggests, is unique, i.e., unlike shared pointers, we cannot have two unique pointers referring to the same underlying object.  Thus, unique pointers don't have copy constructors or the assignment operator. 

We can define and initialize a unique pointer in two ways: 
  1. std::unique_ptr<MyClassuniquePtr1
  2. std::unique_ptr<MyClassuniquePtr2 (new MyClass(1)); 
We don't have assignment operator for unique pointers. Then, how can we assign any object to the first form of definition? 
Using reset, std:move, or swap functions (as we will see below) we can assign an object to the already defined unique pointer. 

release() Function 

An important function of the unique pointer is the release function. As the name suggests, the release function releases the ownership of the underlying object owned by the unique pointer and sets the unique pointer to nullptr. The release function returns a raw pointer to the underlying object. 

Important: It is very important to capture the raw pointer returned by the release function. Otherwise, we will have a memory leak, as we won't have any access to the underlying object, so we won't have any way to delete it. 

Usually, we use the release function to take ownership of a unique pointer and assign it to another smart pointer using the constructor or the reset() function. Alternatively, we can store it in a raw pointer, but in that case, we are responsible for deleting it once our program is done using the object. 

std::unique_ptr<MyClassuniquePtr1 (new MyClass(1));
std::unique_ptr<MyClassuniquePtr2 (uniquePtr1.release());  //ownership is transfered to uniquePtr2
std::unique_ptr<MyClassuniquePtr3
uniquePtr3.reset(uniquePtr2.release()); //ownership is transfered to uniquePtr3

Other than release and reset, we can transfer the ownership with swap() and std::move() as well:

std::unique_ptr<MyClassuniquePtr4 = std::move(uniquePtr3); //ownership is transfered to unqiuePtr4

std::unique_ptr<MyClassuniquePtr5

swap(uniquePtr4uniquePtr5);  //ownership is transfered to unqiuePtr5

uniquePtr5.swap(uniquePtr4);  //ownership is transfered to unqiuePtr4 again. 

Note: The std::move() function sets the original pointer to null. 

Passing a unique_ptr to a Function 

Passing shared pointers is straightforward; when we pass by value, we simply copy the shared pointer. So the reference pointer increases by one, and then when the function returns the reference count decreases by one. For unique pointers, however, we don't have a copy constructor. Thus, we cannot pass a unique pointer by value the way we can pass shared pointers. We have two options to pass a unique pointer to a function: 
  1. Pass by reference. 
  2. Move the pointer using std::move() function. 
void printId_value (std::unique_ptr<MyClassptr) {
    std::cout << ptr->getId() << std::endl
}
void printId_reference (std::unique_ptr<MyClass>& ptr) {
    std::cout << ptr->getId() << std::endl
}
int main (int argcchar** argv) {
    std::unique_ptr<MyClassuniquePtr (new MyClass(1));
    printId_reference(uniquePtr); 
    printId_value(std::move(uniquePtr)); 
    if (!uniquePtr
        std::cout << "uniquePtr is null" << std::endl;
    return 0;
}
Output:
1
1
Destructor for 1
uniquePtr is null

Returning a unique_ptr from a Function

Suppose we have a function that returns a unique pointer. We can define and initialize a unique pointer in its body and return it. Simple. 

std::unique_ptr<MyClassgiveMeAUniquePtr (int id) {
    std::unique_ptr<MyClassp (new MyClass(1));
    return p;
}
int main (int argcchar** argv) {
    std::cout << giveMeAUniquePtr(1)->getId() << std::endl
    return 0;
}
Output:
1 Destructor for 1

But don't we call copy constructor on the return?! We said no copy constructor for unique pointers. 
This is the only exception that we can assume a unique pointer has a copy constructor. The compiler is smart enough to know that pointer is about to be destroyed. Thus, it actually moves the object here. 

Deleter for Unique Pointers

We saw above how using a deleter we can override the default behavior of a shared pointer to delete its object. We can do the same for a unique pointer with a little difference; we have to specify the type of the deleter function as a template parameter for our unique pointer. 

void myClassDeleter(MyClass* obj) {
    auto id = obj->getId();
    delete obj
    std::cout<< "Object with id=" << id << " deleted by my custom deleter." << std::endl
}
int main (int argcchar** argv) {
    std::unique_ptr<MyClassdecltype(myClassDeleter)*> uniquePtr (new MyClass(1), myClassDeleter); 
    return 0;
}

Output:
Destructor for 1 Object with id=1 deleted by my custom deleter.

What is the benefit of specifying the type of the deleter as a template parameter? 
This way, the compiler knows the type of the deleter at the compile time. It is more efficient than finding the type at run-time and making an indirect call. On the other hand, with a shared pointer, we can change the deleter at the run-tie. Thus, unique pointer is more efficient, while shared pointer is more flexible regarding deleters. 

In summary, use a unique pointer when you want to make sure there is only one pointer referring to an object. 
  • You can pass a unique pointer either by reference or std::move() to a function. 
  • You can simply return a unique pointer from a function as if it has a copy constructor. 
  • If you want a custom deleter, you have to specify its type as a template parameter. 

Comments

Popular posts from this blog

In-memory vs. On-disk Databases

ByteGraph: A Graph Database for TikTok

DynamoDB, Ten Years Later

Eventual Consistency and Conflict Resolution - Part 2