06 Pointers and References
Now, we arrive at one of the most feared topics in C++, pointers. Many people associate pointers with confusion, often because they tend to overthink them. In reality, pointers are quite simple once you understand the core concept.
In computing, memory management is crucial. Every application you open is loaded into memory (RAM), and all the data the computer processes is stored there. Without memory, nothing would function, variables, programs, and even the operating system itself rely on it.
A pointer is essentially an integer that holds a memory address. Think of computer memory as a long, one-dimensional array of bytes, each with a unique address, like houses lined up along a street. Just as you wouldn't physically move a house to show someone its location, but rather give them its address, a pointer stores the address of a specific memory block. This approach allows programs to work directly with data without unnecessary duplication.
Address | 1 | 2 | 3 | 4 | ... |
---|---|---|---|---|---|
Byte | Byte 1 | Byte 2 | Byte 3 | Byte 4 | ... |
Value | 0000 0101 | 0000 1001 | 0000 0010 | 0000 0000 | ... |
Pointers are extremely important because everything in computing involves reading and writing to memory. While you can write C++ code without directly using pointers, they offer a powerful way to manage memory.
Also pointers themselves aren’t tied to specific data types. Whether you're dealing with characters, booleans, integers, or any other data type, a pointer is still just a number representing a memory address. The reason we specify a type for pointers in C++ is to indicate how much data should be read or written at that memory location.
Memory Allocation
Memory (RAM) is where our program is loaded and where it stores data. It is divided into two distinct regions: the stack and the heap. The stack is typically a fixed-size memory area, often around 2 MB, while the heap also has a predefined size but can grow dynamically as the application runs.
A common misconception is that the stack and heap are stored in the CPU cache, but they actually reside in RAM. These regions serve as the primary storage areas for variables and other essential data during program execution.
While both stack and heap memory allow us to read and write data, they differ significantly in how memory is allocated and managed. For example, when storing an integer (typically 4 bytes), stack memory is automatically allocated and freed based on scope—once the scope is exited, the memory is released. In contrast, heap memory must be explicitly allocated and deallocated by the programmer, and it persists until it is manually freed. This act of requesting memory is known as memory allocation.
Object Lifetimes
The stack region in memory can be imagined as a stack of books, to access a book in the middle, you must first remove the ones on top. In a computers, instead of books, we push stack frames onto the stack.
When a function is called, a stack frame is created to store local variables, function parameters, return address, and other necessary information like saved registers and the previous stack frame pointer. This structure allows the function to execute and return properly, maintaining the state of the program during its execution. Once the function ends, the stack frame is automatically removed, and all variables within it are cleared from memory.
Scopes in C++ are not limited to functions; classes, statements, and loops also have their own scopes.
Additionally, we can create an isolated scope by enclosing code within curly brackets inside the main
function or any other part of the program.
Variables declared inside such a block exist only within that scope and are destroyed once the block ends.
main.cpp | |
---|---|
output | |
---|---|
On the other hand, objects allocated on the heap persist until they are manually deallocated or handled by automatic mechanisms like smart pointers. Failing to free heap memory can lead to memory leaks, where allocated memory is never reclaimed, or heap exhaustion, where excessive allocations exceed the available heap space, potentially causing a crash.
example | |
---|---|
This is a very common mistake, returning a pointer to a local stack-allocated array. Since the array is created inside the function, it only exists within that function's scope. Once the function returns, the stack frame is destroyed, and the array no longer exists, leaving us with a dangling pointer that points to invalid (or corrupted) memory.
Raw Pointers
We will start by introducing void*
(void pointer), a generic pointer that is not associated with any specific data type.
It can store the address of any variable, but since it lacks type information, we cannot directly read from or write to the memory it points to—we don't know how many bytes belong to it without first converting it to a specific pointer type.
This reinforces the idea that pointers are simply memory addresses, independent of data types.
Declaring Pointers
Pointers are declared by appending an asterisk to a data type, followed by the variable name.
The *
symbol is known as the dereference operator, which allows us to access the value stored at the memory address the pointer holds.
Null Pointers
In the example above, we assigned 0
to a pointer.
Since memory addresses do not start at zero, 0
is an invalid memory address.
However, having an invalid address is a perfectly acceptable state for a pointer, as it indicates that the pointer is not currently pointing to valid memory.
We also used NULL
, which is simply a macro-defined constant representing 0
. While it functions the same as writing 0
directly, it improves readability.
Additionally, we introduced nullptr
, a C++ keyword specifically designed to represent an invalid pointer. Unlike NULL
, nullptr
has stronger type safety, making it the preferred choice in modern C++.
Accessing the Address of a Variable
Earlier, we mentioned that everything created in a program has a memory address where its data is stored. This applies even to simple integer variables, each variable resides at a unique location in memory.
We can access a variable's address by prefixing it with &
, which is called the address-of operator (sometimes referred to as the reference operator).
This operator returns the memory address of the variable, allowing us to work with its location directly.
main.cpp | |
---|---|
output | |
---|---|
Note
The actual address printed will vary every time you run the program. This is due to Address Space Layout Randomization (ASLR) — a security feature used by modern operating systems to randomize memory addresses, making certain types of attacks harder to perform.
In this example, we assign a valid memory address to the pointer by using the address-of operator on a variable.
This ensures that the pointer correctly stores the location of value
in memory.
Dereferencing a Pointer
We have now reached the point where we may want to retrieve the data stored at the memory address held by a pointer. To do this, we use the dereference operator as a prefix to the pointer variable. This allows us to access and manipulate the value stored at that memory location, just as we would with a regular variables.
main.cpp | |
---|---|
output | |
---|---|
This will produce an error because void*
represents a generic memory address without a specific type.
While it can store the address of any data type, it cannot be dereferenced because the compiler does not know how many bytes belong to the variable at that address.
To fix this, we need to create an int*
pointer for an int
value.
This way, the compiler knows that 4 bytes (on most modern systems) after the address belong to the integer, allowing proper reading and writing of the data.
main.cpp | |
---|---|
output | |
---|---|
Dynamic Memory Allocation
Up to this point, all variables have been allocated on the stack, meaning their lifetime is tied to their current scope. This is generally safe, but it also means they are automatically deleted once the scope ends, limiting their lifespan.
To preserve the existence of important values beyond their original scope, we can use pointers in combination with the new
keyword.
This reserves memory on the heap and returns a pointer to the allocated memory.
Using new
, we can create variables on the heap, ensuring they remain accessible even after their original scope ends—provided we manage them correctly.
In the example above:
- We create a pointer of type
char*
. - We use the
new
keyword to allocate 8 bytes of memory on the heap. - Since a
char
is 1 byte in size, allocating an array of 8 chars reserves 8 contiguous bytes in memory. - The pointer
buffer
stores the address of the first element in this allocated memory block.
Initializing Heap Memory
Heap memory is not automatically initialized, meaning it can contain garbage values.
To initialize memory, we can use the memset
function from the <cstring>
header file.
example | |
---|---|
Heap Memory Management
Memory allocated on the heap is not automatically freed when a variable goes out of scope. Unlike stack memory, which is managed by the compiler, heap memory must be manually deallocated to prevent memory leaks. A situation where memory is allocated but never freed, causing a program to consume increasing amounts of memory over time and potential heap overflow, which occurs when the program exhausts all available heap memory and can no longer allocate new data.
To properly free heap memory, we use the delete
keyword followed by the pointer holding the allocated address.
When deallocating a dynamically allocated single object, we use delete
.
However, when deallocating a dynamically allocated array, we must use delete[]
to ensure the entire block of memory is freed correctly.
Constant Pointers
By now, we understand how pointers work, how to create, manipulate, and delete them. However, there are cases where we need to enforce restrictions on either the pointer itself or the data it points to.
A constant pointer applies specific constraints, ensuring that either the pointer’s address remains unchanged, the pointed-to value cannot be modified, or both.
Pointer to Constant
This type of pointer cannot modify the value it points to, but it can be reassigned to another address.
Use case: When you want to protect the data from being modified but allow the pointer to point elsewhere.
main.cpp | |
---|---|
output | |
---|---|
Constant Pointer
This type of pointer cannot be reassigned, but it can modify the value it points to.
Use case: When you want a pointer to always point to the same object but still allow modifications to the object.
main.cpp | |
---|---|
output | |
---|---|
Constant Pointer to Constant
This type of pointer cannot be reassigned and cannot modify the value it points to.
Use case: When you want a pointer to always point to the same object and ensure that the object cannot be modified through the pointer.
main.cpp | |
---|---|
output | |
---|---|
Pointer to Pointer
Since pointers are just variables that store memory addresses, it is possible to create a pointer that points to the location of another pointer. This is known as a pointer to a pointer. While it is theoretically possible to create multiple levels of pointers, doing so is impractical and rarely useful in real-world applications.
A pointer to a pointer is created by adding an additional asterisk to a regular pointer variable. This means the first pointer stores the address of the second pointer, and the second pointer stores the address of the actual data.
main.cpp | |
---|---|
output | |
---|---|
In this example:
buffer
is a pointer to a block of memory on the heap.pointer_to_buffer
is a pointer to a pointer that holds the address ofbuffer
.
While pointers to pointers can exist, they are rarely needed and should be used only when there is a valid use case. For most applications, a single pointer is sufficient.
Pointer Safety
Working directly with addresses and heap memory comes with risks. If not handled correctly, it can lead to serious memory management errors, such as dangling pointers, memory leaks, buffer overflows, and undefined behavior.
In this section, we will cover common pitfalls when working with pointers and best practices to write safer and more reliable C++ code.
Dangling Pointers
A dangling pointer is a pointer that references memory that has already been freed or is no longer valid. Accessing such memory leads to undefined behavior, which can result in crashes, corrupted data, or security vulnerabilities.
How Dangling Pointers Occur:
- Deleted Memory Access - A pointer still holds an address to memory that has already been deallocated.
- Returning Pointers to Local Variables - A pointer to a local variable is returned from a function, but the variable is destroyed when the function exits.
- Uninitialized Pointers - A pointer is used without being properly initialized, leading it to point to an arbitrary or invalid location.
Memory Leaks
A memory leak occurs when dynamically allocated memory is not properly deallocated, causing the program to consume more memory over time without releasing it. If a program continuously leaks memory, it may slow down, crash, or exhaust system resources.
Memory leaks are particularly dangerous in long-running applications, such as servers or embedded systems, where unmanaged memory growth can lead to performance degradation or failure.
Buffer Overflows
A buffer overflow occurs when a program writes more data into a buffer (such as an array) than it was allocated to hold. This results in overwriting adjacent memory, potentially causing program crashes, security vulnerabilities, or unpredictable behavior.
Buffer overflows are particularly dangerous because they can corrupt data, cause segmentation faults, or be exploited by attackers to execute malicious code.
Pointer Aliasing and Ownership
Pointer aliasing occurs when multiple pointers reference the same memory location. While this can be useful, it can also lead to unintended side effects, such as modifying a value unexpectedly or causing performance issues due to compiler optimizations being invalidated.
Preventions
Most pointer-related safety issues stem from raw pointer management and manual memory handling, which are prone to errors like memory leaks, dangling pointers, and undefined behavior.
To avoid these risks, modern C++ provides:
- Smart Pointers (
std::unique_ptr
,std::shared_ptr
,std::weak_ptr
) - Automatically manage memory and prevent leaks. - STL Containers (
std::vector
,std::array
, etc.) - Provide automatic memory management and prevent buffer overflows.
Pointer Arithmetic
After becoming familiar with raw pointers, one of the next important aspects to understand is how to use them in practical scenarios. This is where pointer arithmetic becomes extremely useful. Pointer arithmetic allows us to perform mathematical operations on pointers to navigate through memory locations, particularly when working with arrays or structs.
Pointer arithmetic works by adding or subtracting integer values to pointers, which effectively adjusts the memory address they point to. The pointer itself does not store the data but holds the address of the data in memory. By performing arithmetic on pointers, we can access different elements in arrays or traverse contiguous blocks of memory.
In the example below, pointer arithmetic is used to iterate through an array of structures.
Pointer arithmetic takes into account the size of the object the pointer is pointing to.
In this case, when we increment p_person
, it doesn’t just move by 1 byte.
It moves by the size of a Person
object, which is 68 bytes (64 bytes for name
and 4 bytes for age
).
This is why the pointer moves to the next Person
in the array, not just the next byte in memory.
This topic is further built upon in the section [[07 Data Types#Type Punning|Type Punning]].
References
Pointers and references in C++ are fundamentally similar in terms of what the computer actually does. However, semantically, they have subtle differences. A reference is essentially a syntax shortcut for a pointer, making the code more readable and easier to follow.
As the name suggests, a reference is used to refer to an existing variable.
Unlike pointers, a reference cannot be null and must always be bound to a valid variable.
This means you cannot set a reference to nullptr
, and it must always refer to an existing object.
main.cpp | |
---|---|
output | |
---|---|
In this example, b
is a reference to a
, meaning it acts as an alias for a
.
Any modifications to b
will directly affect a
, and vice versa.
Reference vs. Address-of Operator
In C++, the reference operator (&
) and the address-of operator (&
) can sometimes be confusing, but they serve distinct purposes depending on their usage.
- When
&
is appended to the data type, it signifies a reference. A reference is simply an alias for an existing variable, meaning it acts as another name for that variable. - When
&
is used as a prefix before a variable name, it means the address-of operator, which returns the memory address of the variable.
Pass by Value vs. Pass by Reference
In C++, there are two common ways to pass data into a function: pass-by-value and pass-by-reference. Each method has its own behavior and implications.
In pass-by-value, when we pass a variable to a function, a copy of the variable is created. The function then works with this copy, and any changes made to the parameter within the function do not affect the original variable.
main.cpp | |
---|---|
output | |
---|---|
In this case, the Increment()
function receives a copy of the number
from main()
.
Inside the function, the copy of number
is incremented, but the original number
in main()
remains unchanged.
As a result, the value of number
in main()
stays at 5
.
In pass-by-reference, instead of passing a copy of the variable, we pass the actual variable itself. This allows the function to modify the original variable directly, and any changes made to the parameter within the function will affect the original variable outside of the function as well.
To pass a variable by reference, we use the reference operator in the function parameter list.
main.cpp | |
---|---|
output | |
---|---|
In this case, the Increment()
function receives a reference to number
, meaning it works directly with the original number
in main()
. As a result, after the function call, the value of number
in main()
is updated to 6
.
When to Pass by Reference
There are four main scenarios where pass-by-reference is preferred over pass-by-value:
- Modifying Arguments
- If a function needs to modify its arguments, you should use pass-by-reference or pass-by-pointer.
- Avoiding Unnecessary Copies (Efficiency)
- When a function accepts a large object as a parameter, it's better to use pass-by-const-reference.
- Copy & Move Constructors
- Copy and move constructors must always take a reference to avoid unnecessary object creation and to ensure proper copying or moving of objects.
- Working with Polymorphism (Avoiding Object Slicing)
- When working with polymorphic classes, it's essential to pass objects by reference or pointer rather than by value.
- Passing by value may lead to object slicing, where the derived class data is lost when copied into a base class object.
Function Pointer
So far, we have only called functions directly to execute logic. A function serves as a symbol that we invoke whenever we want to perform a specific action. We can also pass arguments to a function and retrieve values from it, which allows us to write more dynamic code.
main.cpp | |
---|---|
output | |
---|---|
In this example, we have a regular function declaration with a simple definition. However, since functions are stored in memory, we can assign them to pointers.
main.cpp | |
---|---|
output | |
---|---|
In the code above, we use function pointer. The syntax may seem a bit complex at first, so let's break it down:
- The return type of the function comes first (
void
). - Inside the first set of parentheses, we declare the pointer (
*function
). - The second set of parentheses defines the function parameters (which are empty in this case).
This declaration can be simplified using the auto
keyword, which automatically deduces the correct type.
Another way to simplify this syntax is by creating a type alias using using
, especially when the same type is used repeatedly.
main.cpp | |
---|---|
output | |
---|---|
Where to Use Function Pointers
In this example, we define a function pointer and pass it to another function for use, showcasing how function pointers can add flexibility to code.
In the example above, we pass the PrintValue
function as a pointer to the ForEach
function, which then uses the pointer to invoke PrintValue
for each element in the vector.
While function pointers can still be useful in specific cases, such as when interacting with C libraries, modern C++ prefers lambda expressions and std::function
for more flexible, type-safe, and readable code.
Lvalues and Rvalues
In C++, understanding lvalues and rvalues is crucial because they are fundamental concepts that appear frequently in compiler warnings, error messages, and in modern C++ features like move semantics and temporary values.
An lvalue refers to a location value, something that has a persistent memory address. You can think of it as an object or a variable that you can modify or access.
In contrast, an rvalue represents a temporary value, usually something that does not have a lasting memory address. These can be literals or the results of function calls that return a temporary value.
output | |
---|---|
In the example above, a
is an lvalue because it is a variable with a specific memory location where the rvalue 10
is stored.
- An lvalue typically appears on the left side of the
=
(assignment) operator. - You can assign values to lvalues because they have a defined memory location.
An rvalue, however, can be more than just a literal. For example, it can be the result of a function call that returns a value. Rvalues do not refer to objects with persistent memory addresses and are typically used to represent temporary values.
output | |
---|---|
In this example, the function GetValue()
returns an rvalue because the value 10
is a temporary result.
The rvalue cannot be assigned directly to another rvalue but can be assigned to an lvalue like a
.
Lvalue Reference
We explained that an rvalue is not limited to just literals; it can also be the result of a function call that returns a value. Additionally, we can assign a value to the result of a function call.
main.cpp | |
---|---|
output | |
---|---|
The error happens because a function typically returns an rvalue, meaning a temporary value that cannot be assigned to. However, this is not always true, when a function contains a static variable, that variable persists across function calls and can be modified. By returning a reference to a static variable, we allow the function to return an lvalue, making it possible to modify the value directly.
main.cpp | |
---|---|
output | |
---|---|
Rvalue Reference
We have learned that when passing variables as function arguments, they are copied into a new variable created inside the function.
While passing them as lvalue references can improve performance, it also prevents passing literal values because an lvalue reference cannot bind to an rvalue (a temporary value).
To work around this, we can declare the parameter as const
, which allows temporary values (rvalues) to be assigned to a temporary variable behind the scenes.
The compiler creates a temporary variable, assigns the literal value to it, and then binds it to the lvalue reference.
main.cpp | |
---|---|
That is why most C++ functions declare parameters as const
lvalue references (const type&
), allowing them to efficiently accept both lvalues and rvalues.
However, it is also possible to create a function parameter that accepts only temporary values (rvalues) by appending the type with &&
, which is known as an rvalue reference.
main.cpp | |
---|---|
It is considered good practice to overload a function in modern medium-to-large projects if we want to support both lvalues and rvalues while maximizing performance.
By having an overload for const lvalue reference
(which can accept both lvalues and rvalues) and another for rvalue reference
, the compiler will always prefer the more specific overload when an rvalue is passed.
The key difference is that an rvalue reference (&&
) allows moving resources from the source, as it indicates that the variable is temporary and will not persist for long.
On the other hand, a const lvalue reference
(const int&
) signals that the variable is important, cannot be modified, and is passed by reference to avoid unnecessary copying.
Move Semantics
To fully understand this chapter, it is recommended to first reviewing and familiarizing yourself with [[08 Data Structures#Strings|Strings]] and [[09 User-Defined Types]].
So far, we have been introduced to lvalues and rvalues, but this is where their true purpose becomes clear. Move semantics allows us to transfer ownership of resources from one object to another, rather than copying or referencing them. This is particularly useful when we want to avoid the overhead of creating a duplicate and instead reuse existing resources, transferring ownership to a new scope.
Note
The original object is typically left in a moved-from state, meaning it is still valid but its contents are unspecified.
For example, when passing an object into a function that takes ownership, we would normally copy it. The same applies when returning an object from a function, we first create the object in the current stack frame and then copy it into the caller's scope. This is not ideal, as we must construct it in one place and copy it to another, leading to unnecessary overhead.
For simple types like numbers or small structs, this overhead is minimal.
However, for complex classes, such as std::string
, copying becomes expensive due to heap allocations.
Move semantics solves this problem by transferring ownership instead of copying the object, significantly reducing memory operations and improving performance.
This is a large example of implementing a custom string class. However, it should not be considered a proper way to build a string class for real-world applications. It uses a lot of C-style memory management, which is not ideal for production code. The purpose of this example is purely to demonstrate how copying and moving work in C++.
By adding logs inside the copy constructor and move constructor, we can clearly observe the difference between copying and moving an object:
- The copy constructor (
String(const String& other)
) duplicates the resource by allocating new memory and copying the data from the source object. - The move constructor (
String(String&& other) noexcept
) steals the resource from the temporary object (other
) by transferring the pointer to the new object, rather than copying the data. After the transfer, the original object is detached by setting its pointer tonullptr
and its size to0
.
This behavior makes moving much more efficient than copying, especially for large objects that allocate memory on the heap, such as strings or vectors. By moving rather than copying, we avoid unnecessary memory allocations and reduce performance overhead.
Standard Move Function
After understanding rvalue references, move constructors, and their benefits, it’s time to introduce std::move
, a function that allows us to explicitly move objects instead of copying them.
We’ll refer back to the String
example from the section [[#Move Semantics|Move Semantics]], where we used std::move
to transfer ownership of a temporary string into m_Name
inside the Entity
class.
To simplify the explanation, we’ll now demonstrate std::move
using just variables of our custom String
class.
main.cpp | |
---|---|
output | |
---|---|
In this example, string1
is copied into string2
because it is an lvalue (a named variable).
However, if we want to move its resources to string2
instead of copying them, we need to treat string1
as a temporary value (rvalue), because the move constructor requires an rvalue reference.
One way to do this is by casting string1
to an rvalue reference using (String&&)
.
However, this approach is not ideal and does not work with variables deduced using auto
.
To solve this, we use std::move
, which efficiently converts an lvalue into an rvalue, allowing us to move the resource without unnecessary copies.
It also makes the intention clear in the code, signaling that we are intentionally transferring ownership rather than copying.
main.cpp | |
---|---|
output | |
---|---|
Move Assignment Operator
Using std::move
inside a constructor is not the same as using it after an assignment operator.
Constructors are responsible for creating objects, while the assignment operator replaces an existing object's contents.
Since operators behave like regular functions, we need to explicitly define a move assignment operator to enable move semantics during assignment.
main.cpp | |
---|---|
The self-assignment check (if (this != &other)
) is crucial. If we mistakenly attempt to move an object into itself.
Without this check, the move logic would detach the object’s own data, leaving it in an invalid state.
This could lead to double deletion when the destructor runs, causing undefined behavior.
By verifying that this
and other
are different objects, we prevent accidental self-moves.
A move constructor is used when creating a new object from an existing one, while a move assignment operator is used when an existing object is reassigned.
A general rule in C++ is that if you implement a move constructor, you should also provide a move assignment operator, as both serve distinct but complementary purposes.
Smart Pointers
So far, we have relied on C-style raw pointers with new
and delete
, requiring manual memory management.
However, this approach is error-prone, as forgetting to call delete
can lead to memory leaks, while deleting memory incorrectly can cause undefined behavior.
To address these issues, C++ introduced smart pointers, which automate memory allocation and deallocation. Smart pointers wrap around raw pointers and, depending on their type, automatically free memory when it is no longer needed.
Instead of using new
directly, smart pointers provide factory functions (e.g., std::make_unique
, std::make_shared
), which should be preferred as they:
- Improve exception safety by ensuring memory is allocated and assigned in one step.
- Simplify code by eliminating explicit calls to
new
anddelete
.
To use smart pointers, include the <memory>
header from the C++ Standard Library.
Unique Pointer
A unique pointer is a scoped smart pointer, meaning it automatically deallocates the allocated memory when it goes out of scope.
This eliminates the need for manual delete
calls and helps prevent memory leaks.
Here, the Entity
class logs its creation and destruction, allowing us to observe when the unique pointer automatically deallocates the object at the end of main
.
A std::unique_ptr
cannot be copied, ensuring exclusive ownership of the resource.
Attempting to copy it will result in a compilation error.
However, ownership can be transferred using std::move()
.
If multiple parts of a program need access to the same resource, consider using std::shared_ptr
instead.
Shared Pointer
A std::shared_ptr
works differently from a std::unique_ptr
and has more complexity under the hood.
It relies on reference counting, which keeps track of how many shared_ptr
instances are pointing to the same resource.
When the reference count reaches zero, meaning no more shared_ptr
instances are using the resource, the allocated memory is automatically freed.
Instances of std::shared_ptr
are also destroyed at the end of their scope, but only the pointer itself, not the actual object it manages, unless the reference count reaches zero.
If there are other shared_ptr
instances still referencing the same object, the object will remain alive until the last shared_ptr
is destroyed or reset.
Therefore, they are also called strong references because they prevent the object from being destroyed.
Additionally, you should not use new
to create a shared_ptr
.
Instead, it's recommended to use std::make_shared()
.
This is because std::make_shared()
performs a single allocation for both the object and the control block (which stores the reference count), leading to better performance and exception safety compared to manually calling new
.
In this example, there are two instances of std::shared_ptr<Entity>
, one in the main scope (e
) and one inside the inner scope (sharedEntity
).
Even though the inner scope ends and sharedEntity
is destroyed, the object is not deleted because e
still holds a reference to it.
The object will only be destroyed when the last shared pointer managing it is destroyed or reset, which in this case happens when e
goes out of scope at the end of main()
.
Weak Pointer
Weak pointers are used in combination with shared pointers. They also allow sharing access to a resource, but they do not keep the resource alive because they do not increase the reference count.
This can be useful when you need to observe or manipulate a shared resource (e.g., sorting a list of objects) without taking ownership of it.
In this example, the Entity instance will be destroyed at the end of the inner scope, not at the end of main()
.
This happens because we assigned it to a weak pointer, which does not increase the reference count.
Since no other shared_ptr
exists after the inner scope ends, the resource is freed immediately.
When to use Smart Pointers
Smart pointers should be preferred over raw pointers as they provide better memory safety and help prevent memory leaks.
Use std::unique_ptr
when a heap allocation is necessary.
std::shared_ptr
should only be used when multiple owners are required, as it comes with additional overhead due to reference counting and internal management.
However, there may be low-level cases where smart pointers are not sufficient, but these are uncommon in most high-level applications.
Chapter Summary
By completing this lesson, you should now have a solid understanding of computer memory and how objects are allocated on the stack and heap. You’ve also learned why one might be more beneficial than the other in different situations.
Additionally, you now understand ownership, how it works, why it matters, and when it can be useful. Finally, we explored smart pointers, which simplify memory management and help prevent memory leaks, making C++ programs more safe.