Featured image of post 【C++ Syntax】Smart Pointer

【C++ Syntax】Smart Pointer

Digging Deep into Smart Pointers

Table of Contents

Reference Resource: BiteTech C++ Course

Why Do We Need Smart Pointers?

Let’s start with an example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
int div()
{
    int a, b;
    cin >> a >> b;
    if (b == 0)
        throw invalid_argument("Error: Divident is 0");

    return a / b;
}

void func()
{
    int* p = new int();
    cout << div() << endl;
    delete p;
}

int main()
{
    // Catching Exceptions
    try
    {
        func();
    }
    catch(exception& e)
    {
        cout << e.what() << endl;
    }
    return 0;
}

Since the div function may throw an exception, the normal execution flow of the program can be interrupted. As a result, div might not complete, and the pointer p won’t be released. Typically, exceptions are caught at the outermost level, but in this case—without using smart pointers—you would have to add another try-catch block inside func to ensure that p gets properly deleted and memory isn’t leaked.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
int div()
{
    int a, b;
    cin >> a >> b;
    if (b == 0)
        throw invalid_argument("Error: Divident is 0");

    return a / b;
}

void func()
{
    int* p = new int();
    try	// Add exception handling in func to ensure p is properly released
    {
        cout << div() << endl;
    }
    catch (exception& e)	// Alternatively, catch all exceptions using: catch (...)
    {
        delete p;
        p = nullptr;
        throw e;
    }
    
    delete p;
    p = nullptr;
}

int main()
{
    // Catching Exceptions
    try
    {
        func();
    }
    catch(exception& e)
    {
        cout << e.what() << endl;
    }
    return 0;
}

When working with code that involves multiple raw pointers, preventing memory leaks often requires adding nested layers of exception-handling logic. To address this complexity in a cleaner and more elegant way, seasoned developers came up with a better solution: smart pointers.

Fundamental Principles of Smart Pointers

The implementation principle of smart pointers is quite straightforward: store the raw pointer in the constructor, and release it in the destructor.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
template<class T>
class SmartPtr
{
public:
    SmartPtr(T* ptr)
        :_ptr(ptr)
    {}     
    
    ~SmartPtr()
    {
        if (_ptr)
        {
             delete _ptr;
            _ptr = nullptr;
        }
    }
    
private:
    T*_ptr;
};

So, the messy and exception-sensitive code above can be simplified by using smart pointers:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
int div()
{
    int a, b;
    cin >> a >> b;
    if (b == 0)
        throw invalid_argument("Error: Divident is 0");

    return a / b;
}

void func()
{
    int* p = new int();
   
    // No need for exception handling—use smart pointers instead
    SmartPtr<int> sp(p);
    cout << div() << endl;
}

int main()
{
    // Catching Exceptions
    try
    {
        func();
    }
    catch(exception& e)
    {
        cout << e.what() << endl;
    }
    return 0;
}

Regardless of whether an exception is thrown, the pointer p will be properly released. This is because the SmartPtr object’s lifecycle ensures that the resource is cleaned up even if the function exits prematurely due to an exception. In short, SmartPtr takes care of resource management for us. Whether the function completes normally or is interrupted by an exception, the sp object will go out of scope, triggering its destructor and releasing the memory held by p. Essentially, we are delegating resource ownership to the smart pointer, which stores the resource in its constructor and releases it in its destructor.

RAII

RAII (Resource Acquisition Is Initialization) is a technique that uses object lifetimes to manage program resources such as memory, file handles, network connections, mutexes, and more.

In other words, a resource is acquired during object construction, remains valid throughout the object’s lifetime, and is released when the object is destroyed. This approach effectively delegates resource management to an object.

RAII offers two major benefits:

  • No need to manually release resources
  • Resources remain valid throughout the object’s lifetime

This technique indirectly addresses the lack of a garbage collection mechanism in C++.

  • RAII is a resource management paradigm. In addition to smart pointers, constructs like unique_lock and lock_guard are also built upon this idea.
  • Smart pointers are designed as RAII-based classes.

Using Smart Pointers Effectively

The SmartPtr implementation above can’t yet be considered a true smart pointer, because it doesn’t behave like a regular pointer. To make it functionally complete, we need to add some code that enables standard pointer operations.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class T>
class SmartPtr 
{
public:
	SmartPtr(T* ptr = nullptr)
		: _ptr(ptr)
	{}
	~SmartPtr()
	{
		if(_ptr)
        {
            delete _ptr;
            _ptr = nullptr;
        }	
	}
    
