C++11中引入智能指针,智能指针主要用来解决资源管理中遇到的各种问题。在引入智能指针之前,我们必须要操作裸指针,裸指针是导致内存问题的罪魁祸首——空悬指针、内存泄漏、分配失败等。一些著名的开源C项目,现在仍然还需要面临着一些由裸指针引起的内存问题。
如何使用智能指针能够轻易地在C++11标准中找到,如何用好智能指针却并不是那么简单。我们必须要清楚:
- 智能指针解决了哪些问题?
- 智能指针引入了哪些问题?
- 智能指针使用存在哪些坑?
解决
C++11标准库中,智能指针主要包含unique_ptr、shared_ptr、weak_ptr三种。这三种智能指针已经能够解决我们遇到的大多数问题。这些问题包含:
- 内存泄漏
- 指针有效性检测
- 资源独占
- 多线程资源管理
- 跨dll资源管理
内存泄漏
智能指针能够实现自动垃圾回收(Automatic Garbage Collection),这有效的解决了程序中部分内存/资源泄漏问题。智能指针能够有效地防止由于程序异常而导致的资源泄漏。例如:
1 | void func1() { |
指针有效性检测
裸指针只能检测指针是否是nullptr,无法检测出指针指向的对象是否有效。而智能指针能够检测其所指向对象的有效性。
裸指针若不初始化,其值是一个随机值,也就是野指针,而智能指针会默认初始化为nullptr。编译器一般会对使用未初始化的野指针报错,若不报错我们则会面临程序奔溃、内存越界的风险。
1 | void func() { |
裸指针指向的对象被销毁后,未将裸指针设置为nullptr,则裸指针称为空悬指针。出现空悬指针的情况如下:
1 | void func1() { |
访问空悬指针程序会抛出异常write access violation。而对智能指针,只有指针生命期结束或主动指向其他对象时,其所指向的对象才会被销毁(引用计数减一)。故而,智能指针不存在空悬指针问题。
1 | void func1() { |
资源独占
裸指针无法保证资源独占,可能会存在多个指针指向同一个对象,进而导致一些难以控制的问题。譬如:
1 | void func() { |
智能指针中的std::unique_ptr能够独占资源所有权,某时某刻只有一个std::unique_ptr指向特定的对象。
1 | void func() { |
多线程资源管理
智能指针能够很好地解决多线程情况下对象析构问题。这是裸指针难以办到的。对于裸指针来说,如果一个线程要访问该指针,而另一个线程需要delete该指针,后果难以想象。
1 | class T { |
即使有锁的保护,也无法避免程序出现问题,析构操作会将锁也析构了。对于智能指针来说,只要有线程访问持有对象的指针,则该对象不会被析构;如果对象要被析构,则所有线程都无法访问该指针。
跨dll资源管理
某个dll模块如果想要向外界暴露内部资源的指针,如果采用裸指针,就需要注意资源是在内部释放,还是需要外部主动释放问题。一般情况下,我们遵循的原则是谁创建谁释放,然而这无法在语言层面上做到约束。对于需要内部释放的资源,如果外部主动释放了,则会导致重复释放。
1 | class RM { |
对于智能指针来说,资源释放都是通过自动垃圾回收机制。使用该dll资源的用户无需关注是否需要释放资源。
引入
智能指针有利有弊,最严重的问题是延长了对象的生命期。如果不采取特殊的做法,很难保证对象在我们想要析构的地方析构。同时,由于引入了引用计数,会增加拷贝的开销。
延长对象生命期
由于智能指针std::shared_ptr延长了对象的生命期,所以在使用智能指针时需要明确一件事:在我们希望对象析构后,继续使用该对象没有副作用,否则必须要保证对象在我们想要析构时被析构。
1 | std::map<uint32_t, std::shared_ptr<Object>> objects; |
另一方面,我们无法确定对象在何地析构,也就意味着对象可能在关键线程析构,进而降低了系统的性能。为此,可以用一个单独的线程专门来做析构,通过一个*BlockingQueue<std::shared_ptr
增加拷贝开销
智能指针的拷贝相对于裸指针多了引用计数的操作,同时可能还会加锁。所以会增加系统开销。大多数拷贝操作发生在传参,因此推荐使用引用传参方式来替换值传参。
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 | std::unique_ptr<Object> up = new Object(1); /* error */ |
unique_ptr陷阱
尽量不要将std::unique_ptr和裸指针混用。如果二者混用,会导致资源管理混乱,同时很有可能导致程序奔溃,内存泄漏:
1 | Object *b = new Object(); |
release操作并不会释放对象的内存,其仅仅是返回一个指向被管理对象的指针,并释放std::unique_ptr的所有权。
1 | std::unique_ptr<Object> uo = std::make_unique<Object>(); |
shared_ptr陷阱
尽量不要通过std::shared_ptr智能指针的get操作获取其指向对象的裸指针。一方面智能指针析构时其变成了空悬指针,另一方面如果不小心delete了裸指针,那么智能指针将会ACCESS VIOLATION。同时,如果你把获取的裸指针继续赋给智能指针的话,又将是一个严重的问题。
1 | std::shared_ptr<Object> so = new Object(); |
如果要使用智能指针的裸指针,要确保不能将该指针传递到模块外部,同时传递到内部时,也要保证内部对象在智能指针之前释放。
实践
挖掘点智能指针实际使用过程中的实践经验。
异常安全
当使用std::unique_ptr需要注意异常问题。如下代码的执行顺序并不确定:
1 | f(unique_ptr<T>(new T), function_may_throw()); |
当上述代码的执行顺序为:new T→*function_may_throw()→unique_ptr
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 T→unique_ptr
(…)→function_may_throw() - 顺序二:function_may_throw()→new T→unique_ptr
(…)
这两种执行顺序都不存在异常安全问题了。不过要求编译器支持C++17。
注意:std::make_shared和std::make_unique都是异常安全的。
线程安全
对于智能指针,其引用计数增加/减少操作是线程安全的,并且是无锁的。但是其本身并非是线程安全的。因此在多线程访问的情况下,必须要一些同步措施。
1 | std::shared_ptr<Object> po = new Object(); |
独占资源
当我们需要独占某个资源时,尽量使用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 | Object *o = new Object; |
这要使用的缺陷在于:
- 无法确保裸指针是否依然有效;
- 无法确保裸指针不会被二次赋给智能指针。
删除器
如果你使用智能指针管理的资源不是new分配的内存,记住传递给它一个删除器。注意使用*new []*分配的数组,也必须要使用删除器,否则会导致资源泄漏。
1 | std::shared_ptr<Object> po(new Object[10], [](Object *o){delete[]p}); |
需要注意,std::unique_ptr是支持管理数组的。
1 | std::unique_ptr<Object[]) uo(new A[10]); |
std::unique_ptr的删除器有两种实现方式:函数指针、类对象和lambda表达式。上文已经给出了lambda表达式的写法。下面给出其他两个的例子:
1 | class CConnect { |
循环引用
使用std::shared_ptr时要避免循环引用。这也是std::weak_ptr存在的价值。建议在设计类时,如果不需要资源的所有权,而不要求控制对象的生命期时,使用std::weak_ptr替代std::shared_ptr。std::weak_ptr不存在延长对象生命期的问题。
循环引用的经典案例为列表,如下:
1 | struct Node { |
要想打破循环引用,则需要借助std::weak_ptr的力量,如下:
1 | struct Node { |