C++ Smart Pointers: unique_ptr, shared_ptr & Memory-Safe Patterns

C++ Smart Pointers: unique_ptr, shared_ptr & Memory-Safe Patterns

이 글의 핵심

Hands-on guide to C++ smart pointers—unique_ptr, shared_ptr, weak_ptr—with examples and real-world patterns.

Introduction

Smart pointers are RAII-style wrappers that provide automatic memory management. They help avoid leaks and dangling pointers from raw new/delete.

// ❌ Raw pointer (unsafe)
int* ptr = new int(10);  // Allocate on heap
// ... use ...
delete ptr;  // Manual delete (forget it → leak!)
// Issues:
// 1. Forgetting delete → leak
// 2. Exception may skip delete
// 3. Double delete → crash
// 4. Use after delete → UB (dangling)

// ✅ Smart pointer (safe)
// std::make_unique: factory for unique_ptr
// RAII: acquire in constructor, release in destructor
std::unique_ptr<int> ptr = std::make_unique<int>(10);
// delete runs automatically at end of scope
// Still safe if exceptions occur

1. unique_ptr — exclusive ownership

Basic usage

#include <memory>
#include <iostream>

int main() {
    // Create
    std::unique_ptr<int> ptr = std::make_unique<int>(10);
    
    // Use
    std::cout << *ptr << std::endl;  // 10
    *ptr = 20;
    std::cout << *ptr << std::endl;  // 20
    
    // nullptr check
    if (ptr) {
        std::cout << "valid" << std::endl;
    }
    
    // Array
    std::unique_ptr<int[]> arr = std::make_unique<int[]>(5);
    arr[0] = 1;
    arr[1] = 2;
    std::cout << arr[0] << ", " << arr[1] << std::endl;  // 1, 2
    
    return 0;
}  // automatic delete

Move only (no copy)

#include <memory>
#include <iostream>

// Pass by value: transfer ownership
// Memory freed when function returns
void process(std::unique_ptr<int> ptr) {
    std::cout << "value: " << *ptr << std::endl;
}  // ptr destroyed → memory freed

int main() {
    std::unique_ptr<int> ptr1 = std::make_unique<int>(10);
    
    // ❌ No copy: unique_ptr is exclusive
    // std::unique_ptr<int> ptr2 = ptr1;  // compile error
    // Copy constructor is deleted
    
    // ✅ Move: transfer ownership with std::move
    // Ownership moves from ptr1 to ptr2
    // After move, ptr1 is nullptr
    std::unique_ptr<int> ptr2 = std::move(ptr1);
    
    // Check ptr1
    if (!ptr1) {
        std::cout << "ptr1 is nullptr" << std::endl;
    }
    // Check ptr2
    if (ptr2) {
        std::cout << "ptr2 valid: " << *ptr2 << std::endl;
    }
    
    // Pass to function: transfer ownership
    // std::move(ptr2) transfers ownership into process
    // After call, ptr2 is nullptr
    process(std::move(ptr2));
    
    if (!ptr2) {
        std::cout << "ptr2 also nullptr" << std::endl;
    }
    
    return 0;
}

Output:

ptr1 is nullptr
ptr2 valid: 10
value: 10
ptr2 also nullptr

2. shared_ptr — shared ownership

Basic usage

#include <memory>
#include <iostream>

int main() {
    // std::make_shared: preferred way to make shared_ptr
    // One allocation for control block + object (efficient)
    std::shared_ptr<int> ptr1 = std::make_shared<int>(10);
    // use_count(): current reference count
    std::cout << "ref count: " << ptr1.use_count() << std::endl;  // 1
    
    {
        // shared_ptr copies: ref count increases
        // ptr1 and ptr2 share the same object
        std::shared_ptr<int> ptr2 = ptr1;  // copy ok
        // Ref count 2: two owners
        std::cout << "ref count: " << ptr1.use_count() << std::endl;  // 2
        std::cout << "ptr1: " << *ptr1 << std::endl;  // 10
        std::cout << "ptr2: " << *ptr2 << std::endl;  // 10
    }  // ptr2 destroyed → count 2 → 1
       // ptr1 still alive → storage kept
    
    std::cout << "ref count: " << ptr1.use_count() << std::endl;  // 1
    
    return 0;
}  // ptr1 destroyed → count 0 → freed

