38

智能指针二三事

 5 years ago
source link: http://www.freehacker.cn/advanced/smartpointer-analysis/?amp%3Butm_medium=referral
Go to the source link to view the article. You can view the picture content, updated content and better typesetting reading experience. If the link is broken, please click the button below to view the snapshot at that time.

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

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

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

解决

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

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

内存泄漏

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

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。编译器一般会对 使用未初始化的野指针 报错,若不报错我们则会面临程序奔溃、内存越界的风险。

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

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

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 。而对智能指针,只有指针生命期结束或主动指向其他对象时,其所指向的对象才会被销毁(引用计数减一)。故而,智能指针不存在空悬指针问题。

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 */
}

资源独占

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

void func(){
  Object *p1 = new Object();
  Object *p2 = p1;
  delete p1;
  *p2; /* 空悬指针 */
}

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

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该指针,后果难以想象。

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

class RM {
public:
  Object*get();
  ~RM() { /* destruct all Object */ }
};
RM rm;
Object* o = rm.get(); 
delete o; /* error */

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

引入

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

延长对象生命期

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

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 <:shared_ptr> > 把对象析构都转移到那个专用线程中。这种方法的前提就是程序必须要额外开启一条线程。

增加拷贝开销

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

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新特性,在某些编译器上是不支持的,在跨平台应用中使用该操作,需要确认是否所有平台都支持该操作。
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 和裸指针混用。如果二者混用,会导致资源管理混乱,同时很有可能导致程序奔溃,内存泄漏:

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

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

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

shared_ptr陷阱

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

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

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

实践

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

异常安全

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

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

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

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_sharedstd::make_unique 都是异常安全的。

线程安全

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

std::shared_ptr<Object> po = new Object();
/* thread 1 */
std::shared_ptr<Object> new_po;
{
  ScopedLocklock(mutex);
  new_po = po; 
}

/* thread 2 */
std::shared_ptr<Object> new_po = new Object();
{
  ScopedLocklock(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,资源获取时就是初始化时。在使用智能指针使尽量避免下面操作:

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

这要使用的缺陷在于:

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

删除器

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

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 是支持管理数组的。

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

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

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 不存在延长对象生命期的问题。

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

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 的力量,如下:

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();
}

About Joyk


Aggregate valuable and interesting links.
Joyk means Joy of geeK