C++智能指针介绍

117 阅读14分钟

好的,非常乐意为你详细讲解 C++ 智能指针。这是现代 C++(C++11 及之后)中最重要的特性之一,它彻底改变了 C++ 的内存管理方式。

我们将从为什么需要它开始,然后逐一介绍三种核心的智能指针:std::unique_ptrstd::shared_ptrstd::weak_ptr


🎯 1. 为什么需要智能指针?(传统指针的问题)

在 C++ 中,我们经常使用 new 在**堆 (Heap)**上动态分配内存。

C++

void old_way() {
    MyClass* raw_ptr = new MyClass(); // 1. 分配内存
    raw_ptr->do_something();
    // ...
    delete raw_ptr; // 2. 必须手动释放
}

这种“裸指针” (Raw Pointer) 的管理方式非常危险,极易出错:

  1. 内存泄漏 (Memory Leaks)

    • 忘记 delete:最常见的问题。raw_ptr 是一个局部变量,当 old_way() 函数结束时,raw_ptr 变量本身被销毁了,但它所指向的在“堆”上的 MyClass 对象依然存在,这块内存永远无法被回收。
    • 提前返回:如果在 newdelete 之间有 returnbreakcontinuedelete 语句可能被跳过。
    • 发生异常:如果在 do_something() 中抛出了异常,delete raw_ptr; 这一行永远不会被执行,内存泄漏。
  2. 悬挂指针 (Dangling Pointers)
    悬空指针

    • 当一个指针被 delete 后,它并没有自动变为 nullptr,它依然指向那块“已释放”的内存。如果后续代码不慎再次使用了这个指针(“Use-After-Free”),会导致未定义行为(通常是程序崩溃)。
  3. 所有权 (Ownership) 不明

    • 当一个裸指针在多个函数或对象之间传递时,到底负责 delete 它?
    • 如果两个模块都尝试 delete 同一个指针,会引发二次释放 (Double Free) ,导致程序崩溃。
    • 如果谁都不 delete,就是内存泄漏。

💡 2. 核心思想:RAII 与智能指针

为了解决这些问题,C++ 引入了智能指针 (Smart Pointers)

智能指针不是指针,它是一个对象(一个类模板)。它封装(wrap)了一个裸指针,并利用了 C++ 的一个核心特性:RAII (Resource Acquisition Is Initialization) ,即“资源获取即初始化”。

RAII 的核心思想:

  1. 一个对象在它的构造函数中获取资源(这里是 new 出来的内存)。
  2. 这个对象在它的析构函数中释放资源(这里是 delete 封装的裸指针)。
  3. C++ 保证,一个在栈 (Stack)上创建的对象,当它离开作用域时(例如函数返回),它的析构函数必定会被自动调用(即使发生异常,也会“栈展开”)。

智能指针就是这样一个 RAII 对象。你把 new 得到的指针交给它,你就可以“忘记”delete 了。当智能指针对象生命周期结束时,它会在析构函数中自动帮你 delete 掉它所管理的指针。

C++

#include <memory> // 智能指针的头文件

void smart_way() {
    // std::make_unique 是创建 unique_ptr 的推荐方式 (C++14)
    std::unique_ptr<MyClass> smart_ptr = std::make_unique<MyClass>();
    
    smart_ptr->do_something();

    // 当 smart_way() 函数返回时,smart_ptr 离开作用域
    // 它的析构函数被自动调用
    // 析构函数会自动执行 delete smart_ptr.get()
} // <-- 内存在这里被自动释放,即使发生异常也一样!

🔧 3. std::unique_ptr (独占指针)

std::unique_ptr 是最常用、最高效、最“轻量级”的智能指针。

  • 核心概念: 独占所有权 (Exclusive Ownership)

  • 含义: 在任何时刻,只有一个 unique_ptr 可以指向某个特定的对象。它“拥有”这个对象。

  • 特性:

    1. 轻量:它的大小和裸指针一样(没有额外开销),因为它不需要存储“引用计数”之类的东西。
    2. 不可复制 (Non-Copyable) :你不能“拷贝”一个 unique_ptr,因为这会违反“独占”所有权。
    3. 可以移动 (Movable) :你可以将所有权从一个 unique_ptr 转移 (move) 给另一个。

