You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

285 lines
15 KiB
Markdown

2 years ago
# 08 | smart_ptr智能指针到底“智能”在哪里
你好我是Chrono。
上节课在讲const的时候说到const可以修饰指针不过今天我要告诉你请忘记这种用法在现代C++中绝对不要再使用“裸指针naked pointer”了而是应该使用“智能指针smart pointer”。
你肯定或多或少听说过、用过智能指针,也可能看过实现源码,那么,你心里有没有一种疑惑,智能指针到底“智能”在哪里?难道它就是解决一切问题的“灵丹妙药”吗?
学完了今天的这节课,我想你就会有个明确的答案了。
## 什么是智能指针?
所谓的“智能指针”,当然是相对于“不智能指针”,也就是“裸指针”而言的。
所以,我们就先来看看裸指针,它有时候也被称为原始指针,或者直接简称为指针。
指针是源自C语言的概念本质上是一个内存地址索引代表了一小片内存区域也可能会很大能够直接读写内存。
因为它完全映射了计算机硬件所以操作效率高是C/C++高效的根源。当然,这也是引起无数麻烦的根源。访问无效数据、指针越界,或者内存分配后没有及时释放,就会导致运行错误、内存泄漏、资源丢失等一系列严重的问题。
其他的编程语言比如Java、Go就没有这方面的顾虑因为它们内置了一个“垃圾回收”机制会检测不再使用的内存自动释放资源让程序员不必为此费心。
其实C++里也是有垃圾回收的不过不是Java、Go那种严格意义上的垃圾回收而是广义上的垃圾回收这就是**构造/析构函数**和**RAII惯用法**Resource Acquisition Is Initialization
我们可以应用代理模式把裸指针包装起来在构造函数里初始化在析构函数里释放。这样当对象失效销毁时C++就会**自动**调用析构函数,完成内存释放、资源回收等清理工作。
和Java、Go相比这算是一种“微型”的垃圾回收机制而且回收的时机完全“自主可控”非常灵活。当然也有一点代价——你必须要针对每一个资源手写包装代码又累又麻烦。
智能指针就是代替你来干这些“脏活累活”的。它完全实践了RAII包装了裸指针而且因为重载了\*和->操作符,用起来和原始指针一模一样。
不仅如此,它还综合考虑了很多现实的应用场景,能够自动适应各种复杂的情况,防止误用指针导致的隐患,非常“聪明”,所以被称为“智能指针”。
常用的有两种智能指针,分别是**unique\_ptr**和**shared\_ptr**,下面我就来分别介绍一下。
## 认识unique\_ptr
unique\_ptr是最简单、最容易使用的一个智能指针在声明的时候必须用模板参数指定类型
```
unique_ptr<int> ptr1(new int(10)); // int智能指针
assert(*ptr1 == 10); // 可以使用*取内容
assert(ptr1 != nullptr); // 可以判断是否为空指针
unique_ptr<string> ptr2(new string("hello")); // string智能指针
assert(*ptr2 == "hello"); // 可以使用*取内容
assert(ptr2->size() == 5); // 可以使用->调用成员函数
```
你需要注意的是unique\_ptr虽然名字叫指针用起来也很像但**它实际上并不是指针而是一个对象。所以不要企图对它调用delete它会自动管理初始化时的指针在离开作用域时析构释放内存。**
另外,它也没有定义加减运算,不能随意移动指针地址,这就完全避免了指针越界等危险操作,可以让代码更安全:
```
ptr1++; // 导致编译错误
ptr2 += 2; // 导致编译错误
```
除了调用delete、加减运算初学智能指针还有一个容易犯的错误是把它当成普通对象来用不初始化而是声明后直接使用
```
unique_ptr<int> ptr3; // 未初始化智能指针
*ptr3 = 42 ; // 错误!操作了空指针
```
未初始化的unique\_ptr表示空指针这样就相当于直接操作了空指针运行时就会产生致命的错误比如core dump
为了避免这种低级错误,你可以调用工厂函数**make\_unique()**,强制创建智能指针的时候必须初始化。同时还可以利用自动类型推导([第6讲](https://time.geekbang.org/column/article/237964)的auto少写一些代码
```
auto ptr3 = make_unique<int>(42); // 工厂函数创建智能指针
assert(ptr3 && *ptr3 == 42);
auto ptr4 = make_unique<string>("god of war"); // 工厂函数创建智能指针
assert(!ptr4->empty());
```
不过make\_unique()要求C++14好在它的原理比较简单。如果你使用的是C++11也可以自己实现一个简化版的make\_unique(),可以参考下面的代码:
```
template<class T, class... Args> // 可变参数模板
std::unique_ptr<T> // 返回智能指针
my_make_unique(Args&&... args) // 可变参数模板的入口参数
{
return std::unique_ptr<T>( // 构造智能指针
new T(std::forward<Args>(args)...)); // 完美转发
}
```
## unique\_ptr的所有权
使用unique\_ptr的时候还要特别注意指针的“**所有权**”问题。
正如它的名字,表示指针的所有权是“唯一”的,不允许共享,任何时候只能有一个“人”持有它。
为了实现这个目的unique\_ptr应用了C++的“转移”move语义同时禁止了拷贝赋值所以在向另一个unique\_ptr赋值的时候要特别留意必须用**std::move()**函数显式地声明所有权转移。
赋值操作之后指针的所有权就被转走了原来的unique\_ptr变成了空指针新的unique\_ptr接替了管理权保证所有权的唯一性
```
auto ptr1 = make_unique<int>(42); // 工厂函数创建智能指针
assert(ptr1 && *ptr1 == 42); // 此时智能指针有效
auto ptr2 = std::move(ptr1); // 使用move()转移所有权
assert(!ptr1 && ptr2); // ptr1变成了空指针
```
如果你对右值、转移这些概念不是太理解,也没关系,它们用起来也的确比较“微妙”,这里你只要记住,**尽量不要对unique\_ptr执行赋值操作**就好了,让它“自生自灭”,完全自动化管理。
## 认识shared\_ptr
接下来要说的是shared\_ptr它是一个比unique\_ptr更“智能”的智能指针。
初看上去shared\_ptr和unique\_ptr差不多也可以使用工厂函数来创建也重载了\*和->操作符,用法几乎一样——只是名字不同,看看下面的代码吧:
```
shared_ptr<int> ptr1(new int(10)); // int智能指针
assert(*ptr1 == 10); // 可以使用*取内容
shared_ptr<string> ptr2(new string("hello")); // string智能指针
assert(*ptr2 == "hello"); // 可以使用*取内容
auto ptr3 = make_shared<int>(42); // 工厂函数创建智能指针
assert(ptr3 && *ptr3 == 42); // 可以判断是否为空指针
auto ptr4 = make_shared<string>("zelda"); // 工厂函数创建智能指针
assert(!ptr4->empty()); // 可以使用->调用成员函数
```
但shared\_ptr的名字明显表示了它与unique\_ptr的最大不同点**它的所有权是可以被安全共享的**,也就是说支持拷贝赋值,允许被多个“人”同时持有,就像原始指针一样。
```
auto ptr1 = make_shared<int>(42); // 工厂函数创建智能指针
assert(ptr1 && ptr1.unique() ); // 此时智能指针有效且唯一
auto ptr2 = ptr1; // 直接拷贝赋值不需要使用move()
assert(ptr1 && ptr2); // 此时两个智能指针均有效
assert(ptr1 == ptr2); // shared_ptr可以直接比较
// 两个智能指针均不唯一且引用计数为2
assert(!ptr1.unique() && ptr1.use_count() == 2);
assert(!ptr2.unique() && ptr2.use_count() == 2);
```
shared\_ptr支持安全共享的秘密在于**内部使用了“引用计数”**。
引用计数最开始的时候是1表示只有一个持有者。如果发生拷贝赋值——也就是共享的时候引用计数就增加而发生析构销毁的时候引用计数就减少。只有当引用计数减少到0也就是说没有任何人使用这个指针的时候它才会真正调用delete释放内存。
因为shared\_ptr具有完整的“值语义”即可以拷贝赋值所以**它可以在任何场合替代原始指针,而不用再担心资源回收的问题**,比如用于容器存储指针、用于函数安全返回动态创建的对象,等等。
## shared\_ptr的注意事项
那么既然shared\_ptr这么好是不是就可以只用它而不再考虑unique\_ptr了呢
答案当然是否定的,不然也就没有必要设计出来多种不同的智能指针了。
虽然shared\_ptr非常“智能”但天下没有免费的午餐它也是有代价的**引用计数的存储和管理都是成本**这方面是shared\_ptr不如unique\_ptr的地方。
如果不考虑应用场合过度使用shared\_ptr就会降低运行效率。不过你也不需要太担心shared\_ptr内部有很好的优化在非极端情况下它的开销都很小。
另外一个要注意的地方是**shared\_ptr的销毁动作**。
因为我们把指针交给了shared\_ptr去自动管理但在运行阶段引用计数的变动是很复杂的很难知道它真正释放资源的时机无法像Java、Go那样明确掌控、调整垃圾回收机制。
你要特别小心对象的析构函数不要有非常复杂、严重阻塞的操作。一旦shared\_ptr在某个不确定时间点析构释放资源就会阻塞整个进程或者线程“整个世界都会静止不动”也许用过Go的同学会深有体会。这也是我以前遇到的实际案例排查起来费了很多功夫真的是“血泪教训”。
```
class DemoShared final // 危险的类,不定时的地雷
{
public:
DemoShared() = default;
~DemoShared() // 复杂的操作会导致shared_ptr析构时世界静止
{
// Stop The World ...
}
};
```
shared\_ptr的引用计数也导致了一个新的问题就是“**循环引用**”这在把shared\_ptr作为类成员的时候最容易出现典型的例子就是**链表节点**。
下面的代码演示了一个简化的场景:
```
class Node final
{
public:
using this_type = Node;
using shared_type = std::shared_ptr<this_type>;
public:
shared_type next; // 使用智能指针来指向下一个节点
};
auto n1 = make_shared<Node>(); // 工厂函数创建智能指针
auto n2 = make_shared<Node>(); // 工厂函数创建智能指针
assert(n1.use_count() == 1); // 引用计数为1
assert(n2.use_count() == 1);
n1->next = n2; // 两个节点互指,形成了循环引用
n2->next = n1;
assert(n1.use_count() == 2); // 引用计数为2
assert(n2.use_count() == 2); // 无法减到0无法销毁导致内存泄漏
```
在这里两个节点指针刚创建时引用计数是1但指针互指即拷贝赋值之后引用计数都变成了2。
这个时候shared\_ptr就“犯傻”了意识不到这是一个循环引用多算了一次计数后果就是引用计数无法减到0无法调用析构函数执行delete最终导致内存泄漏。
这个例子很简单,你一下子就能看出存在循环引用。但在实际开发中,指针的关系可不像例子那么清晰,很有可能会不知不觉形成一个链条很长的循环引用,复杂到你根本无法识别,想要找出来基本上是不可能的。
想要从根本上杜绝循环引用光靠shared\_ptr是不行了必须要用到它的“小帮手”**weak\_ptr**。
weak\_ptr顾名思义功能很“弱”。它专门为打破循环引用而设计只观察指针不会增加引用计数弱引用但在需要的时候可以调用成员函数lock()获取shared\_ptr强引用
刚才的例子里只要你改用weak\_ptr循环引用的烦恼就会烟消云散
```
class Node final
{
public:
using this_type = Node;
// 注意这里别名改用weak_ptr
using shared_type = std::weak_ptr<this_type>;
public:
shared_type next; // 因为用了别名,所以代码不需要改动
};
auto n1 = make_shared<Node>(); // 工厂函数创建智能指针
auto n2 = make_shared<Node>(); // 工厂函数创建智能指针
n1->next = n2; // 两个节点互指,形成了循环引用
n2->next = n1;
assert(n1.use_count() == 1); // 因为使用了weak_ptr引用计数为1
assert(n2.use_count() == 1); // 打破循环引用,不会导致内存泄漏
if (!n1->next.expired()) { // 检查指针是否有效
auto ptr = n1->next.lock(); // lock()获取shared_ptr
assert(ptr == n2);
}
```
## 小结
好了,今天就先到这里。智能指针的话题很大,但是学习的时候我们不可能一下子把所有知识点都穷尽,而是要有优先级。所以我会捡最要紧的先介绍给你,剩下的接口函数等细节,还是需要你根据自己的情况,再去参考一些其他资料深入学习的。
我们来回顾一下这节课的重点。
1. 智能指针是代理模式的具体应用它使用RAII技术代理了裸指针能够自动释放内存无需程序员干预所以被称为“智能指针”。
2. 如果指针是“独占”使用就应该选择unique\_ptr它为裸指针添加了很多限制更加安全。
3. 如果指针是“共享”使用就应该选择shared\_ptr它的功能非常完善用法几乎与原始指针一样。
4. 应当使用工厂函数make\_unique()、make\_shared()来创建智能指针强制初始化而且还能使用auto来简化声明。
5. shared\_ptr有少量的管理成本也会引发一些难以排查的错误所以不要过度使用。
我还有一个很重要的建议:
**既然你已经理解了智能指针就尽量不要再使用裸指针、new和delete来操作内存了**。
如果严格遵守这条建议用好unique\_ptr、shared\_ptr那么你的程序就不可能出现内存泄漏你也就不需要去费心研究、使用valgrind等内存调试工具了生活也会更“美好”一点。
## 课下作业
最后是课下作业时间,给你留两个思考题:
1. 你觉得unique\_ptr和shared\_ptr的区别有哪些列举一下。
2. 你觉得应该如何在程序里“消灭”new和delete
欢迎你在留言区写下你的思考和答案,如果觉得今天的内容对你有所帮助,也欢迎分享给你的朋友,我们下节课见。
![](https://static001.geekbang.org/resource/image/e5/51/e5298af2501d0156fcc50d50cdb82351.jpg?wh=1000*1677)