为什么需要智能指针?
下面我们先分析一下下面这段程序有没有什么内存方面的问题?提示一下:注意分析MergeSort 函数中的问题。
cpp
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void Func()
{
// 1、如果p1这里new 抛异常会如何?
// 2、如果p2这里new 抛异常会如何?
// 3、如果div调用这里又会抛异常会如何?
int* p1 = new int;
int* p2 = new int;
cout << div() << endl;
delete p1;
delete p2;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
这段代码暴露了手动管理资源在异常情况下极容易导致内存泄漏的问题,这正是我们需要智能指针的根本原因。
我们来逐一分析 Func 函数中三个可能抛异常的场景:
cpp
void Func()
{
int* p1 = new int; // 场景1
int* p2 = new int; // 场景2
cout << div() << endl;// 场景3
delete p1;
delete p2;
}
场景 1:p1 这里 new 抛异常
new int 在内存不足时会抛出 std::bad_alloc 异常。
- 后果 :
p1尚未成功分配,函数直接退出。此时p2还没分配,没有泄漏,程序跳转到main的catch正常处理。
场景 2:p2 这里 new 抛异常
p1 已经成功分配,p2 分配时抛出异常。
- 后果 :异常抛出后,
Func的剩余代码(delete p1; delete p2;)被跳过。p1指向的内存永远不会被释放,发生内存泄漏。
场景 3:div() 内部抛异常(除零)
p1、p2 都分配成功,div() 因 b == 0 抛出异常。
- 后果 :异常直接跳出
Func,同样跳过了后面的delete。两个指针的内存全部泄漏。
核心问题
在抛异常的路径上,没有任何机制能保证 delete 被调用。手动写 try-catch 来兜底虽然可以,但代码会变得臃肿且极易遗漏。
智能指针如何解决
智能指针(如 std::unique_ptr)利用 RAII(资源获取即初始化) 原则,把资源的生命周期绑定到栈对象的生命周期上:
cpp
void FuncSafe()
{
std::unique_ptr<int> p1(new int);
std::unique_ptr<int> p2(new int);
cout << div() << endl;
// 无论是否抛异常,离开作用域时 p1、p2 一定会被析构,内存自动释放
}
当异常发生时,栈展开(stack unwinding)会销毁 p1、p2 这两个局部对象,它们的析构函数会自动执行 delete,从而彻底杜绝内存泄漏,实现异常安全。这也是"为什么需要智能指针"的标准答案。
内存泄漏
什么是内存泄漏,内存泄漏的危害
什么是内存泄漏:内存泄漏指因为疏忽或错误造成程序未能释放已经不再使用的内存的情况 。内 存泄漏并不是指内存在物理上的消失,而是应用程序分配某段内存后,因为设计错误,失去了对 该段内存的控制,因而造成了内存的浪费。
内存泄漏的危害:长期运行的程序出现内存泄漏,影响很大,如操作系统、后台服务等等,出现 内存泄漏会导致响应越来越慢,最终卡死。
cpp
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;
}
内存泄漏分类
C/C++程序中一般我们关心两种方面的内存泄漏:
堆内存泄漏(Heap leak)
堆内存指的是程序执行中依据须要分配通过malloc / calloc / realloc / new等从堆中分配的一 块内存,用完后必须通过调用相应的 free或者delete 删掉。假设程序的设计错误导致这部分 内存没有被释放,那么以后这部分空间将无法再被使用,就会产生Heap Leak。
系统资源泄漏
指程序使用系统分配的资源,比方套接字、文件描述符、管道等没有使用对应的函数释放 掉,导致系统资源的浪费,严重可导致系统效能减少,系统执行不稳定。
如何检测内存泄漏(了解)
在linux下内存泄漏检测:linux下几款内存泄漏检测工具Linux下几款C++程序中的内存泄露检查工具_c++内存泄露工具分析-CSDN博客
在windows下使用第三方工具:VLD工具说明VS编程内存泄漏:VLD(Visual LeakDetector)内存泄露库_visual leak detector vs2020-CSDN博客
其他工具:内存泄漏工具比较
如何避免内存泄漏
-
工程前期良好的设计规范,养成良好的编码规范,申请的内存空间记着匹配的去释放。ps: 这个理想状态。但是如果碰上异常时,就算注意释放了,还是可能会出问题。需要下一条智 能指针来管理才有保证。
-
采用RAII思想或者智能指针来管理资源。
-
有些公司内部规范使用内部实现的私有内存管理库。这套库自带内存泄漏检测的功能选项
-
出问题了使用内存泄漏工具检测。ps:不过很多工具都不够靠谱,或者收费昂贵。
总结一下: 内存泄漏非常常见,解决方案分为两种:
1、事前预防型。如智能指针等。
2、事后查错型。如泄 漏检测工具。
智能指针的使用及原理
RAII
RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内 存、文件句柄、网络连接、互斥量等等)的简单技术。 在对象构造时获取资源 ,接着控制对资源的访问使之在对象的生命周期内始终保持有效,最后在 对象析构的时候释放资源 。借此,我们实际上把管理一份资源的责任托管给了一个对象。
这种做 法有两大好处:
不需要显式地释放资源。
采用这种方式,对象所需的资源在其生命期内始终保持有效。
下面看一段代码:
cpp
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* ptr = new int;
//...
cout << div() << endl;
//...
delete ptr;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
执行上述代码时,如果用户输入的除数为0,那么div函数中就会抛出异常,这时程序的执行流会直接跳转到主函数中的catch块中执行,最终导致func函数中申请的内存资源没有得到释放。
对于这种情况,我们可以在func函数中先对div函数中抛出的异常进行捕获,捕获后先将之前申请的内存资源释放,然后再将异常重新抛出。比如:
cpp
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
int* ptr = new int;
try
{
cout << div() << endl;
}
catch (...)
{
delete ptr;
throw;
}
delete ptr;
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
上述问题也可以使用智能指针进行解决。比如:
cpp
template<class T>
class SmartPtr
{
public:
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete: " << _ptr << endl;
delete _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
throw invalid_argument("除0错误");
return a / b;
}
void func()
{
SmartPtr<int> sp(new int);
//...
cout << div() << endl;
//...
}
int main()
{
try
{
func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
代码中将申请到的内存空间交给了一个SmartPtr对象进行管理。
在构造SmartPtr对象时,SmartPtr将传入的需要被管理的内存空间保存起来。
在SmartPtr对象析构时,SmartPtr的析构函数中会自动将管理的内存空间进行释放。
此外,为了让SmartPtr对象能够像原生指针一样使用,还需要对*和->运算符进行重载。
所以无论程序是正常执行完毕返回了,还是因为某些原因中途返回了,或是因为抛异常返回了,只要SmartPtr对象的生命周期结束就会调用其对应的析构函数,进而完成内存资源的释放。
智能指针的原理
实现智能指针时需要考虑以下三个方面的问题:
1、在对象构造时获取资源,在对象析构的时候释放资源,利用对象的生命周期来控制程序资源,即RAII特性。
2、对*和->运算符进行重载,使得该对象具有像指针一样的行为。
3、智能指针对象的拷贝问题。
概念说明: RAII(Resource Acquisition Is Initialization)是一种利用对象生命周期来控制程序资源(如内存、文件句柄、互斥量等等)的简单技术。
为什么要解决智能指针对象的拷贝问题
对于当前实现的SmartPtr类,如果用一个SmartPtr对象来拷贝构造另一个SmartPtr对象,或是将一个SmartPtr对象赋值给另一个SmartPtr对象,都会导致程序崩溃。比如:
cpp
int main()
{
SmartPtr<int> sp1(new int);
SmartPtr<int> sp2(sp1); //拷贝构造
SmartPtr<int> sp3(new int);
SmartPtr<int> sp4(new int);
sp3 = sp4; //拷贝赋值
return 0;
}
原因:
1、编译器默认生成的拷贝构造函数对内置类型完成值拷贝(浅拷贝),因此用sp1拷贝构造sp2后,相当于这sp1和sp2管理了同一块内存空间,当sp1和sp2析构时就会导致这块空间被释放两次。
2、编译器默认生成的拷贝赋值函数对内置类型也是完成值拷贝(浅拷贝),因此将sp4赋值给sp3后,相当于sp3和sp4管理的都是原来sp3管理的空间,当sp3和sp4析构时就会导致这块空间被释放两次,并且还会导致sp4原来管理的空间没有得到释放。
需要注意的是,智能指针就是要模拟原生指针的行为 ,当我们将一个指针赋值给另一个指针时,目的就是让这两个指针指向同一块内存空间,所以这里本就应该进行浅拷贝,但单纯的浅拷贝又会导致空间被多次释放,因此根据解决智能指针拷贝问题方式的不同,从而衍生出了不同版本的智能指针。
C++中的智能指针
std::auto_ptr
auto_ptr是C++98中引入的智能指针,auto_ptr通过管理权转移的方式解决智能指针的拷贝问题,保证一个资源在任何时刻都只有一个对象在对其进行管理,这时同一个资源就不会被多次释放了。比如:
cpp
int main()
{
std::auto_ptr<int> ap1(new int(1));
std::auto_ptr<int> ap2(ap1);
*ap2 = 10;
//*ap1 = 20; //error
std::auto_ptr<int> ap3(new int(1));
std::auto_ptr<int> ap4(new int(2));
ap3 = ap4;
return 0;
}
但一个对象的管理权转移后也就意味着,该对象不能再用对原来管理的资源进行访问了,否则程序就会崩溃,因此使用auto_ptr之前必须先了解它的机制,否则程序很容易出问题,很多公司也都明确规定了禁止使用auto_ptr。
auto_ptr的模拟实现
1. 核心思想
auto_ptr 利用 RAII(资源获取即初始化) 机制,让对象的生命周期与资源(动态内存)绑定:
构造时获取资源 ,析构时释放资源。
通过重载 * 和 -> 模拟原生指针行为。
拷贝和赋值时 转移所有权,而非共享资源,防止重复释放。
2. 设计要点
所有权管理接口化
引入 release()、reset()、get() 和 swap() 等标准智能指针常用接口,让资源管理逻辑从拷贝构造和赋值中分离出来,职责更单一,代码更清晰安全。
拷贝语义即所有权转移
拷贝构造函数和拷贝赋值运算符的参数都不是 const 的,因为它们需要修改传入对象,将其内部指针置空。内部通过调用 ap.release() 取出资源并移交。
自赋值安全
赋值运算符中通过 if (this != &ap) 防止自赋值。配合 reset(ap.release()),即使不检查自赋值,ap.release() 也会在自赋值时先释放自身再置空,但显式检查更直观且能避免无谓的 delete。
防止误删同一指针
reset() 中判断 if (_ptr != ptr),防止用户 reset(get()) 时误删还持有的资源。
显式构造
构造函数标记为 explicit,避免隐式类型转换导致意外接管资源。
代码:
cpp
#include <iostream>
#include <utility> // std::swap
namespace cl
{
template<class T>
class auto_ptr
{
public:
//构造与析构
//构造函数:接管原始指针,explicit 防止隐式转换
explicit auto_ptr(T* ptr = nullptr) : _ptr(ptr) {}
// 析构函数:释放管理的资源(delete nullptr 安全)
~auto_ptr()
{
delete _ptr;
// 调试可取消注释:std::cout << "delete: " << _ptr << std::endl;
}
//拷贝(所有权转移)
//拷贝构造函数:从 ap 接管资源,ap 被置空
auto_ptr(auto_ptr& ap) : _ptr(ap.release()) {}
//拷贝赋值运算符:释放自身,接管 ap,ap 被置空
auto_ptr& operator=(auto_ptr& ap)
{
if (this != &ap)
{
reset(ap.release());
}
return *this;
}
//指针行为
T& operator*() const { return *_ptr; }
T* operator->() const { return _ptr; }
// 获取原始指针(不转移所有权)
T* get() const { return _ptr; }
// 所有权管理
// 交出所有权,返回原始指针,自身置空
T* release()
{
T* tmp = _ptr;
_ptr = nullptr;
return tmp;
}
// 重置资源:释放旧资源,接管新指针
void reset(T* ptr = nullptr)
{
if (_ptr != ptr) // 防止 delete 自身已持有的指针
{
delete _ptr;
_ptr = ptr;
}
}
// 交换两个 auto_ptr 管理的资源
void swap(auto_ptr& ap)
{
std::swap(_ptr, ap._ptr);
}
private:
T* _ptr; // 管理的资源
};
} // namespace cl
提醒
不能管理数组
析构使用 delete 而非 delete[],因此禁止用 auto_ptr 管理 new T[] 分配的数组。
不可放入标准容器
它的拷贝会转移所有权,破坏容器对元素"可拷贝"的预期(如 std::vector 在扩容时会拷贝元素,导致原指针被置空,引发未定义行为)。C++11 起已被 std::unique_ptr(仅支持移动)取代。