They say that sharing means caring, so today we're not only going to look
at shared pointers but also weak pointers, constructor aliasing,
and atomic operations as we embark on the path to modern C++ mastery. [Intro]
Hey, I'm Dave, welcome to my shop! Today in Dave's Garage we continue our introductory
advanced C++ series by looking at one of the most common template classes you should be using, the
shared_ptr. No incomplete examples here: we'll do everything in working code that you can download
and compile from my github repo. shared_ptr is a feature in the C++ programming language that helps
manage memory more effectively and safely. It's a "smart pointer," which means it's a special kind
of object that behaves like a regular pointer, but with some key additional features. Using a
shared_ptr is like a group of friends who share ownership of a resource, like a shared Netflix
account. When one friend decides to stop using the account, the account is still active as long
as there are other friends using it. The account will only be closed when the last friend stops
using it. In this analogy, the Netflix account is the memory, and the friends are the shared_ptr
objects. Here are some key points about shared_ptr: Automatic memory management: When you
create a shared_ptr, it starts managing the memory of an object you give it. It keeps track of how
many shared_ptr objects are pointing to the same memory. When the last shared_ptr that points to
the memory is destroyed or reset, it automatically frees the memory, preventing memory leaks.
Shared ownership: Multiple shared_ptr objects can share ownership of the same memory. When
you create a new shared_ptr from an existing one, they both point to the same object, and the memory
won't be freed until all the shared_ptr objects are destroyed or reset. Use count: A shared_ptr
keeps track of how many shared_ptr objects are sharing the ownership of the same memory. This
count is called the "use count." You can check the use count of a shared_ptr using its use_count()
method. Inheritance and polymorphism: You can use shared_ptr to manage memory for objects
in an inheritance hierarchy. This means you can have a shared_ptr to a base class that points to
an object of a derived class, and it will still manage the memory correctly. Custom deleters:
Sometimes, you might need to do some extra or specialized cleanup when the object is freed.
shared_ptr allows you to specify a custom function that will be called when the object is about to be
released, which can be useful for additional cleanup tasks. Using shared_ptr can help you
avoid common memory management issues like memory leaks, double deletion, and dangling pointers.
It makes managing memory in your programs easier and safer, especially when working with
complex data structures or objects with shared ownership. With that introduction out of the way,
it's time to jump into the editor and review some working code that demonstrates the effective use
of shared pointers. [Shared Ptr Code] At the top of the file we have the include files we'll need
to compile our example and then three forward references to demo functions we'll be calling.
Since they are defined after main to keep the file tidy, we need these two forward delcarations.
The first code we see is the definition of a base class. The base class has a single method called
"Speak" that will report that kind of object it is. It also has a destructor that will display
some debug info when the object goes out of scope and is destroyed so that we can see exactly when
that happens. Next, we find a Derived class which inherits from Base. It's functionality is exactly
the same, except of course its Speak verb has been overridden to report that it is, in fact,
a Derived class object and not a base. Finally, we declare a custom delete function. In this case,
it does nothing more advanced that simply delete the object, but it reports when that
happens so we can more easily track and observe the object lifetime and ensure that our custom
deleter is called properly. As we'll see later, a custom deleter is a handy feature to use when
cleaning up your object is more complicated than simply freeing the memory associated with it.
Next we encounter our main function. It creates a shared pointer using the make_shared helper
function. While you could create a raw pointer to a Base object using the classic new operator and
then supply that to the shared_ptr in discrete steps, it's generally frowned upon, as managing
raw pointers introduces points of failure where your code could make mistakes and leak memory. So,
we use make_shared which returns a fully constructed shared_ptr to an object directly. All
you need to provide is the class name, and if its constructor took any arguments, those would follow
within what are currently empty parenthesis, since our constructor takes no arguments.
Once we have our shared pointer, we can use it like a regular object pointer.
We call the speak function and it should report that it is a base object. Next, we create another
shared_ptr for type Base but this time, we're going to do two things differently: First,
we're going to give it a new Derived object pointer to initialize itself with instead.
Since the Derived class, as the name implies, derives from Base, the types are compatible.
Notice that we're passing a second parameter to the shared_ptr constructor,
which is the custom_deleter function. By doing this, we cause our custom deleter to
be called when the object goes out of scope instead of the normal one. This gives us the
opportunity to do any other important cleanup that might be needed. As we'll see shortly,
for example, you could use it to close a file handle instead of just deleting the pointer.
Our third shared pointer, SP3, is assigned from our existing SP1 pointer. Rather than creating
a new object, this will simply have the effect of increasing the use count, or reference count,
on the object. It will now not be destroyed until both SP1 and SP3 go out of scope. This happens
automatically without us needing to keep manual track of how many pointers may or may not be
outstanding on the object. When the last reference is removed, the object is freed. Next we'll look
at the use of the reset() function. Reset causes the existing pointer reference to be discarded,
the use count decremented, and if the use count is now zero, the object will be cleaned up as needed.
The pointer is then replaced with whatever you pass to reset. We give reset the new pointer value
to use, which in this case is a newly created Derived object. Through the magic of inheritance,
SP3 only knows that it's a Base object, but when pointing to a derived object, you get the derived
methods when you call the member funtions. That means that even though sp3 is only aware of Base,
when we call speak, it will properly report itself as a Derived object. At this point we do something
you wouldn't normally do in production code, which is to check the use_count of the SP1 pointer.
Since reference counting is automatic, you generally never need to know or care what the
reference count is, but sometimes for debugging or inspection purposes, you might want to know. At
this point in the code, the sp1 use count should be one, and that's what we'll see reported on the
console when we run the code. In our last episode we introduced the vector class, which is just a
fancy name for a dynamic array. You can add as many objects as you like to a vector, and it will
make room for them as needed. It also knows how to clean up its contents when it goes out of scope.
Our container, then, is a vector of shared pointers to Base objects. Now we can add our
three pointers, sp1, sp2, and sp3, to the vector. Note that sp2 is still really a Derived object,
not just a base, but because Derived is a superclass of Base, it all works automatically.
We push_back each of the pointers and our container, the vector, now owns those three
shared pointers. We could also emplace_back, which would be more efficient than copying the pointer,
but that's for a future episode. With our objects in the vector, let's enumerate and operate on the
contents, which we know to be SP1, SP2, and SP3. The loop will run three times,
once for each object in the container. Each time it will call Speak, and we will see
the output of "I am a Base" for sp1, "I am a Derived" for sp2, and "I am a Base" for sp3.
Let's think about what happens when things go out of scope at the end of main. First, sp1, sp2,
and sp3 will be destroyed. Because those pointers have also been copied into the container, the
use_count is greater than 1 for each of them, so their reference counts on the object will simply
be decremented. It's not until the vector goes out of scope that the objects will actually be cleaned
up. When the vector is destroyed, it enumerates its contents and again winds up decrementing
the reference count on each object. This time, however, each object will have a zero reference
count once decremented, and the compiler knows to delete the referenced objects at that time.
It's important to note that you, as the developer, don't need to know anything about the reference
counts or object lifetimes, and that means there's no room for you to do anything
wrong. The reference counts are all managed automatically by the compiler. As long as you use
the shared_ptr type, you get the memory management essentially for free. To be precise though,
while a unique_ptr can be a zero-cost abstraction, shared_ptrs are not, because they introduce the
tiny overhead of tracking the reference count when they are created, assigned, or destroyed.
At the end of the day, however, we're only talking about incrementing or decrementing a
simple integer value, so the overhead, such as it is, is very small. ---- Now that we have a
basic understanding of how the shared pointer works, it's time to look at its companion,
the weak pointer. A weak_ptr is an important concept in C++ programming,
particularly in the context of memory management. It belongs to the C++11 standard library and is
used alongside shared_ptr to address potential issues that can arise due to shared ownership,
such as circular references and memory leaks. programmer, it is essential to understand weak_ptr
in order to effectively manage memory and prevent potential pitfalls when working with shared_ptr.
The primary role of a weak_ptr is to act as an observer for a shared_ptr without claiming
ownership of the managed object. In other words, it provides a mechanism for keeping track of a
shared_ptr's managed object without increasing its reference count. This is particularly useful
when dealing with circular dependencies, as it helps prevent memory leaks that may occur when
shared_ptrs keep each other alive indefinitely. To use a weak_ptr, you typically initialize it with a
shared_ptr or another weak_ptr. It is important to note that the weak_ptr does not contribute
to the reference count of the shared_ptr it is initialized with. This ensures that the weak_ptr
does not inadvertently extend the lifetime of the managed object. Since a weak_ptr does not
guarantee the existence of the managed object, you cannot directly access it. Instead, you must
first convert the weak_ptr to a shared_ptr using a specific function provided by the weak_ptr class.
This conversion guarantees that the managed object is still alive when you access it,
preventing any potential issues with accessing a destroyed object. It's basically the same as
checking for null, but with different semantics. In addition to converting
a weak_ptr to a shared_ptr, you can also check if the managed object has been destroyed using
another function provided by the weak_ptr class. This allows you to take appropriate action based
on the object's existence. Understanding and using the weak_ptr template is crucial for programmers
working with C++ memory management. It is an invaluable tool that enables you to temporarily
observe shared objects without contributing to their reference count, thus preventing memory
leaks and ensuring efficient, less error-prone code. [AdvancedSharedPtr] Down below main I've
written a function called AdvancedSharedPtr that demonstrates the use of weak pointers with shared
pointers. There might be times where you want a zero cost abstraction of a shared pointer, but you
shouldn't just call get() on the shared pointer and then party with the raw pointer. That's how
you get yourself back into the mess of C memory management, and it's something we want to avoid.
In those times you want a simple pointer, however, rather than using a raw pointer, we can use a weak
pointer. You can just assign a shared pointer to a new weak pointer and it will keep an eye on your
shared pointer without bumping its reference count. When it comes time to use or otherwise
dereference the pointer, you call lock on the weak pointer, which will return a shared pointer with
the reference count bumped by one. Once we've done so, and it's given us the shared pointer
called "locked", we can now use the pointer, so we call speak on it. The pointer known as locked goes
out of scope as soon as we leave this code block, which means the reference count gets decremented
and all is back to where it was before we called lock. The weak pointer is still a valid weak
pointer, but if you want to call member functions on the object, you'd need to call lock again.
So, in some ways its helpful to think of a weak pointer as an observational tracking
pointer that knows where the real pointer is, but it doesn't fetch and reference count it
unless you explicitly tell it to. Next, we call reset on the original shared pointer.
We know that's the last reference to the object, so the pointer releases and destroys the object.
But what if we call lock at this point? Well, it would return null, because the object is gone.
To test for this scenario without calling lock to see if it fails, you can call the expired()
member on the weak pointer you're still holding, which will tell you if the shared pointer its
tracking is still valid or not. We check the weak pointer with an expired call, and sure enough,
its expired function returns true, so we print out a message saying so. Next we'll see an example of
an aliasing constructor. The aliasing constructor allows you to create a new std::shared_ptr that
shares ownership of the same managed object with another std::shared_ptr, but points to a
different, related object (often a subobject of the managed object). The use of aliasing
constructors is typically for advanced use cases, such as managing the lifetime of objects that have
different parts with separate lifetimes. First, a shared_ptr named sp2 is created,
which manages a new instance of a Base class object. In the next line, an aliasing constructor
is called for creating a new shared_ptr named sp2_alias. It takes two arguments: the first
one is another shared_ptr in this case sp2, that it will share ownership with, and the second one
is a pointer to the object that sp2_alias should actually point to, which is sp2.get(), signifying
the underlying raw pointer to the object. When we call speak on the alias, it will invoke the speak
member of the original base object as we'd expect. We next display the use count, and sure enough,
the use count is 2, because there are two shared pointers without outstanding reference counts to
the object. Next we find an example of creating a shared pointer to an array of base objects.
This one is about as straightforward as it looks: a shared_ptr named sp3 is created and it in turns
points to and manages the lifetime for an array of three Base objects. Note carefully that it's
NOT an array of shared pointers, it's a shared pointer to a regular C array of objectss. That
why when we index off of the shared pointer sp3 we use the dot notation and not an arrow. SP3
is a pointer to an array, but once we use the indexer, we've deferenced into the array and
we get the actual Base objects in question. In this case, sp2_alias shares ownership with sp2 but
points to the same object, making this a somewhat trivial example of an aliasing constructor. From
there we move onto atomic operations. Imagine you have two threads running and both want to
increment a global counter. You run the risk of a race condition wherein each fetches the
original value, increments it internally, and stores it back. If they're competing like that,
there's a good chance that you only get a single increment instead of two.
We can protect against this and solve the issue with C++'s support for atomic operations.
Atomic operations are needed in this scenario to ensure that the operations on the shared_ptr
instances are thread-safe. Without atomic operations, you risk encountering race conditions,
which can lead to undefined behavior or hard-to-debug issues. In this code,
there are two shared_ptr instances, sp4 and sp5, and two threads, t1 and t2. The threads perform
the following actions: Thread t1 atomically loads the value of sp4 and atomically stores
it into sp5. Thread t2 atomically stores a nullptr into sp4. If atomic operations were not used here,
the following sequence of events could happen: Thread t1 starts reading the value of sp4.
Before t1 finishes reading, thread t2 starts writing nullptr into sp4. Thread t1 finishes
reading the old value of sp4 and starts writing it into sp5. Thread t2 finishes
writing nullptr into sp4. In this scenario, the read and write operations of sp4 and sp5 are
interleaved, causing a race condition. The outcome depends on the order in which these operations are
executed. This can lead to unpredictable behavior and possible crashes or memory corruption.
By using atomic operations, you ensure that the operations on the shared pointers are performed
in a thread-safe manner. The std::atomic_load and std::atomic_store functions guarantee that the
operations on the shared pointers are atomic, and no other operation can interfere with them, thus
preventing race conditions. The two join calls then wait for the two threads to finish. Depending
on which order they executed in, sp5 will be valid or not, and if it is, we call it's speak function.
If sp4 was cleared before being moved to sp5, it won't. We don't know the outcome until we run the
program, and in theory it could be different each time we run it. ---- Next I want to show
you custom deleters. This means that rather than simply calling delete on your object, you can
control precisely what happens when your pointer goes out of scope and the object is destroyed.
Imagine you wanted a smart file pointer that automatically closed the file handle on exit.
That's precisely what we're going to do, and we don't need to write any file handle helper
classes or smart file pointer classes to do it. We can do it all with a shared_ptr and a custom
deleter. Let's have a look. [Custom Deleter] Our file_deleter function will serve as our custom
deleter. The prototype for a deleter is a void return on a function that accepts a pointer to
whatever the object type is, in this case, a FILE pointer. It would make no sense to just delete
a file handle, as they have to be closed, not deleted, so we need to provide our own version.
The readFile function creates a shared pointer that is initialized to contain the FILE handle.
All of the shared ptr reference counting works the same as any other shared ptr,
the ONLY thing we're doing different is to pass our custom delete to the shared ptr constructor
so that it knows to call that function when it's time to clean up. Our sample reads the
contents of the file and prints it out, and when the file handle smart pointer goes out of scope,
our file_deleter is called and the file handle is closed for us. No more weird nesting or gotos in
order to handle cleanup amongst error conditions - it all happens based on scoping rules alone. If
you've enjoyed today's look at smart pointers, please make sure you're subscribed to the
channel so I know there's an actual appetite for these more technical episodes! I'm just in this
for the subs and likes anyway, so you might as well tickle my dopamine by leaving one of each!
In the meantime and in between time, I hope to see you next time, right here in Dave's Garage.