示例:创建、使用和转移

C++

#include <memory>
#include <utility> // for std::move

// 推荐的创建方式 (C++14)
std::unique_ptr<MyClass> p1 = std::make_unique<MyClass>();

// 像裸指针一样使用
p1->do_something();
std::cout << (*p1).value;

// 1. 编译错误:不允许复制!
// std::unique_ptr<MyClass> p2 = p1; // ERROR: copy constructor is deleted

// 2. 正确:转移所有权 (Move)
// p1 将放弃所有权,并变为空 (nullptr)
std::unique_ptr<MyClass> p2 = std::move(p1);

if (p1 == nullptr) {
    std::cout << "p1 现在是空的" << std::endl;
}
if (p2 != nullptr) {
    std::cout << "p2 拥有了该对象" << std::endl;
    p2->do_something();
} // <-- p2 在这里离开作用域,释放 MyClass 对象

// 3. 从函数返回 (所有权转移)
std::unique_ptr<MyClass> create_object() {
    // 工厂函数返回一个对象的所有权
    return std::make_unique<MyClass>();
}
std::unique_ptr<MyClass> p3 = create_object();

// 4. 作为参数传递 (转移所有权)
void take_ownership(std::unique_ptr<MyClass> p) {
    p->do_something();
} // <-- p 在这里析构,释放对象
take_ownership(std::move(p3));
// p3 在此之后也变为空

什么时候用 unique_ptr

答案:默认首选! 只要你不需要多个指针“共享”一个对象,就应该用 unique_ptr

  • 作为类的成员变量(Pimpl 惯用法)。
  • 在函数中创建并返回一个堆上对象(工厂模式)。
  • 存储在 STL 容器中(例如 std::vector<std::unique_ptr<MyClass>>)。

在什么情况下我们不想转移所有权,而只是想“借用”一下 unique_ptr 指向的对象呢?(即如何通过指针或引用来传递它)

unique_ptr 的精髓:它负责“所有权”,而“所有权”的核心是生命周期管理(即什么时候 delete 那个对象)。

大多数情况下,一个函数(“借用者”)只是想**“使用”“操作”**这个对象,它并不关心(也不应该关心)这个对象的“生老病死”。

核心原则:

当一个函数需要访问 unique_ptr 所管理的对象,但不需要接管它的生命周期(即不需要负责 delete 它)时,我们就应该“借用”它。

“借用”的实现方式就是传递 C++ 最原始的“引用”或“指针”。


“借用”的两种主要方式

假设我们有 p1:

std::unique_ptr p1 = std::make_unique();

方式 1:通过“引用” (Reference, &) 传递(首选的 C++ 方式)

这是最安全、最能体现 C++ 风格的方式。引用在 C++ 中被认为是“别名”,它保证不为空(在安全的代码中)。

场景 A:函数需要读取或观察对象(只读)

使用 const T& (常量引用)

C++

// 函数声明:我需要一个 MyClass 对象,并且我保证不会修改它
void observe_object(const MyClass& obj) {
    obj.do_something_const(); // 只能调用 const 成员函数
    // obj.do_something();     // 编译错误!不能修改
}

// 如何调用:
// 我们使用 *p1 来“解引用”,获取 p1 所指向的 MyClass 对象本身
observe_object(*p1); 

场景 B:函数需要修改对象(读写)

使用 T& (非常量引用)

C++

// 函数声明:我需要一个 MyClass 对象,并且我可能会修改它
void modify_object(MyClass& obj) {
    obj.do_something(); // 可以修改
}

// 如何调用:
modify_object(*p1); 

