1. 为什么需要智能指针?

在 C/C++ 中,内存管理是一个核心但又极易出错的话题。传统的内存管理依赖于程序员手动使用 newdelete (或 mallocfree) 来申请和释放内存。这导致了几个经典的问题:

  1. 内存泄漏 (Memory Leaks):忘记调用 delete。当一个动态分配的对象不再被使用,但其内存没有被释放时,这块内存就无法被再次使用,造成了内存泄漏。长时间运行的程序中,内存泄漏会耗尽系统资源,导致程序崩溃。
  2. 悬挂指针 (Dangling Pointers):一个指针指向的内存已经被释放,但指针本身没有被置为 nullptr。如果之后不小心通过这个悬挂指针访问或修改内存,会导致未定义行为(Undefined Behavior),通常表现为程序崩溃或数据损坏。
  3. 重复释放 (Double Free):对同一块内存调用两次或多次 delete。这也是一种严重的未定义行为,通常会导致程序崩溃。
  4. 异常安全问题:在 newdelete 之间如果发生异常,delete 语句可能永远不会被执行,从而导致内存泄漏。
1
2
3
4
5
6
7
8
void risky_function() {
MyObject* ptr = new MyObject(); // 1. 分配资源

// ... 做一些可能抛出异常的操作 ...
do_something_that_might_throw();

delete ptr; // 2. 释放资源。如果上面一行抛出异常,这里永远不会被执行!
}

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

2. 核心思想:RAII (Resource Acquisition Is Initialization)

智能指针的实现依赖于一个 C++ 的核心编程范式:RAII,即“资源获取即初始化”。

  • 核心理念:将资源的生命周期与一个栈上对象的生命周期绑定。
  • 具体实现
    1. 在对象的构造函数中获取资源(例如,通过 new 分配内存)。
    2. 在对象的析构函数中释放资源(例如,调用 delete)。
  • 工作原理:当一个对象在栈上创建时,它的生命周期是由其作用域(scope)决定的。当程序执行离开该作用域时(无论是正常结束、return、还是因为异常),该对象的析构函数会被自动调用。由于资源释放在析构函数中,这就保证了资源总是能被正确释放。

智能指针本质上就是一个封装了原始指针(裸指针)的类模板,它利用 RAII 思想,在其析构函数中自动处理资源的释放。


3. C++ 标准库中的智能指针

现代 C++ (C++11及以后) 在 <memory> 头文件中提供了三种主要的智能指针,每一种都有明确的 所有权(Ownership) 语义。

  1. std::unique_ptr:独占所有权的智能指针。
  2. std::shared_ptr:共享所有权的智能指针。
  3. std::weak_ptrshared_ptr 的观察者,不拥有资源。

4. std::unique_ptr:独占所有者

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

特性

  • 独占所有权:在任何时刻,只有一个 unique_ptr 可以指向并拥有一个给定的对象。当这个 unique_ptr 被销毁时(例如离开作用域),它所指向的对象也会被自动删除。
  • 不可复制:你不能拷贝一个 unique_ptr,因为它代表着独一无二的所有权。
    1
    2
    std::unique_ptr<int> p1 = std::make_unique<int>(42);
    // std::unique_ptr<int> p2 = p1; // 编译错误!
  • 可以移动:虽然不能复制,但可以通过 std::move 将所有权从一个 unique_ptr 转移给另一个。转移后,原来的 unique_ptr 会变成空指针。
    1
    2
    3
    std::unique_ptr<int> p1 = std::make_unique<int>(42);
    std::unique_ptr<int> p2 = std::move(p1); // 正确,p1 现在是 nullptr
    // *p1; // 运行时错误,p1 是空的
  • 轻量级:在大多数情况下,unique_ptr 和裸指针的大小完全相同,没有任何额外的性能开销。它是一个“零成本抽象”。

如何创建和使用

最佳实践是使用 std::make_unique (C++14 及以后版本) 来创建 unique_ptr

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
#include <iostream>
#include <memory>

class MyClass {
public:
MyClass(int val) : value(val) { std::cout << "MyClass(" << value << ") constructed.\n"; }
~MyClass() { std::cout << "MyClass(" << value << ") destructed.\n"; }
void print() { std::cout << "Value is " << value << "\n"; }
private:
int value;
};

// 函数返回一个 unique_ptr,演示所有权转移
std::unique_ptr<MyClass> create_object(int val) {
return std::make_unique<MyClass>(val); // RVO/NRVO 会自动处理移动
}

