Memory Ownership and Smart Pointers
Learn how to manage dynamic memory using unique pointers and the concept of memory ownership
For the rest of this chapter, we'll focus on memory management. This is a complex topic, so it's normal to struggle with these lessons. Most programming languages do not allow developers to intervene in memory management, so this topic can be difficult even for those already familiar with programming.
In our previous lessons, we've primarily worked with stack memory. Stack memory is straightforward and efficient, automatically managing the lifecycle of our variables. We introduced stack memory briefly earlier in the course:
Dangling Pointers and References
Learn about an important consideration when returning pointers and references from functions
Whilst simple programs can rely entirely on stack memory, it has some limitations that means we need to mix in other techniques in more complex programs. Limitations of stack memory include:
- Scope-limited: Variables in stack memory are destroyed when they go out of scope. This prevents us from fully controlling the lifecycle of our objects
- Fixed size: Stack memory is allocated at compile-time, so we can't create dynamic data structures that grow or shrink as needed.
- Size constraints: The stack size is usually much smaller than the heap, limiting the amount of data we can store.
To overcome these limitations, C++ provides dynamic memory allocation using the heap. We'll dive deeper into dynamic memory in a dedicated chapter in the next course, but for now, let's see a brief overview.
Dynamic Memory and Memory Ownership
Dynamic memory allows us to allocate memory at runtime, giving us more flexibility in managing our program's resources. However, this flexibility comes at a cost: dynamic memory management is error-prone. Common issues include:
- Memory leaks: Forgetting to deallocate memory, leading to resource waste.
- Dangling pointers: Using pointers that point to already freed memory.
- Double deletion: Accidentally freeing the same memory twice.
To mitigate these issues, we can implement the design pattern of memory ownership. This concept suggests that resources (like objects stored in dynamically allocated memory) should have clear owners responsible for their lifecycle. We can imagine that certain objects or functions "own" other resources, managing their creation and destruction.
We saw an example of this in the previous lesson - behind the scenes, the std::vector
class assumes ownership of the objects it contains, and manages an area of dynamic memory to store these objects.
Dynamic Arrays using std::vector
Explore the fundamentals of dynamic arrays with an introduction to std::vector
Smart pointers are one way we can implement this ownership model in our own code. In a sense, they try to give us the flexibility of dynamic memory allocation, with the simplicity of stack memory allocation where memory is automatically released when it is no longer needed.
Smart Pointers
The simplest form of smart pointer is a unique pointer, an implementation of which is available in the standard library as std::unique_ptr
. Like any pointer, a unique pointer points to an object in memory. The "unique" refers to the idea that it should be the only unique pointer that points to that object.
As such, we can imagine that the function or object that holds the unique pointer has exclusive ownership of the object that the pointer points to.
Unique pointers implement restrictions to help enforce this design. For example, we'll see later in this lesson that it is difficult to create a copy of a unique pointer, as doing so would mean we now have two unique pointers pointing to the same object, contradicting the uniqueness.
Using std::make_unique()
By including <memory>
, we gain access to the std::make_unique()
function. Using this function is the preferred way of creating unique pointers:
#include <memory>
int main() {
auto Pointer { std::make_unique<int>(42) };
}
The <
and >
within this code indicates that std::make_unique()
is a template function. We cover templates in detail in the next course.
For now, we should just note that we need to pass the type of data we want to create a pointer to within the <
and >
. In the previous example, we wanted to create a pointer to an int
, so we pass int
between the <
and >
Within the (
and )
, we can optionally pass any arguments along to the constructor of our data type. In previous example, we pass 42
, which will become the initial value of our int
.
The net effect of all this is that we have:
- An
int
object allocated in the free store (dynamic memory) - That
int
having the initial value of42
- A
std::unique_ptr
, which we've calledPointer
, within the stack frame of ourmain
function. Thatstd::unique_ptr
is considered the sole owner of the integer that is stored in the heap.
The return type of std::make_unique()
is a std::unique_ptr
of the corresponding type. For example, std::make_unique<int>()
will return a std::unique_ptr<int>
:
#include <memory>
int main() {
std::unique_ptr<int> Pointer {
std::make_unique<int>(42)
};
}
When using std::make_unique()
, many developers will elect to use auto
type deduction:
#include <memory>
int main() {
auto Pointer{std::make_unique<int>(42)};
}
This is because the type of the underlying data (eg, int
) is included in the statement already. Additionally, std::make_unique()
is so ubiquitous that C++ developers soon learn that it returns a std::unique_ptr
, so repeating this type can add noise to our code.
Dereferencing Unique Pointers
Let's see an example of unique pointers with a class. We'll use this class throughout the rest of this lesson:
#include <iostream>
#include <memory>
class Character {
public:
std::string Name;
Character(std::string Name = "Frodo") :
Name { Name }
{
std::cout << "Creating " << Name << '\n';
}
~Character() {
std::cout << "Deleting " << Name << '\n';
}
};
int main() {
auto FrodoPointer{
std::make_unique<Character>("Frodo")
};
auto GandalfPointer{
std::make_unique<Character>("Gandalf")
};
}
This program outputs the following:
Creating Frodo
Creating Gandalf
Deleting Gandalf
Deleting Frodo
As with basic pointers, which are often referred to as "raw" pointers, we can dereference smart pointers and access the object they point to using the *
or ->
operators:
#include <iostream>
#include <memory>
class Character {/*...*/};
int main(){
auto FrodoPointer{
std::make_unique<Character>("Frodo")
};
std::cout << "Logging "
<< (*FrodoPointer).Name << "\n\n";
auto GandalfPointer{
std::make_unique<Character>("Gandalf")
};
std::cout << "Logging "
<< GandalfPointer->Name << "\n\n";
}
Creating Frodo
Logging Frodo
Creating Gandalf
Logging Gandalf
Deleting Gandalf
Deleting Frodo
Copying Unique Pointers
Given the design intent of unique pointers, it doesn't make sense to copy them directly. The std::unique_ptr
class protects against this by preventing its objects from being copied.
We will cover how to implement this capability for our own classes later, thereby controlling (or preventing) the process whereby objects of our custom types are copied.
We can see this in action by trying to copy a std::unique_ptr
:
#include <memory>
int main(){
auto Ptr1{std::make_unique<int>(42)};
auto Ptr2{Ptr1};
}
error: 'std::unique_ptr': attempting to reference a deleted function
When working with functions, passing by value is also a form of copying, so this will also be prevented with a similar error message:
#include <memory>
void SomeFunction(std::unique_ptr<int> Num) {
// ...
}
int main(){
auto Ptr{std::make_unique<int>(42)};
SomeFunction(Ptr);
}
error: 'std::unique_ptr': attempting to reference a deleted function
Of course, there are countless situations where we need to pass a pointer to a function. For those scenarios, smart pointers implement the get()
function, which returns the underlying raw pointer.
This allows other parts of our code to access our objects, without creating copies of our unique pointers:
#include <memory>
void SomeFunction(int* Num) {
// ...
}
int main() {
auto Ptr{std::make_unique<int>(42)};
SomeFunction(Ptr.get());
}
Within the memory ownership paradigm, we can imagine that any function that accepts a raw pointer to a resource is simply requesting access to that resource, but not requesting ownership. The resource is owned by whoever has the std::unique_ptr
to it.
For scenarios where we want to transfer ownership of the resource, we can use std::move()
.
Transferring Ownership using std::move()
Sometimes, we want to transfer ownership of a resource from one unique pointer to another.
This is where std::move()
comes in handy. It's a function available in the <utility>
header that allows us to transfer ownership of a unique pointer.
This mechanism allows us to transfer ownership of resources between different parts of our program, ensuring that at any given time, there's only one owner of the resource.
#include <memory>
#include <utility>
#include <iostream>
void TakeOwnership(std::unique_ptr<int> Num) {
std::cout << "TakeOwnership function now "
"owns the pointer.\n";
std::cout << "Value: " << *Num << '\n';
}
int main() {
auto Number{std::make_unique<int>(42)};
std::cout << "main function owns the pointer.\n";
TakeOwnership(std::move(Number));
// Number is now in a "moved-from" state
if (Number == nullptr) {
std::cout << "Number no longer owns any object.";
}
}
main function owns the pointer.
TakeOwnership function now owns the pointer.
Value: 42
Number no longer owns any object.
In this example:
- We create a
std::unique_ptr
calledNumber
in themain
function. - We use
std::move()
to transfer ownership of the integer to theTakeOwnership()
function. - After the move,
Number
inmain
no longer owns any object (it becomes a null pointer).
It's important to note that after using std::move()
, the original pointer (Number
in this case) is left in a valid but unspecified state. It's safe to reassign it or let it go out of scope, but you shouldn't try to use the object it previously owned.
#include <memory>
#include <utility>
#include <iostream>
void TakeOwnership(std::unique_ptr<int> Num) {}
int main() {
auto Number{std::make_unique<int>(42)};
TakeOwnership(std::move(Number));
std::cout << *Number;
}
Most compilers can detect this, and will generate a warning:
Warning: Use of a moved from object: 'Number'
Summary
In this lesson, we explored the concept of unique pointers, a crucial component of modern memory management. Let's briefly recap the key points:
- Understanding Memory Ownership: We've learned what smart pointers are and how they implement an ownership model for objects in dynamically allocated memory
- Memory Management Simplified: We explored how smart pointers automate memory management, thereby reducing the chances of memory-related errors.
- Creating Unique Pointers using
std::make_unique()
: We now know how to create unique pointers usingstd::make_unique()
, - Access and Ownership Transfer: We learned how to give other functions access to our resources through a raw pointer, generated by
get()
. We also covered how to transfer ownership of resources usingstd::move()
.
Copy Constructors and Operators
Explore advanced techniques for managing object copying and resource allocation