为什么这种方式好?

  • 安全: 引用不能为空。调用 modify_object(*p1) 时,如果 p1nullptr,程序会在调用处崩溃(空指针解引用),这能帮您立即定位问题,而不是把 nullptr 传递到函数内部。
  • 清晰: 函数签名 MyClass& 清楚地表明“我需要一个存在的对象”。

方式 2:通过“原始指针” (Raw Pointer, *) 传递(C 风格,用于“可选”场景)

有时候,一个函数可以接受一个“可选”的对象,即允许传入 nullptr。这时,使用原始指针是更合适的选择。

unique_ptr 提供了一个成员函数 .get() 来获取它所持有的原始指针,而不会放弃所有权。

场景:函数可以处理对象,也可以处理 nullptr

使用 T* 或 const T*

C++

// 函数声明:你可以给我一个指向 MyClass 的指针,
// 如果它是空的,我也会处理
void maybe_use_object(MyClass* ptr) {
    if (ptr != nullptr) {
        // 只有在非空时才使用
        ptr->do_something();
    } else {
        // 处理空指针的情况
    }
}

// 如何调用:
maybe_use_object(p1.get()); // 传递内部的原始指针

// 也可以传递空指针
std::unique_ptr<MyClass> p_empty; // p_empty 默认是 nullptr
maybe_use_object(p_empty.get()); // 传递一个 nullptr

为什么这种方式也有用?

  • 灵活性: 它是唯一能合法、清晰地表示“这里可能没有对象”的方式。

总结:如何选择?

这是一份您可以遵循的“速查表”:

您的意图是...函数参数应该写成...调用时如何传递...
转移所有权 (函数将负责释放)std::unique_ptr<T> pstd::move(p1)
借用 (只读) (函数必须拿到对象)const T& obj*p1
借用 (读写) (函数必须拿到对象)T& obj*p1
借用 (可选) (函数可以接受nullptr)const T* ptrT* ptrp1.get()

⚠️ 一个非常重要的警告:生命周期

“借用”方(无论是引用还是原始指针)的生命周期绝对不能超过“所有者”(unique_ptr)的生命周期。

错误的例子:

C++

MyClass* g_raw_pointer = nullptr; // 一个全局原始指针

void setup_pointer() {
    std::unique_ptr<MyClass> p_local = std::make_unique<MyClass>();
    
    // 错误!我们把一个“借用”的指针
    // 存储到了一个比“所有者” p_local 活得更久的地方
    g_raw_pointer = p_local.get(); 

} // <-- p_local 在这里被销毁,它所管理的 MyClass 对象被 delete

// ...
void use_pointer() {
    // 灾难!g_raw_pointer 现在是一个“悬垂指针”
    // 它指向的内存已经被释放了
    g_raw_pointer->do_something(); // 未定义行为 (很可能崩溃)
}

安全规则: “借用”的指针或引用应该只在调用函数的堆栈上临时使用,永远不要存储它们(除非您能 100% 保证它们的所有者活得更久)。


我们已经深入探讨了 unique_ptr 的“移动”和“借用”。这引出了一个相关问题:如果我们希望**多个“所有者”**共享同一个对象(而不是“独占”),那该怎么办?

这就是 std::shared_ptr 的用武之地。您想了解一下它吗?

🤝 4. std::shared_ptr (共享指针)

std::shared_ptr 解决了“多个指针需要指向同一对象”的场景。

  • 核心概念: 共享所有权 (Shared Ownership)

  • 含义: 多个 shared_ptr 可以指向同一个对象。它们通过引用计数 (Reference Counting) 来管理这个对象的生命周期。

  • 特性:

    1. 引用计数:内部有一个“控制块”,其中包含一个计数器。
    2. 每当一个 shared_ptr 被复制(或赋值给另一个 shared_ptr)时,计数器 +1
    3. 每当一个 shared_ptr 被析构(或被赋新值)时,计数器 -1
    4. 计数器减到 0 时,意味着最后一个拥有该对象的 shared_ptr 消失了,它会在析构时 delete 掉被管理的对象。
    5. 开销:比 unique_ptr 重。它需要额外分配“控制块”的内存,并且增减计数器必须是原子操作(线程安全),这有性能开销。

