详细介绍:【C++闯关笔记】异常的概念与智能指针shared_ptr unique_ptr的用法介绍
系列文章目录
上篇笔记:【C++闯关笔记】一文带你领略C++11常用语法-CSDN博客

文章目录
目录
前言
C语言主要通过错误码的形式处理错误,错误码本质就是对错误信息进行分类编号,拿到错误码以后还要去查询错误信息,比较麻烦。而且错误码的数量不是固定的,它取决于具体的操作系统和C标准库实现。比如Linu中大约有 130-150个 错误码,而windows中则有自己的一套错误码。
如下图在Linux centos系统中大概有133个错误码。

而在C++中通过引入异常机制,做到允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。而智能指针正是C++中解决异常的重要方法,它通过RAII机制,确保在异常发生时能够自动释放资源,从而提高了程序的异常安全性。换句话说智能指针是实现异常安全的基础工具。
本文先介绍异常的概念与使用,再介绍智能指针的使用与原理,以求完整理解C++异常处理的方法与过程。
一、异常
1.异常的概念
异常是一种非正常的运行状态,比如除零错误、访问空指针、数组越界、文件打开失败等。
C++中通过throw表达式抛出异常,然后通过try-catch块来捕获和处理异常。
异常处理机制可以将错误处理代码与正常流程代码分离,使代码更加清晰。
总的来说,异常是C++中处理错误的一种机制,它允许程序在遇到无法正常执行的状况时,跳出当前的执行流程,直接执行预备的特定错误处理代码处来处理所遇到的问题。
2.异常的抛出与捕获
程序出现问题时,通过抛出(throw)一个对象来引发一个异常,该对象的类型以及函数调用链决定了应该由哪个catch的处理代码来处理该异常,被选中的处理代码是调用链中与该对象类型匹配且离抛出异常位置最近的那一个。
具体异常语法由如下三部分组成:
throw 关键字- 抛出异常
// 抛出各种类型的异常
throw 42; // 抛出整数
throw "Error occurred"; // 抛出字符串
throw std::string("Error"); // 抛出字符串对象
// 更常见的:抛出异常类对象
throw std::runtime_error("File not found");
try - 尝试执行可能出错的代码
try
{
// 可能抛出异常的代码
openFile("data.txt");
XX_func();
}
catch - 捕获并处理异常
catch (int errorCode)
{
// 处理整数异常
std::cout << "Error code: " << errorCode << std::endl;
}
catch (const std::stirng& errorstr)
{
// 处理string异常
std::cout << "Error string: " <
下面我们以除零异常为例,展示实际编码中异常的使用步骤:①try - 尝试执行可能出错的代码;②throw 关键字- 抛出异常;③catch - 捕获并处理异常。
double Divide(double a, double b)
{
try
{
// 当b == 0时抛出异常
if (b == 0)
{
std::string s("异常:除数不能为0!");
throw s;
}
else
{
return ((double)a / (double)b);
}
}
catch (std::string& errmsg)
{
cout << errmsg << endl;
}
return 0;
}
int main()
{
double a, b;
cin >> a >> b;
cout << Divide(a, b) << endl;
return 0;
}
可以看到,异常的处理实际上就是执行代码中准备的相关“预案”。
3.异常细节注意
栈展开-跳跃的执行逻辑
抛出异常后,程序开始寻找匹配的catch子句,如果有多个类型匹配的,就选择离他位置更近的那个catch进行处理;如果当前函数中没有try/catch子句,或者是类型不匹配,则退出当前函数,继续在外层调用函数链中查找,上述查找的catch过程被称为栈展开。
当发现异常执行throw后,throw后面的语句将不再被执行,程序的执行从throw位置跳到与之匹配的catch 模块,这个catch可能是同一函数中的一个局部的catch,也可能是函数或其他调用链中另一个函数中的catch,一定要注意控制权从throw位置转移到了catch位置。换句话说,沿着调用链的函数可能提早退出,期间创建的对象将会提前释放。
抛出异常对象后,会生成一个异常对象的拷贝,因为抛出的异常对象可能是一个局部对象,这里的处理类似于函数的传值返回。
查找匹配的处理代码
一般情况下抛出对象和catch是类型完全匹配的,但这其中也有例外,比如,若throw抛出的是派生类类型异常,而catch模块中捕获的是基类,那么该catch模块也会捕获该异常,这个点在继承体系中非常实用。除了了上述派生类异常可被基类catch捕获外,还有包括:非常量向常量的类型转换,也就是权限缩小;允许数组转换成指向数组 元素类型的指针,函数被转换成指向函数的指针。
注意:如果到达main函数,依旧没有找到匹配的catch子句,程序会调用标准库的 terminate 函数终止程序。
异常重新抛出
有时一些错误如果现阶段难以解决,可用在catch捕捉之后将它重新抛出,交给更外层代码模块处理,这被称为异常的重新抛出。
在实际编程中,通常在main函数中添加如下catch代码,catch(...)可以捕获几乎所有类型的异常,但如果走到调用链的这一步就说明之前的catch模块都无法解决这个异常,为了防止程序崩溃,于是设置了这个“压箱底”代码。
catch (...)
{
// 处理所有其他异常
std::cout << "Unknown exception occurred" << std::endl;
}
noexcept异常规范
在C++11推出了noexcept关键字,用法是加在函数参数列表后面,加noexcept表示该函数不会抛出异常,告诉编译器无需过多检查。换句话说,如果一个函数不加noexcept,表示该函数可能会抛出异常。
注意:编译器不会在编译时检查noexcept,也就是说如果一个函数加了noexcept,但里面又throw了异常,这个语法错误是在编译期间检查不到的。一个声明了noexcept的函数抛出了异常,程序会调用 terminate 终止程序。
noexcept(expression)还可以作为一个运算符去检测一个表达式是否会抛出异常,可能会则返回 false,不会就返回true。
4.异常安全
异常抛出后,后面的代码就不再执行,如果前面申请了资源(内存、锁等),后面进行释放,但是中间可能会抛异常就会导致资源没有释放,异常就引发了资源泄漏。尽管中间我们可以捕获异常,释放资源后面再重新抛出,但在一些场景依旧不能避免资源的泄漏,为了解决这个问题,智能指针被设计了出来。
值得注意的是:如果析构函数中抛出异常也要谨慎处理,比如析构函数要释放10个资源,释放到第5个时抛出异常,则也需要捕获处理,否则后面的5个资源就没释放,也资源泄漏了。
二、智能指针
1.RAII-智能指针的设计思路
正如上述所说,智能指针被设计出来的一大初衷就是解决异常可能带来的资源泄露问题,由于解决异常时的代码跳跃问题导致资源不能被及时释放,所以智能指针的首要任务就是解决异常带来的资源泄漏问题。
RAII智能指针的设计思路
RAII是Resource Acquisition Is Initialization的缩写,翻译过来为“资源获取即初始化” 。
RAII思路既在获取资源时把资源委托初始化给一个指针指针对象,通过控制对象实现对资源的访问, 资源在对象的生命周期内始终保持有效,最后在对象析构的时候释放资源,这样保障了资源的正常释放,避免资源泄漏问题。
本质是一种利用对象生命周期来管理获取到的动态资源,避免资源泄漏。
由于之后的资源访问都要通过智能指针,所以智能指针类除了满足RAII的设计思路,还要像迭代器类一 样,重载 operator* / operator-> / operator[ ] 等运算符,方便访问资源。
综上所述,其实我们脑海中已经有了一个智能指针的模型,如下所示:
template
class SmartPtr
{
public:
// RAII
SmartPtr(T* ptr)
:_ptr(ptr)
{}
~SmartPtr()
{
cout << "delete[] " << _ptr << endl;
delete[] _ptr;
}
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](size_t i)
{
return _ptr[i];
}
private:
T* _ptr;
};
当然,库中的智能指针不可能这么简单就完事了,这里列出这些代码的目的只是为了加深对智能指针设计理念与功能的印象。
综上,我们可以尝试对智能指针下一个定义:
智能指针是C++中的一种类模板,它包装了原始指针,并通过RAII(Resource Acquisition Is Initialization) 技术来自动管理动态分配的内存的生命周期。
2.几种常见的智能指针介绍
C++标准库中的智能指针都在<memory>这个头文件中。
C++中的智能指针有好几种,除了weak_ptr外他们都符合上述RAII思想和像迭代器一样访问的行为,而它们的主要区别点在于:解决智能指针拷贝时的思路不同。
被人诟病的auto_ptr
auto_ptr是C++98提出的针对异常问题的解决方案,他的特点是拷贝时直接把被拷贝对象的资源的转移给拷贝对象,这其实是一个严重的bug,因为他会导致被拷贝对象悬空,出现访问报错的问题。
auto_ptr的拷贝构造原理如下所示:
template
class auto_ptr
{
public:
//拷贝构造
auto_ptr(auto_ptr& sp)
:_ptr(sp._ptr)
{
// 转移资源,并将原指针置空
sp._ptr = nullptr;
}
private:
T* _ptr;
};
为什么说转移走资源管理权是一个严重的bug呢?让我们想想指针的使用场景,当我们用一个指针赋值给另一个指针时,比如函数值传参,我们希望的是两个指针指向同一块资源共同管理,可是auto_ptr直接将原指针置空了,这不仅与预期不服,而且还导致了资源丢失问题!
auto_ptr使用示例
#include
#include
using namespace std;
struct Date
{
int _year;
int _month;
int _day;
Date(int year = 1, int month = 1, int day = 1)
:_year(year), _month(month), _day(day)
{}
~Date()
{
cout << "~Date()" << endl;
}
};
int main()
{
auto_ptr ap1(new Date(1,1,1));
ap1->_year++;//正常运行
auto_ptr ap2(ap1);
//原生指针被置空,不推荐使用
ap1->_year++;//运行时报错
return 0;
}
报错信息:

在C++11未出之前,大公司曾宁愿自己造轮子也不使用auto_ptr。笔者建议也尽量不要使用auto_ptr。
独占所有权的unique_ptr
unique_ptr是C++11设计出来的智能指针,他的名字翻译出来是唯一指针,他是怎么解决智能指针的拷贝问题的呢?答案是它不支持拷贝,只支持移动。如果不需要拷贝的场景就建议使用他。
使用示例
unique_ptr up1(new Date(2000, 1, 1));
unique_ptr up2;
//unique_ptr不支持拷贝构造与赋值
//unique_ptr up2(up);//编译报错
//up2 = up;//编译报错
//但支持移动语义
unique_ptr up2(move(up1));
共享所有权的shared_ptr
shared_ptr也是C++11设计出来的智能指针,他的名字翻译出来是共享指针,他的特点是支持拷贝, 也支持移动。如果需要拷贝的场景就需要使用他了。它的底层是用引用计数的方式实现的。
shared_ptr除了支持用指向资源的指针构造,还支持make_shared 用初始化资源对象的值直接构造,由于结构原因,使用make_shared初始化整体效率更好。
使用示例
shared_ptr sp(new Date(3, 3, 3));
//支持拷贝构造与移动赋值
shared_ptrsp1(sp);
shared_ptrsp2;
sp2 = sp1;
//支持移动构造
shared_ptr sp3(move(sp));
3.删除器
智能指针析构时默认是delete释放资源,所以如果不是new出来的资源,析构时就会崩溃。这也带来了一个问题:如果我们让智能指针管理的是除了内存资源外的其他资源,那么应该怎么释放它们呢?——这时候删除器就起到了作用。
那么什么是删除器呢?
所谓删除器本质就是一个可调用对象,它可以是函数指针、函数对象(仿函数)、lambda表达式等,这个可调用对象中实现了我们想要的释放资源的方式,当构造智能指针时若给了定制的删除器, 在智能指针析构时就会调用删除器去释放资源。
值得注意的是对于unique_ptr,删除器是类型的一部分,因此不同删除器的unique_ptr是不同类型。

