智能指针的增强

1. std::shared_ptr<T[]>

在 C++ 的世界里,手动管理内存(newdelete)是噩梦的开始。忘了 delete 会导致内存泄漏(程序越跑越慢),重复 delete 会导致程序崩溃

std::shared_ptr 是 C++11 引入的救世主,到了 C++17,它变得更加成熟。可以把 shared_ptr 想象为一个拿着绳子(指针)拽着气球(堆内存对象)的人。

  • 引用计数:气球上挂了一个标签,写着 “几根绳子拴着我”。
  • 赋值/拷贝:相当于多接了一根绳子,计数 +1。
  • 重置/销毁:相当于解开一根绳子,计数 -1。
  • 释放:当最后一根绳子解开,气球飞走(自动执行 delete,内存被释放)。

在创建std::shared_ptr的时候,永远优先使用 std::make_shared,不要直接用 new

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
#include <memory>
#include <iostream>

class Widget
{
public:
Widget() { std::cout << "Widget 构造\n"; }
~Widget() { std::cout << "Widget 析构\n"; }
void work() { std::cout << "工作中...\n"; }
};

int main()
{
std::shared_ptr<Widget> p1 = std::make_shared<Widget>();

p1->work(); // 像普通指针一样使用 ->

// 此时引用计数为 1,离开 main 函数后,计数归零,自动析构
return 0;
}

在 C++11 和 C++14 中,std::shared_ptr 对数组的支持非常不友善。如果你写过这样的代码:

1
2
// ❌ C++11/14 中的错误示范
std::shared_ptr<int> p(new int[10]);

这是一个严重的错误

  • p 只是一个普通的 shared_ptr<int>
  • p 析构时,它会调用 delete,而不是 delete[]
  • 结果:分配了 10 个整数的数组,却只释放了第 1 个,剩下的 9 个造成了内存泄漏

如果非要在 C++11/14中使用 shared_ptr 管理数组,必须手动指定删除器,代码非常丑陋:

1
2
// ✅ C++11 写法(很繁琐,容易写错)
std::shared_ptr<int> p(new int[10], std::default_delete<int[]>());

C++17 允许在模板参数中添加 [] 来表明这是一个数组指针,shared_ptr<T[]> 现在被编译器识别为了一种专门管理数组的智能指针。对于 std::shared_ptr<T>,我们通常使用 *ptrptr->,但对于 std::shared_ptr<T[]>

  • 析构时自动调用 delete[]
  • 提供了数组下标运算符 operator[],可以直接像数组一样访问。
  • operator*operator-> 被禁用(编译报错)。
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 <memory>
#include <iostream>

int main()
{
// 【关键修改】必须写成 int[] 而不是 int
// 只有 int[] 才支持 operator[] 并且知道要用 delete[] 释放
std::shared_ptr<int[]> arr(new int[5]);

// 赋值:现在编译器能找到 operator[] 了
for (int i = 0; i < 5; ++i)
{
arr[i] = i * 10;
}

// 读取
std::cout << "第一个元素: " << arr[0] << std::endl;
std::cout << "第三个元素: " << arr[2] << std::endl;

int x = arr[0]; // OK
// int y = *arr; // 编译错误!不能解引用数组
// arr->method(); // 编译错误!数组没有成员访问

return 0;
}

注意事项C++17 中std::make_shared 依然不支持创建数组!

以下代码在 C++17 标准库中是无法编译的,这个功能直到 C++20 才被加入标准库

1
2
// C++17 编译错误!C++20 编译通过
auto p = std::make_shared<int[]>(10);

所以在 C++17 中,要老老实实用 new创建对象,类型带上 [],告诉编译器这是数组

1
std::shared_ptr<int[]> arr(new int[5]);

2. std::weak_ptr<T[]>

想象 shared_ptr持有者,手里拿着绳子的气球。而 weak_ptr 只是一个 旁观者。它手里有一个望远镜,透过望远镜看着气球。

  • 旁观者不持有绳子:所以 weak_ptr 的创建和销毁,不会影响气球的引用计数。
  • 气球随时可能飞走:如果所有持有者都松手了(shared_ptr 计数归零),气球(对象)就会爆炸销毁。此时,旁观者透过望远镜看去,只能看到一片虚空。

之所以叫 weak(弱),是因为它对对象的生命周期没有控制权。它不能直接操作对象(不能直接用 ->*),因为它不知道对象是否还活着。它必须先申请变成持有者才能操作。

正如 shared_ptr 有数组版本,weak_ptr 也对应支持了 T[]。它用于解决 shared_ptr 数组的循环引用问题,或者用于观察数组而不持有所有权。

使用 lock() 方法可以将 weak_ptr<T[]> 提升为 shared_ptr<T[]>,从而获得临时使用权:

  • lock() 的作用:尝试获取该对象的一个 shared_ptr
  • 如果对象活着:成功返回一个指向对象的 shared_ptr,引用计数临时 +1。
  • 如果对象死了:返回一个空的 shared_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
29
30
31
32
33
34
35
36
37
38
39
40
41
#include <memory>
#include <iostream>

int main()
{
std::weak_ptr<int[]> weak_arr;
{
std::shared_ptr<int[]> strong_arr(new int[5]);
for(int i=0; i<5; ++i)
{
strong_arr[i] = i + 1;
}
weak_arr = strong_arr; // 弱引用管理
if (auto temp = weak_arr.lock())
{
std::cout << "访问数组元素: " << temp[2] << std::endl; // 输出 3
std::cout << "引用计数: " << temp.use_count() << std::endl;
}

} // strong_arr 离开作用域,数组被销毁

if(weak_arr.expired())
{
std::cout << "观察的对象已经阵亡了..." << std::endl;
}
else
{
std::cout << "观察的对象还活着 ! " << std::endl;
}
// 此时 weak_arr 已过期
if (auto temp = weak_arr.lock())
{
std::cout << "观察的对象还活着 !" << std::endl;
}
else
{
std::cout << "观察的对象已经阵亡了..." << std::endl;
}

return 0;
}

C++17 明确规定了 wp.lock() 是一个原子操作。它相当于:检查对象是否存在 并且 如果存在则增加引用计数,这整个动作是不可分割的。也就是说多线程环境下我们可以放心使用该函数。

正确的多线程写法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 错误写法
if (!wp.expired())
{
// 这里依然不安全!因为在这两行代码之间,对象可能被别的线程销毁
// auto sp = wp.lock();
}

// 正确且线程安全的写法
auto sp = wp.lock(); // 1. 尝试获取所有权
if (sp)
{
// 2. 如果 sp 不为空,说明我们成功持有了对象,
// 且引用计数已 +1,对象在这一刻绝对不会被销毁。
sp->doSomething(); // 安全
}
else
{
std::cout << "对象已失效" << std::endl;
}
  • expired():仅适用于我不想使用对象,我只是想看看它还在不在的场景。
  • lock():适用于我想使用对象,如果在我就用,不在就算了的场景。

只要我们想访问对象,必须使用 lock()并且判断 lock()的返回值,千万不要用 expired() 预判。