正如其名所示,std::unique_ptr<T> 表示对所指对象的独占所有权。这实际上是处理动态分配内存时最常见(甚至可能是最普遍)的所有权语义。
回顾本章最初的(故意不完整的)示例,无法从源码中确定所指对象的所有权关系。现在用 std::unique_ptr 替代原始指针进行重写:
#include <memory>
class X {
// ...
};
std::unique_ptr<X> f();
void g(std::unique_ptr<X>&);
void h() {
// 可以写成 std::unique_ptr<X> 而非 auto
auto p = f();
g(p);
} // p 在此处隐式释放其所指向的 X 对象
f()返回的对象负责管理指针所指向的X对象的生命周期,同时g()函数虽然使用了封装的X*指针,但并不会接管所指X对象的生命周期管理。再加上p是一个对象类型,无论g()抛出异常,还是调用f()时忘记使用返回值导致p销毁,程序都能保持异常安全 —— 最终得到的代码比原始版本更简洁也更优雅!
你可能会想:“但我肯定能偷走g()里那个std::unique_ptr托管的指针”,没错 —— 不仅能偷,还很容易偷,unique_ptr提供了不止一种直接访问底层指针的方式。但类型系统的设计初衷是保护程序免于意外,让合理编写的代码顺利运行。它能防范墨菲(意外事故),却防不住马基雅维利(蓄意破坏的代码)。
如果故意写烂代码,最终得到的必然是个烂程序。这难道不是理所当然的吗?
仅通过函数签名配合std::unique_ptr的使用就能讲述完整逻辑。注意,以下示例中的函数刻意保留了未实现状态,以明确我们仅关注其签名:
// ...
// 动态创建一个 X 或 X 的派生类对象,
// 并将其返回,不会导致内存泄漏
unique_ptr<X> factory(args);
// 按值传递 unique_ptr,由于 unique_ptr 不可复制,
// 实际上是通过移动语义传递
unique_ptr<X> borrowing(unique_ptr<X>);
// 按引用传递,允许修改指针所指向的对象。
// 实际上,使用 X* 可能更合适
void possible_mutation(unique_ptr<X>&);
// 按 const 引用传递,用于查看指针指向的对象,
// 但不会修改它。实际上,使用 const X* 更合适
void consult(const unique_ptr<X>&);
// sink() 会“消耗”传入的对象:进来之后就不再传出。
// 虽然可以按值传递,但使用右值引用可能更清晰地表达意图
void sink(unique_ptr<X> &&);
// ...
unique_ptr 类型是一种非常出色的工具,如果还未熟悉,应该尽快掌握。以下是一些关于该类型的要点及其在对象指针管理中的应用:
unique_ptr<T> 对象不可复制,其复制构造函数和复制赋值成员函数被标记为删除。这g() 通过引用接收参数 —— g() 与调用方共享指针指向的对象,但并不获取其所有权。也可以将 g() 表达为接收 X* 参数,按照现代惯例,这表示函数使用指针但不拥有:
#include <memory>
class X {
// ...
};
std::unique_ptr<X> f();
void g(X*);
void h() {
// 可以写成 std::unique_ptr<X> 而不是 auto
auto p = f();
g(p.get());
} // p 在此处隐式释放其所指向的 X 对象
unique_ptr<T> 同时是可移动的 —— 移动后的 unique_ptr<T> 行为类似于空指针,因此该类型的移动操作在语义上实现了所有权的转移。这使得实现需要间接管理资源的各种类型变得更加简单。
考虑以下 solar_system 类的例子,假设了一个 Planet 类型以及一个 create_planet() 实现:
#include "planet.h"
#include <memory>
#include <string>
#include <vector>
std::unique_ptr<Planet>
create_planet(std::string_view name);
class solar_system {
std::vector<std::unique_ptr<Planet>> planets {
create_planet("mercury.data"),
create_planet("venus.data"), // 等
};
public:
// solar_system 默认不可复制(因为包含 unique_ptr)
// solar_system 默认可以移动(move)
// 不需要手动编写 ~solar_system 析构函数
// 因为 planets 会隐式地管理其资源(自动释放)
}
如果当初决定用 vector<Planet*> 或 Planet* 来实现 solar_system,则这个类型的内存管理就必须由 solar_system 自己来完成,从而增加了该类型的复杂性。而使用了 vector<unique_ptr<Planet>>,一切就默认都是正确的。根据具体需求,直接使用 vector<Planet> 可能会更好。但为了示例的需要,必须使用指针。
unique_ptr<T> 提供了与 T* 几乎相同的操作,包括 operator*() 和 operator→(),还能通过 == 或 != 比较两个 unique_ptr<T> 是否指向同一个 T 对象。后两个操作可能看起来有些奇怪,这个类型代表了对所指对象的独占所有权 —— 当使用 unique_ptr<T> 的引用时,比较操作就有意义了:
#include <memory>
template <class T>
bool point_to_same(const std::unique_ptr<T> &p0,
const std::unique_ptr<T> &p1) {
return p0 == p1;
}
template <class T>
bool have_same_value(const std::unique_ptr<T> &p0,
const std::unique_ptr<T> &p1) {
return p0 && p1 && *p0 == *p1;
}
#include <cassert>
int main() {
// 两个指向不同对象的指针,但这两个对象具有相同的值
std::unique_ptr<int> a{ new int { 3 } };
std::unique_ptr<int> b{ new int { 3 } };
assert(point_to_same(a, a) && have_same_value(a, a));
assert(!point_to_same(a, b) && have_same_value(a, b));
}
unique_ptr<T> 不支持指针运算。如果需要进行指针运算(有时确实需要 —— 比如在第13章编写自己的容器时),可以通过其 get() 成员函数获取 unique_ptr<T> 所拥有的原始指针。这在以下场景中特别有用:与C库交互、进行系统调用,或调用那些使用原始指针但不获取其所有权的函数。
哦,这里有个有趣的事 —— 除了本章稍后会讨论的几个情况外,sizeof(unique_ptr<T>)==sizeof(T*)。使用智能指针,而非原始指针,在内存空间上通常不会产生额外的开销。默认情况下,unique_ptr<T> 对象中唯一存储的状态就是一个 T*。
unique_ptr的一个优点是它提供了处理数组的特化。考虑以下几点:
void f(int n) {
// p 指向一个值为 3 的 int 对象
std::unique_ptr<int> p{ new int{ 3 } };
// q 指向一个包含 n 个 int 对象的数组,
// 所有元素初始化为 0
std::unique_ptr<int[]> q{ new int[n] {} };
// 示例用法
std::cout << *p << '\n'; // 输出 3
for(int i = 0; i != n; ++i) {
// operator[] supported for unique_ptr<T[]>
q[i] = i + 1;
}
// ...
} // q 的析构函数会对其所指对象调用 delete[]
// p 的析构函数会对其所指对象调用 delete
这有什么实际用途呢?完全取决于需求。例如:
需要动态大小的T类型数组(可自动扩容),就用vector
需要固定大小的数组,且满足:
足够小能放入执行栈
元素数量N在编译期已知,就用原生数组T[]或std::array
需要固定大小的数组但:
体积过大不适合栈内存
元素数量n在运行时才确定,可以用vector
如果选择后者,需要自行维护数组长度,unique_ptr不提供这个功能。当然,也可以将其封装成自己的抽象类型,比如下面这个fixed_size_array<T>:
#include <cstddef>
#include <memory>
template <class T>
class fixed_size_array {
std::size_t nelems{};
std::unique_ptr<T[]> elems {};
public:
fixed_size_array() = default;
auto size() const { return nelems; }
bool empty() const { return size() == 0; }
fixed_size_array(std::size_t n)
: nelems { n }, elems{ new T[n] {} } {
}
T& operator[](int n) { return elems[n]; }
const T& operator[](int n) const { return elems[n]; }
// etc.
}
这是一个朴素的实现方案,将元素数量的信息与资源的隐式所有权结合在一起。不需要手动编写复制操作(除非需要特殊实现!),移动操作和析构函数也无需定义,这些操作都会自动提供合理的行为。
当类型T可简单构造时,这个实现相对高效,但在多数使用场景下(确实)不如vector<T>高效。原因在于:vector的内存管理策略远比该类实现优秀……这个话题稍后再详述。
值得一提的是,与标量类型的情况相同,unique_ptr<T[]>的sizeof值也等于sizeof(T*) —— 这个特性想必大家都会欣赏。
各位可能会认为:“在我的代码库中,我们不使用 delete 释放对象,因为[在此填入您的理由],所以我不能用 unique_ptr”。确实存在许多无法直接通过 delete 操作符销毁对象的情况:
访问限制情况:当 T::~T() 是 private 或 protected 时,unique_ptr
特殊销毁语义:需要调用 destroy() 或 release() 等成员函数而非 delete
特殊销毁语义:需要调用独立函数执行资源释放外的操作
不论资源释放方式多么特殊,unique_ptr<T> 都支持通过自定义删除器来实现。这个删除器可以是函数对象或普通函数,会在智能指针析构时应用于内部存储的 T* 指针。
实际上,unique_ptr 的完整模板签名如下(展示其支持自定义删除器的设计):
template<class T, class D = std::default_delete<T>>
class unique_ptr {
// ...
}
这里,default_delete<T>本身本质上是这样的:
template<class T> struct default_delete {
constexpr default_delete() noexcept = default;
// ...
constexpr void operator()(T *p) const { delete p; }
}
由于参数 D 设有默认类型,通常编写代码时可以忽略这个参数。在 unique_ptr<T,D> 的签名中,参数 D 应该是无状态的 —— 不会存储在 unique_ptr 对象内部,而是在需要时实例化,并作为一个接收指针的函数使用,负责完成所指对象的最终清理工作。
假设有下面这个析构函数被声明为私有的类(这是一种常见技巧,用于强制要求对象只能通过动态分配创建,而不能以自动或静态变量的形式存在,这类对象无法隐式销毁):
#include <memory>
class requires_dynamic_alloc {
~requires_dynamic_alloc() = default; // 私有
// ...
friend struct cleaner;
};
// ...
struct cleaner {
template <class T>
void operator()(T *p) const { delete p; }
};
int main() {
using namespace std;
// requires_dynamic_alloc r0; // 不行
//auto p0 = unique_ptr<requires_dynamic_alloc>{
// new requires_dynamic_alloc
//}; // 不行,因为默认删除器无法使用 delete
auto p1 = unique_ptr<requires_dynamic_alloc, cleaner>{
new requires_dynamic_alloc
}; // 可以,因为会使用 cleaner::operator() 来释放所指对象
}
通过将清理函数对象声明为友元,requires_dynamic_alloc 类允许该清理器专门访问其 protected 和 private 成员(包括私有析构函数)。
假设可通过一个接口使用对象,该接口对使用端代码隐藏了以下信息:是否是指向资源的唯一所有者;是否与其他对象共享该资源。
进一步假设这种资源共享,是通过侵入式引用计数实现的(许多平台都采用这种方式),此时断开资源连接的方式就是调用其 release() 成员函数。该函数会:记录已断开的连接;如果当前对象是其最后一个使用者,则释放资源。
为简化使用端代码,代码库可提供了一个 release() 独立函数,当指针非空时会调用其成员函数 release()。
这种情况下仍可使用 unique_ptr,但语法稍有不同 —— 需要将函数指针作为构造参数传入(该指针会存储在内部)。因此,这种以函数指针作为删除器的 unique_ptr 特化,会导致轻微的内存开销增加:
#include <memory>
struct releasable {
void release() {
// 为了本示例的简洁而做了过度简化
delete this;
}
protected:
~releasable() = default;
};
class important_resource : public releasable {
// ...
};
void release(releasable *p) {
if(p) p->release();
}
int main() {
using namespace std;
auto p = unique_ptr<important_resource,
void(*)(releasable*)>{
new important_resource, release
}; // 好的,将使用 release() 来删除所指向的对象
}
如果函数指针带来的内存开销(包括大小和对齐要求)不可接受 —— 正在开发资源受限的平台,或需要处理包含大量 unique_ptr 对象的容器(导致开销呈指数级增长) —— 这里有个解决方案:将删除器函数的运行时调用,转移到类型系统中:
#include <memory>
struct releasable {
void release() {
// 为了本示例的简洁而做了过度简化
delete this;
}
protected:
~releasable() = default;
};
class important_resource : public releasable {
// ...
};
void release(releasable *p) {
if(p) p->release();
}
int main() {
using namespace std;
auto p = unique_ptr<important_resource,
void(*)(releasable*)>{
new important_resource, release
}; // 好的,将使用 release() 来删除所指向的对象
static_assert(sizeof(p) > sizeof(void*));
auto q = unique_ptr<
important_resource,
decltype([](auto p) { release(p); })
>{
new important_resource
};
static_assert(sizeof(q) == sizeof(void*));
}
指针p的情况:使用函数指针作为删除器,需要存储函数地址。指针q的情况:用假设的lambda类型替代函数指针,实例化时将自动调用目标函数并传递指针参数。
这种方法简单高效,若使用得当还能节省内存空间!
自 C++14 起,unique_ptr<T> 配套提供了一个工厂函数,能将参数完美转发给 T 的构造函数,分配并构造 T 对象,同时创建持有该对象的 unique_ptr<T>,最终返回结果对象。这个函数就是 std::make_unique<T>(args...),其简化实现大致如下:
template <class T, class ... Args>
std::unique_ptr<T> make_unique(Args &&... args) {
return std::unique_ptr<T>{
new T(std::forward<Args>(args)...);
}
}
当然,该函数也有创建 T[] 数组的变体。有读者可能会好奇这个函数的意义 —— 事实上它最初并未随 unique_ptr 一起发布(unique_ptr 是 C++11 的类型)。但请看以下(人为设计的)示例:
template <class T>
class pair_with_alloc {
T *p0, *p1;
public:
pair_with_alloc(const T &val0, const T &val1)
: p0{ new T(val0) }, p1{ new T(val1) } {
}
~pair_with_alloc() {
delete p1; delete p0;
}
// 复制和移动操作留给读者自行发挥
}
通过这个示例可以推测,某些情况下使用端代码更倾向于动态分配 T 对象(尽管实践中直接使用对象而非对象指针会让代码更简单)。根据 C++ 对象中子对象按声明顺序构造的规则,p0 会先于 p1 构造:
// ...
T *p0, *p1; // p0 在 p1 之前声明
public:
// 下面这些操作的顺序是:
// - new T(val0) 会在 p0 的构造之前发生
// - new T(val1) 会在 p1 的构造之前发生
// - p0 的初始化会在 p1 的初始化之前完成
pair_with_alloc(const T &val0, const T &val1)
: p0{ new T(val0) }, p1{ new T(val1) } {
}
// ...
然而,假设操作顺序是:
new T(val0) → 构造 p0
new T(val1) → 构造 p1
如果 new T(val1) 抛出异常(可能由于内存分配失败或 T 的构造函数失败),会发生什么?有读者可能以为 pair_with_alloc 的析构函数会清理资源,但事实并非如此 —— 只有当构造函数完成时,析构函数才会调用;否则,根本没有需要销毁的对象!
当然,这个问题有解决方案。最佳实践正是使用正在讨论的 unique_ptr<T> 替代原始指针 T*。让我们重写 pair_with_alloc:
#include <memory>
template <class T>
class pair_with_alloc {
std::unique_ptr<T> p0, p1;
public:
pair_with_alloc(const T &val0, const T &val1)
: p0{ new T(val0) }, p1{ new T(val1) } {
}
// 析构函数隐式正确
// 复制和移动操作会隐式生成并正常工作
}
这个版本中,如果操作顺序是:
new T(val0) → 构造 p0
new T(val1) → 构造 p1
当 new T(val1) 抛出异常时:pair_with_alloc 对象不会销毁(因为其构造尚未完成);但 p0 已构造,因此会自动销毁。
代码因此变得更简单、更安全!这与 make_unique<T>() 有什么关系?这里暗藏玄机。仔细看看构造函数中的操作顺序:
// ...
std::unique_ptr<T> p0, p1; // p0 在 p1 之前声明
public:
// 以下假设我们将这些操作标识为:
// A: new T(val0)
// B: p0 的构造
// C: new T(val1)
// D: p1 的构造
// 我们知道:
// - A 发生在 B 之前
// - C 发生在 D 之前
// - B 发生在 D 之前
pair_with_alloc(const T &val0, const T &val1)
: p0{ new T(val0) }, p1{ new T(val1) } {
}
// ...
根据注释中的规则描述,操作顺序可能存在以下情况:
A→B→C→D(理想的顺序)
A→C→B→D(有风险的顺序)
C→A→B→D(最危险的顺序)
后两种情况下,两个new T(...)会先执行,然后才执行两个unique_ptr<T>的构造。此时若第二个new操作或T的构造函数抛出异常,仍会导致资源泄漏。
这正是make_unique<T>()的价值所在 —— 工厂函数:使用端代码永远不会处于“new操作悬空结果”的状态。只有两种确定状态:
获得完整的unique_ptr
完全失败(无资源泄漏)
#include <memory>
template <class T>
class pair_with_alloc {
std::unique_ptr<T> p0, p1;
public:
pair_with_alloc(const T &val0, const T &val1)
: p0{ std::make_unique<T>(val0) },
p1{ std::make_unique<T>(val1) } {
}
// 析构函数隐式正确
// 复制和移动操作会隐式生成并正常工作
};
#include <string>
#include <random>
#include <iostream>
class risky {
std::mt19937 prng{ std::random_device{}() };
std::uniform_int_distribution<int> penny{ 0,1 };
public:
risky() = default;
risky(const risky &) {
if(penny(prng)) throw 3; // 50% 的概率抛出异常
}
~risky() {
std::cout << "~risky()\n";
}
};
int main() {
// 即使抛出异常,下面的对象也不会发生内存泄漏
if(std::string s0, s1; std::cin >> s0 >> s1)
try {
pair_with_alloc a{ s0, s1 };
pair_with_alloc b{ risky{}, risky{} };
} catch(...) {
std::cerr << "Something was thrown...\n";
}
}
make_unique<T>()本质上是一种安全机制,主要作用是避免使用端代码中出现无主资源。好处是能减少代码重复:
unique_ptr<some_type> p0 { new some_type{ args } };
auto p1 = unique_ptr<some_type> { new some_type{ args } };
auto p2 = make_unique<some_type>(args);
显然,p0和p1要求拼写两次指向类型的名称,而p2只写一次,所以p2的写法不仅更安全,也更简洁 —— 这正是现代C++推崇的代码风格。