目录
C++智能指针
RAII
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、网络连接、互斥量等等)的简单技术。
在对象构造时获取资源,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源。借此,我们实际上把管理一份资源的责任托管给了一个对象。这种做法有两大好处:
- 不需要显式地释放资源。
- 采用这种方式,对象所需的资源在其生命期内始终保持有效
在C++中,auto_ptr、unique_ptr以及shared_ptr都遵循RAII原则,但是weak_ptr因为不直接管理空间,所以不遵循RAII原则
基本使用
在C++ 11标准中,一共有三种常用的智能指针,分别是unique_ptr
、shared_ptr
和weak_ptr
,下面是其三个的基本特性:
unique_ptr
:C++ 11中的unique_ptr
的前身是C++ 扩展库boost中scope_ptr
/scope_array
,因为unique_ptr
是一个模版类型,所以需要传递指针指向对象的类型作为模版参数,构造时可以使用普通指针进行构造,也可以直接在构造处通过new
开辟空间,默认情况下只能指向一个内存空间,如果开辟连续的空间,因为unique_ptr
底层默认使用的是delete
,所以需要额外给一个自定义的删除器,另外,unique_ptr
不支持赋值构造和拷贝构造,所以不可以使用unique_ptr
进行构造;当unique_ptr
指针销毁时,会自动调用析构函数销毁指针指向的内容;unique_ptr
不支持所有权的隐式转移;必须使用std::move
显式转移所有权。shared_ptr
:C++ 11中的shared_ptr
的前身是C++扩展库boost中shared_ptr
,基本使用方法与unique_ptr
基本一致,但是shared_ptr
可以支持赋值构造和拷贝构造,所以如果涉及到指针需要拷贝的情况,就可以考虑使用shared_ptr
weak_ptr
:C++ 11中新增的智能指针,用于解决shared_ptr
循环引用的问题
以下面的结构测试各种智能指针:
struct Date
{
int _year;
int _month;
int _day;
Date(int year = 1900, int month = 1, int day = 1)
:_year(year)
, _month(month)
, _day(day)
{}
~Date()
{
cout << "~Date()" << endl;
}
};
unique_ptr
基本使用
// 用于连续空间的释放
struct Free
{
void operator()(Date* ptr)
{
delete[] ptr;
}
};
// 用于释放fopen返回的文件指针
struct FileCloser
{
void operator()(FILE* fp)
{
fclose(fp);
}
};
int main()
{
// 创建一个对象
unique_ptr<Date> up(new Date);
// 创建一个对象数组
// 如果直接使用下面的写法,需要单独传递一个删除器
unique_ptr <Date, Free> upArr(new Date[3]);
// 默认情况下,unique_ptr对数组有特化版本,也可以不需要传递删除器
unique_ptr<Date[]> upArr1(new Date[3]);
// unique_ptr不支持拷贝和赋值
//unique_ptr<Date> up1(up);
//unique_ptr<Date> up2 = up;
// 删除器可以用于没有特化版本的情况
unique_ptr<FILE, FileCloser> upFile(fopen("test.txt", "r"));
return 0;
}
shared_ptr
基本使用
// 释放连续的空间
struct Free
{
void operator()(Date* ptr)
{
delete[] ptr;
}
};
int main()
{
// shared_ptr基本使用与unique_ptr一致
// 创建一个对象
shared_ptr<Date> sp(new Date);
// 创建一个对象数组
// 如果直接使用下面的写法,需要单独传递一个删除器
// 不同与unique_ptr,shared_ptr的删除器可以使用lambda表达式或者函数对象
shared_ptr<Date> spArr(new Date[3], Free()); // 使用函数对象
shared_ptr<Date> spArr1(new Date[3], [](Date* ptr) {delete[] ptr; }); // 使用lambda表达式
// shared_ptr支持拷贝和赋值
shared_ptr<Date> sp1(sp);
shared_ptr<Date> sp2 = sp;
return 0;
}
特殊地,shared_ptr
支持使用make_shared
创建shared_ptr
,make_shared
是一个可变模版参数函数模版,函数返回一个shared_ptr
,因为支持可变模版参数,所以可以传递构造对象的值,但是需要注意make_shared
模版参数需要与接收的shared_ptr
模版参数一致,make_shared
在头文件<memory>
中,使用时可以考虑引入
int main()
{
// 使用make_shared创建对象
// make_shared会在一次内存分配中同时创建对象和控制块
shared_ptr<Date> sp3 = make_shared<Date>(2019, 1, 1);
return 0;
}
unique_ptr
和shared_ptr
原理与模拟实现
unique_ptr
原理与模拟实现
原理:unique_ptr
本质是使用一个普通指针进行构造,给出了基本的指针操作,并且默认情况下会释放指针指向的单一空间,特点是将拷贝构造函数和赋值运算符重载函数修饰为=delete
基本结构模拟实现如下:
#pragma once
namespace simulate_unique_ptr
{
template<class T>
class unique_ptr
{
public:
// 构造函数
unique_ptr(T* ptr)
:_ptr(ptr)
{}
// 析构函数
~unique_ptr()
{
if (_ptr)
{
cout << "~unique_ptr()" << endl;
delete _ptr;
}
}
// 禁止拷贝构造函数和赋值重载函数
unique_ptr(const unique_ptr<T>& ptr) = delete;
unique_ptr<T>& operator=(const unique_ptr<T>& ptr) = delete;
// operator*()
T& operator*()
{
return *_ptr;
}
// operator->()
T* operator->()
{
return _ptr;
}
private:
T* _ptr; // 普通指针
};
}
shared_ptr
原理与模拟实现
原理:因为shared_ptr
支持拷贝,但是如果默认拷贝和赋值,则会出现同一块空间被释放两次导致的错误,所以在shared_ptr
中还存在一个引用计数,用于记录有多少个shared_ptr
指向同一块空间
基本结构模拟实现如下:
基本结构设计
对于构造shared_ptr
指针来说,与unique_ptr
一致,但是对于引用计数来说,需要注意不可以使用普通的成员变量直接进行计数,因为这种方法会导致不论是创建对象、拷贝还是赋值,每一个对象中的引用变量始终为1或者0,也不可以使用静态成员变量进行计数,考虑到静态变量是所有对象够用一份,当两个指针指向同一块空间时没有任何问题,此时计数器为2,但是如果有一个新的指针指向另一块空间,此时计数器更新为3,但是前面的两个指针和第三个指针不是指向同一块空间,不应该更改前面两个指针的计数器。两种情况分析如下图所示:
考虑到以上两种情况后,设计的计数器需要满足两个条件:1. 指向同一块空间的shared_ptr
共用一个引用计数器 2. 指向不同空间的shared_ptr
使用不同的引用计数器。可以使用在堆上开辟内存的计数器,成员变量只需要一个指针,指向堆上已经开辟好的计数器即可,如果有指向同一块空间的另一个shared_ptr
时,只需要使其指针指向相同的计数器空间即可,初始情况下引用计数器的数值为1
析构函数和构造函数设计
设计构造函数时,只需要考虑两点:1. 使用普通指针构造shared_ptr
2. 将计数器指针指向在堆上开辟的空间
设计析构函数时,需要考虑到何时释放空间的问题,如果有两个指针指向同一块空间,则需要避免出现两次释放同一块空间的问题,所以可以考虑析构第一个指针时将该指针置为空并且计数器减小1
拷贝构造函数设计
设计拷贝构造函数只需要考虑将原来指针中的内容拷贝到新指针,再将计数器加1即可
赋值运算符重载函数设计
设计赋值重载需要考虑到下面的问题:
- 自己给自己赋值,包括两种情况:1. 两个相同的指针 2. 指向同一个位置的两个指针
- 赋值之前需要先释放被赋值的指针原来的空间,否则会出现内存泄漏问题(原因借下面的图分析)
- 赋值需要更改用于赋值的指针的引用计数器
具体步骤如下图所示:
#pragma once
namespace simulate_shared_ptr
{
template <class T>
class shared_ptr
{
public:
// 构造函数
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_count(new int(1))
{}
void release()
{
if (--(*_count) == 0)
{
// 释放指向的空间
delete _ptr;
// 释放计数器空间
delete _count;
// 将指针置为空
_ptr = nullptr;
_count = nullptr;
}
else
{
// 如果不为0,只将当前指针置为空,防止出现野指针问题
_ptr = nullptr;
}
}
// 析构函数
~shared_ptr()
{
release();
}
// 赋值运算符重载函数
shared_ptr<T> operator=(const shared_ptr<T>& ptr)
{
// 防止自己给自己赋值
if (_ptr != ptr._ptr)
{
// 释放被赋值指针
release();
// 将被赋值指针指向ptr指针指向的内容
_ptr = ptr._ptr;
_count = ptr._count;
// 更改赋值指针的引用计数
++(*_count);
}
return *this;
}
// 拷贝构造函数
shared_ptr(const shared_ptr<T>& ptr)
:_ptr(ptr)
, _count(ptr._count)
{
++(*_count);
}
// 其他部分
// 获取原生指针
T* get() const
{
return _ptr;
}
// 获取引用计数
int use_count() const
{
return *_pcount;
}
// operator*()
T& operator*()
{
return *_ptr;
}
// operator->()
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
int* _count;
};
}
添加可自定义删除器功能
与unique_ptr
不同的是,shared_ptr
的自定义删除器是在构造对象参数列表中传递,并且可以使用任意函数对象,所以可以考虑使用function
包装器对象作为成员(release
函数中调用,因为没有模版参数,所以不可以直接使用构造函数中的模版参数对象),默认情况下使用delete
需要注意,因为需要默认情况下使用delete
,所以需要使在没有传递自定义删除器时调用delete
函数,此时lambda需要写在成员的后面,而不是构造函数关于自定义删除器的缺省参数,便于两个构造函数都可以初始化_del
成员
#pragma once
#include <functional>
namespace simulate_shared_ptr
{
template <class T>
class shared_ptr
{
public:
// ...
// 支持自定义删除器的构造函数
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _count(new int(1))
, _del(del)
{}
// ...
void release()
{
if (--(*_count) == 0)
{
// 释放指向的空间
_del(_ptr);
// ...
}
else
{
// ...
}
}
private:
// ...
function <void(T*)> _del = [](T* ptr) {delete ptr; };
};
}
shared_ptr
循环引用问题与weak_ptr
的使用
以下面的代码为例:
struct ListNode
{
int _data;
shared_ptr<ListNode> _next;
shared_ptr<ListNode> _prev;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
// 创建两个节点相互指向
shared_ptr<ListNode> p1(new ListNode);
shared_ptr<ListNode> p2(new ListNode);
p1->_next = p2;
p2->_prev = p1;
return 0;
}
上面的代码会出现内存泄漏的问题,首先分析内存泄漏出现的原因:
- 首先p2指针析构,因为p1指针的_next成员与p2指针共同管理一片空间,所以此时该空间的引用计数器由2变为1,p2指针置为空即可
- 接着析构p1指针,因为p2指针的_prev成员与p1指针共同管理一片空间,所以此时该空间的引用计数器由2变为1,p1指针置为空即可
此时两个指针都已经被释放了,所以程序结束,但是实际上因为还有_next和_prev指针管理空间导致这两片空间依旧没有被释放,从而造成内存泄漏
循环引用问题:
- 当p2指针析构时,当前空间引用计数器减1
- 当p1指针析构时,当前空间引用计数器减1
- 当_next指针需要析构时,需要p1指针指向的空间析构
- 当_prev指针需要析构时,需要p2指针指向的空间析构
- (循环开始)析构p2指针时,当前空间引用计数器减1
....
为了解决shared_ptr
的循环引用问题,可以将ListNode
的_next
和_prev
指针改为weak_ptr
,因为weak_ptr
支持使用shared_ptr
构造
struct ListNode
{
int _data;
weak_ptr<ListNode> _next;
weak_ptr<ListNode> _prev;
~ListNode()
{
cout << "~ListNode()" << endl;
}
};
int main()
{
// 创建两个节点相互指向
shared_ptr<ListNode> p1(new ListNode);
shared_ptr<ListNode> p2(new ListNode);
p1->_next = p2;
p2->_prev = p1;
return 0;
}
输出结果:
~ListNode()
~ListNode()
需要注意,weak_ptr
不可以单独用于管理对象,例如下面的代码:
// weak_ptr不可以单独使用,需要通过lock()创建shared_ptr
// weak_ptr<ListNode> p3(new ListNode); // 错误
// 正确代码:
shared_ptr<ListNode> p3(new ListNode);
weak_ptr<ListNode> p4 = p3;
// 通过lock()创建shared_ptr
shared_ptr<ListNode> p5 = p4.lock();
weak_ptr
本身不会影响由 shared_ptr
维护的引用计数,所以weak_ptr的基本结构类似下面的代码:
template<class T>
class weak_ptr
{
public:
weak_ptr()
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.get()) // 使用shared_ptr构造,不改变引用计数器
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.get();// 使用shared_ptr赋值,不改变引用计数器
return *this;
}
private:
T* _ptr = nullptr;
};
并且当最后一个 shared_ptr
对象离开作用域或被设置为无效时,它指向的对象才会被删除,即使此时仍然存在 weak_ptr
指向该对象,除非使用lock()
函数,例如下面的代码中
int main()
{
weak_ptr<int> wp;
shared_ptr<int> sp;
{
shared_ptr<int> n1(new int);
wp = n1;
cout << wp.expired() << endl;
// 如果没有lock(),weak_ptr即使指向了n1的空间,一旦n1离开作用域,n1的空间就会被释放,wp指向的空间就同样被释放
// 通过lock()获取shared_ptr,可以保证n1的空间不会被释放
sp = wp.lock();
}
cout << wp.expired() << endl; // 没有lock()时,值为1,有lock()时,值为0
return 0;
}
输出结果:
0
0
智能指针的前身:auto_ptr
C++ 98时没有前面类型的智能指针,但是可以使用auto_ptr
达到基本一致的效果,但是auto_ptr
本身的原理是一种管理权转移的思想,所以存在已经将管理权转移,但是依旧使用被转移资源的指针进行内容访问导致的空指针解引用问题,所以为了更好地管理,auto_ptr
基本上被unique_ptr
和shared_ptr
代替
// auto_ptr的问题
auto_ptr<Date> ap(new Date);
auto_ptr<Date> ap1 = ap;
// ap的空间管理权已经转移给ap1,ap空指针访问
ap->_day = 10;
内存泄漏问题
内存泄漏:指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况。内存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现内存泄漏会导致响应越来越慢,最终卡死
例如下面的代码:
#include "vld.h"
void MemoryLeaks()
{
// 1.内存申请了忘记释放
int* p1 = (int*)malloc(sizeof(int));
int* p2 = new int;
// 2.异常安全问题
int* p3 = new int[10];
// Func(); // 如果这里Func函数抛异常导致 delete[] p3将不会执行,p3没被释放.
delete[] p3;
}
int main()
{
MemoryLeaks();
return 0;
}
检查内存溢出情况(仅供参考):
内存泄漏的分类
C/C++程序中一般我们关心两种方面的内存泄漏:
- 堆内存泄漏(Heap leak) :堆内存指的是程序执行中依据须要分配通过
malloc
/calloc
/realloc
/new
等从堆中分配的一块内存,用完后必须通过调用相应的free
或者delete
删掉。假设程序的设计错误导致这部分内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。 - 系统资源泄漏:指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
避免内存泄漏
- 工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。
这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智能指针来管理才有保证。
- 采用RAII思想或者智能指针来管理资源。
- 有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项。
- 出问题了使用内存泄漏工具检测。
不过很多工具都不够靠谱,或者收费昂贵。
总结:内存泄漏非常常见,解决方案分为两种:1. 事前预防型,如智能指针等 2. 事后查错型,如泄漏检测工具。