本篇主要描述C++的几种常用智能指针。
初入职场时,C++
是唯一的开发语言。干了两年多年,感觉互联网是风口,然后毫不犹豫的跳到了一家互联网创业公司,使用的编程语言是 Go
+ Python
。没干两年又重新跳回了传统 IT 企业,用 C++ 重操旧业,发现项目中基本都用的 C++ 11 的特性。
堆、栈
刚入职场那会,C++11 还没被广泛应用,记得当时分配/释放资源也是凭感觉:对于简单 POD
类型(Plain Old Data)或者对象较小的类一般都是直接在栈上分配资源;对于对象很大的或者在对象大小在编译器不能确定的,一般在堆上分配资源。
对于堆上分配内存,需要正确使用 new 和 delete 即可。每个 new 出来的对象都应该用 delete 来释放。但是有时候就忘记 delete 或者由于中间代码抛出异常,导致最后 delete 得不到执行,所以也花费了很多时间去查内存泄漏。C++ 11 引入的只能指针就解决了手动申请、释放内存的问题。
比如如下代码,如果中间抛出异常,delete 就执行不到了。
void foo()
{
bar* ptr = new bar();
…
delete ptr;
}
栈上分配/释放内存就容易的多了,跟所在函数的生命周期绑在一起。当函数执行完成,这些内存也自然就释放掉了。对于含有构造和析构函数的非 POD 类型,栈上的内存分配也同样有效,编译器能自动调用析构函数,包括在函数执行发生异常的情况下(称之为栈展开 stack unwinding)。
下面展示了异常抛出时析构调用的情况:
#include <stdio.h>
class Obj {
public:
Obj() { puts("Obj()"); }
~Obj() { puts("~Obj()"); }
};
void foo(int n)
{
Obj obj;
if (n == 42)
throw "life, the universe and everything";
}
int main()
{
try {
foo(41);
foo(42);
}
catch (const char* s) {
puts(s);
}
}
执行结果:
Obj()
~Obj()
Obj()
~Obj()
life, the universe and everything
智能指针
什么是智能指针
所谓智能指针,当然相对裸指针而言的。指针是源自 C 语言的概念,本质上是一个内存地址索引,能够直接读写内存。因为它完全映射了计算机硬件,所以操作效率很高。当然,这也是引起无数 bug 的根源:访问无效数据、指针越界、内存分配后没有及时释放等等导致一系列严重的问题。
其实,C++ 也有类似 Java、Go 一样的垃圾回收机制,不过不是严格意义上的垃圾回收,而是广义上的垃圾回收,就是使用 RAII( Resource Acquisition is Initialization )。
RAII 是 C++ 特有的资源管理方式。RAII 依托于栈和构造/析构函数,通过代理模式,把裸指针包装起来,在构造函数里初始化,在析构函数里释放。这样当对象失效销毁时,C++ 就会自动调用析构函数,完成内存释放,资源回收等清理工作。
RAII 的精髓就是两点:
- 获得资源后立即初始化对象。
- 运用析构函数确保资源被释放。一旦对象被销毁( 离开作用域或者从队列中 remove ),其析构函数自然会被调用,于是资源被释放。
智能指针完全实践了 RAII,包装了裸指针,而且因为重载了 * 和 -> 操作符,用起来和原始指针一模一样。
常见智能指针
智能指针即运用了 RAII 的资源管理方式,能够防止手动使用 new/delete 导致的内存泄漏问题。关于智能指针,常用的有unique_ptr
、shared_ptr
和 weak_ptr
。
- unique_ptr 是最简单、容易使用的一个智能指针,在声明的时候必须用模板参数指定类型,并且指针的所有权是唯一的,不允许共享的,任何时候只能有一个对象持有它。
- shared_ptr 允许多个智能指针共享地拥有同一个对象,shared_ptr 实现上采用了引用计数,所以一旦一个 shared_ptr 指针放弃了 “所有权” ( reset 或离开作用域等等 ),其他的 shared_ptr 对该对象内存的引用不受影响。
- weak_ptr 可以指向 shared_ptr 指针指向的对象内存,却并不拥有该内存。而使用 weak_ptr 成员 lock, 则可返回其指向内存的一个 shared_ptr 对象,如果所在对象内存无效时,返回指针空值,weak_ptr 可以有效防止 shared_ptr 循环引用的问题。
认识 unique_ptr
unique_ptr 在声明的时候必须用模板参数指定类型:
unique_ptr<int> ptr1(new int(19)); // int 智能指针
assert(*ptr1 == 10);
assert(ptr1 != nullptr);
unique_ptr<std::string> ptr2(new string("hello world")); // string 智能指针
assert(*ptr2 == "hello world");
assert(ptr2->size() = 5);
一般初学智能指针有一个容易犯的错误是把它当普通对象来用,不初始化,而是声明后直接使用:
unique_ptr<int> ptr3; // 未初始化智能指针
*ptr3 = 42; // 错误,操作了空指针
未初始化的 unique_ptr 表示空指针,这样就相当于操作了空指针,运行时就会产生致命的错误(core dump)。为了避免这种低级错误,你可以调用工厂函数 make_unique(),强制创建智能指针的时候必须初始化。同时还可以利用自动推导 auto :
auto ptr4 = make_unique<int>(42); // 工厂函数创建智能指针
auto ptr5 = make_unique<string>("hello world"); // 工厂函数创建智能指针
unique_ptr 指针的所有权是唯一的,为了实现这个目的,unique_ptr 应用了 C++ 的转移语义,同时禁止了拷贝赋值,所以在向另一个 unique_ptr 赋值的时候,要特别留意,必须使用 std::move() 函数显示地声明所有权转移。
auto ptr1 = make_unique<int>(42);
assert(ptr1 && *ptr1 == 142);
auto ptr2 = std::move(ptr1); // 使用 move() 转移所有权
assert(!ptr1 && ptr2); // ptr1 变成了空指针
赋值操作之后,指针的所有权就被转走了,原来的 unique_ptr 变成了空指针,新的 unique_ptr 接替了管理权,保证所有权的唯一性。
认识 shared_ptr
shared_ptr 和 unique_ptr 差不多,也可以使用工厂函数来创建,也重载了 * 和 -> 操作符,用法几乎一样。但 shared_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 内部使用了 “引用计数”,如果发生拷贝赋值,引用计数就会增加,而发生析构销毁的时候,引用计数就会减少,只有当引用计数减少到 0, 它才会真正调用 delete 释放内存。
shared_ptr 的注意事项
- 不要与裸指针混用
int *ptr(new int(10));
shared_ptr<int> ptr1(x);
shared_ptr<int> ptr2(x);
虽然 ptr1、ptr2 都指向 ptr 所指的内存,但他们是独立的,并且裸指针本身也很危险,ptr 随时变成空悬指针( dangling pointer )
,但是 ptr1、ptr2 都不知道。
- 避免循环引用
#include <iostream>
#include <memory>
using namespace std;
class AObj;
class BObj;
class AObj {
public:
std::shared_ptr<BObj> bPtr;
~AObj() { cout << "AObj is deleted!"<<endl; }
};
class BObj {
public:
std::shared_ptr<AObj> APtr;
~BObj() { cout << "BObj is deleted!" << endl; }
};
int main()
{
auto ap = make_shared<AObj>();
auto bp = make_shared<BObj>();
ap->bPtr = bp;
bp->APtr = ap;
}
智能指针最大的一个陷阱是循环引用,循环引用会导致内存泄漏。解决方法是用 weak_ptr 打破循环。
- 析构操作尽量简单
因为我们把指针交给了 shared_ptr 去自动管理,所以在运行阶段,引用计数的变动非常复杂、很难知道它真正释放资源的时机,无法像 Java、Go 那样明确掌握的垃圾回收机制。
需要特别小心对象的析构函数,不要有非常复杂、严重的阻塞操作,一旦 shared_ptr 在某个不确定时间点析构释放资源,就会阻塞整个进程或者线程。
总结
- 智能指针是代理模式的具体应用,它使用 RAII 技术代理了裸指针,能够自动释放内存,无效手动 delete。
- unique_ptr 为独占式智能指针,它为裸指针添加了很多限制,使用更加安全;shared_ptr 为共享式智能指针,功能非常完善,用法几乎与原是指针一样。
- 应当使用工厂函数 make_unique()、make_shared() 来参加智能指针,强制初始化,而且还能使用 auto 来简化声明。
- 工程项目中尽量不要使用裸指针,new/delete 来操作内存,尽量使用智能指针。