Output:

ref count: 1
ref count: 2
ptr1: 10
ptr2: 10
ref count: 1

Reference counting

#include <memory>
#include <iostream>
#include <vector>

class Resource {
public:
    Resource(int id) : id_(id) {
        std::cout << "Resource " << id_ << " created" << std::endl;
    }
    
    ~Resource() {
        std::cout << "Resource " << id_ << " destroyed" << std::endl;
    }
    
    int getId() const { return id_; }
    
private:
    int id_;
};

int main() {
    std::vector<std::shared_ptr<Resource>> resources;
    
    {
        auto r1 = std::make_shared<Resource>(1);
        resources.push_back(r1);
        resources.push_back(r1);
        resources.push_back(r1);
        
        std::cout << "ref count: " << r1.use_count() << std::endl;  // 4
    }  // r1 gone but vector still holds refs
    
    std::cout << "vector size: " << resources.size() << std::endl;  // 3
    std::cout << "ref count: " << resources[0].use_count() << std::endl;  // 3
    
    resources.clear();  // drop refs → destroy Resource
    
    return 0;
}

Output:

Resource 1 created
ref count: 4
vector size: 3
ref count: 3
Resource 1 destroyed

3. weak_ptr — breaking circular references

The circular reference problem

#include <memory>
#include <iostream>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed" << std::endl; }
};

class B {
public:
    std::shared_ptr<A> a_ptr;  // circular ref!
    ~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        
        a->b_ptr = b;
        b->a_ptr = a;  // circular reference
        
        std::cout << "a ref count: " << a.use_count() << std::endl;  // 2
        std::cout << "b ref count: " << b.use_count() << std::endl;  // 2
    }  // a,b dtor never runs — not freed!
    
    std::cout << "block end" << std::endl;
    
    return 0;
}

Output:

a ref count: 2
b ref count: 2
block end

Problem: A and B destructors never run (leak).

Fixing it with weak_ptr

#include <memory>
#include <iostream>

class B;

class A {
public:
    std::shared_ptr<B> b_ptr;
    ~A() { std::cout << "A destroyed" << std::endl; }
};

class B {
public:
    std::weak_ptr<A> a_ptr;  // use weak_ptr
    ~B() { std::cout << "B destroyed" << std::endl; }
};

int main() {
    {
        std::shared_ptr<A> a = std::make_shared<A>();
        std::shared_ptr<B> b = std::make_shared<B>();
        
        a->b_ptr = b;
        b->a_ptr = a;  // weak_ptr does not bump strong count
        
        std::cout << "a ref count: " << a.use_count() << std::endl;  // 1
        std::cout << "b ref count: " << b.use_count() << std::endl;  // 2
    }  // A and B destroy normally
    
    std::cout << "block end" << std::endl;
    
    return 0;
}

Output:

a ref count: 1
b ref count: 2
B destroyed
A destroyed
block end

Using weak_ptr

#include <memory>
#include <iostream>

int main() {
    // weak_ptr: non-owning weak reference
    std::weak_ptr<int> weak;
    
    {
        // create shared_ptr
        std::shared_ptr<int> shared = std::make_shared<int>(42);
        // assign to weak_ptr: no strong count bump
        // does not extend shared lifetime
        weak = shared;
        
        // strong count still 1
        std::cout << "shared ref count: " << shared.use_count() << std::endl;  // 1
        
        // use weak_ptr: get shared_ptr via lock()
        // lock(): shared if alive, else nullptr
        if (auto locked = weak.lock()) {
            // locked: temporary shared (count++)
            std::cout << "value: " << *locked << std::endl;  // 42
            // ref count 2: shared + locked
            std::cout << "ref count: " << locked.use_count() << std::endl;  // 2
        }  // locked destroyed → back to 1
    }  // shared gone → count 0 → freed
    
    // check weak_ptr expiry
    // expired(): was object destroyed?
    if (weak.expired()) {
        std::cout << "weak_ptr expired" << std::endl;
    }
    
    // lock() on expired weak_ptr
    if (auto locked = weak.lock()) {
        std::cout << "value: " << *locked << std::endl;
    } else {
        // object gone — lock fails
        std::cout << "lock failed" << std::endl;
    }
    
    return 0;
}

Output:

shared ref count: 1
value: 42
ref count: 2
weak_ptr expired
lock failed

4. Practical examples

Example 1: Resource management

#include <memory>
#include <iostream>
#include <fstream>

class FileHandler {
private:
    std::unique_ptr<std::ofstream> file;
    std::string filename;
    
public:
    FileHandler(const std::string& filename) : filename(filename) {
        file = std::make_unique<std::ofstream>(filename);
        if (!file->is_open()) {
            throw std::runtime_error("failed to open file: " + filename);
        }
        std::cout << "file opened: " << filename << std::endl;
    }
    
    ~FileHandler() {
        if (file && file->is_open()) {
            file->close();
            std::cout << "file closed: " << filename << std::endl;
        }
    }
    
    void write(const std::string& data) {
        if (file && file->is_open()) {
            *file << data << std::endl;
        }
    }
};

int main() {
    try {
        FileHandler handler("output.txt");
        handler.write("Hello");
        handler.write("World");
        // file still closed on exception
    } catch (const std::exception& e) {
        std::cerr << "error: " << e.what() << std::endl;
    }
    
    return 0;
}

Example 2: Factory pattern

#include <memory>
#include <iostream>
#include <string>

class Animal {
public:
    virtual void speak() = 0;
    virtual ~Animal() {}
};

class Dog : public Animal {
public:
    void speak() override {
        std::cout << "Woof!" << std::endl;
    }
    ~Dog() {
        std::cout << "Dog destroyed" << std::endl;
    }
};

class Cat : public Animal {
public:
    void speak() override {
        std::cout << "Meow!" << std::endl;
    }
    ~Cat() {
        std::cout << "Cat destroyed" << std::endl;
    }
};

std::unique_ptr<Animal> createAnimal(const std::string& type) {
    if (type == "dog") {
        return std::make_unique<Dog>();
    } else if (type == "cat") {
        return std::make_unique<Cat>();
    }
    return nullptr;
}

int main() {
    auto animal1 = createAnimal("dog");
    if (animal1) {
        animal1->speak();
    }
    
    auto animal2 = createAnimal("cat");
    if (animal2) {
        animal2->speak();
    }
    
    auto animal3 = createAnimal("bird");
    if (!animal3) {
        std::cout << "unknown animal" << std::endl;
    }
    
    return 0;
}

Output:

Woof!
Meow!
unknown animal
Cat destroyed
Dog destroyed

Example 3: Cache (shared_ptr)

#include <memory>
#include <unordered_map>
#include <iostream>
#include <string>

class Resource {
private:
    std::string name;
    
public:
    Resource(std::string n) : name(n) {
        std::cout << "load resource: " << name << std::endl;
    }
    
    ~Resource() {
        std::cout << "unload resource: " << name << std::endl;
    }
    
    void use() {
        std::cout << name << " in use" << std::endl;
    }
    
    std::string getName() const { return name; }
};

class ResourceCache {
private:
    std::unordered_map<std::string, std::shared_ptr<Resource>> cache;
    
public:
    std::shared_ptr<Resource> getResource(const std::string& name) {
        if (cache.find(name) == cache.end()) {
            cache[name] = std::make_shared<Resource>(name);
        }
        return cache[name];
    }
    
    void printCacheSize() {
        std::cout << "cache size: " << cache.size() << std::endl;
    }
    
    void clear() {
        cache.clear();
        std::cout << "cache cleared" << std::endl;
    }
};

int main() {
    ResourceCache cache;
    
    {
        auto r1 = cache.getResource("texture1");
        auto r2 = cache.getResource("texture1");  // same object
        r1->use();
        
        std::cout << "r1 ref count: " << r1.use_count() << std::endl;  // 3 (r1, r2, cache)
        std::cout << "r2 ref count: " << r2.use_count() << std::endl;  // 3
    }  // r1,r2 gone but cache holds ref
    
    cache.printCacheSize();  // 1
    
    cache.clear();  // clear cache → unload
    
    return 0;
}

Output:

load resource: texture1
texture1 in use
r1 ref count: 3
r2 ref count: 3
cache size: 1
cache cleared
unload resource: texture1

5. Common pitfalls

Pitfall 1: Avoiding make_unique / make_shared

#include <memory>

