目录
2.3.4、shared_ptr的循环引用与weak_ptr

一、异常
1.1、异常的概念
异常就是程序在运行过程中发生的意外或不正常情况(如除以零、内存分配失败、文件未找到等),会打断程序的正常运行。
异常具有突发性。
1.2、抛异常与捕获异常
出现异常后就涉及到对异常的处理,C++基于异常的处理有 三个关键字:throw,try,catch。
当检测到异常情况后,throw 抛出一个异常对象(可以是基本类型、自定义对象或标准异常类对象)。
cpp
// 求 x / y =?
double divide(double x, double y)
{
if (y == 0) {
string err{ "divided by zero" };
throw err; // 抛出异常,进行类型匹配,然后被对应的catch捕捉
}
else
return x / y;
}
对于可能会发生异常的代码块或者函数就需要用 try 包裹,程序就会监视这块代码的执行。
catch 紧跟在 try 的后面,用来捕获并处理特定类型的异常。
基本语法格式如下;
cpp
try
{
// 可能抛出异常的代码函数调用或操作;
}
catch (异常类型1& e)
{
// 处理异常类型1的逻辑
}
catch (异常类型2& e)
{
// 处理异常类型2的逻辑
}
catch (...)
{
cout << "未知异常" << endl;
// 捕获所有其他类型的异常(兜底处理)
}
异常的处理过程我们可以通过上面的divide函数来简单体验一下:
cppdouble divide(double x, double y) { if (y == 0) { string err{ "divided by zero" }; throw err; // 抛出异常,进行类型匹配,然后被对应的catch捕捉 } else return x / y; } int main() { while(1) { try { double x, y; cin >> x >> y; double ret = divide(x, y); cout << ret << endl; } catch (string err) { cout << err << endl; } catch (...) { cout << "未知原因" << endl; } } }可以看到,虽然在程序运行过程中出现了除以0这样的异常情况,但我们的程序并没有崩溃。
我们知道有些程序(app)在运行过程中崩溃是很严重的,而通过这种抛异常的防止来处理异常,就非常方便了。
1.2、异常的栈展开
抛出异常后,throw后面的代码不再执行(当前函数的执行会立即中断),程序会寻找最近的 catch 块来捕获和处理该异常,如果当前的函数中没有catch或者没有与之匹配的catch,就会顺着函数的调用链继续寻找,直到找到匹配的catch。

在这个过程中,程序会自动销毁函数栈中所有的局部对象(包括类对象,会调用其析构函数),
++那么就会有一个问题:++当我们new给局部对象动态开辟内存,如果不能正确处理,就会存在内存泄漏的风险。这里就涉及到一个我们下面会讲的 **RAII(资源获取即初始化)**和智能指针 。
异常的重新抛出
这里我们可以先通过一个"异常的重新抛出"来解决一些简单的场景:假如我们在divide函数内部通过new的方式动态申请了内存,就可以通过异常重新抛出的方式解决内存泄漏的问题。
cppdouble Divide(int a, int b) { if (b == 0) throw "Division by zero condition!"; return (double)a / (double)b; } // 先捕获异常后并不处理异常,异常还是交给外层处理,这里捕获了再重新抛出去。 void solutionFunc() { // 如果上面的Divide函数抛异常,就不能将new的资源释放,导致内存泄漏 int* array = new int[10]; // 动态开辟内存 int len, time; try { cin >> len >> time; cout << Divide(len, time) << endl; } // 抛异常后先在函数内部捕捉异常,将new的资源释放后,重新将异常抛出,原来捕获到什么就抛出什么 catch (...) { // 捕获异常释放内存 cout << "delete []" << array << endl; delete[] array; throw; // 异常重新抛出,捕获到什么抛出什么 } } int main() { try { solutionFunc(); } catch (string e) { cout << e << endl; } catch (...) { cout << "未知异常" << endl; } return 0; }这确实算是一种解决方法,但是,如果有多次动态内存开辟时,在new的过程中抛一个异常出来,那就又是一个大坑了,还得用到我们下面的**RAII** 和智能指针。
当然了,异常的重新抛出还有其他的一些用途。
++注意:++当害怕有些异常无法被catch 捕获,而导致程序崩溃时,就可以用catch(...)来兜底,catch(...) 可以捕获任意类型的异常。因为如果异常不能被捕获就会调用终止函数:std::terminate()来终止程序(程序崩溃)。一般catch(...)都放在main函数中。
cppcatch(...) {} // 可以用来捕获任意类型的异常,放在最后防止程序崩溃
1.4、异常的规范------noexcept
在我们的代码中,对于确定不会抛出异常的函数,可以用noexcept修饰,这样可以大大地简化我们的代码。
(1)在C++98中采用在函数参数列表后面加throw(),来表示不会抛异常;加throw(类型1, 类型2, ...),来表示会抛出多种类型的异常。
(2)这种方式不仅复杂,而且在实践中并不好用。所以C++11 引入了noexcept ,后面加上noexcept表示不会抛异常,反之可能抛异常。
(3)需要注意的是:编译器并不会在编译时检查noexcept,但如果noexcept声明后的函数抛出了异常,那么程序就会调用std::terminate() 终止程序。
noexcept(expression)还可以作为一个运算符去检测一个表达式expression是否会抛出异常,可能会则返回 false,不会就返回true。
cpp
int main()
{
int i = 0;
string s;
cout << noexcept(++i) << endl; // true---1
cout << noexcept(s.push_back('a')); // false---0
return 0;
}

***补充:***C++标准库也定义了一套自己的异常继承体系库,基类是exception,所以我们日常写程序,需要在主函数捕获异常,获取异常信息,就可以调用what函数,what是一个虚函数,派生类可以重写。
二、智能指针
2.1、智能指针使用场景
上面我们已经介绍了,当抛异常,异常捕获过程中会栈展开,在整个过程中程序会自动释放函数栈的局部对象(包括类对象会调用其析构函数)。当有多个new 的对象,而其中一个new抛了异常,那么程序就会中断,剩下的内存得不到正确的释放,就会内存泄漏。或者,你打开了一个文件,但是因为抛异常而中断,那么文件就不能被正确的关闭。这时候我们就需要智能指针来解决

2.2、RAII和智能指针的设计思路
**RAII** 即Resource Acquisition Is Initialization的缩写,意思是资源获得即初始化。
这是一种C++中非常重要的管理资源的思想和技术。
核心要点是:将资源的生命周期与一个对象的生命周期绑定,当对象被释放时资源就被释放了。
(1)那这里的资源是什么呢?
凡是需要手动释放的东西,动态开辟的内存,文件,网络连接,互斥锁,数据库连接等等。
(2)那怎么释放资源呢?
在该对象的析构函数中释放管理的资源。
换个形象的说法:假设RAII对象是一个非常负责任的管家。**获取资源:**当你进入一个房间,这个管家会主动地帮你拿好你的东西。这对应于RAII对象的构造和赋值。
**使用资源:**在这个房间里,你可以随便使用任何一件东西。相当于你访问获取到的资源。
**释放资源:**当你离开房间(不管是正常离开还是匆忙离开),管家都会帮你把东西回归原位。对应于RAII对象释放你一开始申请到的这些需要手动释放的资源。
这时候就相当于你把原来需要你管理,而且不好控制的这些资源,交给了一个管家,同时这个管家还把这些资源可以准确地管理释放,又不影响你去使用和访问这些资源。
C++中智能指针 就是用来自动管理动态内存的模板类,核心作用是避免手动new / delete导致内存泄漏,出现野指针,重复释放等问题。其设计思想就基于**RAII**来实现的,将动态申请的内存的生命周期与一个智能指针对象生命周期绑定,当智能指针对象的生命周期结束时自动析构从而达到释放内存的目的。
C++98提供了auto_ptr 这个智能指针,其特点是将被拷贝对象的资源转移给拷贝对象,这就++造成了被拷贝对象的悬空++,如果我们不注意去访问被拷贝对象,就会报错。所以说这是一个非常糟糕的设计,强烈不推荐使用auto_ptr。
C++11之后又引入了三个比较实用的智能指针:unique_ptr,shared_ptr 和weak_ptr。
2.3、标准库中智能指针的使用
C++中所有的智能指针的核心声明、定义以及相关辅助函数 都包含在一个叫 <memory> 的头文件 中。这是相关的的文档:<memory> 智能指针 。
2.3.1、auto_ptr
虽然说auto_ptr是一个非常糟糕的设计,但我还是带大家来看看。
cpp
#include<memory>
void test01() {
auto_ptr<string> ap1(new string("hello"));
auto_ptr<string> ap2(ap1);
cout << *ap2 << endl;
// cout << *ap1 << endl; // 对空指针的解引用
}

2.3.2、unique_ptr
unique_ptr的特点是,不支持拷贝构造和赋值拷贝,根据其名字就可以猜个大概,唯一的指针。
但是unique_ptr支持移动构造,即用右值类型的对象来构造。所以,当你不需要拷贝构造的时候就非常推荐选择用unique_ptr。
cpp
unique_ptr<string> up1(new string("hello"));
// unique_ptr<string> up2(up1); // 拷贝构造---报错
unique_ptr<string> up3(new string("hds"));
// up3 = up1; // 复制拷贝---报错
unique_ptr<string> up5(move(up1)); // 支持移动构造
2.3.3、shared_ptr
shared_ptr 的特点是,支持拷贝构造和赋值拷贝,所以其叫做共享指针。同时,shared_ptr还支持移动构造,所以,需要拷贝的时候就可以用shared_ptr。
cpp
shared_ptr<string> sp1(new string[5]);
shared_ptr<string> sp2(new string[10]);
shared_ptr<string> sp3(sp1); // 拷贝构造
sp2 = sp1; // 赋值拷贝
shared_ptr<string> sp4(move(sp1)); // 移动构造
++需要注意的是++,拷贝构造并不是将被拷贝对象的数据再拷贝一份给 拷贝对象,而是让这两个对象共同管理这一份数据,底层实现就是让多个对象的指针指向同一块内存。有点类似于浅拷贝,所以++有个非常坑的问题就是++:在析构时会存在对同一块内存重复释放。那C++委员会大佬是怎样解决这个问题的呢?
底层其实采用了引用计数的方法来保证只对这块内存空间只释放一次。那什么又是引用计数呢?
其实就是在智能指针类的内部创建一个用来专门计数的成员变量,每次实例化出一个对象时,默认这个变量的值为1,如果用这个对象去拷贝构造另一个对象,就让这个变量的值++,而我们要释放时,只有当引用计数为1时才去释放内存,就不会重复对一块内存释放了。还有很重要的一点:我们需要一个类似于全局变量的成员变量来充当引用计数,不然就会出现:用sp1拷贝sp2,sp2中的应用计数++到了2,但你sp1的引用计数却没有同步这个变化,不就坑死了。
所以,用来引用计数的这个成员变量也是一个指针,当拷贝后,对这个指针指向的数据进行++就可以让所有拷贝对象中的引用计数保持同步了。
接下来我们就带大家来看看shared_ptr的引用计数,后面我也会向大家实现一个简单的shared_ptr

cpp
void test04() {
shared_ptr<string> sp1(new string("hds"));
cout << "sp1对象引用计数:" << sp1.use_count() << endl;
shared_ptr<string> sp2(sp1); // 拷贝构造
cout << "sp1对象引用计数:" << sp1.use_count() << endl;
cout << "sp2对象引用计数:" << sp2.use_count() << endl;
}

通过输出的结果,可以看到引用计数在sp1和sp2两个对象中同步变化。
2.3.4、shared_ptr的循环引用与weak_ptr
什么是循环引用呢,我们通过一个例子来向大家形象的说明:
我们先定义一个双向链表的节点,但是有一点细节需要我们注意:由于我们想用智能指针来管理我们的双向链表,准确地说是让智能指针管理节点,所以节点的next指针和prev指针类型应该也是shared_ptr<ListNode>类型的,不然next 指针指向另一个节点时就会报错(类型不匹配)。
cpp
struct ListNode
{
int _data;
std::shared_ptr<ListNode> _next;
std::shared_ptr<ListNode> _prev;
~ListNode() { cout << "~ListNode()" << endl; }
};
int main()
{
// 循环引用 -- 内存泄露
std::shared_ptr<ListNode> n1(new ListNode);
std::shared_ptr<ListNode> n2(new ListNode);
cout << "n1对象的引用计数" << n1.use_count() << endl;
cout << "n2对象的引用计数" << n2.use_count() << endl;
n1->_next = n2; // 链接双向链表
n2->_prev = n1;
cout << "n1对象的引用计数" << n1.use_count() << endl;
cout << "n2对象的引用计数" << n2.use_count() << endl;
return 0;
}

由于n1节点的next指针指向n2节点,n2节点的prev指针指向n1节点,导致n1和n2节点的引用计数++到了2。这有什么毛病呢?

1、右边的节点什么时候释放呢,左边节点中的_next管着呢,_next析构后,右边的节点就释放了。
2、_next什么时候析构呢,_next是左边节点的的成员,左边节点释放,_next就析构了。
3、左边节点什么时候释放呢,左边节点由右边节点中的_prev管着呢,_prev析构后,左边的节点就释 放了。
4、_prev什么时候析构呢,_prev是右边节点的成员,右边节点释放,_prev就析构了。
当程序结束时,析构n1和n2节点。
当由于之前n1和n2的引用计数为2,导致两节点析构一次后引用计数无法减到零,而真正释放内存需要引用计数减到零,所以就会导致内存泄漏。
通过上面打印的结果,我们也可以印证这一点,程序结束后节点并没有被析构。
C++为了解决这个问题,又引入了weak_ptr弱指针来避免引用计数不正确的去++,导致内存泄漏。当我们用到可能会存在循环引用的场景就需要使用weak_ptr。
对于上面的场景,原因就是因为next指针和prev指针在指向另一个节点时导致节点引用计数多加了1次,所以我们可以用weak_ptr来管理这两个指针。
cppstruct ListNode { int _data; std::weak_ptr<ListNode> _next; // 用weak_ptr来管理next和prev std::weak_ptr<ListNode> _prev; ~ListNode() { cout << "~ListNode()" << endl; } };
实际上weak_ptr不支持RAII,也不支持访问资源。即weak_ptr不参与资源管理,所以不需要重载*,->,[ ]等。
所以我们看文档发现weak_ptr构造时不支持绑定到资源,只支持绑定到shared_ptr对象,绑定到shared_ptr对象时,不增加shared_ptr对象的引用计数,那么就可以 解决上述的循环引用问题。
如果weak_ptr绑定的 shared_ptr对象已经释放了资源,那么他去访问资源就是很危险的。weak_ptr支持 expired 检查指向的 资源是否过期 ,use_count 也可获取shared_ptr的引用计数,weak_ptr想访问资源时,可以调用 lock 返回一个管理资源的shared_ptr,如果资源已经被释放,返回的shared_ptr是一个空对象,如 果资源没有释放,则通过返回的shared_ptr访问资源是安全的。
2.4、智能指针的原理
通过上面的学习相信大家已经对智能指针有了一个大概的认识了。
接下来我们继续深入,在此之前,我先向大家展示一个已经简单实现的unique_ptr,帮助大家理解。
cpp
template<class T>
class unique_ptr
{
public:
unique_ptr(T* ptr)
:_ptr(ptr)
{}
unique_ptr(const unique_ptr<T>& up) = delete; // 拷贝构造
unique_ptr& operator=(const unique_ptr<T>& up) = delete; // 赋值拷贝
unique_ptr(unique_ptr<T>&& up) // 移动构造
:_ptr(up._ptr)
{
up._ptr = nullptr;
}
unique_ptr<T>& operator=(unique_ptr<T>&& up) // 移动赋值拷贝
{
delete _ptr;
_ptr = up._ptr;
up._ptr = nullptr;
}
~unique_ptr()
{
delete _ptr;
_ptr = nullptr;
}
T operator*() { return *_ptr; } // 重载*
T* operator->() { return _ptr; } // 重载->
T& operator[](size_t pos) { return _ptr[pos]; } 重载[]
private:
T* _ptr; // 成员变量,指向动态申请的内存空间
};
通过delete将拷贝构造和赋值拷贝禁用,为了访问数据,重载了解引用,->和[ ]等操作符。
接下来我们正式进入对shared_ptr的模拟实现(简单版),我先问大家一个问题:在上面的unique_ptr实现中我们在析构时采用了delete,因为智能指针默认在析构时采用delete。
这不问题就来了:对于不是new出来的资源,比如用智能指针管理一个文件,我们就需要关闭文件,而不是delete;或者需要用delete[ ] 来释放资源,这时候智能指针在析构时程序就会崩溃。

所以,我们就需要定制一个释放不同类型资源的东西,即删除器。在构造智能指针对象时,支持我们传一个删除器,用来释放特定的资源。
删除器
由于delete[ ] 释放资源的方式比较常用,所以unique_ptr 和shared_ptr都特化了一个 [ ] 的版本,unique_ptr<value_type[ ]> up(new value_type[10]),
shared_ptr<val_type[ ]> sp(new val_type[10]),val_type即相应的类型。

我们还可以自己定制删除器:
(1)仿函数
相信仿函数大家都已经不陌生了,需要注意的是:unique_ptr 和 shared_ptr在底层支持删除器的方式不同,unique_ptr是在模板参数支持删除器的,也就是把删除器作为了一个模板参数;而shared_ptr是构造参数支持的,也就是将删除器作为了一个成员变量。所以仿函数在传参时有一些不同:
这里我们用一个自定义类型Data来测试
cpp
struct Data {
int _year;
int _month;
int _day;
Data(int year = 1, int month = 1, int day = 1) // 默认构造
:_year(year)
,_month(month)
,_day(day)
{ }
~Data() { cout << "~Data()" << endl; } // 析构
};
// -------------------------------仿函数----------------------------------------------
template<class T>
struct Del {
void operator()(T* ptr)
{
delete[] ptr;
}
};
void test03()
{
unique_ptr<Data, Del<Data>> up1(new Data[3]); // 传一个仿函数类型给模板参数
Del<Data> del;
shared_ptr<Data> sp1(new Data[3], del); // 传一个仿函数对象
}

(2)函数指针
cpp
// 函数指针
template<class T>
void DelArray(T* ptr)
{
delete[] ptr;
}
void test04()
{
unique_ptr<Data, void(*)(Data*)> up(new Data[3], DelArray<Data>);
shared_ptr<Data> sp(new Data[3], DelArray<Data>);
}

(3)lambda表达式
cpp
// lambda
void test05()
{
auto Del = [](Data* ptr) { delete[] ptr; };
unique_ptr < Data, decltype(Del)> up(new Data[3],Del);
shared_ptr<Data> sp(new Data[3], Del);
// shared_ptr<Data> sp(new Data[3], [](Data* ptr) { delete[] ptr; }); // 写法二
}

(4)实现其他资源管理的删除器:
cpp
struct Fclose {
void operator()(FILE* ptr)
{
cout << "fclose()" << endl;
fclose(ptr);
}
};
void test06() {
// 实现其他资源管理的删除器------------文件资源管理
shared_ptr<FILE> sp5(fopen("Test.cpp", "r"), Fclose()); // 仿函数
// lambda
shared_ptr<FILE> sp6(fopen("Test.cpp", "r"), [](FILE* ptr) {
cout << "fclose:" << ptr << endl;
fclose(ptr);
});
}

shared_ptr简单模拟实现源码⭐️⭐️⭐️
面试重点:尤其需要注意赋值拷贝构造中的细节:
cpp
#include<iostream>
using namespace std;
#include<functional>
namespace hds
{
template<class T>
class shared_ptr
{
public:
shared_ptr(T* ptr)
:_ptr(ptr)
{
_pcount = new atomic<int>(1);
}
// 定制删除器的拷贝构造
template<class D>
shared_ptr(T* ptr, D del)
: _ptr(ptr)
, _pcount(new atomic<int>(1))
, _del(del)
{}
// 拷贝构造
shared_ptr(const shared_ptr<T>& sp)
:_ptr(sp._ptr)
,_pcount(sp._pcount)
{
(*_pcount)++;
}
// 赋值拷贝
// sp1 = sp4;
shared_ptr<T>& operator=(const shared_ptr<T>& sp)
{
// 先判断sp1与sp4是否已经指向同一块资源
if (_ptr != sp._ptr)
{
//如果sp1指向的资源的引用计数为1,那么当sp1指向另一块资源时就要先将这块资源释放
if (--(*_pcount) == 0)
{
_del(_ptr);
delete _pcount;
}
_ptr = sp._ptr;
_pcount = sp._pcount;
(*_pcount)++;
}
return *this;
}
// 析构:当引用计数为0时释放内存
~shared_ptr()
{
if (--(*_pcount) == 0)
{
_del(_ptr);
delete _pcount;
}
}
T operator*() { return *_ptr; } // 重载解引用
T* operator->() { return _ptr; } // 重载->
T& operator[](size_t pos) { return _ptr[pos]; } //重载[]
T* get()const { return _ptr; } // 获取指向资源的指针
size_t use_count() { return *_pcount; } // 获取引用计数
private:
T* _ptr;
atomic<int>* _pcount; // 保证引用计数为原子操作
// function包装器包装删除器,通过我们传一个定制的删除器,然后走一个拷贝
function<void(T*)> _del = [](T* ptr) {delete ptr; }; // 默认为delete
};
}
weak_ptr简单模拟实现源码
与shared_ptr对象绑定。
cpp
#include"shared_ptr.h"
namespace hds
{
template<class T>
class weak_ptr
{
public:
weak_ptr()
{}
weak_ptr(const shared_ptr<T> sp)
:_ptr(sp.get())
{}
weak_ptr<T>& operator=(const shared_ptr<T> sp)
{
_ptr = sp.get();
return *this;
}
private:
T* _ptr = nullptr;
};
}
2.5、shared_ptr的线程安全问题
引用计数的修改是线程安全的,因为shared_ptr 的引用计数是通过原子操作实现的:
只要一个对象的引用计数发生变化,与这个对象关联的所有对象的引用计数都会同步变化。
多个线程同时拷贝 同一个
shared_ptr(增加引用计数);多个线程同时销毁 不同的
shared_ptr实例(减少引用计数);
仅保证自身引用计数的线程安全,不保证其管理的对象(如sp指向的int对象)的线程安全。多个线程同时对一个shared_ptr对象管理的数据进行修改,就会引发数据的竞争,即shared_ptr对象到底是遵循你线程1的修改呢,还是遵顼你线程2的修改呢...
使用atomic<int>* count,就可以保证 引用计数的线程安全问题,即保证对引用计数进行原子操作。
2.6、C++11和boost中智能指针的关系
Boost库是为C++语言标准库提供扩展的一些C++程序库的总称,Boost社区建立的初衷之一就是为 C++的标准化工作提供可供参考的实现,Boost社区的发起人Dawes本人就是C++标准委员会的成员 之一。在Boost库的开发中,Boost社区也在这个方向上取得了丰硕的成果,C++11及之后的新语法 和库有很多都是从Boost中来的。
• C++ 98 中产生了第一个智能指针auto_ptr。
• C++ boost给出了更实用的scoped_ptr/scoped_array和shared_ptr/shared_array和weak_ptr等.
• C++ TR1,引入了shared_ptr等,不过注意的是TR1并不是标准版。
• C++ 11,引入了unique_ptr和shared_ptr和weak_ptr。需要注意的是unique_ptr对应boost的 scoped_ptr。并且这些智能指针的实现原理是参考boost中的实现的。
三、内存泄漏
3.1、什么是内存泄漏?
内存泄漏大家肯定也不陌生,即对于一些不再使用的内存,没有释放导致的,目前我们最可能接触到的就是动态申请的内存。可能是你忘记释放了,或者释放的方式不对,或者程序发生异常终止而没来得及释放。
内存泄漏并不是指内存在物理上的消失,而是应用程序分 配某段内存后,因为设计错误,失去了对该段内存的控制,因而造成了内存的浪费。
对于普通的程序,一般运行结束后(进程结束),如果你没有释放,操作系统就会帮你释放,所以没什么太大的影响。
但是对于长期运行的程序,如软件app,操作系统等,一旦出现内存泄漏就会导致可用的内存不断减少,最终导致程序运行变慢,卡死。
3.2、怎么避免内存泄漏?
既然内存泄漏这么严重,那我们该怎样避免呢?
首先我们要有这个意识,申请的内存记得释放;但出现异常之后还是会导致内存泄漏,所以,我们尽量用智能指针管理我们的资源,或者根据RAII的思想自己造轮子。
此外,我们还可以通过检测内存泄漏的工具来检测我们的程序是否有内存泄漏的问题,具体的工具如果大家感兴趣可以参考以下资料:
• linux下内存泄漏检测:linux下几款内存泄漏检测工具
• windows下使用第三方工具:windows下的内存泄露检测工具VLD使用_windows内存泄漏检测工 具-CSDN博客