示例:创建和共享

C++

#include <memory>

// 推荐的创建方式 (C++11)
std::shared_ptr<MyClass> sp1 = std::make_shared<MyClass>();
std::cout << "当前引用计数: " << sp1.use_count() << std::endl; // 输出: 1

{
    // 复制构造函数,sp2 也指向 sp1 的对象
    std::shared_ptr<MyClass> sp2 = sp1;
    std::cout << "当前引用计数: " << sp1.use_count() << std::endl; // 输出: 2
    
    // sp2 和 sp1 都可以使用
    sp2->do_something();

} // <-- sp2 在这里离开作用域,析构
  // 引用计数 -1,变为 1

std::cout << "当前引用计数: " << sp1.use_count() << std::endl; // 输出: 1

// 为什么用 make_shared?
// 1. 性能:MyClass 对象和“控制块”的内存可以一次性分配,减少内存分配次数。
// 2. 异常安全:避免了在 `new MyClass()` 成功但 `shared_ptr` 构造失败时导致的内存泄漏。

什么时候用 shared_ptr

当你无法明确界定“谁是唯一的所有者”时。

  • 你希望将一个对象存储在多个数据结构中,并且希望它在“所有引用它的地方都失效”后才被销毁。
  • 异步回调(例如,this 指针可能在回调被调用前失效,使用 shared_from_this)。

🚫 5. std::weak_ptr (弱指针)

shared_ptr 有一个致命问题:循环引用 (Circular Reference)

问题:循环引用

C++

struct NodeB;

struct NodeA {
    std::shared_ptr<NodeB> b_ptr;
    ~NodeA() { std::cout << "NodeA 析构\n"; }
};

struct NodeB {
    std::shared_ptr<NodeA> a_ptr;
    ~NodeB() { std::cout << "NodeB 析构\n"; }
};

void circular_reference_problem() {
    auto a = std::make_shared<NodeA>(); // a 的计数 = 1
    auto b = std::make_shared<NodeB>(); // b 的计数 = 1

    a->b_ptr = b; // b 的计数 = 2 (a 持有 b)
    b->a_ptr = a; // a 的计数 = 2 (b 持有 a)

} // <-- 函数结束,a 和 b 两个 shared_ptr 离开作用域
// a 的计数从 2 -> 1
// b 的计数从 2 -> 1

// **内存泄漏!**
// a 的计数是 1 (因为 b 还指着它),所以 NodeA 不会被析构。
// b 的计数是 1 (因为 a 还指着它),所以 NodeB 也不会被析构。
// 它们互相“续命”,导致谁也无法释放。

解决方案:std::weak_ptr

weak_ptr 是一种非拥有 (Non-owning) 的智能指针。

  • 核心概念: 它只是一个“观察者”。

  • 含义: 它可以指向 shared_ptr 管理的对象,但它不会增加引用计数。

  • 特性:

    1. 它不能阻止所指向的对象被销毁。
    2. 不能直接通过 weak_ptr 使用对象(没有 ->* 操作符)。
    3. 你需要检查它指向的对象是否还“存活”。

示例:打破循环

C++

struct NodeB_Fixed;

struct NodeA_Fixed {
    std::shared_ptr<NodeB_Fixed> b_ptr; // A 强引用 B
    ~NodeA_Fixed() { std::cout << "NodeA 析构\n"; }
};

struct NodeB_Fixed {
    // B 弱引用 A
    std::weak_ptr<NodeA_Fixed> a_ptr; // <-- 关键!
    ~NodeB_Fixed() { std::cout << "NodeB 析构\n"; }
};

