第七章 智能指针
# 第七章 智能指针
在接下来的几个问题中,我们将重点讨论智能指针是什么、何时以及为何要使用它们。
但首先,让我们讨论一个与之密切相关的编程习惯用法。
# 问题51:解释资源获取即初始化(RAII,Resource Acquisition Is Initialization)习惯用法
RAII是C++的基础习惯用法。它指出,在系统中供应有限的任何资源,都必须在使用前先获取。
这里所说的资源包括:
- 分配的堆内存;
- 执行线程;
- 打开的套接字;
- 文件;
- 锁定的互斥锁;
- 磁盘空间;
- 数据库连接。
另一方面,那些在使用前无需获取的资源不属于RAII的范畴,例如:
- CPU核心和时间;
- 缓存容量;
- 网络带宽;
- 电力消耗;
- 甚至栈内存。
但RAII不仅关乎资源获取,还涉及资源释放。RAII还确保在控制对象的生命周期结束时,所有资源都会以获取的相反顺序被释放。同样,当一个对象的资源获取失败时,该对象或其任何成员已经成功获取的所有资源都必须以相反顺序释放。
这可能足以说明这个习惯用法的名字有多糟糕,语言创造者比雅尼·斯特劳斯特鲁普(Bjarne Stroustrup)也对此感到遗憾,或许“作用域绑定资源管理(Scope Bound Resource Management)”是个更好的名字,但大多数人仍然称其为RAII。
在实际应用中,一个遵循RAII的类会在构造时获取所有资源,并在析构时释放所有资源。你无需调用诸如init()/open()
或destroy/close
这样的方法。
在标准库中,std::string
、std::vector
或std::thread
都是遵循RAII的类。
另一方面,如果你考虑原始指针,它们并不遵循RAII概念。当一个指针超出作用域时,它不会自动被销毁,你必须在它丢失并造成内存泄漏之前手动删除它。而标准库中的智能指针(std::unique_ptr
、std::shared_ptr
)提供了这样一种封装机制。
SomeLimitedResource* resource = new SomeLimitedResource();
resouce->doIt();
delete resource;
2
3
使用智能指针应用RAII,情况就会好很多:
std::unique_ptr<SomeLimitedResource> resource =
std::make_unique<SomeLimitedResource>();
resouce->doIt();
2
3
你也可以编写自己的RAII处理程序:
class SomeLimitedResourceHandler {
public:
SomeLimitedResourceHandler(SomeLimitedResource* resourc\
e) :
m_resource(resource) {}
~SomeLimitedResourceHandler() { delete m_resource; }
void doit() {
m_resource->doit();
}
private:
SomeLimitedResource* m_resource;
};
SomeLimitedResourceHandler resourceHandler(new SomeLimitedResource());
resourceHandler.doit();
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 问题52:我们应该在什么时候使用 unique_ptr ?
如果你需要使用智能指针,默认情况下,你应该选择std::unique_ptr
。它是一种小巧、快速且仅支持移动操作的智能指针,用于管理具有独占所有权语义的资源。
它的大小与原始指针相同,并且在大多数操作中,所需的指令数量也相同。
如前所述,它用于表示独占所有权。无论它指向什么,它都拥有该对象。std::unique_ptr
是一种仅支持移动的类型,不允许复制,只能有一个所有者。它没有引用计数,这使得它比std::shared_ptr
更小、更快。
默认情况下,资源的销毁是通过delete
操作完成的,但也可以指定自定义的删除器。有状态的删除器和作为删除器的函数指针会增加std::unique_ptr
对象的大小。一旦指针超出作用域,资源就会被销毁。
在C++11中,可以这样创建一个unique_ptr
:
std::unique_ptr<T> ptr (new T());
// or;
T* t = new T();
std::unique_ptr<T> ptr2 (t);
2
3
4
C++14引入了std::make_unique
来简化创建过程:
std::unique_ptr<T> ptr = std::make_unique<T>();
新的指针创建方式更安全,因为在之前,你可能会不小心将同一个原始指针传递给两个新的unique_ptr
,就像这样:
T* t = new T();
std::unique_ptr<T> ptr (t);
std::unique_ptr<T> ptr2 (t);
2
3
std::unique_ptr
的一个常见用法是作为对象层次结构中工厂函数的返回类型。如果后来发现使用shared_ptr
更合适,转换也非常简单:
std::unique_ptr<T> unique = std::make_unique<T>();
std::shared_ptr<T> shared = std::move(unique);
2
# 问题53:使用共享指针(shared pointers)的原因有哪些?
共享指针为C++开发者带来了双重优势。它提供自动清理功能(也称为垃圾回收),适用于所有带有析构函数的类型,而且这种清理是可预测的,不像其他语言中的垃圾回收器那样。
与std::unique_ptr
或原始指针相比,std::shared_ptr
对象通常要大两倍,因为它们不仅包含一个原始指针,还包含另一个指向动态分配内存区域的原始指针,在这个区域进行引用计数。
默认情况下,对象通过delete
进行销毁,但和std::unique_ptr
一样,也可以传递自定义删除器。值得注意的是,删除器的类型不会影响std::shared_ptr
的类型。
一旦指针超出作用域,资源就会被销毁。
共享指针自C++11起可用,有两种初始化共享指针的方式:
std::shared_ptr<T> ptr (new T());
std::shared_ptr<T> ptr2 = std::make_shared<T>();
2
第二种创建指针的方式,即通过std::make_shared
,更加安全,因为这样可以避免意外地传入同一个原始指针两次,而且还能避免为引用计数内存进行动态分配的开销。
避免从原始指针类型的变量创建std::shared_ptr
,因为这样难以维护,也很难判断所指向的对象何时会被销毁。
使用std::shared_ptr
进行共享所有权的资源管理。
# 问题54:何时使用 weak_ptr ?
弱指针std::weak_ptr
是一种智能指针,它不会影响对象的引用计数,因此它所指向的对象可能已经被销毁。
std::weak_ptr
由shared_ptr
创建,如果所指向的对象被销毁,弱指针就会过期。
auto* sp = std::make_shared<T>();
std::weak_ptr<T> wp(sp);
// . . .
sp = nullptr ;
if (wp.expired()) {
std::cout << "wp doesn't point to a valid object anymore" << '\n';
}
2
3
4
5
6
7
8
9
如果你想使用弱指针,可以调用它的lock()
函数,该函数会返回一个std::shared_ptr
,如果指针已过期则返回nullptr
;或者你也可以直接将弱指针传递给shared_ptr
的构造函数。
std::shared_ptr<T> sp = wp.lock();
std::shared_ptr<T> sp2(wp);
2
那么它有什么用呢?
它在处理循环引用时很有用,可以打破循环。假设你有一个Keyboard
(键盘)、一个Logger
(日志记录器)和一个Screen
(屏幕)对象的实例。Screen
和Keyboard
都有一个指向Logger
的共享指针,而Logger
应该有一个指向Screen
的指针。
Keyboard -> Logger <–> Screen
Logger
应该使用什么指针呢?如果使用原始指针,当Screen
被销毁时,Logger
仍然存在,Keyboard
对Logger
仍有共享所有权,此时Logger
中指向Screen
的指针就成了悬空指针。
如果使用共享指针,它们之间会形成循环依赖,导致无法被销毁,进而产生资源泄漏。
这时std::weak_ptr
就派上用场了。当Screen
被销毁时,Logger
中指向Screen
的指针仍然是悬空指针,但正如我们上面看到的,这种情况很容易被检测到。
此外,它在缓存和观察者模式中也很有用。想了解更多细节,可以查阅参考书籍。
# 问题55:相较于 new 操作符,std::make_shared 和std::make_unique 有哪些优势?
首先回顾一下。std::make_shared
在C++11中被添加到标准模板库(STL),而std::make_unique
直到C++14才被添加。
与直接使用new
相比,make
函数消除了源代码的重复,提高了异常安全性,并且std::make_shared
生成的代码更简洁、运行速度更快。
auto ptr = std::make_shared<T>(); // 优先使用这种方式
std::shared_ptr<T> ptr2(new T);
2
如你所见,使用make
函数时,我们只需要输入一次类型名,无需重复输入。
当使用new
时,如果在构造过程中抛出异常,在某些情况下可能会导致资源泄漏,因为此时指针还没有被make
函数“处理”。
std::make_shared
也比直接使用new
更快,因为它只分配一次内存来存储对象和用于引用计数的控制块。而使用new
则需要进行两次内存分配。
遗憾的是,如果要指定自定义删除器,上述make
函数通常就无法使用了,至少不常使用。
# 问题56:是否应该始终使用智能指针(smart pointers)而不是原始指针?
不是的。尽管在很多情况下原始指针被认为很危险,但它仍然有其用武之地。
std::unique_ptr
用于转移所有权,std::shared_ptr
用于共享所有权。如果一个函数与所有权无关,那么它就没有必要使用智能指针作为指针参数。
在这种情况下使用智能指针只会使函数的应用程序编程接口(API)更受限制,运行时成本也更高。
同时,我们应该尽量避免使用new
关键字。使用new
通常意味着在其他地方需要使用delete
。除非在C++11中,你要创建一个std::unique_ptr
,而此时std::make_unique
还不可用。
总之,尽量避免使用new
。涉及所有权时,使用智能指针和相关的工厂函数——除非有特殊情况(查看《Effective Modern C++》第21条内容)。否则,如果你不想共享或转移所有权,就使用原始指针。这样可以使API更友好,运行时性能也更快。
# 问题57:何时以及为何要将指针初始化为 nullptr ?
如果确定指针会被初始化,那么用nullptr
进行预初始化就没有意义。从另一个角度来看,如果不确定是否会初始化指针,那么就应该将其初始化为nullptr
。
例如,根据你使用的编译器及其设置,下面这段代码可能会发出警告。希望你把警告当作错误来对待:
T* fun() {
T* t;
if (someCondition()) {
t = getT();
}
return t;
}
2
3
4
5
6
7
// Clang发出的警告:
prog.cc:22:6: warning: variable 't' is used uninitialized whenever 'if' condition is false [-Wsometimes-uninitialized]
if (someCondition()) {
^~~~~~~~~~~~~~~
prog .cc:25:9: note: uninitialized use occurs here
return t;
^
prog.cc:22:2: note: remove the 'if' if its condition is always true
if (someCondition()) {
^~~~~~~~~~~~~~~~~~~~~
prog.cc:21:6: note: initialize the variable 't' to silence this warning
T* t;
^
= nullptr
2
3
4
5
6
7
8
9
10
11
12
13
14
但为什么这是个问题呢?会出什么错呢?
当你没有初始化指针就尝试使用它时,可能会出现3个问题:
- 指针可能指向你无法访问的内存位置,这种情况下会导致段错误,使程序崩溃。
- 指针可能会意外地指向一些实际数据,如果你不知道它指向什么,可能会对数据造成不可预测(而且很难调试)的更改。
- 你无法确定指针是否已经被初始化。你怎么区分有效地址和声明指针时碰巧存在的地址呢?
如果你在声明指针时无法初始化它,将其初始化为nullptr
可以大大减少或消除这些问题:
- 虽然使用这样的指针仍然会导致段错误,但至少我们可以可靠地测试它是否为
nullptr
,并据此采取相应措施。如果是一个随机值,在程序崩溃之前我们一无所知。 - 如果将指针初始化为
nullptr
,它就永远不会指向任何数据,因此不会意外地修改任何内容,只会按预期进行操作。 - 因此,我们可以确定地判断指针是否已被初始化,并据此做出决策。
T* fun() {
T* t = nullptr ;
if (someCondition()) {
t = getT();
}
return t; // 不再有警告!
}
2
3
4
5
6
7
这是一个风格问题,但就我个人而言,我更倾向于避免将指针初始化为nullptr
。在这种情况下,我宁愿直接返回nullptr
,否则就在声明指针时用正确的值进行初始化。
T* fun() {
if (!someCondition()) {
return nullptr ;
}
return getT();
}
2
3
4
5
6
另外,最好避免需要返回nullptr
的情况。