C++11 智能指针






4.93/5 (75投票s)
C++11 中的各种智能指针
引言
哎呀。又是一篇关于 C++11 智能指针的文章。如今,我听到很多人在谈论新的 C++ 标准,也就是 C++0x/C++11。我研究了 C++11 的一些语言特性,这确实是一项了不起的工作。本文我将只专注于 C++11 的智能指针部分。
背景
普通/原始/裸指针有什么问题?
我们来逐一分析。
人们避免使用指针,因为如果处理不当,它们会带来很多问题。这就是为什么编程新手不喜欢指针。指针涉及许多问题,比如确保指针所引用对象的生命周期、悬垂引用和内存泄漏。
如果一个内存块被多个指针变量指向,而其中一个指针被释放时没有通知其他指针,就会导致悬垂引用。众所周知,内存泄漏发生在一个内存块从堆中获取后没有被释放回堆中。
有人说,我写的代码干净且无错,为什么我还要用智能指针?一个程序员问我:“嘿,这是我的代码。我从堆中获取了内存,操作了它,之后我正确地释放了它。智能指针有什么必要?”
void Foo( )
{
int* iPtr = new int[5];
//manipulate the memory block
.
.
.
delete[ ] iPtr;
}
在理想情况下,上面的代码运行良好,内存也得到了正确释放。但请考虑代码执行的实际环境。在内存分配和释放之间的指令可能会做一些讨厌的事情,比如访问无效内存位置、除以零,或者说另一位程序员为了修复一个 bug 而介入你的程序,并根据某些条件添加了一个过早的 return
语句。
在上述所有情况下,你将永远不会到达释放内存的地方。这是因为前两种情况会抛出异常,而第三种情况是过早返回。因此,在程序运行时,内存就会泄漏。
对于以上所有问题,一站式的解决方案就是智能指针 [如果它们真的足够智能的话]。
什么是智能指针?
智能指针是一个遵循 RAII 模型的类,用于管理动态分配的内存。除了少数例外,它提供了普通指针提供的所有接口。在构造时,它拥有内存,并在其超出作用域时释放该内存。通过这种方式,程序员就无需操心管理动态分配的内存了。
C++98 引入了第一个此类指针,名为 auto_ptr
。
auto_ptr
让我们看看 auto_ptr
的用法,以及它在解决上述问题上有多“智能”。
class Test
{
public:
Test(int a = 0 ) : m_a(a)
{
}
~Test( )
{
cout<<"Calling destructor"<<endl;
}
public:
int m_a;
};
void main( )
{
std::auto_ptr<Test> p( new Test(5) );
cout<<p->m_a<<endl;
}
上面的代码很“智能”,能够释放与其关联的内存。我们所做的是,获取一个内存块来存放一个 Test
类型的对象,并将其与 auto_ptr p
关联起来。因此,当 p
超出作用域时,关联的内存块也被释放了。
//***************************************************************
class Test
{
public:
Test(int a = 0 ) : m_a(a)
{
}
~Test( )
{
cout<<"Calling destructor"<<endl;
}
public:
int m_a;
};
//***************************************************************
void Fun( )
{
int a = 0, b= 5, c;
if( a ==0 )
{
throw "Invalid divisor";
}
c = b/a;
return;
}
//***************************************************************
void main( )
{
try
{
std::auto_ptr<Test> p( new Test(5) );
Fun( );
cout<<p->m_a<<endl;
}
catch(...)
{
cout<<"Something has gone wrong"<<endl;
}
}
在上面的例子中,虽然抛出了异常,但指针仍然被正确释放了。这是因为当异常被抛出时会发生栈展开(stack unwinding)。由于属于 try
块的所有局部对象都被销毁,p
超出了作用域,并释放了其关联的内存。
问题1: 到目前为止,auto_ptr
是智能的。但它的智能性存在更根本的缺陷。当一个 auto_ptr
被赋值给另一个 auto_ptr
时,它会转移所有权。在函数之间传递 auto_ptr
时,这确实是个问题。比方说,我在 Foo()
中有一个 auto_ptr
,这个指针从 Foo
传递给另一个函数,比如 Fun()
。现在,一旦 Fun()
执行完毕,所有权并不会返回给 Foo
。
//***************************************************************
class Test
{
public:
Test(int a = 0 ) : m_a(a)
{
}
~Test( )
{
cout<<"Calling destructor"<<endl;
}
public:
int m_a;
};
//***************************************************************
void Fun(auto_ptr<Test> p1 )
{
cout<<p1->m_a<<endl;
}
//***************************************************************
void main( )
{
std::auto_ptr<Test> p( new Test(5) );
Fun(p);
cout<<p->m_a<<endl;
}
由于 auto_ptr
的怪异行为,上面的代码会导致程序崩溃。发生的情况是,p
拥有一个内存块,当调用 Fun
时,p
将其关联内存块的所有权转移给了 auto_ptr p1
,p1
是 p
的副本。现在 p1
拥有了之前由 p
拥有的内存块。到目前为止还算正常。现在 fun
执行完毕,p1
超出作用域,内存块被释放。那么 p
呢?p
不再拥有任何东西,这就是为什么当下一行代码执行时会引发崩溃,因为它访问 p
时以为它还拥有某些资源。
问题2: 另一个缺陷。auto_ptr
不能用于对象数组。我的意思是你不能将它与 new[]
运算符一起使用。
//***************************************************************
void main( )
{
std::auto_ptr<Test> p(new Test[5]);
}
上面的代码会产生一个运行时错误。这是因为当 auto_ptr
超出作用域时,会对关联的内存块调用 delete
。如果 auto_ptr
只拥有单个对象,这是没问题的。但在上面的代码中,我们在堆上创建了一个对象数组,它应该使用 delete[]
而不是 delete
来销毁。
问题3: auto_ptr
不能与 vector、list、map 等标准容器一起使用。
由于 auto_ptr
更容易出错并且将被弃用,C++11 推出了一套新的智能指针,每种都有其特定的用途。
shared_ptr
unique_ptr
weak_ptr
shared_ptr
好了,准备好享受真正的智能吧。第一种是 shared_ptr
,它有一个叫做“共享所有权”的概念。shared_ptr
的目标非常简单:多个共享指针可以指向同一个对象,当最后一个共享指针超出作用域时,内存会自动释放。
创建
void main( )
{
shared_ptr<int> sptr1( new int );
}
使用 make_shared
宏可以加速创建过程。由于 shared_ptr
内部需要分配内存来保存引用计数,make_shared()
的实现方式能够高效地完成这项工作。
void main( ) { shared_ptr<int> sptr1 = make_shared<int>(100); }
上面的代码创建了一个 shared_ptr
,它指向一个内存块,该内存块用于存放一个值为 100 的整数,引用计数为 1。如果基于 sptr1
创建另一个共享指针,引用计数会增加到 2。这个计数被称为*强引用*。除此之外,共享指针还有另一个引用计数,称为*弱引用*,这将在我们讨论弱指针时解释。
你可以通过调用 use_count()
来找出有多少个 shared_ptr
指向该资源。而在调试时,你可以通过观察 shared_ptr
的 stong_ref
来获取它。
析构
默认情况下,shared_ptr
通过调用 delete
来释放关联的资源。如果用户需要不同的析构策略,他/她可以在构造 shared_ptr
时自由指定。由于默认的析构策略,下面的代码会是个麻烦的源头。
class Test
{
public:
Test(int a = 0 ) : m_a(a)
{
}
~Test( )
{
cout<<"Calling destructor"<<endl;
}
public:
int m_a;
};
void main( )
{
shared_ptr<Test> sptr1( new Test[5] );
}
在这种情况下,shared_ptr 拥有一个对象数组,而默认的析构策略在其超出作用域时调用 "delete" 来释放关联的内存。实际上,应该调用 delete[]
来销毁数组。用户可以通过一个可调用对象(例如,一个函数、lambda 表达式、函数对象)来指定自定义的释放器。
void main( )
{
shared_ptr<Test> sptr1( new Test[5],
[ ](Test* p) { delete[ ] p; } );
}
上面的代码运行良好,因为我们已经指定了析构应该通过 delete[]
来进行。
接口
shared_ptr
提供了像普通指针一样的解引用操作符 *
和 ->
。除此之外,它还提供了一些更重要的接口,如:
get()
: 获取与shared_ptr
关联的资源。reset()
: 放弃对关联内存块的所有权。如果这是最后一个拥有该资源的shared_ptr
,那么资源会被自动释放。unique
: 了解资源是否仅由这一个shared_ptr
实例管理。operator bool
: 检查shared_ptr
是否拥有一个内存块。可以与if
条件一起使用。
好了,关于 shared_ptr
的内容就这些了。但 shared_ptr
也有一些问题:.
问题
void main( )
{
shared_ptr<int> sptr1( new int );
shared_ptr<int> sptr2 = sptr1;
shared_ptr<int> sptr3;
sptr3 = sptr2;
}
下表给出了上述代码的引用计数值。
所有 shared_ptr
共享同一个引用计数,因此属于同一个组。上面的代码是没问题的。让我们看另一段代码。
void main( )
{
int* p = new int;
shared_ptr<int> sptr1( p);
shared_ptr<int> sptr2( p );
}
上面这段代码将会导致一个错误,因为两个来自不同组的 shared_ptr
共享一个资源。下表为你展示了根本原因。
为了避免这种情况,最好不要从裸指针创建共享指针。
class B;
class A
{
public:
A( ) : m_sptrB(nullptr) { };
~A( )
{
cout<<" A is destroyed"<<endl;
}
shared_ptr<B> m_sptrB;
};
class B
{
public:
B( ) : m_sptrA(nullptr) { };
~B( )
{
cout<<" B is destroyed"<<endl;
}
shared_ptr<A> m_sptrA;
};
//***********************************************************
void main( )
{
shared_ptr<B> sptrB( new B );
shared_ptr<A> sptrA( new A );
sptrB->m_sptrA = sptrA;
sptrA->m_sptrB = sptrB;
}
上述代码存在循环引用。我的意思是,类 A 持有一个指向 B 的共享指针,而类 B 持有一个指向 A 的共享指针。在这种情况下,与 sptrA
和 sptrB
关联的资源都不会被释放。请参考下表。
当 sptrA
和 sptrB
超出作用域时,它们的引用计数都降为 1,因此资源没有被释放!!!!!
- 如果一个内存块与属于不同组的
shared_ptr
相关联,那么就会出错。所有共享相同引用计数的shared_ptr
属于一个组。我们来看一个例子。 - 从裸指针创建共享指针还涉及另一个问题。在上面的代码中,假设只使用
p
创建了一个共享指针,代码运行正常。再假设一个程序员不小心在共享指针的作用域结束之前删除了裸指针p
。糟糕!!!又一次崩溃…… - 循环引用:如果涉及到共享指针的循环引用,资源将不会被正确释放。考虑以下代码片段。
为了解决循环引用的问题,C++ 提供了另一种智能指针类,名为 weak_ptr
。
Weak_Ptr
弱指针提供共享语义,而非所有权语义。这意味着弱指针可以共享由 shared_ptr
持有的资源。因此,要创建一个弱指针,必须已经有某个实体拥有该资源,而这个实体就是共享指针。
弱指针不允许使用指针支持的常规接口,比如调用 *
、->
。因为它不是资源的所有者,因此它不给程序员任何机会去误用它。那么我们该如何使用弱指针呢?
答案是,通过一个 weak_ptr
创建一个 shared_ptr
并使用它。因为这样做可以确保在使用期间,通过增加强引用计数来防止资源被销毁。由于引用计数增加了,可以保证在你用完由 weak_ptr
创建的 shared_ptr
之前,计数至少为 1。否则可能会发生的情况是,在使用 weak_ptr
的过程中,由 shared_ptr
持有的资源超出了作用域,内存被释放,从而造成混乱。
创建
弱指针的构造函数接受一个共享指针作为其参数之一。从一个共享指针创建弱指针会增加该共享指针的*弱引用*计数器。这意味着共享指针正在与另一个指针共享其资源。但是,当共享指针超出作用域时,这个计数器不会被考虑用来释放资源。也就是说,如果共享指针的强引用变为 0,那么无论弱引用的值是多少,资源都会被释放。
void main( )
{
shared_ptr<Test> sptr( new Test );
weak_ptr<Test> wptr( sptr );
weak_ptr<Test> wptr1 = wptr;
}
我们可以观察共享/弱指针的引用计数器。
将一个弱指针赋值给另一个弱指针会增加弱引用计数。
那么,当一个弱指针指向由共享指针持有的资源,而该共享指针在其超出作用域时销毁了关联的资源,会发生什么呢?这个弱指针会过期(expired)。
如何检查弱指针是否指向一个有效的资源?有两种方法:
- 调用
use_count()
方法来获取计数。注意,这个方法返回的是强引用计数,而不是弱引用计数。 - 调用
expired()
方法。这比调用use_count()
更快。
要从 weak_ptr
获取一个 shared_ptr
,可以调用 lock()
或直接将 weak_ptr
转型为 shared_ptr
。
void main( )
{
shared_ptr<Test> sptr( new Test );
weak_ptr<Test> wptr( sptr );
shared_ptr<Test> sptr2 = wptr.lock( );
}
如前所述,从 weak_ptr
获取 shared_ptr
会增加强引用计数。
现在让我们看看如何使用 weak_ptr
来解决循环引用的问题。
class B;
class A
{
public:
A( ) : m_a(5) { };
~A( )
{
cout<<" A is destroyed"<<endl;
}
void PrintSpB( );
weak_ptr<B> m_sptrB;
int m_a;
};
class B
{
public:
B( ) : m_b(10) { };
~B( )
{
cout<<" B is destroyed"<<endl;
}
weak_ptr<A> m_sptrA;
int m_b;
};
void A::PrintSpB( )
{
if( !m_sptrB.expired() )
{
cout<< m_sptrB.lock( )->m_b<<endl;
}
}
void main( )
{
shared_ptr<B> sptrB( new B );
shared_ptr<A> sptrA( new A );
sptrB->m_sptrA = sptrA;
sptrA->m_sptrB = sptrB;
sptrA->PrintSpB( );
}
Unique_ptr
这几乎可以看作是容易出错的 auto_ptr
的替代品。unique_ptr
遵循独占所有权语义,即在任何时候,资源都只由一个 unique_ptr
拥有。当 unique_ptr 超出作用域时,资源被释放。如果资源被其他资源覆盖,先前拥有的资源也会被释放。因此,它保证了关联的资源总是会被释放。
创建
unique_ptr
的创建方式与 shared_ptr
相同,但它额外提供了对对象数组的支持。
unique_ptr<int> uptr( new int );
unique_ptr
类提供了专门用于创建对象数组的特化版本,当指针超出作用域时,它会调用 delete[]
而不是 delete
。在创建 unique_ptr
时,可以将对象数组指定为模板参数的一部分。这样,程序员就不必提供自定义的释放器,因为 unique_ptr
已经处理了。
unique_ptr<int[ ]> uptr( new int[5] );
资源的所有权可以通过赋值从一个 unique_ptr
转移到另一个。
请记住,unique_ptr
不提供拷贝语义 [拷贝赋值和拷贝构造是不可能的],但提供移动语义。
在上述情况下,如果 upt3
和 uptr5
已经拥有了某个资源,那么在拥有新资源之前,旧资源将被正确销毁。
接口
unique_ptr
提供的接口与普通指针非常相似,但不允许指针算术运算。
unique_ptr
提供了一个名为 release
的函数,该函数会放弃所有权。release()
和 reset()
的区别在于,release
只是放弃所有权,并不会销毁资源,而 reset
会销毁资源。
该用哪一个?
这完全取决于你想要如何拥有资源。如果需要共享所有权,就用 shared_ptr
,否则用 unique_ptr
。
除此之外,shared_ptr
比 unique_ptr
稍重一些,因为它内部会分配内存来进行大量的簿记工作,如强引用、弱引用等。但 unique_ptr
不需要这些计数器,因为它是资源的唯一所有者。
使用代码
我附上了已经实现的代码,用以解释每种指针的细节。我为每条指令都添加了足够的注释。如果代码有任何问题,请随时联系我。弱指针的示例演示了共享指针在循环引用情况下的问题,以及弱指针是如何解决这个问题的。
历史
这是本文的第一个版本。我会根据反馈和评论随时更新。