    // Overload operators & and * to give SmartPtr pointer-like behavior
	T& operator*() {return *_ptr;}
	T* operator->() {return _ptr;}
    
private:
	T* _ptr;
};

Potential Pitfalls of Smart Pointers

Let’s start with an example:

1
2
3
4
5
6
7
int main()
{
    SmartPtr<int> sp1(new int());
    SmartPtr<int> sp2 = sp1;
    
    return 0;
}

Here, both sp1 and sp2 point to the same memory location. When the program exits, both objects invoke their destructors, resulting in the same resource being deleted twice—a classic case of double deletion.

To address this issue, smart pointers evolved into three distinct strategies:

  • Ownership transfer: auto_ptr
  • Copy prevention: unique_ptr
  • Shared ownership via reference counting: shared_ptr
    • Which can lead to circular references, resolved by introducing weak_ptr

auto_ptr

Overall, auto_ptr is considered a flawed design. It breaks the intuitive behavior of pointers, and many companies explicitly forbid its use.

How auto_ptr Works

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
// C++98 ownership transfer with auto_ptr
namespace my_smart_pointer
{
    template<class T>
    class auto_ptr
    {
    public:
    	auto_ptr(T* ptr)
   		 :_ptr(ptr)
    	{}
        
    	auto_ptr(auto_ptr<T>& sp)
    	:_ptr(sp._ptr)
    	{
            // Transfer ownership
            sp._ptr = nullptr;
        }
        
        auto_ptr<T>& operator=(auto_ptr<T>& ap)
        {
            // Check for self-assignment
            if (this != &ap)
            {
                // Release the resource held by this object
                if (_ptr)
                {
                    delete _ptr;
                    _ptr = nullptr;
                }        	
                
                // Transfer the resource from ap to this object
                _ptr = ap._ptr;
                ap._ptr = nullptr;
            }
            return *this;
        }
        
    	~auto_ptr()
        {
            if (_ptr)
            {
                cout << "delete:" << _ptr << endl;
                delete _ptr;
                _ptr = nullptr;
         	}
        }
        
        // Use like a regular pointer
        T& operator*()
        {
        	return *_ptr;
        }
        
        T* operator->()
        {
       		return _ptr;
        }
        
    private:
        T* _ptr;
    };
}

It may cause a null pointer error:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
// main.cpp
int main()
{
    std::auto_ptr<int> sp1(new int());
    std::auto_ptr<int> sp2(sp1); // Ownership transfer

    // sp1 is dangling and can no longer be assigned
    //*sp1 = 10; 
    *sp2 = 10;
    cout << *sp2 << endl;
    cout << *sp1 << endl;
    return 0;
}

unique_ptr

A straightforward and aggressive solution is to simply prevent copying. This is the recommended approach, but it becomes unusable in scenarios where copying is actually required.

How unique_ptr Works

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
namespace my_smart_pointer
{
    template<class T>
    class unique_ptr
    {
    public:
        unique_ptr(T* ptr)
         :_ptr(ptr)
        {}
        ~unique_ptr()
        {
            if (_ptr)
            {
                cout << "delete:" << _ptr << endl;
                delete _ptr;
                _ptr = nullptr;
            }
        }

        T& operator*()
        {
            return *_ptr;
        }
        T* operator->()
        {
            return _ptr;
        }
        
        unique_ptr(const unique_ptr<T>& sp) = delete;
        unique_ptr<T>& operator=(const unique_ptr<T>& sp) = delete;
        
    private:
        T* _ptr;
    };
}

shared_ptr

Shared ownership among multiple shared_ptr instances is achieved through reference counting.

How shared_ptr Works

Four key points:

  • Internally, shared_ptr maintains a reference count for each resource to track how many objects share it.
  • When a shared_ptr is destroyed (i.e., its destructor is called), it signals that the object no longer uses the resource, and the reference count is decremented by one.
  • If the reference count reaches zero, it means this was the last owner of the resource, and the resource must be released.
  • If the reference count is not zero, it means other objects are still using the resource, so it must not be released—otherwise, they would be left with dangling pointers.
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
namespace my_smart_pointer
{
    template<class T>
    class shared_ptr
    {
    public:
        shared_ptr(T* ptr = nullptr)
            :_ptr(ptr)
            , _pRefCount(new int(1))
            {}

