Memory Ownership and Smart Pointers

Learn how to manage dynamic memory using unique pointers and the concept of memory ownership
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, Unlimited Access
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted

In our previous lessons, we've primarily worked with stack memory. Stack memory is straightforward and efficient, automatically managing the lifecycle of our variables. However, it comes with limitations:

  1. Fixed size: Stack memory is allocated at compile-time, so we can't create dynamic data structures that grow or shrink as needed.
  2. Scope-limited: Variables in stack memory are destroyed when they go out of scope, which can be limiting for objects that need to live longer.
  3. 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:

  1. Memory leaks: Forgetting to deallocate memory, leading to resource waste.
  2. Dangling pointers: Using pointers that point to already freed memory.
  3. 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.

Smart pointers help implement this ownership model.

In a sense, they try to combine the best of both worlds. They 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.

Memory Ownership and 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.

Creating Unique Pointers with 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 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 of 42
  • A std::unique_ptr, which we’ve called Pointer, within the stack frame of our main function. That std::unique_ptr is considered the sole owner of the integer that is stored in the heap.

The return type of 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)
  };
}

However, when using std::make_unique, it’s somewhat common 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, make_unique is so ubiquitous that C++ developers soon learn that it returns a unique_ptr, so repeating this 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. As a result, the unique_ptr class takes some steps to prevent this. For example, the copy constructor has been deleted, meaning code like this will result in a compilation error:

#include <memory>

int main(){
  auto Ptr1{std::make_unique<int>(42)};
  auto Ptr2{Ptr1};  
}
error: 'std::unique_ptr': attempting to reference a deleted function

We will better understand copy constructors, and learn how we can add them to our own classes in the next course.

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 has a raw pointer to a resource simply has requesting access to that resource, but does not own it. The resource is owned by whoever has the unique_ptr.

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.\n";
  }
}
main function owns the pointer.
TakeOwnership function now owns the pointer.
Value: 42
Number no longer owns any object.

In this example:

  1. We create a unique_ptr called Number in the main function.
  2. We use std::move() to transfer ownership of the integer to the TakeOwnership function.
  3. After the move, Number in main 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'

const Unique Pointers

Similar to raw pointers, there are two ways we can use const with std::unique_ptr. This creates four possible combinations:

1. Non-const pointer to non-const

Most of the examples we’ve seen in this lesson have been like this. Neither the pointer nor the object it points to are const, so we can modify either:

#include <memory>

int main() {
  auto Pointer{std::make_unique<int>(42)};

  // Modify the underlying object
  (*Pointer)++;

  // Modify the pointer
  Pointer.reset();
}

2. Const pointer to non-const

Here, we cannot modify the pointer, but we can modify the object it points to:

#include <memory>

int main() {
  const auto Pointer{
    std::make_unique<int>(42)};

  // Modify the underlying object
  (*Pointer)++;

  // Error - can't modify the pointer
  Pointer.reset();
}

3. Non-const pointer to const

Here, we can modify the pointer, but not the object it points to:

#include <memory>

int main() {
  auto Pointer{
    std::make_unique<const int>(42)};

  // Error - can't modify the underlying object
  (*Pointer)++;

  // Modify the pointer
  Pointer.reset();  
}

4. Const pointer to const

Finally, both the pointer and the underlying object can be const, preventing us from modifying either:

#include <memory>

int main() {
  const auto Pointer{
    std::make_unique<const int>(42)};

  // Error - can't modify the underlying object
  (*Pointer)++;  

  // Error - can't modify the pointer
  Pointer.reset();  
}

Summary

In this lesson, we delved into the concept of unique pointers, a crucial component of modern memory management. Let's briefly recap the key points:

  1. Understanding Memory Ownership: We've learned what smart pointers are and how they implement an ownership model for objects in dynamically allocated memory
  2. Memory Management Simplified: We explored how smart pointers automate memory management, thereby reducing the chances of memory-related errors.
  3. Creating Unique Pointers using std::make_unique: We now know how to create unique pointers using std::make_unique,
  4. 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 using std::move().

Preview of the Next Lesson: Arrays

In our next lesson, we will delve into the world of arrays using std::vector. The key topics we’ll cover include:

  • Introduction to Arrays: Understanding arrays as a fundamental concept in programming, where they serve as a way to store collections of items (like numbers, objects, etc.) in an ordered manner
  • Introduction to std::vector: Introducing std::vector as an implementation of a dynamic array, and the difference between dynamic and static arrays.
  • Creating and Initializing a std::vector: Learn how to declare std::vector, initialize it with values, and understand its dynamic nature.
  • Accessing Elements: Methods to access and modify elements in a std::vector, including using the subscript operator.
  • Iterating over std::vector: How to iterate through std::vector using various methods, including range-based for loops.
  • Practical Examples and Applications: Real-world scenarios and code examples to solidify the understanding of std::vector.

Was this lesson useful?

Next Lesson

Dynamic Arrays using std::vector

Explore the fundamentals of dynamic arrays with an introduction to std::vector
Abstract art representing computer programming
Ryan McCombe
Ryan McCombe
Posted
3D art showing a progammer setting up a development environment
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, Unlimited Access
Memory, References and Pointers
3D art showing a progammer setting up a development environment
This lesson is part of the course:

Intro to C++ Programming

Become a software engineer with C++. Starting from the basics, we guide you step by step along the way

Free, unlimited access

This course includes:

  • 57 Lessons
  • Over 200 Quiz Questions
  • 95% Positive Reviews
  • Regularly Updated
  • Help and FAQ
Next Lesson

Dynamic Arrays using std::vector

Explore the fundamentals of dynamic arrays with an introduction to std::vector
Abstract art representing computer programming
Contact|Privacy Policy|Terms of Use
Copyright © 2024 - All Rights Reserved