智能指针二三事

韭菜的自我修养。

李笑来

C++11中引入智能指针,智能指针主要用来解决资源管理中遇到的各种问题。在引入智能指针之前,我们必须要操作裸指针,裸指针是导致内存问题的罪魁祸首——空悬指针、内存泄漏、分配失败等。一些著名的开源C项目,现在仍然还需要面临着一些由裸指针引起的内存问题。

如何使用智能指针能够轻易地在C++11标准中找到,如何用好智能指针却并不是那么简单。我们必须要清楚:

  • 智能指针解决了哪些问题?
  • 智能指针引入了哪些问题?
  • 智能指针使用存在哪些坑?

解决

C++11标准库中,智能指针主要包含unique_ptrshared_ptrweak_ptr三种。这三种智能指针已经能够解决我们遇到的大多数问题。这些问题包含:

  • 内存泄漏
  • 指针有效性检测
  • 资源独占
  • 多线程资源管理
  • 跨dll资源管理

内存泄漏

智能指针能够实现自动垃圾回收(Automatic Garbage Collection),这有效的解决了程序中部分内存/资源泄漏问题。智能指针能够有效地防止由于程序异常而导致的资源泄漏。例如:

1
2
3
4
5
6
7
8
9
10
void func1() {
Object* p = new Object();
p->doSomething(); /* throw exception */
delete p; /* memory leak and resource leak in Object */
}
void func2() {
std::shared_ptr<int> p = std::make_shared<int>(10);
p->doSomething(); /* throw exception */
}

指针有效性检测

裸指针只能检测指针是否是nullptr,无法检测出指针指向的对象是否有效。而智能指针能够检测其所指向对象的有效性。

裸指针若不初始化,其值是一个随机值,也就是野指针,而智能指针会默认初始化为nullptr。编译器一般会对使用未初始化的野指针报错,若不报错我们则会面临程序奔溃、内存越界的风险。

1
2
3
4
5
void func() {
char* p; /* p为野指针 */
static char* pp; /* pp非野指针 */
std::shared_ptr<char> sp;
}

裸指针指向的对象被销毁后,未将裸指针设置为nullptr,则裸指针称为空悬指针。出现空悬指针的情况如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
void func1() {
char *p = nullptr;
{
char c = 'a';
p = &c;
} /* c释放,p为空悬指针 */
}
void func2() {
char *p = new char();
delete p; /* p为空悬指针 */
}
int* func3() {
int num = 123;
return &num; /* 返回一个空悬指针 */
}

访问空悬指针程序会抛出异常write access violation。而对智能指针,只有指针生命期结束或主动指向其他对象时,其所指向的对象才会被销毁(引用计数减一)。故而,智能指针不存在空悬指针问题。

1
2
3
4
5
6
7
void func1() {
std::shared_ptr<int> sp1 = std::make_shared<int>(1);
sp1 = std::make_shared<int>(2); /* 对象释放后又重新构造一个对象,sp1可以继续使用 */
{
std::shared_ptr<int> sp2 = std::make_shared<int>(1);
} /* 对象释放,但也无法使用sp2 */
}

资源独占

裸指针无法保证资源独占,可能会存在多个指针指向同一个对象,进而导致一些难以控制的问题。譬如:

1
2
3
4
5
6
void func() {
Object *p1 = new Object();
Object *p2 = p1;
delete p1;
*p2; /* 空悬指针 */
}

智能指针中的std::unique_ptr能够独占资源所有权,某时某刻只有一个std::unique_ptr指向特定的对象。

1
2
3
4
5
void func() {
std::unique_ptr<Object> up1(new Object());
std::unique_ptr<Object> up2 = up1; /* error */
up2 = std::move(up1); /* up1转移所有权给up2,up1为nullptr */
}

多线程资源管理

智能指针能够很好地解决多线程情况下对象析构问题。这是裸指针难以办到的。对于裸指针来说,如果一个线程要访问该指针,而另一个线程需要delete该指针,后果难以想象。

1
2
3
4
5
6
7
8
9
10
11
class T {
public:
~T() { /* destruct resource in mutex */ }
void update() { /* update resource in mutex*/}
};
extern T *t;
/* thread 1 */
delete t;
t = nullptr;
/* thread 2 */
if (t) t->update();

即使有锁的保护,也无法避免程序出现问题,析构操作会将锁也析构了。对于智能指针来说,只要有线程访问持有对象的指针,则该对象不会被析构;如果对象要被析构,则所有线程都无法访问该指针。

跨dll资源管理

