C++ | 智能指针初探
智能指针初探
在 c/c++ 语言中有一种特殊的类型——指针类型。
指针作为实体,是一个用来保存一个内存地址的计算机语言中的变量。它可以直接对内存地址中的数据进行操作,是一种非常灵活的变量。指针被誉为是c语言的灵魂,而作为c语言的继承者c++来说更是继承了指针灵活多用的特点并把它发扬光大。在这里我们不去重点讨论普通的指针,而是讨论其中更为特殊的指针——智能指针。
智能指针到底智能在哪?又为什么叫智能指针呢?别着急 ,慢慢看下去你就了解了。
本文目录:
一个内存泄漏的简单实例
模拟auto_ptr设计智能指针
实现 Smart_ptr 智能指针
优化 Smart_ptr 智能指针
注:本次实验中模拟的 auto_ptr 并不是严格意义上的实现,只是针对其主要设计思路进行的一种模拟。
垃圾回收机制:
首先让我们来看一个小概念,垃圾回收机制。
程序运行过程中会申请大量的内存空间,而对于一些无用的内存空间,如果不及时清理的话,会持续占用内存资源,还会导致程序崩溃,影响用户体验。更严重的还会影响整个系统的运行情况。
而对 C/C++ 语言来说,在栈上申请的空间由操作系统接管,系统会自动回收它,而在堆上申请的空间就需要我们程序人员自行进行分配了。
垃圾回收机制,是系统防止程序发生内存泄漏的一种保护机制。作用呢就是将程序运行期间产生的垃圾(不再使用的数据),或对内存中已经死亡的或者长时间没有使用的对象进行清除和回收。从而达杜绝内存泄漏,使程序高效的使用内存。
C++中对内存内存泄漏的处理
我们都知道C++语言是一种很强大的语言,但强者也有强者的傲慢。在C++中是没有垃圾回收机制的,至于原因吗,主要是为了保证C++语言的高效。C/C++语言的高效仅次于汇编语言,因此常用作底层和服务器的开发,如果在C++中加入垃圾回收机制反而影响了它高效的特点,这与我们最初的理念背道而驰了。
在C++中通过一对关键字 new
与 delete
实现对堆区空间的申请与释放,如果在进行 new 之后而没有释放那么就会造成内存泄漏。对于我们平时的小型程序而言,如果内存泄漏的只需重新启动该程序即可,而对于大型程序,比如在服务器上运行的服务,它们重启的代价将会很大很大。因此我们除了在开发过程中要尽量避免发生内存泄漏外,还迫切的需要一种机制来避免内存泄漏。
前面也说了,C++是一种非常强大的语言。在对既要保证了高效的前提下,又不发生内存泄漏的问题上,C++给出的答案是智能指针。
内存泄漏实例
我们先来看一段会发生内存泄漏的例子。
#include <iostream>
using namespace std;
void func(int* ptr)
{
int* p = new int;
if (ptr == NULL)
{
return;
}
*p = *ptr; ///////
delete p;
}
int main()
{
func(NULL);
return 0;
}
在上面这段代码中,我们在 func()
函数中加入对指针的判空操作,但我们在 main()
函数中传入了一个 NULL
参数,这会导致我们程序在执行时传入的 NULL 值在 if()
中为真,进而使得程序提前跳出 func()
,那么对于 p
所指向的那块内存空间我们并没有对其进行释放,这就造成了内存泄漏的问题。
检查内存泄漏情况
下面让我们使用 CRT函数检查内存泄漏,分别对应普通指针和 auto_ptr 型智能指针。在程序顶部添加一个宏和一个头文件,使用 _CrtDumpMemoryLeaks();
函数捕获内存泄漏情况。
#define CRTDBG_MAP_ALLOC
#include <crtdbg.h>
#include <iostream>
using namespace std;
void func(int* ptr)
{
int* p = new int; // ⑴
//auto_ptr<int> p(new int); //⑵
if (ptr == NULL)
{
return;
}
*p = *ptr;
delete p; // ⑴
}
int main()
{
func(NULL);
_CrtDumpMemoryLeaks();
return 0;
}
测试一,检查 int* p = new int;
的内存泄漏情况,注释掉 ⑵ 部分,输出如下:
使用调试功能执行,可以看到在第{81}次内存分配操作时,发生了内存泄漏。
测试二,检查 auto_ptr<int> p(new int);
的内存泄漏情况,注释掉 ⑴部分,输出如下:
使用调试功能执行,可以看到该程序没有发生了内存泄漏。
这就是C++的智能指针机制,不需要我们手动释放,在对象销毁时会自动的释放内存。下面就让我们来走进C++的智能指针,拒绝内存泄漏。
模拟auto_ptr 型智能指针
在这里我们要讨论的主要是 boost
库中的智能指针和 C++标准库
中的智能指针。在C++11标椎以前,C++中只有一种 auto_ptr
型智能指针,但 auto_ptr 有很大的缺陷,在C++11标椎以后就被摈弃掉了。
在C++11标椎以后又引入了三种智能指针,分别是 unique_ptr
、 shared_ptr
、 weak_ptr
三种智能指针。这三种智能指针是借鉴与 boost 库引用C++标准的。其中,Boost库是一个可移植、提供源代码的C++库,作为标准库的后备,是C++标准化进程的开发引擎之一,是为C++语言标准库提供扩展的一些C++程序库的总称。
我们都知道,在栈上开辟的空间会由系统进行释放,而对于一个在栈上申请的对象来说,它销毁时主要做了两件事。1、调用析构函数,2、释放对象所占资源。而对于一个在堆上申请的空间我们程序人员无法直接对其操作。通常情况下在申请堆区空间后都会返回一个指向该片堆区空间的指针,由栈上的变量代为接收从而间接的操作该片内存空间。
结合以上两点特点,我们能否让一个对象接管堆区申请的空间,在对象生存周期结束时,自动实现对堆区资源的释放呢?这也正是智能指针的思想。
下面我们可以模拟实现智能指针
在实现智能指针前,首先得知道他是用来替代普通指针无法管理堆区内存释放而产生的。说白了,指针具有的特性它得具有,指针不具备的特性它也得具备才行。那么下面总结一下要成为智能指针需要具备哪些特性呢?
指针具备的特性:
- 可以多个指针指向同一个对象。—————智能指针要注意内存可能重复释放的问题
- 指针可以给其他指针赋值。————设计智能指针的拷贝构造函数,和赋值函数
- 指针指向特殊空间NULL。————智能指针也可以指向NULL,
delete NULL
不会产生错误
智能指针特有的特性:
- 在对象生成前是不知道堆区申请的空间类型的 ————通过C++泛型提供的模板设计模板类
- 在对象生存周期结束时,释放指向的堆区空间 ————在对象的析构函数中添加 delete 手动释放空间。
整理出上述特性后可以着手设计智能指针了。
给出我们的模板类 class Smart_ptr
,和其数据成员 T* m_ptr
。
template<typename T>
class Smart_ptr
{
private;
T* m_ptr;
};
现在实现智能指针主要难点在于,既要解决智能指针间的相互赋值问题,期间还要保证不会有指针悬挂的情况产生。也就是如何避免多个智能指针对堆区进行重复释放。
针对上面的问题,首先我们来设计构造函数。
构造函数比较简单,只需要将传入的参数赋值到其类中的成员变量即可。
Smart_ptr(T* ptr) :m_ptr(ptr)
{}
而多个指针的问题就得从拷贝构造函数开始设计了。
针对上面的问题,我们来设计拷贝构造函数。
Smart_ptr(const Smart_ptr<T>& rhs);
在 auto_ptr 的拷贝构造中采用的策略是指针独占堆内存的形式,即 将堆区空间以转让的方式赋值。比如 sp1
申请了一块堆内存,现在 sp2
使用拷贝构造得到该堆区内存的使用权,而原 sp1
的做法是将该堆内存转让出去。并且为了避免成为野指针将会把sp1
指针指向NULL 。
// 在 main() 中执行下列代码:
Smart_ptr<int> sp1(new int); //sp1-->堆内存
Smart_ptr<int> sp2(sp1); //sp1-->NULL sp2-->堆内存
/* sp1 将堆内存转让给 sp2 */
了解完 auto_ptr 的设计原理后,我们需要实现对指针的置空操作。
需要注意的是,拷贝构造函数传入的参数是一个常对象,因此我们在拷贝构造函数内部需要对 rhs
进行强制转换。我们可以使用C++的强制类型转换const_cast<type>
,或者使用关键字 mutable
去除常性。当然用C语言中的强制类型转换也是可以的。
// 注意在 C 中强制类型转换格式为 (type)obj , C++中 static_cast<type>(obj)
const_cast<Smart_ptr<T>*>(this)->m_ptr = NULL; // C++
((Smart_ptr<T>*)this)->m_ptr = NULL; // C
我们通过设计一个浅拷贝构造函数就可以了,同时要考虑到后续赋值函数中可能也存在指针置空操作的问题,我们单独设计一个 release()
函数进行释放。常对象只能调用常方法,因此我们的 release()
函数需要设计成一个常方法。
下面是拷贝构造函数和 release() 函数的实现
// 拷贝构函数
Smart_ptr(const Smart_ptr<T>& rhs)
:m_ptr(rhs.m_ptr)
{
rhs.release();
}
// release() 声明为常方法。
void release() const
{
const_cast<Smart_ptr<T>*>(this)->m_ptr = NULL;
}
接下来我们可以对赋值函数进行设计了。
赋值函数也是智能指针设计不可缺少的一处,比如在出现下面的这种赋值方式。如果不事先对赋值函数进行设计,在代码执行该处时就会发生内存泄漏。
// 在 main() 中
Smart_ptr<int> sp1(new int); //sp1-->堆内存1
Smart_ptr<int> sp2(new int); //sp2-->堆内存2
sp2 = sp1; // 堆内存2丢失
/* sp1 把堆内存1转让给 sp2,
而原本sp2 指向的堆内存2
的地址被丢弃,造成内存泄漏 */
为了防止此类情况的发生,在赋值函数的设计中需要将原有对象(堆内存)进行释放,在把指针指向传入对象的堆内存。
// 赋值函数
Smart_ptr<T>& operator=(const Smart_ptr<T>& rhs)
{
if (this != this) // 自赋值检查
{
delete m_ptr;
m_ptr = rhs.m_ptr;
rhs.release();
}
}
设计运算符重载函数
如果想通过智能指针实现对堆内存的操作就得实现相应的运算法的重载。针对智能指针而言,操作无非是解引用 *
运算符和 ->
成员访问符。
T& operater* () // 解引用
{
return *m_ptr
}
T* operater->()
{
return m_ptr;
}
模拟实现 auto_ptr 智能指针类:
以上就是模拟的 auto_ptr 型智能指针的设计流程。在 auto_ptr 中规定只有一个智能指针对堆内存拥有管理权限,而多个智能指针指向同一个堆内存就会导致前面的智能指针失效,这也是 auto_ptr 的缺陷所在。
// 模拟 auto_ptr 智能指针代码
template<typename T>
class Smart_ptr
{
public:
Smart_ptr(T* ptr) :m_ptr(ptr) {}
Smart_ptr(const Smart_ptr<T>& rhs)
:m_ptr(rhs.m_ptr)
{
rhs.release();
}
Smart_ptr<T>& operator=(const Smart_ptr<T>& rhs)
{
if (this != this) // 自赋值检查
{
delete m_ptr;
m_ptr = rhs.m_ptr;
rhs.release();
}
return *this;
}
~Smart_ptr()
{
delete m_ptr;
m_ptr = NULL;
}
T& operator*() // 解引用
{
return *m_ptr;
}
T* operator->()
{
return m_ptr;
}
private:
T* m_ptr;
void release() const
{
const_cast<Smart_ptr<T>*>(this)->m_ptr = NULL;
}
};
// 测试
int main()
{
Smart_ptr<int> sp1(new int); //sp1-->堆内存1
Smart_ptr<int> sp2(sp1); //sp2-->堆内存1
Smart_ptr<int> sp3(new int); //sp2-->堆内存2
sp3 = sp2;
return 0;
}
缺点:正如我们提到的那样,auto_ptr 有一个缺陷所在,注定它被后面的几种智能指针所取代。
// 如在 main() 中写下如下代码,程序就会出错
Smart_ptr<int> sp1(new int);
Smart_ptr<int> sp2(sp1);
*sp1 = 10; //sp1 访问了保留区 0x0000 0000
在 auto_ptr 中有许多的不足,而我们也可以根据自己所学的知识对他进行改进和优化。
优化设计 auto_ptr 智能指针
auto_ptr 缺陷的根本原因在与它的设计理念为只能同时有一个智能指针对堆内存进行管理,也就是说 auto_ptr 的设计理念为:管理权 唯一,释放权 唯一。
那么我们是否可以考虑重新设计智能指针,使得在同一时间可以有多个智能指针进行管理,而在释放时只由一个智能指针进行释放。也就是我们所说的:管理权 不唯一,释放权 唯一。
我们换个思路,在使用的堆区空间被申请后,只要是合法的访问修改都是没问题的。换句话说,不管哪个智能指针都可以对其进行管理,而在最后释放时只需要让其中一个智能指针对其进行释放即可。
在具体设计的时候我们发现,在使用时所有指针都可以使用堆内存这一点毫无争议,但释放时只能释放一次,所以把释放权交给哪一个智能指针去做这一点尤为关键。通过以往的经验,我们可以设计一个变量充当标志,而在这里面又两个问题需要我们考虑。
1、怎样设置标志,明确释放权的归属问题。
2、通过怎样的方式来转移释放权。
对于第一个问题,在这里我们设置一个 flag,通 true 和 false 的取值来判断该智能指针是否具有释放权。对于第二个问题,我们给出两条准则。
- 旧的智能指针具有释放权,通过该指针生成的新的智能指针就具有释放权。同时剥夺原指针指针的释放权。
- 旧的智能指针不具有释放权,通过该指针生成的新的智能指针也就不具有释放权。
什么意思呢?,如下图所示。
sp1 申请堆内存空间,从而拥有该空间的释放权。
sp2 通过拷贝构造 sp1 得到堆内存的使用权,同时 sp1 放权至 sp2。sp1 失去释放权。
sp3 通过拷贝构造 sp1 得到堆内存的使用权,但是 sp1 已经没有释放权了,因此 sp3 也无法获得释放权。
正式通过这两条准则可以保证所有引用这片堆内存的指针中,只有一个指针唯一拥有释放权。我们在设计该智能指针时采用了设置标志位的方法,使得智能指针突破了其独有享用堆内存对象的权力,下面让我们来看看该智能指针的具体实现。
带标志位的智能指针
template<typename T>
class Smart_ptr
{
public:
Smart_ptr(T* ptr) :m_ptr(ptr)
{
flag = true;
}
Smart_ptr(const Smart_ptr<T>& rhs)
:m_ptr(rhs.m_ptr)
{
flag = rhs.flag;
rhs.flag = false;
}
Smart_ptr<T>& operator=(const Smart_ptr<T>& rhs)
{
if (this != this) // 自赋值检查
{
~Smart_ptr();
m_ptr = rhs.m_ptr;
flag = rhs.flag;
rhs.flag = false;
}
return *this;
}
~Smart_ptr()
{
if (flag)
{
delete m_ptr;
}
m_ptr = NULL;
}
T& operator*() // 解引用
{
return *m_ptr;
}
T* operator->()
{
return m_ptr;
}
private:
T* m_ptr;
mutable bool flag; // 去除常性
};
int main()
{
Smart_ptr<int> sp1(new int);
Smart_ptr<int> sp2(sp1);
Smart_ptr<int> sp3(sp1);
*sp1 = 10;
cout << *sp1 << endl;
return 0;
}
缺点:我们优化后的这款智能指针同样存在缺点。如果在程序中,有意外的函数调用使得该片堆内存被提前释放,那么其他的智能指针任然指向其地址,而指向的那片空间已经归还给操作系统。如果强行对其操作,就可能会对其他程序或自身程序运行时的数据进行非法的数据修改,产生极大危害。如以下代码
void func(Smart_ptr<int> sp)
{
// 空
}
int main()
{
Smart_ptr<int> sp1(new int);
Smart_ptr<int> sp2(sp1); // 拥有释放权
Smart_ptr<int> sp3(sp1);
func(sp2);
*sp1 = 10; // 野指针非法操作
return 0;
}
在 func()
中的 sp
是一个形参,其生命周期在该函数调用结束后进行的栈帧回退操作时被回收。
而在实参传递形参的过程中,调用了拷贝构造函数进行临时量的构造,而传入的实参 sp2 恰好具备释放权,这就造成了释放权的转移。使该临时对象 sp
具有释放权。而 sp 又是个短命鬼,刚拿到堆区对象没多久就释放了。
从而导致提前释放了该堆内存。虽然该程序中在语法上以及在编译器中都不会报错,程序表面上运行正常,但它已经修改了不属于自己的内存空间的值。如果有其他程序(或自身程序)正在使用该片堆内存,就会读取到被污染的数据造成严重的后果。
因为指针灵活多用的特点,并且它可以直接操作内存。如果使用不当就会造成极大地危害。我们把以上过程抽象成生活中的实例来进行理解。
抽象解释
我们把堆区看成是一个大公寓,在 sp1 申请堆内存时就如同他开了一间房间,而后来的 sp2、sp3以及sp 都是他的兄弟姐妹只要获得他的允许都可以来这里玩。现在 sp 这个傻蛋向他哥哥 sp2 把公寓的房卡借过去用几天,谁知道他用完就把公寓给退掉了,但是他的哥哥们不知道这件事。过了几天他们去公寓玩。这里就牵扯到一个问题,如果公寓已经出租给别人了,那么他们再进去就是非法入室,因为该片内存已经分配给了别人,如果这个公寓没人住,那就相安无事。但前者是绝对不允许的行为,在我们的程序设计中最后的程序的漏洞、bug等问题往往都是因为一开始的一个不起眼的编译器警告,何况是直接对内存修改这么严重的问题。其中,在上述所说的例子中,我们把释放权模拟做房卡,把同类型的智能指针理解为兄弟姐妹。编译器就是整栋公寓的负责人,而他们在非法入侵公寓时没有管理人员人来提醒他们已经退房了,也就是编译器没有给出报错或是警告。因此,这也提醒我们在平时的编程中,不要太过依赖编译器,毕竟编译器是写死的程序没有咱程序猿懂得变通,并且一定不要出现类似上述的问题。
总结
在本次的智能指针初探中,我们详细的讨论了 auto_ptr 智能指针的缺陷,并且模拟实现的 auto_ptr 智能指针并实现了对其的优化。但是最后我们发现在我们设计的智能指针中还是有着许多的问题,这也是C++11摒弃 auto_ptr 的原因。文章一开始也说了,C++11中新引进了三种智能指针,并且还有 boost 库中也还有其他智能指针,它们具体是怎么实现的呢?有是如何避免我们之前犯过的错误的?那些指针就一定是完美的吗?这些问题将留到我们下回再做讨论。