而对于shared_ptr,删除器不是类型的一部分,因此不同删除器的shared_ptr是相同类型,可以放在同一个容器中。

总结来说:
unique_ptr是在类模板参数支持的,shared_ptr是构造函数参数支持的;
unique_ptr的删除器是模板参数,在编译时确定,所以是类型的一部分。
shared_ptr的删除器是构造参数,在运行时绑定,所以不影响类型。
删除器示例
上面说到:智能指针析构时默认是进行delete释放资源,如果不是new出来的资源,释放时会报错。那么new[ ]出来的呢?实际上因为new[ ]经常使用,所以 unique_ptr和shared_ptr都特化了一份[ ]的版本,但这里我们假设没有这个特化版本,而是通过删除器来实现对new[ ]出来的资源进行释放。
仿函数做删除器
我们设计一个Del的仿函数,以示例unique_ptr与shared_ptr在删除器使用上的区别。
template
class Del
{
public:
void operator()( T * p)
{
delete[] p;
}
};
我们依旧使用上面的Date日期类作为申请的数据类型。
由于unique_ptr的删除器属于模板参数的一部分,所以在实例化时需要在<>中显式传入删除器,如下所示:
unique_ptr>up4(new Date[10]);
而shared_ptr的删除器是构造参数,在运行时绑定,所以当形参传递给构造函数使用,如下所示:
shared_ptrsp4(new Date[10], Del());
lambda表达式做删除器
由于每个lambda表达式都有一个唯一的类型(即使两个lambda看起来一样),所以需要用decltype( )推导lambda对象的类型,再在模板参数中指定这个类型unique_ptr需要在模板中传入推导好的类型,并且在构造函数中显式传入lambda对象:类型声明 + 实例传递,如下所示:
auto del = [](Date* p) {delete[] p; };
unique_ptr up2(new Date[10],del);
而shared_ptr直接在构造函数中传入删除器即可,如下所示:
shared_ptr sp3(new Date[10],[](Date* ptr) {delete[]ptr; });
3.尝试模拟实现shared_ptr
在日常使用中,还是shared_ptr的出场频率高一些,所以我们选择shared_ptr作为模拟实现的对象。
核心要点设计:
①引用计数:引用计数用静态成员的方式是无法实现的,要使用堆上动态开辟的方式,构造智能指针对象时来一份资源,就要new一个引用计数出来。
原因分析:类中静态成员变量属于全体实例化出来的对象,如果用静态成员方式实现,咋一看好像可以,但仔细想想就会发现当我们创建一个新的shared_ptr时,无论它管理的是什么对象,都会增加同一个静态计数,这样计数会一直增长。
②用一个function包装器成员变量存储外边传来的删除器,这样就不用每用一种删除器就重载一种构造函数,统一了不同删除器的类型,提供了运行时多态。
namespace karsen
{
template
class Mysharde_ptr
{
public:
Mysharde_ptr(T* ptr = nullptr)
:_ptr(ptr), _pcount(new int(1))
{}
//重载一个支持删除器版本的
template
Mysharde_ptr(T* ptr = nullptr, D del = D())
: _ptr(ptr), _pcount(new int(1)),_del(del)
{
}
Mysharde_ptr(const Mysharde_ptr& sp)
:_ptr(sp._data),_pcount(++(*sp._pcount)),_del(sp._del)
{
}
Mysharde_ptr& operator=(const Mysharde_ptr& sp)
{
//让该Mysharde_ptr减去之前指向的资源
_release();
if (sp._ptr != _ptr)
{
_ptr = sp._ptr;
_del = sp._del;
_pcount = sp._pcount;
++(*_pcount);
}
return *this;
}
void _release()
{
if (--(*_pcount) == 0)
{
// 判断是否是最后一个管理的对象,释放资源
_del(_ptr);
delete _pcount;
_ptr = nullptr;
_pcount = nullptr;
}
}
//类似迭代器
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T* get() const
{
return _ptr;
}
int use_count()const
{
return *_pcount;
}
~Mysharde_ptr()
{
_release();
}
private:
T* _ptr = nullptr;
int* _pcount = nullptr;
std::function_del = [](void* ptr) {delete ptr; };
};
}
4.shared_ptr循环引用问题
看上去shared_ptr既可以拷贝,又可以移动,似乎比unique_ptr好用许多,实则shared_ptr隐藏有一个致命的缺陷:循环引用问题!
还记得上面介绍shared_ptr是如何解决拷贝问题的吗?引用计数,在循环引用的场景下会导致双发互相指向,从而导致资源没释放而内存泄漏。
为了解决循环引用问题,C++又推出了weak_ptr。
说回正题,究竟什么是循环引用问题呢?比如我们有如下ListNode的数据类型,我们用shared_ptr管理它们。
struct ListNode
{
int _data;
//这样写,编译器无法从ListNode推导成shared_ptr
//ListNode* _next = nullptr;
//ListNode* _prev = nullptr;
std::shared_ptr _next;
std::shared_ptr _prev;
~ListNode()
{
std::cout << "~ListNode()" << std::endl;
}
};
int main()
{
// 循环引用 -- 内存泄露
std::shared_ptr n1(new ListNode);
std::shared_ptr n2(new ListNode);
n1->_next = n2;
n2->_prev = n1;
return 0;
}
看出来了吗,所示代码其实已经发生了内存泄漏了!让我们分析分析:
①首先创建了两个shared_ptr指针对象n1、n2管理两个节点,此时n1、n2引用计数均为1;