int main() {
{
std::cout << "Entering inner scope.\n";
auto ptr = create_object(100);
ptr->print();
std::cout << "Leaving inner scope.\n";
} // ptr 在这里离开作用域,它的析构函数被调用,MyClass(100) 被自动销毁

std::cout << "Returned to main scope.\n";
return 0;
}

输出:

1
2
3
4
5
6
Entering inner scope.
MyClass(100) constructed.
Value is 100
Leaving inner scope.
MyClass(100) destructed.
Returned to main scope.

何时使用 unique_ptr

  • 默认首选:当你需要一个指向动态分配对象的指针时,unique_ptr 应该是你的第一选择。
  • 明确所有权:用于表示对象有一个清晰的、单一的所有者。例如,工厂函数返回一个新创建的对象。
  • 管理非内存资源:通过提供自定义删除器(Custom Deleter),unique_ptr 也可以管理如文件句柄、数据库连接、网络套接字等资源。

5. std::shared_ptr:共享所有者

std::shared_ptr 允许多个指针共享对同一个对象的所有权。

特性

  • 共享所有权:可以有多个 shared_ptr 指向同一个对象。
  • 引用计数shared_ptr 内部维护一个引用计数。每当有一个新的 shared_ptr 指向该对象(通过拷贝构造或拷贝赋值),引用计数加一。每当有一个 shared_ptr 被销毁或指向其他对象,引用计数减一。
  • 自动销毁:当引用计数变为 0 时,表示没有任何 shared_ptr 再指向该对象,此时对象会被自动删除。
  • 可以复制shared_ptr 可以被自由地复制。
  • 开销较大:相比 unique_ptrshared_ptr 有额外的开销。它需要动态分配一个“控制块”(Control Block)来存储引用计数、弱引用计数和删除器等信息。因此,shared_ptr 的大小是裸指针的两倍,并且有线程安全的引用计数操作开销。

如何创建和使用

最佳实践是使用 std::make_shared (C++11 及以后版本) 来创建 shared_ptr

为什么用 make_shared?
std::make_shared 只进行一次堆内存分配,同时为对象和控制块分配空间。而 std::shared_ptr<T>(new T()) 需要两次堆内存分配(一次为 T 对象,一次为控制块),效率更低且有异常安全风险。

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
#include <iostream>
#include <memory>
#include <vector>

void process_data(std::shared_ptr<MyClass> sp) {
std::cout << "Inside process_data, use_count: " << sp.use_count() << "\n";
sp->print();
}

int main() {
auto sp1 = std::make_shared<MyClass>(200);
std::cout << "After creation, sp1 use_count: " << sp1.use_count() << "\n"; // 输出 1

{
std::shared_ptr<MyClass> sp2 = sp1; // 拷贝,引用计数增加
std::cout << "Inside scope, sp1 use_count: " << sp1.use_count() << "\n"; // 输出 2
std::cout << "Inside scope, sp2 use_count: " << sp2.use_count() << "\n"; // 输出 2

process_data(sp1); // 传参也是拷贝,函数内引用计数为3
} // sp2 离开作用域,引用计数减少

std::cout << "Outside scope, sp1 use_count: " << sp1.use_count() << "\n"; // 输出 1

return 0;
} // sp1 离开作用域,引用计数变为0,MyClass(200)被销毁

shared_ptr 的陷阱:循环引用 (Circular Reference)

这是 shared_ptr 最著名的问题。如果两个对象互相持有对方的 shared_ptr,它们的引用计数永远不会变为 0,从而导致内存泄漏。

关于循环引用的分析,我们首先需要明确一点的是,指针变量是存储在栈上的(在64位操作系统上通常是固定的8个字节),其指向在堆上动态分配的内存块,当作用域的生命周期结束(如函数结束)时,只是栈上的指针变量被销毁了,堆上动态分配的内存块并未被销毁,因此当我们手动管理内存时才需要使用delete去释放堆上的内存。然而对于shared_ptr来说,其通过引用计数智能实现了这点,当引用计数归零时,会智能地释放堆上的内存。

然后我们来看下面的例子,我们在main函数中创建了两个shared_ptr分别指向两个Node,其中n1内部又有一个shared_ptr指向n2n2内部又有一个shared_ptr指向n1,因此智能指针n1n2此时的引用计数都为2,当main函数结束时,n1n2被销毁,此时智能指针n1n2的引用计数变为1,由于堆上的内存只有在引用计数为0时才会被自动释放,因此此时内存不会被智能指针自动释放,造成内存泄漏。