        shared_ptr(const shared_ptr<T>& sp)
            :_ptr(sp._ptr)
            , _pRefCount(sp._pRefCount)
            {
                ++(*_pRefCount);
            }
        
         ~shared_ptr()
        {
            Release();
        }

        shared_ptr<T>& operator=(const shared_ptr<T>& sp)
        {
            //if (this != &sp)
            if (_ptr != sp._ptr) 
            {
                Release();	// First release the resource previously managed by this object
                _ptr = sp._ptr;
                _pRefCount = sp._pRefCount;
                ++(*_pRefCount);
            }
            return *this;
        }
        
        void Release()
        {
            if (--(*_pRefCount) == 0 && _ptr)
            {
                cout << "delete:" << _ptr << endl;
                delete _ptr;
                _ptr = nullptr;
                 
                delete _pRefCount;
                _pRefCount = nullptr;
            }
        }
        
        T& operator*()
        {
            return *_ptr;
        }
        T* operator->()
        {
            return _ptr;
        }

    private:
        T* _ptr;
        int* _pRefCount;
    };
}

Thread Safety Issues with shared_ptr During Copy Assignment

Since the reference count is stored on the heap, problems can arise when two threads—say, t1 and t2—each hold a smart pointer and simultaneously perform copy assignment from the same shared_ptr instance sp. This can lead to both threads trying to increment or decrement the reference count at the same time, resulting in incorrect behavior.

Here’s a simplified illustration of the issue:

We can examine the thread safety of shared_ptr from two perspectives:

  • The reference count is shared among multiple shared_ptr instances. If two threads simultaneously increment or decrement the count (e.g., ++ or --), these operations are not atomic. For example, if the count starts at 1 and both threads increment it, the result might still be 2—leading to inconsistencies. This can cause resources to be leaked or prematurely destroyed, resulting in crashes. Therefore, reference count operations must be synchronized. In other words, shared_ptr ensures thread safety for reference counting.
  • The actual object managed by the smart pointer resides on the heap. If two threads access it concurrently, thread safety issues may arise depending on how the object itself is used.

Here’s the revised version of the code that addresses these concerns:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
namespace my_smart_pointer
{
    template<class T>
    class shared_ptr
    {
    public:
        shared_ptr(T* ptr = nullptr)
            :_ptr(ptr)
        , _pRefCount(new int(1))
        , _pmtx(new mutex)
        {}

        shared_ptr(const shared_ptr<T>& sp)
        :_ptr(sp._ptr)
        , _pRefCount(sp._pRefCount)
        , _pmtx(sp._pmtx)
        {
            AddRef();
        }

        void Release()	// Decrementing the reference count requires lock protection
        {
            _pmtx->lock();
            bool flag = false;
            if (--(*_pRefCount) == 0 && _ptr)
            {
                cout << "delete:" << _ptr << endl;
                delete _ptr;
                _ptr = nullptr;
                
                delete _pRefCount;
                _pRefCount = nullptr;
                flag = true;
            }

            _pmtx->unlock();

            if (flag == true)	
            {
                delete _pmtx;
                _pmtx = nullptr;
            }    
        }

        void AddRef()	// Incrementing the reference count also requires lock protection
        {
            _pmtx->lock();
            ++(*_pRefCount);
            _pmtx->unlock();
        }

        shared_ptr<T>& operator=(const shared_ptr<T>& sp)
        {
            //if (this != &sp)
            if (_ptr != sp._ptr)
            {
                Release();

                _ptr = sp._ptr;
                _pRefCount = sp._pRefCount;
                _pmtx = sp._pmtx;
                AddRef();
            }
            return *this;
        }

        int use_count()
        {
            return *_pRefCount;
        }

        ~shared_ptr()
        {
            Release();
        }

        T& operator*()
        {
            return *_ptr;
        }
        T* operator->()
        {
            return _ptr;
        }
        T* get() const
        {
            return _ptr;
        }
    private:
        T* _ptr;
        int* _pRefCount;
        mutex* _pmtx;
    };
}

Smart pointer implementations in the C++ standard library are thread-safe by design.

Limitation of shared_ptr: Circular References

What Is a Circular Reference?

Let’s look at a typical scenario:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
struct ListNode
{
    int _data;
    shared_ptr<ListNode> _prev;
    shared_ptr<ListNode> _next;
    ~ListNode(){ cout << "~ListNode()" << endl; }
};