②让它们互相指向,此时由于_next与_prev的类型也是shared_ptr,所以引用计数都加1;

③程序结束,n1和n2析构后,管理两个节点的引用计数减到1。

那么这两片空间什么时候销毁呢?
左边的空间什么时候回收呢?左边的空间被右边空间里ListNode中的prev指向着呢,_prev销毁后左边的空间就能释放。
_prev怎么样才能被销毁呢?需要右边空间被回收才能被销毁。
那右边的空间怎么才能被回收呢?右边的空间被左边空间里ListNode中的_next指向着呢,需要_next销毁才能被回收。
那么_next怎么样才能被销毁呢?需要左边的空间被回收……
至此逻辑上成功形成循环引用,谁都不会释放就形成了循环引用,导致内存泄漏。
那么如何解决呢?当n1->_next = n2时不增加n2的引用计数,就不会形成循环引用了。我们只需要将ListNode中的next与prev的类型改成weak_ptr即可。
weak_ptr构造时不支持绑定到资源,只支持绑定到shared_ptr或者置空,当绑定到shared_ptr时,还不增加shared_ptr的引用计数,因此能够解决shared_ptr的循环引用问题我。
struct ListNode
{
int _data;
//这样写类型不匹配,编译器无法推导成=智能指针
//ListNode* _next = nullptr;
//ListNode* _prev = nullptr;
std::weak_ptr _next;
std::weak_ptr _prev;
~ListNode()
{
std::cout << "~ListNode()" << std::endl;
}
};
weak_ptr
weak_ptr也是C++11设计出来的智能指针,他的名字翻译出来是弱指针,他完全不同于shared_ptr与unique_ptr,他不支持RAII不能用它直接管理资源,实际上weak_ptr起到了算是一个辅助shared_ptr的角色。weak_ptr的产生本质是要解决shared_ptr 的一个循环引用导致内存泄漏的问题。
细节问题
①既然weak_ptr只支持绑定到shared_ptr,那么上述std::weak_ptr<ListNode> _next与_prev该怎么初始化呢?
——此时就需要为ListNode新增setNext函数,用传入的shared_ptr初始化内部的_next。_prev同理。
如下所示
struct ListNode
{
void setNext(std::shared_ptr next)
{
_next = next; // 这里进行实际的"绑定"
}
}
int main() {
// 创建两个节点
auto node1 = std::make_shared(1);
auto node2 = std::make_shared(2);
// 建立双向链接
node1->setNext(node2); // node1 的 _next 弱引用指向 node2
node2->setNext(node1); // node2 的 _next 弱引用指向 node1
std::cout << "程序结束,离开作用域...\n";
return 0;
// node1 和 node2 离开作用域,引用计数降为0,对象被正确销毁!
}
②既然weak_ptr不拥有资源,那么用它封装的ListNode指针_next岂不是不能解引用->?
——虽然 weak_ptr不能直接使用 -> 运算符,但通过成员函数lock() 访问,它会创建一个新的 shared_ptr,共享该资源的所有权,并将这个新的 shared_ptr 返回调用处。这会增加资源的引用计数,确保在你使用期间对象不会被销毁。如果资源已被释放:它返回一个空的shared_ptr。
使用示例:
structListNode
{
public:
int value;
std::weak_ptr next; // 使用 weak_ptr 避免循环引用
std::weak_ptr prve;
void setNext(std::shared_ptr next)
{
_next = next; // 这里进行实际的"绑定"
}
};
int main()
{
// 创建两个节点的链表: node1 -> node2
auto node1 = std::make_shared(1);
auto node2 = std::make_shared(2);
node1->setNext(node2) // node1 的 next 弱引用 node2
// 访问 node1 的 next(即 node2)
if (auto next_node = node1->next.lock())
{
// 有效的 shared_ptr,可以安全使用 ->
std::cout << "node1->next->value = " << next_node->value << std::endl;
}
else
{
std::cout << "下一个节点已被释放" << std::endl;
}
return 0;
}
文章总结
本文系统介绍了C++异常处理机制与智能指针的实现原理。
首先对比了C语言错误码与C++异常处理的区别,阐述了异常的概念、抛出捕获机制及异常安全的重要性。
之后重点讲解了智能指针的设计思路,包括RAII机制、auto_ptr的缺陷、unique_ptr的独占所有权和shared_ptr的共享所有权实现。 再然后详细分析了shared_ptr的循环引用问题及weak_ptr的解决方案,并给出了shared_ptr的模拟实现代码。通过异常处理与智能指针的结合,C++实现了更安全可靠的资源管理方式,为开发健壮程序提供了基础保障。
整理不易,读完点赞,手留余香~
浙公网安备 33010602011771号