void circular_reference_solution() {
    auto a = std::make_shared<NodeA_Fixed>(); // a 计数 = 1
    auto b = std::make_shared<NodeB_Fixed>(); // b 计数 = 1

    a->b_ptr = b; // b 计数 = 2
    b->a_ptr = a; // a 计数 = 1 (weak_ptr 不增加计数)
}
// 函数结束:
// 1. a (shared_ptr) 离开作用域,a 计数从 1 -> 0。
// 2. NodeA 被析构!
// 3. 在 NodeA 的析构函数中,a->b_ptr (shared_ptr) 被销毁。
// 4. b 计数从 2 -> 1。
// 5. b (shared_ptr) 离开作用域,b 计数从 1 -> 0。
// 6. NodeB 被析构!
// (顺序可能是 5->6->3->4->1->2,但结果一样)
// **内存被正确释放!**

如何使用 weak_ptr

你需要先把它“锁住” (.lock()),尝试获取一个临时的 shared_ptr

C++

// 假设你有一个 weak_ptr
std::weak_ptr<MyClass> wp = ...;

// 尝试提升 (lock) 为 shared_ptr
if (std::shared_ptr<MyClass> sp = wp.lock()) {
    // 提升成功!
    // 这意味着在 if 语句块内,对象是存活的
    // sp 保证了对象在此期间不会被释放
    sp->do_something();
} else {
    // 提升失败,对象已经被销毁了
    std::cout << "对象已不存在" << std::endl;
}

什么时候用 weak_ptr

  1. 打破 shared_ptr 的循环引用(如上例中的父子/A-B 关系)。
  2. 缓存 (Caching) :你有一个缓存系统,希望缓存的对象在“别处都不用”时自动销毁,但你又想能随时访问它。std::map<Key, std::weak_ptr<Value>> 就很合适。
  3. 观察者模式Subject 可以持有 std::vector<std::weak_ptr<Observer>>,当 Observer 自行销毁时,Subject 不需要知道,它在通知时只需检查 lock() 是否成功。

💡 6. 智能指针最佳实践(总结)

  1. 首选 std::make_unique (C++14) :当你创建对象时,优先使用 unique_ptr

  2. 需要共享时才用 std::make_shared:当你明确知道所有权需要被共享时,才使用 shared_ptr

  3. 使用 weak_ptr 打破 shared_ptr 循环:当你发现 shared_ptr 构成了循环(例如树结构中的“子节点指向父节点”),使用 weak_ptr 作为“非拥有”的一方。

  4. 不要混用裸指针和智能指针

    • 绝对禁止MyClass* raw = new MyClass();
    • std::shared_ptr<MyClass> p1(raw);
    • std::shared_ptr<MyClass> p2(raw);
    • 这会导致 p1p2 都有各自的引用计数(都为1),它们都会在析构时尝试 delete raw,导致二次释放
  5. 如何向函数传递智能指针?(非常重要)

    • 情况1:函数只是“使用”对象,不关心所有权。

      • 最佳实践: 传递裸指针 (MyClass*) 或引用 (MyClass&)。
      • 原因: 这最快,并且解耦。调用者可以用 unique_ptrshared_ptr 或普通栈对象。
      void use_object(MyClass* obj) { obj->do_something(); }
      // 调用:
      use_object(my_unique_ptr.get());
      use_object(my_shared_ptr.get());
      
    • 情况2:函数需要“共享”所有权(例如,把它存为成员)。

      • 最佳实践:常量引用传递 shared_ptr

      C++

      void store_object(const std::shared_ptr<MyClass>& ptr) {
          m_member_ptr = ptr; // 复制,引用计数+1
      }
      
    • 情况3:函数需要“夺取”或“转移”所有权。

      • 最佳实践:传递 unique_ptr(需要 std::move)。

      C++

      void take_ownership(std::unique_ptr<MyClass> ptr) {
          m_member_ptr = std::move(ptr); // 接收所有权
      }
      // 调用:
      take_ownership(std::move(my_unique_ptr));