int main()
{
    shared_ptr<ListNode> node1(new ListNode);
    shared_ptr<ListNode> node2(new ListNode);
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
   
    // Circular reference prevents resource from being released
    node1->_next = node2;
    node2->_prev = node1;
    
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
    return 0;
}

In this scenario, the following situation occurs:

  • node1 and node2 are two smart pointer objects pointing to two separate nodes. Their reference counts are both 1, so there’s no need to manually call delete.
  • node1’s _next points to node2, and node2’s _prev points back to node1. As a result, both reference counts become 2.
  • When node1 and node2 are destroyed, their reference counts drop to 1. However, node1 still holds _next, which points to node2, and node2 still holds _prev, which points to node1. In theory, if _next were destroyed, node2 would be released; if _prev were destroyed, node1 would be released.
  • But _next is a member of node1, and node1 is managed by node2’s _prev. _prev is a member of node2, which is managed by node1’s _next. This creates a circular reference, where each object indirectly keeps the other alive. As a result, neither object is ever released.

This is essentially a “chicken-and-egg” problem in memory management.

How to Solve It: weak_ptr

Strictly speaking, weak_ptr is not a smart pointer in the traditional sense, because it doesn’t follow the RAII (Resource Acquisition Is Initialization) principle. It behaves like a pointer, but it doesn’t manage or release resources. Instead, it was specifically designed to break circular references caused by shared_ptr.

Based on the earlier implementation of shared_ptr, a simplified version of weak_ptr might look like this (the actual implementation in the C++ standard library is much more complex):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
namespace my_smart_pointer
{
    template<class T>
    class weak_ptr
    {
    public:
        weak_ptr()
        :_ptr(nullptr)
        {}

        weak_ptr(const shared_ptr<T>& sp)
        :_ptr(sp.get())
        {}

        weak_ptr<T>& operator=(const shared_ptr<T>& sp)
        {
            _ptr = sp.get();
            return *this;
        }

        T& operator*()
        {
            return *_ptr;
        }

        T* operator->()
        {
            return _ptr;
        }

    private:
        T* _ptr;
    };
}

So, in the previous example, we can refactor the code to use weak_ptr. This way, when we assign node1->_next = node2; and node2->_prev = node1;, the _next and _prev members—now implemented as weak_ptr—won’t increase the reference counts of node1 and node2. As a result, we avoid the circular reference problem entirely.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
struct ListNode
{
    int _data;
    weak_ptr<ListNode> _prev;	// Replace shared_ptr with weak_ptr here to avoid circular reference
    weak_ptr<ListNode> _next;
    ~ListNode(){ cout << "~ListNode()" << endl; }
};

int main()
{
    shared_ptr<ListNode> node1(new ListNode);
    shared_ptr<ListNode> node2(new ListNode);
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
    node1->_next = node2;
    node2->_prev = node1;
    cout << node1.use_count() << endl;
    cout << node2.use_count() << endl;
    
	return 0;
}

Evolution of Smart Pointers in C++

C++ does not have a built-in garbage collection (GC) mechanism. This means that manually releasing allocated resources is a persistent challenge—especially when dealing with exception safety. A small oversight can easily lead to memory leaks. Such leaks gradually consume available memory, and since many operations in a program depend on memory, it’s crucial to avoid them as much as possible.

To address this, smart pointers were introduced based on the RAII (Resource Acquisition Is Initialization) principle.

Phase One:

C++98 introduced auto_ptr for the first time. However, its design had serious flaws and is no longer recommended for use.

Phase Two:

For the next decade, the C++ standard made little progress in this area. In response, a group of experts created an unofficial community project known as Boost, which included a wide range of utilities. Within Boost, they redesigned smart pointers:

  • scoped_ptr / scoped_array (non-copyable versions)
  • shared_ptr / shared_array (reference-counted versions)
  • weak_ptr (designed to solve circular reference issues)
  • scoped_ptr is intended for managing single objects allocated with new, and releases them using delete in its destructor.
  • scoped_array is for managing arrays allocated with new[], and releases them using delete[]. It also overloads the operator[] for array access.

Phase Three:

C++11 officially introduced smart pointers into the standard library, drawing inspiration from Boost’s implementation with some refinements. Features like rvalue references and move semantics in C++11 were also heavily influenced by Boost.

Custom Deleters