某个dll模块如果想要向外界暴露内部资源的指针,如果采用裸指针,就需要注意资源是在内部释放,还是需要外部主动释放问题。一般情况下,我们遵循的原则是谁创建谁释放,然而这无法在语言层面上做到约束。对于需要内部释放的资源,如果外部主动释放了,则会导致重复释放。

1
2
3
4
5
6
7
8
class RM {
public:
Object* get();
~RM() { /* destruct all Object */ }
};
RM rm;
Object* o = rm.get();
delete o; /* error */

对于智能指针来说,资源释放都是通过自动垃圾回收机制。使用该dll资源的用户无需关注是否需要释放资源。

引入

智能指针有利有弊,最严重的问题是延长了对象的生命期。如果不采取特殊的做法,很难保证对象在我们想要析构的地方析构。同时,由于引入了引用计数,会增加拷贝的开销。

延长对象生命期

由于智能指针std::shared_ptr延长了对象的生命期,所以在使用智能指针时需要明确一件事:在我们希望对象析构后,继续使用该对象没有副作用,否则必须要保证对象在我们想要析构时被析构。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::map<uint32_t, std::shared_ptr<Object>> objects;
std::shared_ptr<Object> create(uint32_t index) {
std::shared_ptr<Object> po = std::make_shared<Object>();
objects.emplace(index, po);
return po;
}
void destroy(uint32_t index) {
objects.erase(index);
}
/* thread 1 */
auto po = create(1);
po->doSomething(); /* make sure handle po is acceptable after try to destroy po */
/* thread 2 */
destroy(1);

另一方面,我们无法确定对象在何地析构,也就意味着对象可能在关键线程析构,进而降低了系统的性能。为此,可以用一个单独的线程专门来做析构,通过一个BlockingQueue>把对象析构都转移到那个专用线程中。这种方法的前提就是程序必须要额外开启一条线程。

增加拷贝开销

智能指针的拷贝相对于裸指针多了引用计数的操作,同时可能还会加锁。所以会增加系统开销。大多数拷贝操作发生在传参,因此推荐使用引用传参方式来替换值传参。

1
bool func(const std::shared_ptr<Object> &po);

踩坑

智能指针使用过程中难免会遇到一些坑点。本节记录一些注意事项,避免低级失误。

unique_ptr初始化

std::unique_ptr不支持拷贝和赋值。为std::unique_ptr赋初始值有两种方式:new操作和std::make_unique操作。使用这两种方式时都有需要注意的地方:

  • std::unique_ptr单参数版本的构造函数是explicit,所以不能使用=赋值;
  • std::make_unique操作是C++14新特性,在某些编译器上是不支持的,在跨平台应用中使用该操作,需要确认是否所有平台都支持该操作。
1
2
3
std::unique_ptr<Object> up = new Object(1); /* error */
std::unique_ptr<Object> up(new Object(1)); /* ok */
std::unique_ptr<Object> up = std::make_unique<Object>(1); /* ok when compiler support */

unique_ptr陷阱

尽量不要将std::unique_ptr和裸指针混用。如果二者混用,会导致资源管理混乱,同时很有可能导致程序奔溃,内存泄漏:

1
2
3
4
Object *b = new Object();
std::unique_ptr<Object> uo1, uo2;
uo1.reset(b);
uo2.reset(b); /* uo1和uo2将指向同一个位置 */

release操作并不会释放对象的内存,其仅仅是返回一个指向被管理对象的指针,并释放std::unique_ptr的所有权。

1
2
3
std::unique_ptr<Object> uo = std::make_unique<Object>();
Object* o = uo.release();
delete o;

shared_ptr陷阱

尽量不要通过std::shared_ptr智能指针的get操作获取其指向对象的裸指针。一方面智能指针析构时其变成了空悬指针,另一方面如果不小心delete了裸指针,那么智能指针将会ACCESS VIOLATION。同时,如果你把获取的裸指针继续赋给智能指针的话,又将是一个严重的问题。

1
2
3
4
std::shared_ptr<Object> so = new Object();
Object *o = so.get();
delete o;
so->doSomething(); /* access violation */

如果要使用智能指针的裸指针,要确保不能将该指针传递到模块外部,同时传递到内部时,也要保证内部对象在智能指针之前释放。

实践

挖掘点智能指针实际使用过程中的实践经验。

异常安全

当使用std::unique_ptr需要注意异常问题。如下代码的执行顺序并不确定:

1
f(unique_ptr<T>(new T), function_may_throw());

当上述代码的执行顺序为:new Tfunction_may_throw()unique_ptr(…)时,当function_may_throw()抛出异常,则会导致内存泄漏。以下写法能够避免内存泄漏:

1
f(std::make_unique<T>(), function_may_throw());

在C++17中对参数的执行顺序做了约束:

The initialization of a parameter, including every associated value computation and side effect, is indeterminately sequenced with respect to that of any other parameter.

也就意味上面那个不定执行顺序的代码,只可能有两种执行顺序:

  • 顺序一:new Tunique_ptr(…)function_may_throw()
  • 顺序二:function_may_throw()new Tunique_ptr(…)

这两种执行顺序都不存在异常安全问题了。不过要求编译器支持C++17。

注意:std::make_sharedstd::make_unique都是异常安全的。

线程安全

对于智能指针,其引用计数增加/减少操作是线程安全的,并且是无锁的。但是其本身并非是线程安全的。因此在多线程访问的情况下,必须要一些同步措施。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
std::shared_ptr<Object> po = new Object();
/* thread 1 */
std::shared_ptr<Object> new_po;
{
ScopedLock lock(mutex);
new_po = po;
}
/* thread 2 */
std::shared_ptr<Object> new_po = new Object();
{
ScopedLock lock(mutex);
po = new_po;
}

独占资源

当我们需要独占某个资源时,尽量使用std::unique_ptr,不要使用std::shared_ptr。这样可以避免std::shared_ptr所面临的生命期延长问题。同时,多个std::shared_ptr可以访问修改同一个对象,这在资源独占时是不可接受的。

std::shared_ptr相对于std::unqiue_ptr资源开销更大,这是因为std::shared_ptr需要维护一个指向动态内存对象的线程安全的引用计数器。因此,资源独占时,首选std::unique_ptr智能指针。

RAII

RAII,Resource Acquisition Is Initialization,资源获取时就是初始化时。在使用智能指针使尽量避免下面操作:

1
2
Object *o = new Object;
std::shared_ptr<Object> po(o);

这要使用的缺陷在于:

  • 无法确保裸指针是否依然有效;
  • 无法确保裸指针不会被二次赋给智能指针。

删除器

如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器。注意使用new []分配的数组,也必须要使用删除器,否则会导致资源泄漏。

1
2
std::shared_ptr<Object> po(new Object[10], [](Object *o){delete[]p});
std::shared_ptr<Object> po(new Object[10], default_deleter<Object[]>());

需要注意,std::unique_ptr是支持管理数组的。

1
std::unique_ptr<Object[]) uo(new A[10]);

std::unique_ptr的删除器有两种实现方式:函数指针、类对象和lambda表达式。上文已经给出了lambda表达式的写法。下面给出其他两个的例子:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CConnect {
public:
void disconnect();
}
void deleter(CConnect *obj) {
obj->disconnect();
delete obj;
}
std::unique_ptr<CConnect, decltype(Deleter)*> up(new CConnect, deleter);
class Deleter {
public:
void operator() (CConnect *obj) {
obj->disconnect();
delete obj;
}
}
std::unique_ptr<CConnect, Deleter> up1(new CConnect);
std::unique_ptr<CConnect, Deleter> up2(new CConnect, up1.get_deleter());

循环引用

使用std::shared_ptr时要避免循环引用。这也是std::weak_ptr存在的价值。建议在设计类时,如果不需要资源的所有权,而不要求控制对象的生命期时,使用std::weak_ptr替代std::shared_ptrstd::weak_ptr不存在延长对象生命期的问题。

循环引用的经典案例为列表,如下:

1
2
3
4
5
6
7
8
9
struct Node {
std::shared_ptr<Node> _pre;
std::shared_ptr<Node> _next;
int data;
}
std::shared_ptr<Node> n1(new Node);
std::shared_ptr<Node> n2(new Node);
n1->_next = n2;
n2->_pre = n1;

要想打破循环引用,则需要借助std::weak_ptr的力量,如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
struct Node {
std::weak_ptr<Node> _pre;
std::weak_ptr<Node> _next;
int data;
}
std::shared_ptr<Node> n1(new Node);
std::shared_ptr<Node> n2(new Node);
n1->_next = n2;
n2->_pre = n1;
std::shared_ptr<Node> spn = n2->_pre.lock();
if (spn) {
spn->doSomething();
}

参考


本文作者:ZeroJiu
本文链接: http://www.freehacker.cn/advanced/smartpointer-analysis/
版权声明:本博客所有文章除特别声明外,均采用 CC BY-NC-SA 3.0 CN 许可协议。转载请注明出处!
温馨提示:开启科学上网访问本站,能获得更好的阅读体验,并启用Disqus评论功能和作者交流。