前言
C++当中的内存管理机制需要我们自己来进行控制,比如 在堆上 new 了一块空间,那么当这块空间不需要再使用的时候。我们需要手动 delete 掉这块空间,我们不可能每一次都会记得,而且在很大的项目程序当中,造成内存泄漏也是不少了。
C++ 不像 Java一样,有 gc,也就是垃圾回收站器,因为 Java 在操作系统之上还有一层虚拟机,这层虚拟机可以理解为运行的一个进程,所有的Java 程序都是在这个 虚拟机 之上运行的。在虚拟机当中就有 这个 gc 在运作。
gc 简单来说就是把所有动态开辟都记录起来,当不需要使用的时候就自动释放了。
但是 Java 在实现这个 虚拟机 是有消耗的,所以在特别需要效率的项目上很多都用的是 C++,比如在游戏开发,服务器当中。
而且,在C++ 当中 我们自己控制 delete 空间的话,就算是我们想起来 要控制,程序当中设计得复杂,我们都不能完全的控制,比如下述:
pair<int, int>* pa = new pair<int,int>;
func();
delete pa;
如果没有 func()函数的话,那么最后肯定是能释放掉的,但是,现在有一个不确定因素在 func()函数当中,我们不清楚在func()函数当中到底做了什么,我们就不能 100% 没问题。假设在func()当中抛异常,被主函数当中捕获了,那么就有可能会直接跳过这个 delete。
虽然,抛异常的跳过的问题,我们可以 先 delete 需要释放的空间,然后再抛出异常,解决很多场景。但是有一个场景是不能解决的:
new 也是可能会抛出异常的,虽然概率很低,但是也是有可能的,那么下面的场景:
如果 p1 的new 抛出异常,那么还好,没有什么问题,如果是 p2 抛出异常的话,那么 p1 开的空间应该怎么解决呢?
如果此时我们再把 func()函数加上:
pair<int, int>* p1 = new pair<int,int>;
pair<int, int>* p2 = new pair<int,int>;
try{
func();
}catch(...){
delete p1;
delete p2;
throw ....................
}
delete pa;
如果 func()函数抛出异常,那么就要 delete p1 和 p2。
那如果还不嫌麻烦,如果在多来几个呢? p3 p4 p5 p6 ····························
基于上述问题,就搞出了 智能指针。
智能指针
智能指针虽然听上去很高端,但实际很简单。
就更之前在 各种库当中实现的迭代器是一样,把指针包装了一下,在其中实现各个函数,比如指针需要的 operator*() , operator->() 函数等等,最重要的是实现 ~析构函数,他是我们自动 释放空间的核心,因为在创建类的对象之后,编译器会自动的调用这个对象的析构函数,用于析构这个对象和对象空间。
template<class T>
class SmartPtr
{
public:
// RAII
// 资源交给对象管理,对象生命周期内,资源有效,对象生命周期到了,释放资源
// 1、RAII管控资源释放
// 2、像指针一样
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
private:
T* _ptr;
};
这样的话,只需要用这个指针构造 一个指针指针对象,那么就会自动维护这个指针:
pair<int, int>* p1 = new pair<int,int>;
SmartPtr sp1(p1);
此时,p1 指针维护的空间我就不需要手动释放了,自己就会释放。
当然上述的实现,是分开的,我们可以不用 p1 传进去,直接把 new pair<int,int>; 当参数传进去就行,因为 new 本身返回的就是 这个 空间的指针。
SmartPtr<pair<string, string>> sp2(new pair<string, string>);
SmartPtr<pair<string, string>> sp3(new pair<string, string>);
所以说,虽然 C++ 没有 gc ,但是有智能指针,我们可以自己实现,自己实现的智能指针没有什么问题的话,这个智能指针是可以帮助我们解决绝大部分的内存泄漏问题的,异常也不用怕了。
有人把这种机制叫做 RAII。
RAII
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。
我们实际上把管理一份资源的责任托管给了一个对象。对象是给编译器进行管理。也就是利用了对象无论以什么方式离开了作用域都会调用其析构函数的特点。
也就是获取到资源的时候,马上初始化。
这样做的好处是:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效。
智能指针像指针一样使用
其实上述也说过了,智能指针的构造函数,是利用指针来构造这个智能指针的对象,析构函数是释放这个指针指向的空间。
那么,指针的使用还有 operator*() operator->() operator[]() 这样子的函数要支持。
实现就更迭代器的实现是一样的,可读可写。
template<class T>
class SmartPtr
{
public:
// RAII
// 资源交给对象管理,对象生命周期内,资源有效,对象生命周期到了,释放资源
// 1、RAII管控资源释放
// 2、像指针一样
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
智能指针之间的赋值问题(拷贝问题)
如下面两个智能指针:
SmartPtr<string> sp1(new string("xxxxx"));
SmartPtr<string> sp2(new string("yyyyy"));
我们把两个指针 delete 的地址打印一下,输出:
两个空间都释放了。
但是,如果上述的两个智能指针对象,进行相互赋值操作的话,就会出现问题:
SmartPtr<string> sp1(new string("xxxxx"));
SmartPtr<string> sp2(new string("yyyyy"));
sp1 =sp2;
此时输出:
发现,两次释放的是一个空间,这就出问题了啊。
首先,是对一块空间析构了两次,我们说对同一块空间析构两次是可能会出现问题的。
其次是,原本是两个智能指针维护的是两块空间,但是现在我们只是放了一块,另一块空间没有释放就造成了内存泄漏。
其实是因为我们没有写 拷贝构造函数,如果我们没有写拷贝构造函数的话,编译器就会自己实现一个 默认的浅拷贝的 拷贝构造函数。按照之前我们应该自己用现代写法来实现了一下 拷贝构造函数。
但是,在智能指针当中我们就不能 用上述深拷贝的方式来解决,因为我们写的智能指针,本质上就是要模拟实现指针来实现的。而原生指针在赋值这一块,本来就是浅拷贝。
auto_ptr
为了解决上述所说的 内存管理问题,在C++98 当中就提出了 auto_ptr 。它可以解决上述的问题,但是这个 auto_ptr 有一个非常大的坑,具体请看下述:
为了演示 到底是没有释放空间,我们需要创建一个类来帮助我们,因为 ,如果是我们自己实现的智能指针,那么我们可以在 析构的时候打印一下,来提示我们此时发生了空间的释放,但是在库当中的实现的 auto_prt 我们没办法打印,但是, delete 一个对象就调用这个对象的析构函数,所以我们实现一个对象的析构函数,在其中打印这个 以下提示我们就行:
class A
{
public:
A(int a = 1)
:_a(a)
{
cout << "A(int a = 1)" << endl;
}
~A()
{
cout << "~A()" << endl;
}
private:
int _a;
};
如何使用 auto_ptr 呢?如下所示:
std::auto_ptr<A> sp1(new A(1));
auto_ptr 是一个模版类,模版参数只需要给出 需要管理的类型即可,不需要给出指针(如上述的 int )。auto_ptr 的构造函数,可以直接传入这个 需要管理的空间的 地址,像上述我们就直接使用 new 开空间后返回这个空间的地址来实现。
输出:
发现,没有显示释放这个空间我们都释放了这个空间。
如果我们进行像上述在拷贝问题当中实现的赋值一样的,如下代码所示:
auto_ptr<A> aptr1(new A(1));
auto_ptr<A> aptr2(new A(1));
aptr1 = aptr2;
输出:
A(int a = 1)
A(int a = 1)
~A()
~A()
发现,它也正常释放了。
我们要发现他的问题,就得知道他做了什么,如下例子阐述:
如上述所示,我们用 ap1 构造了 ap3,也是相当于是 赋值了,那么此时发生了什么呢?
其实 auto_ptr 指针在赋值这一块,做了一件事情 ----- 管理权转移。
如上述例子,他把 原本属于 ap1 的空间,转给了 赋值之后的 ap3 管理了,所以,我们看到上述的 ap1 指向的是 empty。
在 auto_ptr 当中的 拷贝构造函数,就相当于是实现了 移动构造的玩法,直接交换 ap1 和 ap3 当中的指针变量,把 ap1 指向 ap3 当中的空,把 ap3 当中的指针指向 ap1 原本维护的指针。
但是只是相当于,这里并不是移动构造的玩法,移动构造是把 右值 当中的 将亡值(即将释放的值),把其中的资源直接转移到 新的 值 来维护。如果不是 将亡值 ,比如是 左值,我们是不敢直接转移的,因为 此时左值在后序程序当中很有可能会用到。
但是 auto_ptr 当中也就相当于是 强制进行 移动构造,不管是左值还是右值,这么左的风险,在上述也说过了,ap1 是左值,在后序是很有可能会用到了,这就是它最大的问题。
举例:
如果是不懂的人,对 赋值之后的 ap1 进行操作的话,编译器甚至都不会报错,但是一运行程序就会出事:
auto_ptr<A> ap1(new A(1));
auto_ptr<A> ap2(new A(1));
auto_ptr<A> ap3(ap1);
// 此时把 _a 成员变量修改为了 public:
ap1->_a++;
ap2->_a++;
成功生成解决方案:
出事儿:
auto_ptr 其实是一段失败的代码,但是已经有人使用 autp_ptr 写了项目,委员会不敢删,所以延续至今,但是在一般实践,公司当中明确规定不能使用 auto_ptr 。
auto_ptr 模拟实现
// C++98 管理权转移 auto_ptr
namespace bit
{
template<class T>
class auto_ptr
{
public:
auto_ptr(T* ptr)
:_ptr(ptr)
{}
auto_ptr(auto_ptr<T>& sp)
:_ptr(sp._ptr)
{
// 管理权转移
sp._ptr = nullptr;
// 一定要置空
// 不然两个对象管理同一块空间
// 在析构函数释放的时候就会释放两次
// 就会报错
}
auto_ptr<T>& operator=(auto_ptr<T>& ap)
{
// 检测是否为自己给自己赋值
if (this != &ap)
{
// 释放当前对象中资源
if (_ptr)
delete _ptr;
// 转移ap中资源到当前对象中
_ptr = ap._ptr;
ap._ptr = NULL;
}
return *this;
}
~auto_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
}
unique_ptr
在C++11 当中提供更加稳定的 unique_ptr 智能指针。
cplusplus.com/reference/memory/unique_ptr/
unique_ptr 的解决方案的话,非常的简单粗暴,就是直接不允许你进行 unique_ptr 智能指针之间的赋值拷贝。
unique_ptr<A> up1(new A(1));
unique_ptr<A> up2(new A(1));
up1 = up2;
编译报错:
error C2280: “std::unique_ptr<A,std::default_delete<A>> &std::unique_ptr<A,std::default_delete<A>>::operator =(const std::unique_ptr<A,std::default_delete<A>> &)”: 尝试引用已删除的函数
unique_ptr的模拟实现
对于 unique_ptr 的模拟实现很简单,我们在类当中说过,要想某一个函数不给外部使用,右里那个三种方式,第一种是只声明不实现;第二种是 把这个函数用 private 修饰:第三种是在函数之后写上 "= delete" 意思就是把这个函数删除掉。
但是第一种,值声明不实现,别人可以在外部给你实现这个函数,所以建议用后面的两种方法。
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
~unique_ptr()
{
if (_ptr)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
}
}
// 像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
unique_ptr(const unique_ptr<T>&sp) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>&sp) = delete;
private:
T* _ptr;
};
std::shared_ptr
当然,上述的 unique_ptr 版本的智能指针只是给我们手撕智能指针的模版,我们手撕一个 unique_ptr 非常简单。
但是 unique_ptr 比较不能解决 指针 赋值的问题。
所以,就有了 shared_ptr 智能指针的出现。
shared_ptr<A> sp1(new A(1));
shared_ptr<A> sp2(new A(1));
sp1 = sp2;
sp1->_a++;
sp2->_a++;
cout << sp1->_a << endl;
cout << sp2->_a << endl;
输出:
A(int a = 1)
A(int a = 1)
~A()
3
3
~A()
shared_ptr 模拟实现
解决指针拷贝问题
上述几种不同的 智能指针不同就不同在 拷贝构造函数 和 operator=() 两个函数的实现不同,
所以我们主要实现 shared_ptr 的拷贝构造函数,operator=()当中的 可以服用拷贝构造函数。
用一个shared_ptr 指针给另一个 shared_ptr 赋值和 拷贝构造,其中肯定是要 两个 智能指针管理两块空间的,主要是要解决 两个对象析构两次会报错的问题。
我们选择使用 引用计数的方式来解决:
也就是记录一下有多少 引用 引用了当前空间。在 某一个 智能指针 对象析构的时候,不先释放空间,先判断 当前引用计数是不是 0 ,如果是 大于 0的,说明当前 不止当前智能指针 指向这块空间,那么就 --引用计数;如果当前引用计数 是 0 ,那么说明当前就本 智能指针 指向这块空间,就可以 释放这块空间。
但是,在类当中 应该使用什么 成员变量来 记录这个 引用计数呢?普通的变量可能是不能满足的,如果是 非静态的变量,每一个是存储在各个类当中的,我们要实现统一计数的话,非常的麻烦。
所以,我们使用静态的成员变量来存储这个 引用计数,因为 静态的成员变量不单单属于某一个对象,而是属于这个整个类,你可以理解为静态的成员变量是属于每一个对象的,每一个对象共用一个 静态成员变量。
类似这样:
代码实现:
public:
// 构造函数
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
// 析构函数
~shared_ptr()
{
// -- 引用计数 之后 如果 == 0 ,就可以释放空间了
if (--(*_pcount) == 0)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pcount;
}
}
// 拷贝构造函数
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_pcount(sp._pcount)
{
++(*_pcount);
}
// operator=() 函数实现
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
// 先判断 赋值和被赋值的两个指针是否是重复的
// 是就直接返回
if (_ptr == sp._ptr)
return *this;
// 判断当前 赋值指针在赋值出去之前
// 是否是所维护空间的唯一指针
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
// 开始赋值
_ptr = sp._ptr;
_pcount = sp._pcount;
// 引用计数++
++(*_pcount);
return *this;
}
private:
T* _ptr;
int* _pcount;
};
如上所示,operator=()函数才是最难实现的,我们要判断当前对象,也就是被赋值 的对象 在当前赋值之前,是否 右引用其他对象,如果引用了其他的对象,那么要先 -- 引用计数,看-- 之后的引用计数是否 == 0,如果是 0 ,就要把原有的 空间给释放掉,然后才能进行赋值操作,赋值就简单了,直接把 赋值对象 当中的 两个成员赋值过来,然后在 ++ 引用计数即可。
而且,还需要注意,自己给自己赋值的情况:比如 sp1 和 sp2 都指向了 同一块空间,那么对于 :
sp1 = sp1 和 sp2 =sp1 两者实际上都是自己给自己赋值,在上述的代码当中不进行判断的话,是没有什么问题的,但是,在上述判断之前空间,和赋值新的空间的操作都是多余做的了,所以我们可以 在函数的开口就判断,是不是自己给自己赋值的情况。
如果 有一个 sp3 委会一块空间,这块空间没有被其他指针来维护的话, 假设现在有 sp3 = sp3 这样的赋值的话,如果没有特殊判断来终止赋值的话,sp3 维护的资源 就会在 operator=() 当中 被释放掉,然后在把 sp3 赋值给 sp3 的话,就是一个已经被释放了的空间地址,当我们再次使用这个 sp3 的时候就会出问题。
可以利用 资源来判断是不是 同一块空间,也可以用 计数来判定也是可以的:
// 判断当前对象当中的原生指针指向的空间是否和
// 当前要赋值的指针指向的空间是相同的
if(_ptr == sp._ptr_
return *this;
shared_ptr 完整代码实现:
template<class T>
class shared_ptr
{
public:
// RAII
// 像指针一样
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
~shared_ptr()
{
if (--(*_pcount) == 0)
{
cout << "delete:" << _ptr << endl;
delete _ptr;
delete _pcount;
}
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
// sp3(sp1)
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_pcount(sp._pcount)
{
++(*_pcount);
}
// sp1 = sp5
// sp6 = sp6
// sp4 = sp5
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
// 先判断 赋值和被赋值的两个指针是否是重复的
// 是就直接返回
if (_ptr == sp._ptr)
return *this;
// 判断当前 赋值指针在赋值出去之前
// 是否是所维护空间的唯一指针
if (--(*_pcount) == 0)
{
delete _ptr;
delete _pcount;
}
// 开始赋值
_ptr = sp._ptr;
_pcount = sp._pcount;
// 引用计数++
++(*_pcount);
return *this;
}
// 返回引用计数
int use_count() const
{
return *_pcount;
}
// 拿到原生指针
T* get() const
{
return _ptr;
}
private:
T* _ptr;
int* _pcount;
};
循环引用问题(用 weak_ptr 指针)
shared_ptr 指针几乎没缺点,但是也不是意味着 完全没有缺点的,如下所示:
struct Node
{
A _val;
Node* _next;
Node* _prev;
};
int main()
{
shared_ptr<Node> sp1(new Node);
shared_ptr<Node> sp2(new Node);
sp1->_next = sp2;
sp2->_prev = sp1;
return 0;
}
向上述的结点的指针链接 是非常 常规的操作,但是上述的操作就有问题,虽然上述能够自动释放空间,但是 以为 _next 和 _prev 两个指针的类型但是 Node*,不是 shared_ptr 智能指针类型的,所以这里是经典的类型不匹配。
有人就想到,把 _next 和 _prev 的指针类型改为 shared_ptr<Node> 的不就行了吗?确实可以:
struct Node
{
A _val;
shared_ptr<Node> _next;
shared_ptr<Node> _prev;
};
但是,当我们把调用的函数都输出一遍,输出:
只调用了 构造函数,但是 析构函数是没有 调用的,也就是说此时发生了 内存泄漏,而且,我们惊讶的发现,当只调用 sp1->_next = sp2; 的时候,就没有问题,正常释放两个空间当中的内容:
A(int a = 1)
A(int a = 1)
~A()
~A()
所以,问题就出在 sp1->_next = sp2; sp2->_prev = sp1; 这两句当中当中和,我们先把两个结点 链接关系画一下:
如上述所示,因为在 sp1._next 和 sp2._prev 各自都链接上了 对方的结点空间,所以 ,两个结点空间的 引用计数 静态变量 就会 ++ ,现在两个空间的 静态空间变量都是 2 了。
当主函数当中执行完毕之后,因为 sp1 比 sp2 先声明,所以 sp2 要先析构;sp2 析构,sp2 空间上的 引用计数就 -- 到1;然后 sp1 析构,也是一样的过程;sp2 和 sp1 析构之后如下所示:
此时,就出现了 循环引用 的场景 ,_prev 是在 sp2 这个空间当中的 ,_next 是在 sp1 这个空间当中的,那么此时 不管谁先释放了,谁都不愿先调用自己的析构函数,因为:
- 如果想要析构 _prev ,那么就要析构 第二个结点空间 的时候才会析构到 _prev ;
- 但是,要想析构到 第二个结点空间,就得先析构 _next 所在的第一个结点空间,而第一个 结点所在空间要析构,又要 _prev 先析构,这不就死锁了吗?
两个都不能先析构,那么就不能析构了。
循环引用的场景介绍:
在外部有两个 智能指针,维护这两个不同的空间;在这两个空间当中,又各自有一个指针,你的指针管理着我的空间,我的指针管理着你的空间。
所以,为了解决循环引用问题,专门写了一个指针叫做 weak_ptr ,
weak_ptr 不是智能指针,而是 为了专门解决 循环引用问题,而专门写出来的一个 指针。
所以我们发现,在官方库当中 weak_ptr 都没有 指针类型传参的构造函数:
weak_ptr 实现,就是不使用 引用计数,不参与资源释放的管理,但是可以访问其中的资源。
如果上述的 sp1 和 sp2 当中的空间都没有使用引用计数来管理的话,当 第一步 sp2 和 sp1 释放的时候,就会直接把这两个空间给释放了。就没有有后续 _next 和 _prev 两个指针的事情了。
所以,weak_ptr 只是拿到智能指针当中的 指针,可以访问这个原生指针,但是对于这个指针维护的空间,和引用计数, 我 weak_ptr 是完全不管的,我值管访问,对于空间的维护是其他 智能指针管理的。
而且,虽然 weak_ptr 不支持用原生指针来构造对象,但是支持用 shared_ptr 智能指针来构造对象,所以,我们可像下面一样写:
struct Node
{
A _val;
weak_ptr<Node> _next;
weak_ptr<Node> _prev;
};
int main()
{
shared_ptr<Node> sp1(new Node);
shared_ptr<Node> sp2(new Node);
sp1->_next = sp2;
sp2->_prev = sp1;
return 0;
}
输出:
A(int a = 1)
A(int a = 1)
~A()
~A()
weak_ptr 完整代码:
template<class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
// 下述的拷贝构造函数 和 operator=()函数
// 都不管其中的 空间释放 引用计数等等
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();
return *this;
}
// 下述是指针操作
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
// 不使用引用计数
};
}
boost库简介
Boost库_百度百科 (baidu.com)
他其实是在 C++11 出来之前,由 C++委员会当中的一些成员,创建了 Boost 库社区,在这个社区当中探索出了很多 有用的语法,比如:右值引用,等等。
在 boost 库当中也诞生了 更好的 智能指针:
C++11 当中相当于是沿用了 boost 库当中的一些智能指针,进行了一些细节上的修改,基本上属于是 cv了。