C++ 提供的智能指针种类有限。在探讨标准库提供的选项之前,先来看一个待解决的问题。考虑以下(故意不完整的)程序,各位读者能发现其中的问题吗?
class X {
// ...
};
X *f();
void g(X *p);
void h() {
X *p = f();
g(p);
delete p;
}
这段代码虽然合法,但绝不符合现代 C++ 的编程规范。它存在诸多潜在问题,以下仅列举部分典型风险:
所有权不明确:无法确定 g() 是否会调用 delete p,可能导致 h() 中对已销毁对象进行二次删除
异常不安全:若 g() 抛出异常,h() 中的 delete p; 将永远不会执行,导致内存泄漏
责任模糊:不清楚 h() 是否应该拥有 p 的所有权(即是否负责释放 p),这个责任可能属于 g() 或其他函数
分配方式未知:无法确认 p 指向的对象是通过 new、new[] 还是其他方式(如 malloc()、其他语言的分配器或代码库中的自定义工具)分配的
动态分配不确定:不能保证 p 一定指向动态分配的内存 —— 可能指向 f() 中声明的全局或静态变量(虽然这是糟糕的做法,但确实存在,例如:非标准实现的单例模式)
作为对比,来看两个可能的 f() 实现方案(实际存在更多可能性,但这些已足够说明问题):
X *f() { // 这里是一种可能性
return new X;
}
X *f() { // 这里是另一种可能性
static X x;
return &x;
}
第一种情况下,对返回的指针调用 delete 可能是合理的。但第二种情况下,这样做将导致灾难性后果。然而,函数的签名中没有信息能够明确告知调用方,当前属于哪种情况 —— 甚至可能是完全不同的第三种情形。
作为某种“额外惊喜”:如果调用 f() 时没有使用返回值会发生什么?若 f() 的实现是 return new X; 或类似形式,代码就会发生内存泄漏 —— 这显然不是我们期望的结果。自 C++17 起,你可以通过 [[nodiscard]] 属性标记函数返回类型来缓解这个问题,但这仍需要开发者保持警惕。尽管有时不得不这样做,但通常应尽量避免从函数返回原始指针。
这里还存在其他潜在陷阱,都指向一个共同的核心问题 —— 使用原始指针时,无法直接从源码中解读其语义。更具体地说:
无法确定谁负责管理指针本身
无法确定谁负责管理指针所指对象
多年来,原始指针无法明确表达所有权关系这一缺陷,已成为 C++ 程序中反复出现的错误根源。
现在考虑另一种情况:
// ...
void f() {
X *p = new X;
thread th0{ [p] { /* 使用 *p */ };
thread th1{ [p] { /* 使用 *p */ };
th0.detach();
th1.detach();
}
f() 分配了一个由 p 指向的 X 对象,随后两个线程 th0 和 th1 复制了 p(从而共享 p 指向的 X 对象)。最终,th0 和 th1 分离(detached),所以即使 f() 执行完毕,这两个线程仍会继续运行直至完成。如果不知道 th0 和 th1 的结束顺序,就无法明确应该由哪个线程负责调用 delete 释放 p。这同样是指针所指对象的所有权不明确的问题,但与前一个例子类型不同,因此需要不同智能指针选择的方案。
明确最后所有者的情况:如果存在一个明确标识的最后所有者(无论指针之间是否共享所指对象),通常应使用 std::unique_ptr。
共享所有权且销毁顺序未知的情况:当所指对象被至少两个“共同所有者”共享,且这些所有者的销毁顺序无法预先确定时(虽然更小众但确实存在),std::shared_ptr 是更合适的选择。
后续章节将详细探讨这些类型的作用和意义,有助于各位读者在具体场景中做出明智选择。
尽管尚未深入探讨标准智能指针的细节,但有必要简要说明 std::unique_ptr 和 std::shared_ptr 的核心语义:
std::unique_ptr:表示对所指对象的独占所有权。
std::shared_ptr:表示对所指对象的共同所有权(共享所有权)。
关键区别在于:
拥有(尤其是共同拥有)所指对象则负责其生命周期管理。
共享(但不拥有)所指对象仅表示可访问该对象,但不负责释放。
以下代码结合 std::unique_ptr 和原始指针,通过类型系统明确所有权语义:
#include <memory>
#include <iostream>
// print_pointee() 与调用者共享一个指针
// 但并不接管指针的所有权
template <class T> void print_pointee(T *p) {
if (p) std::cout << *p << '\n';
}
std::unique_ptr<T> make_one(const T &arg) {
return std::make_unique<T>(arg);
}
int main() {
auto p = make_one(3); // p 是一个 std::unique_ptr<int>
print_pointee(p.get()); // 调用者和被调用者在此调用期间共享该指针
}
这种设计通过类型签名清晰传达了意图:process 函数无需(也不应)释放传入的指针,而 p 的生命周期由 std::unique_ptr 自动管理。
这里使用 std::unique_ptr 对象来建模所有权 —— make_one() 构造 std::unique_ptr<T> 并将所有权转移给调用者;然后,该调用者保留该对象的所有权,并与其它函数(这里是 print_pointee())共享底层指针,但不会放弃所指对象的所有权。使用但不拥有则通过原始指针来建模。
这个高度简化的场景展示了拥有资源和共享资源之间的区别 —— main() 中的 p 拥有资源,但它与非所有者 print_pointee() 中的 p 共享该资源。这些都是安全且符合 C++ 惯用法的代码。
了解到标准智能指针类型代表所有权后,知道只要存在单一、明确的资源最后使用者,std::unique_ptr 是首选类型;它比 std::shared_ptr 轻量得多(后面会看到),并提供了适当的所有权语义。
当然,也存在 std::unique_ptr 不是理想选择的使用场景。考虑以下简化、非线程安全且不完整的代码片段:
class entity {
bool taken{ false };
public:
void take() { taken = true; }
void release() { taken = false; }
bool taken() const { return taken; }
// ...
};
constexpr int N = ...;
// entities 是存放实体对象的地方。我们没有动态分配它们,
// 但如果真的需要动态分配的话,我们会使用 unique_ptr<entity>,
// 因为这些对象将在此处被最后一次使用(即在此作用域中管理其生命周期)。
array<entity,N> entities;
class nothing_left{};
// 这个函数返回一个不拥有所有权的指针(第 6 章将介绍比原始指针更易用的替代方案)
entity * borrow_one() {
if(auto p = find_if(begin(entities), end(entities), [](auto && e) { return !e.taken(); };
p != end(entities)) {
p->take();
return &(*p); // 非拥有指针
}
throw nothing_left{};
}
borrow_one() 与调用代码共享指针,但不共享该指针的所有权 —— 实体对象的提供者仍然全权负责这些对象的生命周期。这既不适合使用 std::unique_ptr(资源的唯一所有者),也不适合使用 std::shared_ptr(资源的共同所有者)。正如将在第6章看到的,除了原始指针外,还有其他方式可以表达非所有权的指针语义。
这里的重点是:函数签名传递了语义信息,选择能准确表达意图的类型至关重要。要做到这一点,必须首先明确自己的设计意图。在接下来的章节中探索如何利用标准智能指针时,请务必牢记这一原则。