void func(std::unique_ptr<int> p1, std::unique_ptr<int> p2) {
    // ...
}

int main() {
    // ❌ unsafe: exception safety issue
    // func(std::unique_ptr<int>(new int(1)), std::unique_ptr<int>(new int(2)));
    // evaluation order not guaranteed → possible leak:
    // 1. new int(1)
    // 2. new int(2)
    // 3. unique_ptr ctor (exception can leak 1,2)
    
    // ✅ safe
    func(std::make_unique<int>(1), std::make_unique<int>(2));
    
    // ✅ or
    auto p1 = std::make_unique<int>(1);
    auto p2 = std::make_unique<int>(2);
    func(std::move(p1), std::move(p2));
    
    return 0;
}

Pitfall 2: shared_ptr cycles

#include <memory>
#include <iostream>

// ❌ cycle
class Node {
public:
    std::shared_ptr<Node> next;
    std::shared_ptr<Node> prev;  // cycle!
    int value;
    
    Node(int v) : value(v) {
        std::cout << "Node " << value << " created" << std::endl;
    }
    
    ~Node() {
        std::cout << "Node " << value << " destroyed" << std::endl;
    }
};

void testCircular() {
    auto n1 = std::make_shared<Node>(1);
    auto n2 = std::make_shared<Node>(2);
    
    n1->next = n2;
    n2->prev = n1;  // circular ref — dtors never run
    
    std::cout << "n1 ref count: " << n1.use_count() << std::endl;  // 2
    std::cout << "n2 ref count: " << n2.use_count() << std::endl;  // 2
}

// ✅ use weak_ptr
class NodeFixed {
public:
    std::shared_ptr<NodeFixed> next;
    std::weak_ptr<NodeFixed> prev;  // weak_ptr
    int value;
    
    NodeFixed(int v) : value(v) {
        std::cout << "NodeFixed " << value << " created" << std::endl;
    }
    
    ~NodeFixed() {
        std::cout << "NodeFixed " << value << " destroyed" << std::endl;
    }
};

void testFixed() {
    auto n1 = std::make_shared<NodeFixed>(1);
    auto n2 = std::make_shared<NodeFixed>(2);
    
    n1->next = n2;
    n2->prev = n1;  // weak_ptr does not bump strong count
    
    std::cout << "n1 ref count: " << n1.use_count() << std::endl;  // 1
    std::cout << "n2 ref count: " << n2.use_count() << std::endl;  // 2
}

int main() {
    std::cout << "=== circular ref test ===" << std::endl;
    testCircular();
    std::cout << "end function (dtors NOT called!)" << std::endl;
    
    std::cout << "\n=== weak_ptr test ===" << std::endl;
    testFixed();
    std::cout << "end function (dtors called)" << std::endl;
    
    return 0;
}

Output:

=== circular ref test ===
Node 1 created
Node 2 created
n1 ref count: 2
n2 ref count: 2
end function (dtors NOT called!)

=== weak_ptr test ===
NodeFixed 1 created
NodeFixed 2 created
n1 ref count: 1
n2 ref count: 2
NodeFixed 2 destroyed
NodeFixed 1 destroyed
end function (dtors called)

Pitfall 3: Passing unique_ptr to functions

#include <memory>
#include <iostream>

// Option 1: transfer ownership
void takeOwnership(std::unique_ptr<int> ptr) {
    std::cout << "ownership transfer: " << *ptr << std::endl;
}

// Option 2: pass by const& (keep ownership)
void borrow(const std::unique_ptr<int>& ptr) {
    std::cout << "borrow: " << *ptr << std::endl;
}

// Option 3: raw pointer (non-owning)
void observe(int* ptr) {
    if (ptr) {
        std::cout << "observe: " << *ptr << std::endl;
    }
}

int main() {
    auto ptr = std::make_unique<int>(10);
    
    // ❌ compile error
    // takeOwnership(ptr);  // no copy
    
    // ✅ transfer ownership
    // takeOwnership(std::move(ptr));  // ptr becomes nullptr
    
    // ✅ pass by const&
    borrow(ptr);  // ptr still valid
    
    // ✅ raw .get()
    observe(ptr.get());  // ptr still valid
    
    std::cout << "ptr valid: " << (ptr ? "yes" : "no") << std::endl;
    
    return 0;
}