The official C++ standard library did not adopt the scoped_array and shared_array implementations from the Boost library. So how does it handle situations where multiple objects are allocated with new[]? We can solve this by writing a custom functor-based deleter.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// Functor-based custom deleter
template<class T>
struct FreeFunc 
{
    void operator()(T* ptr)
    {
        cout << "free:" << ptr << endl;
        free(ptr);
	}
};

template<class T>
struct DeleteArrayFunc 
{
    void operator()(T* ptr)
    {
        cout << "delete[]" << ptr << endl;
        delete[] ptr;
    }
};

int main()
{
    FreeFunc<int> freeFunc;
    std::shared_ptr<int> sp1((int*)malloc(4), freeFunc);
    DeleteArrayFunc<int> deleteArrayFunc;
    std::shared_ptr<int> sp2((int*)malloc(4), deleteArrayFunc);
    std::shared_ptr<A> sp4(new A[10], [](A* p){delete[] p; });

    std::shared_ptr<FILE> sp5(fopen("test.txt", "w"), [](FILE* p)
    	{
        	fclose(p); 
        });
    
    return 0;
}

Essentially, we pass a functor object to the smart pointer that defines how the resource should be released. Since the default deletion behavior in the C++ standard library’s smart pointers is delete for single objects, we can handle other cases—such as arrays or custom cleanup logic—by writing our own functor-based deleters.

Supplement 1: RAII in Practice – Lock Guards

Throwing exceptions can not only lead to memory leaks, but also cause deadlocks in certain scenarios. For example:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
#include <mutex>

int div()
{
    int a, b;
    cin >> a >> b;
    if (b == 0)
        throw invalid_argument("Error: Divident is 0");

    return a / b;
}

void func()
{
    mutex mtx;
    
    mtx.lock();
    cout << div() << endl;	// If div throws an exception, the following unlock won't be executed — leading to a deadlock
	mtx.unlock();
}

int main()
{
    try
    {
        func();
    }
    catch(exception& e)
    {
        cout << e.what() << endl;
    }
    return 0;
}

By applying the RAII principle, we can encapsulate the locking and unlocking logic within a class constructor and destructor. This effectively solves the problem mentioned above.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
template<class Lock>
class LockGuard
{
public:
    LockGuard(Lock& lock)
        :_lock(lock)
    {
        _lock.lock();	// Place the locking logic inside the constructor
    }
    
    ~LockGuard()
    {
        cout << "unlock" << endl;
        _lock.unlock();	// Place the unlocking logic inside the destructor
    }
    
    // Copying and assignment are not allowed
    LockGuard(LockGuard<Lock>&) = delete;
    LockGuard<Lock>& operator=(LockGuard<Lock>&) = delete;
    
private:
    Lock& _lock;	// Locks are non-copyable — this example uses a reference member to ensure the same lock is used
}

Apply the LockGuard class within the func function:

1
2
3
4
5
6
7
void func()
{
    mutex mtx;
    LockGuard<mutex> lock_guard(mtx);	// Use LockGuard to manage locking and unlocking automatically

    cout << div() << endl;	
}

Supplement 2: Understanding Memory Leaks

What Is a Memory Leak?

A memory leak occurs when a program fails to release memory or resources that are no longer in use—often due to negligence, coding errors, or poor exception safety. It doesn’t mean the memory physically disappears; rather, the application allocates a block of memory but, due to flawed design, loses control over it, resulting in wasted memory.

If the process terminates normally, the operating system will reclaim the memory. So in many cases, restarting the program can temporarily resolve the issue.

Why Memory Leaks Are Harmful

Memory leaks can have serious consequences for long-running programs—such as operating systems or backend services that cannot be restarted casually. Over time, leaks cause available memory to shrink, leading to slower response times, system freezes, or failures in operations that require memory (e.g., storing data in containers, opening files, creating sockets, sending data).

How to Prevent Memory Leaks

  • Be cautious when writing C/C++ code.
  • Use smart pointers to manage resources in complex or error-prone areas (preventive strategy).
  • If you suspect a memory leak, use diagnostic tools to detect and resolve it (reactive strategy).
    • Recommended tool: Valgrind — a powerful memory analysis tool for Linux.
Licensed under CC BY-NC-SA 4.0
Last updated on Oct 18, 2025 20:22 +0200
发表了12篇文章 · 总计5万0千字
本博客已运行
Built with Hugo
Theme Stack designed by Jimmy