为什么n1被销毁了,它的成员变量other这个shared_ptr没有被销毁?因为other这个shared_ptrn1不同,它存储在堆上,所以不会被销毁,也就导致引用计数不归零。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
struct Node {
std::shared_ptr<Node> other;
~Node() { std::cout << "Node destructed.\n"; }
};

int main() {
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();

n1->other = n2; // n2 的引用计数为 2 (n2, n1->other)
n2->other = n1; // n1 的引用计数为 2 (n1, n2->other)

// 当 main 函数结束,n1 和 n2 被销毁,引用计数各减为 1
// n1->other 仍然持有 n2,n2->other 仍然持有 n1
// 引用计数永远不为 0,两个 Node 对象都不会被销毁,造成内存泄漏!
}

为了解决这个问题,我们引入了 std::weak_ptr

何时使用 shared_ptr

  • 当资源需要被多个所有者共享,且这些所有者的生命周期不确定,无法明确由谁来最后删除资源时。例如:
    • 图状数据结构中的节点。
    • 观察者模式中,被观察者可能比观察者先销毁。
    • 需要将对象存储在多个数据结构中。

6. std::weak_ptr:弱引用观察者

std::weak_ptrshared_ptr 的好搭档,它专门为了解决循环引用问题而生。

特性

  • 非拥有性weak_ptr 指向一个由 shared_ptr 管理的对象,但它不增加引用计数。它只是一个观察者。
  • 不能直接访问对象:你不能通过 *-> 直接解引用 weak_ptr
  • 检查对象是否存在:可以通过 expired() 方法检查它所指向的对象是否已经被销毁。
  • 安全地获取 shared_ptr:使用 lock() 方法。如果对象仍然存在,lock() 会返回一个指向该对象的有效的 shared_ptr(并增加引用计数);如果对象已被销毁,lock() 会返回一个空的 shared_ptr。这是访问 weak_ptr 所指对象的唯一安全方式。

解决循环引用

我们将上面例子中的一个 shared_ptr 改为 weak_ptr

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
struct Node {
// 使用 weak_ptr 打破循环
std::weak_ptr<Node> other;
~Node() { std::cout << "Node destructed.\n"; }
};

int main() {
auto n1 = std::make_shared<Node>();
auto n2 = std::make_shared<Node>();

n1->other = n2; // n2 的引用计数仍为 1 (来自n2本身)
n2->other = n1; // n1 的引用计数仍为 1 (来自n1本身)

// 使用 weak_ptr 指向的对象
if (auto sp = n1->other.lock()) {
// sp 是一个 shared_ptr,可以安全使用
std::cout << "n2 still alive.\n";
} else {
std::cout << "n2 has been destroyed.\n";
}
} // main 结束,n1 和 n2 销毁,引用计数变为0,对象被正确析构

输出:

1
2
3
n2 still alive.
Node destructed.
Node destructed.

因为 weak_ptr 不增加引用计数,当 n1n2 离开作用域时,它们的引用计数都能顺利降为 0,从而正确释放内存。

何时使用 weak_ptr

  • 打破 shared_ptr 的循环引用:这是其最主要的应用场景。
  • 缓存:当你想缓存一个大对象但又不希望因为缓存的存在而阻止该对象被销毁时。
  • 观察者模式:当需要安全地检查一个对象是否还存在,但又不影响其生命周期时。

7. 总结

特性 std::unique_ptr std::shared_ptr std::weak_ptr
所有权 独占、唯一 共享、引用计数 非拥有、观察者
可否复制 否(但可移动)
性能开销 极低(几乎为零) 较高(控制块、原子操作) 较高(需要访问控制块)
主要用途 默认选择,管理有单一所有者的资源 管理有多个不确定生命周期所有者的资源 打破 shared_ptr 循环引用,监视对象
创建方式 std::make_unique std::make_shared shared_ptr 构造
  1. 默认使用 std::unique_ptr:这是最安全、最高效的选择。它清晰地表达了所有权。
  2. 当你确实需要共享所有权时,才使用 std::shared_ptr:仔细思考你的设计,是否真的无法确定一个唯一的“所有者”。如果答案是肯定的,那么 shared_ptr 就是正确的工具。
  3. 当你使用 shared_ptr 并且可能出现循环引用时,使用 std::weak_ptr:在相互引用的类中,将其中一方(通常是“子”指向“父”或“被拥有者”指向“拥有者”)的指针改为 weak_ptr
  4. 裸指针 (T*) 还有用吗? 有。用于非拥有仅观察的场景,并且你能百分之百保证该指针的生命周期不会超过它所指向的对象的生命周期。典型的例子是函数参数和 this 指针。