Output:

borrow: 10
observe: 10
ptr valid: yes

6. Example: resource manager

#include <memory>
#include <vector>
#include <iostream>
#include <string>

class ResourceManager {
private:
    std::vector<std::unique_ptr<std::string>> resources_;
    
public:
    // add resource
    void add(std::unique_ptr<std::string> resource) {
        resources_.push_back(std::move(resource));
    }
    
    // create and add
    void create(const std::string& value) {
        resources_.push_back(std::make_unique<std::string>(value));
    }
    
    // take (move out)
    std::unique_ptr<std::string> take(size_t index) {
        if (index >= resources_.size()) return nullptr;
        
        auto resource = std::move(resources_[index]);
        resources_.erase(resources_.begin() + index);
        return resource;
    }
    
    // count
    size_t count() const {
        return resources_.size();
    }
    
    // print
    void print() const {
        std::cout << "Resources (" << resources_.size() << "):" << std::endl;
        for (size_t i = 0; i < resources_.size(); ++i) {
            if (resources_[i]) {
                std::cout << "  [" << i << "]: " << *resources_[i] << std::endl;
            } else {
                std::cout << "  [" << i << "]: (moved)" << std::endl;
            }
        }
    }
};

int main() {
    ResourceManager mgr;
    
    // add resource
    mgr.add(std::make_unique<std::string>("Resource 1"));
    mgr.create("Resource 2");
    mgr.create("Resource 3");
    
    std::cout << "initial state:" << std::endl;
    mgr.print();
    
    // take resource
    auto r = mgr.take(1);
    std::cout << "\ntook resource: " << *r << std::endl;
    
    std::cout << "\nremaining:" << std::endl;
    mgr.print();
    
    return 0;
}

Output:

initial state:
Resources (3):
  [0]: Resource 1
  [1]: Resource 2
  [2]: Resource 3

took resource: Resource 2

remaining:
Resources (2):
  [0]: Resource 1
  [1]: Resource 3

Summary

Key takeaways

  1. unique_ptr: exclusive; no copy; movable
  2. shared_ptr: shared ownership; ref counting
  3. weak_ptr: break cycles; no strong count bump
  4. make_unique/make_shared: exception safety; performance
  5. RAII: automatic cleanup

Smart pointer comparison

Featureunique_ptrshared_ptrweak_ptr
Ownershipexclusivesharednone
Copynoyesyes
Moveyesyesyes
Overheadnoneref countnone
Usedefaultsharingcycles
Arraysyes (T[])limited-

Practical tips

Selection guide:

  • Default: unique_ptr
  • Sharing: shared_ptr
  • Cycles: weak_ptr
  • Arrays: unique_ptr<T[]> or vector

Performance:

  • unique_ptr: same cost as raw pointer
  • shared_ptr: small refcount overhead
  • make_shared: often one allocation

Caveats:

  • Prefer make_unique / make_shared
  • Watch for cycles
  • Do not use moved-from objects
  • Avoid dangling pointers

Next steps

  • C++ RAII
  • C++ Move Semantics
  • C++ weak_ptr

More articles connected to this topic.

  • C++ smart pointer basics | unique_ptr & shared_ptr
  • C++ RAII & smart pointers
  • C++ smart pointers & circular references [#33-3]

Practical tips

Tips you can apply at work.

Debugging

  • Check compiler warnings first
  • Reproduce with a minimal test

Performance

  • Do not optimize without profiling
  • Define measurable goals first

Code review

  • Anticipate typical review feedback
  • Follow team conventions

Practical checklist

Use this when applying these ideas in production.

Before you code

  • Is this the best fix for the problem?
  • Can teammates maintain this?
  • Meets performance requirements?

While coding

  • All warnings fixed?
  • Edge cases covered?
  • Error handling OK?

At review

  • Intent clear?
  • Tests sufficient?
  • Documented?

Use this checklist to reduce mistakes and improve quality.


C++, smart pointers, unique_ptr, shared_ptr, memory management — searches like these should help you find this article.


  • C++ shared_ptr vs unique_ptr |
  • C++ smart pointer basics | unique_ptr & shared_ptr
  • C++ unique_ptr advanced | custom deleters & arrays
  • C++ circular references | shared_ptr leaks
  • C++ RAII & Smart Pointers |