一、异常
1.传统处理错误的方式vs异常
1.
C语言传统处理错误的方式无非就是返回错误码或者直接是终止运行的程序。例如通过assert来断言,但assert会直接终止程序,用户对于这样的处理方式是难以接受的,比如用户误操作了一下,那app直接就终止退出了吗?这对用户来说,体验效果是很差的,毕竟我只是不小心误操作了而已,程序就直接退出了,那太不合理了!而像返回错误码这样的方式也不够人性化,需要程序员自己去找错误,系统级别的很多接口在出错的时候,总是会把错误码放到全局变量errno里面,程序员还需要通过打印出errno的值,然后对照着错误码表来得出errno对应的错误信息是什么。
而实际中,C语言基本都是使用错误码来处理程序发生错误的情况,部分情况下使用终止程序的方式来处理错误。
2.
异常是C 引入的处理错误的一种方式,当一个函数或者接口在发生错误时,可以直接throw异常对象,然后catch会捕获异常对象,对发生的异常作相关的处理。
try用于激活某一段需要测试的代码块,即这段代码块会对某种错误发生时抛出异常对象。注意try catch需要配合使用,在某一个调用接口中,如果只有catch没有try会发生报错,同样只有try没有catch也会报错,所以try和catch必须配套使用,一个用于激活异常对象的抛出,一个用于捕获抛出的异常对象。
throw就是在被保护的代码块,当发生某种错误时,throw可以选择抛出异常对象,在抛出异常对象后,执行流会直接跳到异常对象类型匹配的catch块。
catch用于捕获异常对象,异常对象可以有多个类型,catch块的参数需要匹配好对应需要处理的异常的类型。
代码语言:javascript复制try
{
func();// 保护的标识代码
}
catch( ExceptionName e1 )
{
// catch 块
}
catch( ExceptionName e2 )
{
// catch 块
}
catch( ExceptionName eN )
{
// catch 块
}
2.异常的使用规则
2.1 异常的抛出和捕获原则
1.
异常是通过抛出对象引发的,该对象的类型决定了激活哪个catch的处理代码。
例如下面代码中,当b为0的时候,Division函数会抛出异常对象,该异常对象的类型就是一个常量字符串,在抛出对象之后,执行流会直接跳到和异常对象类型匹配的catch块,也就是参数为常量字符串类型的catch块,随后在此catch块中进行相应的打印错误信息或者其他处理方式都可。
抛异常可以抛出更为丰富的错误信息,这些完全由程序员自己来决定,而错误码这样传统的处理方式,错误信息都是已经被语言所规定好的,可扩展性不强,所以异常对象的自定义这一点就比错误码这样的方式强很多了。
代码语言:javascript复制double Division(int a, int b)
{
// 当b == 0时抛出异常
if (b == 0)
throw "Division by zero condition!";
else
return ((double)a / (double)b);
}
void Func()
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;// throw const char*对象
}
int main()
{
try
{
Func();
}
catch (const char* errmsg)
{
cout << errmsg << endl;
// 记录日志,进行统一处理。想在main里面统一进行异常的捕获
}
catch (int errid)
{
cout << errid << endl;
}
catch (...)
{
}
return 0;
}
2.
被选中的处理异常的catch块是调用链中与该异常对象类型匹配且离抛出异常对象位置最近的catch块。
例如下面代码中,func1抛出异常对象e,而调用链如下所示,main调func3,func3调func2,func2调func1,当抛出异常对象后,会先检查自己是否有类型匹配catch块和try,如果有那就直接跳转到catch块进行异常对象的处理,如果没有,那就会检查调用链中的接口是否有匹配的catch块,如果有那就指向,如果没有则继续向后查找catch块。
3.
异常对象在被catch块捕获时,catch块中通常都是用引用来作为接收异常对象类型的参数。
在C 中,当异常被抛出时,异常处理机制会确保异常对象在对应的catch块执行期间保持有效。异常对象不会因为离开函数栈帧而被销毁。这是因为C 标准库实现了一个特殊的内存管理策略来处理异常对象。
当异常被抛出时,异常对象会被创建并复制到一个特殊的内存区域,称为异常存储区。这个区域是由C 运行时库管理的,与程序的栈内存和堆内存是分开的。因此,在异常处理流程中,即使函数栈帧被销毁,异常对象仍然有效,可以在catch块中被捕获。
使用引用来捕获异常对象,可以避免异常对象的复制,当异常对象较大时,可以直接引用存储在异常存储区的对象,这样可以提高性能。
4.
catch(…)可以捕获任意类型的异常,但缺点是不知道异常类型是什么。
那如果在下面的场景当中呢?Func中进行了内存资源的申请,如果没有try catch,只有输入len time以及调用Divison的三行代码,是不是会发生内存泄露呢?因为Division中会抛异常,那如果Func中没有catch,则会直接去main中匹配对应的catch块,此时就会由于执行流的跳转,导致Func中的p1发生内存泄露,无法执行到delete p1;
所以常见的做法就是在Func中重新try 并catch异常,如果规定统一在main里面捕获异常的话,那Func就起到一个中间件的作用,将异常捕获再重新抛出,这样的话,执行流就会先跳到Func()内部的catch处,所以此时就可以释放p1,防止内存泄露的产生,但如果抛出的异常对象多了该怎么办呢?我们也来Func里疯狂的写一堆中间件作用的try catch块吗?这样是不是有点太挫了?所以有一种方式就是catch(…)和throw,即为捕获所有异常,然后再重新将捕获到的异常全部抛出。那么在catch(…)里面就可以释放p1,防止内存泄露的发生。
但实际上还有一种处理方式就是用智能指针,用智能指针的好处就是不用自己手动释放资源,如果不用自己手动释放资源的话,那Func就不需要作为中间件捕获异常了,因为在Func里面不会出现内存泄露的问题,我们也不用让throw对象后的执行流在Func中待一会儿,以便手动释放资源。
代码语言:javascript复制void Func()
{
int* p1 = new int[10];
try
{
int len, time;
cin >> len >> time;
cout << Division(len, time) << endl;// throw const char*对象
// func(); throw double对象
//如果说这里抛出的异常对象非常多,那func作为中间层就要做很多的工作,例如重新抛出异常,还要写具体类型的catch
//所以如果中间件func需要抛出的异常对象非常多,那就直接统一捕获所有异常
}
catch (...)
{
delete[] p1;
cout << "delete[] p1->" << p1 << endl;
throw;//捕获到什么异常,那就抛出什么异常
}
//catch (const char* errmsg)
//{
// //第一种做法,重新再把异常抛出去
// //第二种做法,通过智能指针来拯救一下这里的代码,防止出现没有delete的情况而导致的内存泄露。
// cout << "delete[] p1" << endl;
// delete[] p1;
// throw "Division by zero condition!";//重新抛出异常
// //cout << errmsg << endl;
//}
//2.如果上面的代码不捕获异常,那进入Division之后,抛出异常,会直接跳到main里面的catch,则p1内存泄露
//想要解决,那就需要智能指针上场了,否则太容易造成内存泄露(try throw catch就像goto语句似的,很容易内存泄露)
delete[] p1;
//1.异常只要被捕获,就会跳到catch执行代码,执行完之后,后面的代码就正常运行了
cout << "delete[] p1" << endl;
}
5.
实际异常的抛出和捕获在类型匹配时有特殊的情况,例如可以用基类类型捕获派生类类型对象,这个在实际中应用的非常广泛。
2.2 在函数调用链中异常栈展开匹配原则
1.
一般在异常被抛出的时候,会先检查当前异常对象所在函数栈中是否有try catch块,如果有那就继续检查是否匹配,如果匹配则直接跳到catch块执行代码。如果没有那就退出当前函数栈,继续向上查找调用链,直到找到合适的catch块,如果一直都没有找到合适的catch块,则程序会终止退出。
所以一般为了防止软件终止退出,我们都会留最后一道防线,也就是捕获所有异常,有可能程序员在抛异常的时候,抛出的异常是非法的,此时catch(…)就可以捕获这种未显式定义类型的异常,不至于让软件终止退出。
在匹配到相应的catch块并执行完catch块内的代码之后,执行流就可以正常向后执行。
代码语言:javascript复制double Division(int a, int b)
{
try
{
if (b == 0)
{
string s("Division by zero condition!");
throw s;//捕获的是s的拷贝,那就有点效率低,所以用右值引用
}
else
{
return ((double)a / (double)b);
}
}
catch (const string& errstr)
{
cout << "除0错误" << endl;//自己既可以抛异常,又可以catch异常
}
}
void Func()
{
int len, time;
cin >> len >> time;
Division(len, time) ;// throw const char*对象
cout << " " << endl;//catch执行完之后,后面的执行流正常了就
}
int main()
{
try
{
Func();
}
catch (const string& errmsg)//捕获的时候,尽量用引用
{
cout << errmsg << endl;
}
catch (...)//最后一道防线,不至于让软件终止退出
{
//程序出现异常,程序是不应该被随意终止的
//出现未知异常,一般就是异常对象没有被正确的捕获,类型没匹配正确
cout << "未知异常" << endl;
}
return 0;
}
3.异常安全和异常规范
1.
最好不要在构造函数中抛异常,因为可能由于执行流的跳转而导致对象未初始化完全,进而导致对象的不完整。
最好也不要在析构函数中抛异常,因为也有可能由于执行流的跳转而导致对象未析构完全,进而导致内存资源泄露,文件未关闭等等问题产生。
在C 中经常会由于异常而导致资源泄露的问题产生,比如在new和delete之间抛出异常会导致内存泄露,在lock和unlcok之间抛出异常会导致死锁。C 经常使用RAII来解决上面这种问题,即将资源的生命周期和对象的生命周期进行绑定,对象初始化时资源创建,对象析构时资源销毁。
2.
C 98中,搞出了一个异常规格,即为在函数后面加throw(类型),表示这个函数抛出的异常类型都有哪些,如果括号中为空,表示该函数不抛出任何异常。当然这不是必须的,C 委员会并没有强制要求必须在函数后面加关于抛异常类型的声明,并且由于设计的太复杂,所以大家也都不爱用这样的方式,如果一个函数抛4个异常,我还得回头看异常的类型分别都是什么,那太麻烦了,每个函数都要看他声明后面的类型是什么,那太繁琐了,并且由于没有强制要求,所以C 98搞出来的这套规定也就形同虚设,没人这么用。
而C 11中新增了关键字noexcept,表示该函数不会抛任何异常,这个关键字还是挺不错的,可以告诉使用者这个函数不会抛任何异常,你不用担心。当然如果函数声明后面没有noexcept的话,则表示该函数可以抛任意类型的异常。
代码语言:javascript复制// 这里表示这个函数会抛出A/B/C/D中的某种类型的异常
void fun() throw(A,B,C,D);
// 这里表示这个函数只会抛出bad_alloc的异常
void* operator new (std::size_t size) throw (std::bad_alloc);
// 这里表示这个函数不会抛出异常
void* operator delete (std::size_t size, void* ptr) throw();
// C 11 中新增的noexcept,表示不会抛异常
thread() noexcept;
thread (thread&& x) noexcept;
4.自定义异常体系
1.
实际在公司的大型项目里面,有很多人负责项目的不同模块,比如负责网络服务,缓存,sql等等不同的小组,都要抛异常,他们各自抛出的异常类型都是不一样的,仅仅靠一个类来实例化出异常对象是无法满足这么多小组的需求的,所以此时继承就派出用场了,捕获异常的调用者可以用父类类型捕获所有子类中抛出的异常对象,这样的话,每个小组都可以有自己定义出来的派生类,以满足他们各自抛出异常的需求。
2.
下面便是模拟服务器开发中的异常继承体系,可以看到基类为Exception,有三个派生类分别为SqlException,CacheException,HttpServerException,分别对应SQL异常,缓存异常和http服务异常,每个派生类都重写了虚函数what,这样在父类捕获异常对象之后,可以多态式的调用不同异常对象内部的虚函数what。
然后我们又自己写了一个调用链,HttpServer调用CacheMgr,CacheMgr调用SQLMgr,三个函数在满足某一较为随机的条件的情况下都会抛出异常,我们统一在main里面用基类捕获所有派生类的异常对象,然后用基类对象就可以调用派生类里面重写的what虚函数了。当然如果你想对某个异常作特殊处理的话,也可以单独捕获这种类型的异常,但一般情况下,直接用基类捕获就可以,然后通过多态调用的方式来处理异常。
所以通过下面的这样的方式就可以解决多个小组抛异常时,外面捕获异常时能够统一捕获的场景了,这样自定义的异常继承体系也是许多公司主流使用的处理错误的方式。
多态知识回顾
打印出 的就是缓存函数抛异常了,打印调用成功就是在调用链中没有一个函数抛异常。
5.标准库的异常体系和异常的优缺点
1.
实际上,C 标准库也我们实现了一套异常体系,同样也是以父子类的继承体系设计的,实际使用中,我们也可以自己去继承exception类,自己实现一个新的派生异常类,但实际大部分的公司都不会去使用标准库的这一套异常体系,而是选择自己定义一套异常继承体系,类似于我们上面定义的那样,因为C 标准库设计的不够好用。
2.
下面的这部分代码使用的就是标准库的异常继承体系,reserve和at都会抛异常,我们便可以使用标准库异常体系中的基类exception来捕获reserve和at抛出的异常对象。
代码语言:javascript复制int main()
{
try
{
vector<int> v(10, 5);
// 这里如果系统内存不够也会抛异常
v.reserve(1000000000);
// 这里越界会抛异常
v.at(10) = 100;
}
catch (const exception& e) // 这里捕获父类对象就可以
{
cout << e.what() << endl;
}
catch (...)
{
cout << "Unkown Exception" << endl;
}
return 0;
}
由于这套标准异常体系不怎么好用,因此已经被许多公司废弃了。
3.
下面是异常的优缺点,虽然异常也有不少的缺点,但总体来说利大于弊,并且相比传统的处理错误的方式已经优化了很多,所以还是很鼓励使用异常来处理错误的。
二、智能指针
1.为什么需要智能指针?
1.
下面的这段代码中,如果p1new抛异常,则程序会直接终止,并报出bad_alloc异常,然后main中的catch会捕获异常,由于此时p2没有创建,则不会发生内存泄露。如果p2new抛异常,程序同样会直接终止,并报出bad_alloc异常,main也会同样捕获异常,但由于此时p1已经成功分配开辟好内存,而delete p1无法执行到,则会因此产生内存泄露的问题。如果是div抛异常,则Func中的catch会将异常捕获并成功释放p1和p2,因此不会产生内存泄露的问题。
2.
有人觉得像上面那样释放资源太麻烦了,老是需要考虑一堆因素,这里抛异常会怎么样,那里抛异常又会怎么样,所以大佬们觉得这样的方式太繁琐了,就搞出来了RAII(Resource Acquisition Is Initialization),即为资源获取时即为初始化时,其实意思就是在构造函数里面进行资源的获取,在析构函数里面进行资源的释放回收,将对象的生命周期与资源的生命周期绑定,这样的话,我们就不用再手动写一堆delete来回收资源了,因为当对象销毁时,资源就会被自动回收,无须手动回收内存资源,这样不仅可以减少思考的负担,而且还能避免很多潜在的内存泄露问题的产生。何乐而不为呢?
下面就是我们实现的一个简易版本的智能指针,其实智能指针主要由两部分构成,一部分是RAII,一部分是像指针一样,原先我们是通过内置类型原生指针来管理申请好的资源,现在我们有了智能指针之后,就可以直接使用智能指针来管理资源了,为了实现资源的管理,我们还要实现解引用以及成员访问等运算符的重载。
如果用智能指针来管理我们所申请到的资源,我们就不用再担心没有回收资源而产生的内存泄露问题了,就算是抛异常我们也不害怕,因为当执行流离开函数栈帧的时候,由于函数栈帧的销毁,则智能指针对象也会跟着销毁,此时会调用析构函数完成智能指针所指向的资源的回收工作。
如果到这里你就觉得智能指针已经学完了,那说明老铁你还是太天真了,实际上这才仅仅只是智能指针的一个开始,智能指针最大的难题其实在于拷贝,即指针之间的拷贝!(我们不能进行深拷贝,因为这不符合拷贝的原意,本身拷贝的原意就是让多个指针指向同一个资源,同时对一个资源进行管理,你给我深拷贝这不是驴唇不对马嘴吗?我要的就是原意的拷贝,不是什么所谓的深拷贝)
代码语言:javascript复制int div()
{
int a, b;
cin >> a >> b;
if (b == 0)
{
throw invalid_argument("除0错误");
}
return a / b;
}
template <class T>
class SmartPtr
{
public:
//构造函数保存资源 - RAII
SmartPtr(T* ptr)
:_ptr(ptr)
{}
//像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](size_t pos)
{
return _ptr[pos];
}
//析构函数释放资源
~SmartPtr()
{
delete[] _ptr;
cout << "delete[] " << _ptr << endl;
}
private:
T* _ptr;
};
void Func()
{
SmartPtr<int> sp1(new int[10]);
SmartPtr<int> sp2(new int[20]);
*sp1 = 10;
cout << --sp1[0] << endl;
cout << div() << endl;
}
int main()
{
try
{
Func();
}
catch (exception& e)
{
cout << e.what() << endl;
}
return 0;
}
2.智能指针的使用和原理
2.1 auto_ptr
1.
C 98率先提出来的一个智能指针就是auto_ptr,这个指针解决拷贝的方案非常的荒唐,荒唐至极,以至于从C 98发行出来到现在被骂了好多年,所以很多的公司已经明令禁止不允许使用auto_ptr。
他的实现方案就是在智能指针发生拷贝的时候,将资源的管理权转移,并将原来指向资源的原生指针置为空指针,这是一件非常荒唐的事情。因为有可能会出现空指针访问的情况产生,这会引发很多不必要的麻烦。所以平常在使用的时候,就直接不要用这个auto_ptr了。
代码语言:javascript复制 template <class T>
class auto_ptr
{
public:
//构造函数保存资源 - RAII
auto_ptr(T* ptr)
:_ptr(ptr)
{}
auto_ptr(auto_ptr<T>& ap)//非常荒唐的一种做法,委员会不知道怎么想的
:_ptr(ap._ptr)
{
ap._ptr = nullptr;
}
//像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
T& operator[](size_t pos)
{
return _ptr[pos];
}
//析构函数释放资源
~auto_ptr()
{
delete[] _ptr;
cout << "delete[] " << _ptr << endl;
}
private:
T* _ptr;
};
2.2 unique_ptr
1.
unique_ptr是如何解决智能指针的拷贝问题呢?他的实现策略也非常的简单,直接禁止拷贝。这也是为什么叫做unique的原因,因为唯一嘛!也就是不允许拷贝。
2.
unique_ptr的=运算符用的是移动语义,他也是将资源的管理权转移,转移过后p1就会变为空指针,所以我们一般也不愿意使用unique_ptr的赋值重载。
代码语言:javascript复制用法1:移动语义转移资源管理权
std::unique_ptr<int> p1(new int(42));
std::unique_ptr<int> p2;
p2 = std::move(p1); // 转移所有权
用法2:将原生指针赋值给unique_ptr
std::unique_ptr<int> p;
int* ptr = new int(42);
p = ptr; // 转移所有权
用法3:将unique_ptr的资源管理权释放给原生指针
std::unique_ptr<int> p(new int(42));
int* ptr;
ptr = p.release(); // 释放所有权
2.3 shared_ptr
2.3.1 拷贝和赋值(在堆上创建引用计数)
1.
shared_ptr实现拷贝和赋值的方式是通过引用计数来实现的,即智能指针不仅仅需要管理某一资源块,还另外在堆上开辟一个int大小的4字节空间,用于存放引用计数,当智能指针发生拷贝时,多个智能指针同时管理一块资源,引用计数会 ,表示当前资源共被多少个管理者进行管理,当某一智能指针对象销毁时,引用计数会先被- -,当引用计数减为0的时候,表示已经没有智能指针管理这块资源了,此时就需要delete释放这个空间资源块。
2.
shared_ptr的赋值需要考虑的因素会更多一些,我们需要保证不能是自己赋值给自己的情况,也就是两个智能指针都管理着一个资源块,那这两个智能指针在赋值的时候,我们不做任何处理。
其他正常情况的赋值,比如sp1=sp2,那先需要判断sp1的引用计数减减之后是否为0,如果为0则需要释放引用计数和其管理的资源块空间,然后就是 sp2的引用计数,再将_ptr和_pcount的值都赋值为sp2内部成员变量的值,这样sp1就会变成管理sp2指向的资源的智能指针了。
代码语言:javascript复制//构造函数保存资源 - RAII
shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pcount(new int(1))
{}
shared_ptr(const shared_ptr& sp)
:_ptr(sp._ptr)
,_pcount(sp._pcount)
{
(*_pcount) ;
}
shared_ptr<T>& operator=(shared_ptr<T>& sp)
{
if (_ptr != sp._ptr)
{
(*sp._pcount);
release();
_ptr = sp._ptr;
_pcount = sp._pcount;
}
return *this;
}
void release()//减减引用计数,判断是否需要释放资源。
{
if (--(*_pcount) == 0)
{
delete _pcount;
delete _ptr;
}
}
~shared_ptr()
{
release();
}
3.
任何情况下都不要用auto_ptr,因为他已经被许多的公司废弃了。如果你不想被拷贝,那就可以用unique_ptr。如果你允许被拷贝,那就可以用shared_ptr,但在使用shared_ptr的时候,要小心循环引用的问题,以及线程安全的问题,循环引用我们可以通过weak_ptr来解决,线程安全可以通过加锁来解决。另外上面我们实现的shared_ptr对于_ptr所指向资源的释放,默认用的是delete,那如果_ptr管理的资源是int10呢?又或是FILE文件呢?我们还能用delete来释放资源吗?当然不可以,此时就需要一个叫做定制删除器的东西来解决,但其实定制删除器无非也就是仿函数来实现的,没啥新奇的。
最后说一点,面试官可能让大家在面试的时候手撕智能指针,大家千万不要手撕auto_ptr,因为这个指针都已经被废弃了,你还手撕?所以如果没有明确要求,那就手撕个unique_ptr,因为这个比较简单。如果稍微难一些,那可能会要求你手撕shared_ptr,但shared_ptr可能会让你手撕线程安全版本的,还会让你讲讲引用计数的知识。
2.3.2 线程安全(和引用计数一样,在堆上申请一把锁)
1.
在有了前面linux多线程的基础之后,理解下面这些线程安全的问题那简直易如反掌,当多线程在同时对一个智能指针作拷贝的时候,这个智能指针在堆上开辟的引用计数就会频繁的被多线程访问和操作,由于sp2和sp3都拷贝自sp1,所以sp1的引用计数就是共享资源,sp2和sp3都会操作这个引用计数,那就一定需要保护这个共享资源引用计数,否则一定是会出问题的。
2.
保护共享资源就需要加锁,这把锁可不可以是静态锁呢?当然是不行的,那样所有智能指针对象用的都是同一把锁了,我们需要保护的是多个线程同时管理同一块资源时的引用计数,又不是所有的引用计数都需要保护,如果一个引用计数仅仅只被一个线程管理,那我们还用加锁吗?当然是不用的!所以这把锁也应该是动态开辟出来的,当多个线程同时管理一个资源的时候,那么由于多个智能指针指向的锁只有一把,所以想要对引用计数作操作就需要申请锁,这样我们就可以实现对引用计数操作的保护了。
代码语言:javascript复制shared_ptr(T* ptr = nullptr)
:_ptr(ptr)
,_pcount(new int(1))
, _pmtx(new mutex)
{}
shared_ptr(const shared_ptr& sp)
:_ptr(sp._ptr)
,_pcount(sp._pcount)
,_pmtx(sp._pmtx)
{
_pmtx->lock();//当多线程在进入拷贝构造函数的时候,下面代码必须是串行执行的!
(*_pcount) ;
_pmtx->unlock();
}
void release()//减减引用计数,判断是否需要释放资源。
{
bool flag = false;//flag有没有线程安全的问题呢?肯定没有,因为flag在线程栈里面,他并不是线程间的共享资源
//当多线程在进入release函数的时候,下面代码必须是串行执行的!
_pmtx->lock();
if (--(*_pcount) == 0)
{
flag = true;
delete _pcount;
delete _ptr;
}
_pmtx->unlock();
//release这里有问题,锁mutex没销毁!引用计数减到0的时候,new出来的_pmtx也是要销毁的。
if (flag) delete _pmtx;
}
3.
那shared_ptr是不是线程安全的呢?我们说他是线程安全的,但他的线程安全指的是他自己的引用计数 或- -的操作是安全的,当shared_ptr发生拷贝或赋值或析构时,shared_ptr本身是线程安全的,但shared_ptr管理的资源并不是线程安全的,就比如下面例子中对日期类进行管理,当多线程同时对共享的日期类资源作操作的时候,通过结果可以看出,日期类资源并不是线程安全的。
其实这个也很好理解,我们说shared_ptr是否是线程安全,本身说的就是shared_ptr指针本身,至于管理的资源是否是线程安全,shared_ptr没理由保护啊。
2.3.3 循环引用(weak_ptr,可以指向,但不参与资源的管理)
1.
shared_ptr其实已经很完美了,它本身既支持拷贝和赋值,又是线程安全的,但可惜太可惜,shared_ptr也有他自己的不足,就是循环引用问题。
当使用shared_ptr来管理链表节点的时候,链表结点发生链接时,会出现类型不匹配的问题,无法将对象赋值给原生指针,所以我们索性将_next和_prev也改为shared_ptr,只要改为了shared_ptr此时就会出大问题了。
2.
当互相指向的时候,分别管理不同结点的智能指针n1和n2在销毁时,引用计数会各自减为1,因为_next和_prev还各自管理着对方,而_next和_prev也都是智能指针。
此时就会出现死循环管理的问题,listnode1和2都无法被销毁,因为只有引用计数减为0的时候,结点才会被销毁。(类成员变量什么销毁呢?类对象销毁的时候他就会跟着销毁。)
3.
为了解决这样的问题,C 11引入了weak_ptr,weak_ptr解决这样的问题也很简单,直接不让weak_ptr参与资源的管理即可,什么叫不参与资源的管理呢?说白了就是让weak_ptr能够支持和shared_ptr的赋值,但在赋值的时候,引用计数是不会 的,也就是说,weak_ptr支持了结点之间的指向,但不支持对引用计数作操作。
实现weak_ptr也很简单,我们只要支持weak_ptr和shared_ptr之间的赋值运算符重载就可以,这样就可以完成结点之间的链接工作,但在结点指针_next或_prev 与 shared_ptr赋值的时候,shared_ptr所指向的引用计数并不会发生变化,这样就解决了循环引用问题。同时weak_ptr也要支持像指针一样的操作,解引用和成员选择运算符都要支持。
代码语言:javascript复制template <class T>
class weak_ptr
{
public:
weak_ptr()
:_ptr(nullptr)
{}
weak_ptr(const shared_ptr<T>& sp)
:_ptr(sp.getptr())
{}
weak_ptr<T>& operator=(const shared_ptr<T>& sp)
{
_ptr = sp.getptr();//getptr()仅仅是为了解决类外无法访问shared_ptr的private成员_ptr
return *this;
}
//像指针一样使用
T& operator*()
{
return *_ptr;
}
T* operator->()
{
return _ptr;
}
private:
T* _ptr;
};
struct ListNode
{
int val;
wyn::weak_ptr<ListNode> _prev;
wyn::weak_ptr<ListNode> _next;
~ListNode()
{
cout << "~ListNode" << endl;
}
};
2.3.4 定制删除器(其实就是可调用对象)
1.
定制删除器听起来很牛,但其实很简单,库里面默认提供了一个default_delete类,如果你想自己实现定制删除器可以自己实现可调用对象,传给shared_ptr指针。
2.
下面的定制删除器中,仿函数DeleteArray用于delete 的情况,仿函数CloseFile用于关闭指针,所以如果当前默认的释放资源方式不符合你的需求的话,你可以自己实现定制删除器,并将此删除器传给shared_ptr,shared_ptr内部在析构函数中会调用这个可调用对象进行资源的释放。
但我们自己实现的shared_ptr和库里面有点不同,我们无法在构造函数的时候就传递定制删除器,只能通过增加模板参数的方式来实现删除器的使用,主要我们自己实现的是简易版本的,意在将原理分析清楚,库里面的shared_ptr的构造函数可以直接支持传递删除器,实际底层又套了很多的类来解决的,因为他还要将这个删除器传递给析构函数,删除器肯定是在析构函数中使用的嘛,所以还有一个中间传递的过程。
我们为了简易化这个过程,直接增加了shared_ptr第二个模板参数,通过这个参数我们直接在类内创建删除器对象,然后在析构函数中通过这个可调用对象实现资源的释放。
我们直接用D创建出类成员变量:定制删除器对象_del,在析构函数中进行指向资源的释放
3.C 11和boost中智能指针的关系
下面的话题了解一下就行,没什么重要的。
C 98 中产生了第一个智能指针auto_ptr.
C boost给出了更实用的scoped_ptr和shared_ptr和weak_ptr.
C TR1,引入了shared_ptr等。不过注意的是TR1并不是标准版。
C 11,引入了unique_ptr和shared_ptr和weak_ptr。需要注意的是unique_ptr对应boost的scoped_ptr。并且这些智能指针的实现原理是参考boost中的实现的。
三、特殊类设计和C 类型转换
1.常见的四种特殊类
请设计一个类,不能被拷贝
一个类如果被拷贝,只会在两种情况下发生,一种是拷贝构造,一种是拷贝赋值。
在C 98中,采取的方式是将拷贝构造和拷贝赋值函数只声明不定义,并且将其封为private,这样就可以设计出不能被拷贝的类了。如果不封private的话,可以在类外进行函数的定义,在类外调用拷贝构造。如果只封private的话,我们也要防止类内进行拷贝,防止类内进行拷贝的方法就是只声明不定义,这样即使类内可以调用拷贝和赋值,但依旧无法拷贝出对象,因为函数没有定义只有声明函数名是不会进入符号表的,函数没有有效的地址,无法完成拷贝或赋值工作。
代码语言:javascript复制class CopyBan
{
// ...
private:
CopyBan(const CopyBan&);
CopyBan& operator=(const CopyBan&);
//...
};
在C 11中,扩大了关键字delete的用法,delete不仅可以释放空间资源,还可以在成员函数声明尾部加上delete,表示该成员函数被禁掉,编译器会删除这样的类成员函数,此时无论类内还是类外都无法调用到已经被删除的成员函数。
代码语言:javascript复制class CopyBan
{
// ...
CopyBan(const CopyBan&)=delete;
CopyBan& operator=(const CopyBan&)=delete;
//...
};
请设计一个类,只能在堆上创建对象
如果只想在堆上创建对象,首先需要做到的一点就是封构造函数,不允许在类外创建出对象,但类内必须得能创建出对象,如果你类内连对象都创建不出来,那这个类一个对象都没有了,这不是我们想看到的结果。然后就是实现一个静态方法,在该静态方法中完成堆对象的创建。
因为在类外有可能通过解引用堆对象指针的方式来进行拷贝构造出栈上的对象,所以为了防止这样情况的发生,我们一定要封掉拷贝构造,防止类外创建出栈上的对象。
拷贝赋值封不封都无所谓,因为如果实现上面的要求之后,这个类的所有对象都一定是在堆上创建的了,那已存在的对象之间的赋值就一定是堆上的对象之间的赋值,所以没必要禁拷贝赋值。
代码语言:javascript复制class HeapOnly
{
public:
static HeapOnly* CreateObj()
{
return new HeapOnly;
}
HeapOnly(const HeapOnly& hp) = delete;//禁拷贝构造
//拷贝赋值不用禁,因为赋值的前提是已经存在的对象,那已经存在的对象之间的赋值不都还是在堆上吗?
private:
//封为私有,不让你随便在任意内存位置创建对象,但没有说不让你创建对象
HeapOnly(){}
};
首先需要知道的一点是,栈和数据段上的对象在其作用域结束或程序终止时,会自动调用析构函数完成对象的资源清理工作,我们不需要手动释放。而堆上的对象则需要程序员手动delete释放空间资源,因为堆上的对象生命周期不受作用域的限制,只由动态分配内存和释放内存的过程控制。
所以除上面那样的方式之外,我们还可以封掉析构函数。因为封掉析构函数的话,栈和数据段上就无法创建出对象了,因为这些对象无法调用析构函数完成资源清理,所以就只能在堆上创建出对象 了,所以封掉析构函数也是一种只在堆上创建对象的做法。
代码语言:javascript复制class HeapOnly
{
public:
HeapOnly()
{
}
void Destroy()
{
this->~HeapOnly();
}
private:
~HeapOnly()
{
}
HeapOnly(const HeapOnly& hp) = delete;
};
请设计一个类,只能在栈上创建对象
将构造函数私有化,这样类外就无法使用new来创建堆区的对象了,如果以这样的方式来实现,那么我们必须提供一个静态的成员方法用于返回开辟在栈上的对象,此时就不能禁用拷贝构造函数了,因为静态方法StackOnly的返回值必须是值拷贝,同时就会带来一个问题,main里面实际可以利用拷贝构造来定义出静态区对象,那此时就不满足在栈上创建对象了,所以如果你要求没那么严格,那就别禁拷贝构造了,允许静态区和栈区都可以创建出对象。但如果你要求比较严格,必须只能在栈上创建对象,那就禁掉拷贝构造,在使用对象时,通过StackOnly方法返回的匿名对象进行使用,或者间接的用一个右值引用来使用这个对象。
除了将构造函数私有化之外,实际我们还可以显示的禁掉operator new函数,因为new对象的时候会去调用operator new这个函数,封掉之后,类外就无法在堆区上创建出对象了。如果这样的话,那其实就不用私有化构造函数和提供静态方法了,我们可以直接在类外调用构造函数创建出栈上的对象,然后我们再封掉拷贝构造函数,这样也可以防止在数据段上创建出对象。
(这里回顾一个知识点,new和delete在创建和销毁对象时,会调用opeartor new和operator delete这两个全局函数,而operator new实际封装了malloc,operator delete实际封装了_free_dbg,而_free_dbg是free的宏定义,所以free实际就是_free_dbg,那么operator实际也就是封装了free。所以你可以将operator new/delete理解为malloc和free,也就是空间资源的申请和释放。
所以new的实现原理就是先去调用operator new函数来完成空间资源的申请,然后在申请的空间上调用构造函数完成对象的构造。delete的实现原理是在申请的空间上先去调用析构函数完成对象中资源的清理工作(清理的资源一般都是动态开辟的空间资源),然后调用operator delete函数释放申请的空间)
代码语言:javascript复制方法1: 禁用void* operator new(size_t size)
class StackOnly
{
public:
StackOnly() {}
void* operator new(size_t size) = delete;
StackOnly(const StackOnly& st) = delete;
};
int main()
{
StackOnly st1;//栈
static StackOnly st2(st1);//数据段
StackOnly* st3 = new StackOnly;//堆
return 0;
}
方法2: 私有化构造函数,提供类静态方法
class StackOnly
{
public:
static StackOnly CreateObj()
{
return StackOnly();
}
StackOnly(const StackOnly& st) = delete;
void test()//测试外部使用对象的调用方法
{
cout << "test()" << endl;
}
private:
StackOnly() {}
};
int main()
{
//下面是两种使用栈上对象的方法
StackOnly::CreateObj().test();//栈
StackOnly&& st1 = StackOnly::CreateObj();//栈
st1.test();
static StackOnly st2(st1);//数据段
StackOnly* st3 = new StackOnly;//堆
return 0;
}
请设计一个类,不能被继承
在C 98中,可以私有化基类的构造函数,此时派生类无法调到基类的构造函数完成成员变量的初始化,则该基类便无法被继承。
在C 11中,通过final关键字来修饰类,表示该类为最终类,无法被继承。
2.单例模式(只有唯一的一个实例化对象)
1.
单例模式是设计模式的一种,实际上之前在学习STL的时候,我们就已经接触过了两个设计模式了,迭代器模式和配接器模式,一个是封装底层细节让上层达到统一使用的一种模式,一个是利用现有的容器通过条件设置等方式搞出的一种模式。设计模式是一种工程性的已有代码设计经验的总结,java很喜欢谈23种设计模式,C 到不怎么偏爱设计模式,只需要了解和使用常见的几种设计模式即可。
而单例模式也是一种使用非常广泛的设计模式,该模式可以保证程序中该类的实例化对象有且只有一份,并能够提供一个访问该唯一对象的方法。而单例模式的实现方式有饿汉和懒汉两种。
2.
饿汉的实现方式比较简单,即为类加载的时候,就创建好这唯一的一份对象,也就是说当二进制文件.exe加载到内存的时候,这个对象就会被创建好,我们只需要提供一个获取单例的静态方法GetInstance(),这个方法只返回那个唯一的静态对象的地址,通过这个指针来调用类成员方法。除此之外,要禁用拷贝构造,防止创建出第二个对象,这样就完成了饿汉的单例模式,怎么样,是不是很简单?
代码语言:javascript复制class Singleton
{
public:
static Singleton* GetInstance()
{
return &m_instance;
}
private:
// 构造函数私有
Singleton(){};
// C 98 防拷贝
Singleton(Singleton const&);
Singleton& operator=(Singleton const&);
// or
// C 11
Singleton(Singleton const&) = delete;
Singleton& operator=(Singleton const&) = delete;
static Singleton m_instance;
};
类型 类名::类静态成员(下面代码看起来可能有点绕)
Singleton Singleton::m_instance; // 在程序入口之前就完成单例对象的初始化
3.
懒汉的实现方式较复杂一点,它实际就是一种延迟加载的思想,即为当函数被调用的时候才会去创建出单例对象,比如下面的GetInstance_lazy,当此函数被调用的时候,才会建立函数栈帧,才会去执行new Singleton,此时单例对象才会被创建出来。
但GetInstance_lazy有线程安全的问题,当多个线程在竞争的执行GetInstance_lazy中的new Singleton时,可能出现实例化出多个对象的场景,例如当某个线程判断空指针成功之后,被切换出去了,然后另一个线程也来判断空指针成功了,此时它会申请好单例对象的内存空间,而被切换出去的线程重新被调度上来的时候,他恢复自己的上下文,会继续向后运行,所以这个线程也会申请一次单例对象,这样就会出现2个以上的单例对象,不符合单例模式的需求,所以为了保证线程安全,则需要进行加锁!
但是加锁这里也会涉及到一个问题,比如我们用最原始的锁进行lock和unlock,当然这样的方法也可行,比如之前在POSIX那里我们不想用RAII的加锁和解锁时,我们一直用的都是原生锁的lock和unlock,但在这里还是有可能出一点问题的,比如new抛异常怎么办?那就会导致死锁的发生,线程不会释放锁,所以为了避免这样问题的发生,我们选择采用更为安全的加锁方式,即为RAII风格的加锁,在构造函数中进行加锁,在析构函数中完成解锁,但在构造函数那里还有一个细节,比如锁是不允许被拷贝和赋值的,所以我们必须用引用接收互斥锁,class LockGuard只负责加锁和解锁, 传递锁的工作应该是上层的事情,所以class LockGuard的类成员变量是一个引用的互斥锁,当然引用的成员变量是必须在初始化列表初始化的。(复习一个知识点,当类中成员变量出现const修饰,引用的成员变量,或自定义对象没有合适的默认构造函数时,必须在初始化列表的位置显示初始化,不可以在构造函数内部对成员变量赋初值)
除此之外还需要说明的一个问题是关于释放单例对象资源的话题,单例对象是在堆上开辟出来的,所以必须由程序员手动释放该空间资源,那我们能不能在Singleton的内部实现一个析构函数,然后delete掉单例对象呢?当然是不能的!因为单例对象是调不到析构函数的,因为单例对象的生命周期只和申请与释放的操作挂钩,和栈区,数据段上的对象是不一样的,他们在生命结束的时候会自动调用析构函数,但堆上的空间并不会,必须由程序员手动释放空间资源。所以下面代码中有两种方式,第一种就是自己实现一个DelInstance接口,当我们不想在使用单例对象时,可以手动调用这个接口来释放单例对象的空间资源。另一种就是实现一个内部的垃圾回收类GC,用这个类来定义出静态对象_gc,这个_gc是单例类的成员变量,所以当程序结束时,静态对象_gc的生命结束,会自动调用自己的析构函数,而GC本身就是内部类,可以直接访问到静态指针_SinglePtr,所以在_gc调用自己析构函数的时候,单例对象的空间资源也会随着_gc的销毁而释放掉了,这样属于自动的方式,我们什么都不用管。
最后需要再说一点的是关于double check,用RAII风格的加锁确实可以保证GetInstance_lazy的线程安全,但是他的效率却并不怎么高,因为每一个线程来调用的时候,都需要先加锁,进入临界区之后才能知道_SinglePtr是否为空指针,然后才能返回已经分配好内存的指针_SinglePtr。那能不能让判断的效率高一点呢?当然是可以的,我们可以在加锁之前在判断一次是否为空指针,那么此时判断逻辑就不会是串行判断了,而是并行 并发式的判断,那效率自然就会高起来。
另一种实现懒汉的方式就是直接返回静态对象,而不是以返回指针的方式来实现,当然这样的方式也是可以实现延迟加载的,因为只有在函数GetInstance被调用的时候,才会开辟好这个静态对象,这样的方式开辟出来的对象就是存在数据段上面的,上面那样的方式是存在堆区的。
4.
在介绍完饿汉模式和两种懒汉模式的实现方式后,我们来谈谈他们的缺点和优点。
饿汉模式→数据段
a.单例对象初始化数据过多时,会导致程序启动速度变慢,执行流进入main主函数的速度会很慢。
b.不存在线程安全的问题,因为类加载的时候就已经开辟初始化好单例对象了。
c.多个单例对象之间初始化有依赖关系的时候,饿汉模式无法控制,这完全取决于操作系统加载文件到内存的工作。
懒汉模式→堆
a.单例对象是在需要的时候才会被创建,所以不会影响程序的启动速度,执行流在进入main函数之后,调用GetInstance时,单例对象才会被创建。
b.存在线程安全的问题,所以需要加锁和double check的方式来保证安全和高效。
c.可以自己控制多个单例对象的初始化的顺序,通过自己手动调用GetInstance来控制。
懒汉模式→数据段
a.C 11之前不能保证局部静态对象初始化是线程安全的,C 11之后可以保证静态局部对象初始化是线程安全的。所以这样的方式对于支持C 11不是很好的编译器来说,可能不是线程安全的。在使用这样的方式时,要注意使用环境,如果C 11支持很好,这样的方式是没有问题的。
5.
虽然delete掉拷贝构造之后,理论上就已经够用了,但是为了更好保证单例模式的正确性,通常会同时delete拷贝构造和拷贝赋值这两个函数。
3.C 的四种强转类型转换
1.
C 对于C语言的显示类型转换和隐式类型转换深恶痛绝,因为隐式类型转换一不小心就会带来许多提前没有预料到的错误,例如以前的size_t和int之间类型的提升,另外C语言的显示类型转换针对的场景太过于笼统,都是以一种相同形式书写,难以跟踪错误的转换。
所以C 直接加入了四种强转类型转换,期望程序员们能够用规范的显示的类型转换,不要用C语言之前的隐式类型转换以及笼统的显示类型转换了。但这只是一种期望,C 是要兼容C语言的,所以以前的类型转换方式不能被废弃,依旧作为历史包袱遗留下来了。
static_cast
static_cast用于非多态类型的转换,编译器任何的隐式类型转换都可以用static_cast来进行转换,但static_cast不能用于两个不相关的类型进行转换。
代码语言:javascript复制int main()
{
// 隐式类型转换
int i = 1;
//C 规范转换 -- static_cast适用于相似类型的转换(这些类型的表示意义差不多)
double d = static_cast<double>(i);
printf("%d, %.2fn", i, d);
}
reinterpret_cast
reinterpret是重新诠释的意思,可以用于指针类型之间的转换,也可以将指针类型转换为整数类型,比如将void*类型指针转换为一个实际类型的指针,或者将一个派生类指针转换为基类指针。
代码语言:javascript复制int main()
{
int i = 0;
// 显示的强制类型转换
int* p1 = &i;
//C 规范转换 -- reinterpret_cast适用于不相关的类型之间的转换。
//这里不能用static_cast,因为int和int*不算是相似类型。
int address = reinterpret_cast<int>(p1);
int* p2 = reinterpret_cast<int*>(i);
printf("%x, %dn", p1, address);
}
const_cast
const_cast最常用的用途就是删除const属性,方便对变量进行赋值。
所以const修饰的变量并不是存放在.rodata段的,他是可以被修改的,通过const_cast就可以删除变量的const属性,从而对变量进行修改,这样的变量叫做常变量。(一般来说,有人喜欢直接将.rodata段叫做代码段,当然这也可以,因为.rodata段和代码段的位置很近,两者都是只读属性,这么叫也没啥太大问题)
下面是经典的一个问题,编译器对const修饰的变量的值会做优化,在取这个值的时候不会去内存里面拿这个值,而是直接去寄存器里面取,而在vs下面,值都不是存放在寄存器里面,而是直接作为一个对应的符号压到函数栈帧里面。所以在打印的时候,为了优化,不会去内存里面取被修改后的a的值,而是直接拿函数栈帧里面的值或者是拿寄存器的值,所以打印出来的结果就是2和3,而监视窗口看到的值是内存里面的,所以就都是3。如果不想让编译器做出这样的优化,我们可以考虑适用volatile关键字,保持内存可见性,这样打印出来的a的值就不会被优化了。
代码语言:javascript复制int main()
{
//C 规范转换 -- const_cast 去掉const属性,单独分出来这个类型转换,警示你C 的const很危险,用的时候谨慎一些
//const_cast最常用的用途就是删除变量的const属性,方便赋值
volatile const int a = 2;//const变量并不是存在于常量区,而是存在于栈上的,属于常变量
int* p2 = const_cast<int*>(&a);
*p2 = 3;
cout << a << endl;
cout << *p2 << endl;
}
下面的代码是另一种修改值的方式,即为通过引用的方式来修改常变量的值,实验结果和上面相同,加上volatile关键字之后,就可以保持内存可见性了。
代码语言:javascript复制int main()
{
volatile const int a = 10;
int& b = const_cast<int&>(a);
b = 100;
cout << a << endl;
cout << b << endl;
}
dynamic_cast
dynamic_cast用于将一个父类对象的指针或引用转换为子类对象的指针或引用类型,我们称为动态转换。
至于子类对象的指针或引用转为父类对象的指针或引用,这个过程是天然的,不需要强制转换,只有反过来的时候才需要强制类型转换。
例如下面代码中,可以将基类类型的ptr转为派生类类型的dptr,如果ptr指向的是父类则会存在越界访问的风险,如果ptr指向的是子类则没什么问题,只不过把指针的访问范围挪动几个字节即可。
当dynamic_cast转换类型失败的时候,会返回一个空指针,如果转换成功,则返回指向派生类对象的有效指针。
代码语言:javascript复制class Base
{
public:
virtual void f() {}
int _base = 0;
};
class Derive : public Base
{
public:
int _derive = 0;
};
void Func(Base* ptr)
{
// C 规范的dynamic_cast是安全的,如果ptr指向的是父类对象,那么下面就会转换失败,返回0。指向子类则转换成功
Derive* dptr = dynamic_cast<Derive*>(ptr);//父类转成子类,存在越界访问的风险
cout << dptr << endl;
if (dptr)
{
dptr->_base ;
dptr->_derive ;//这里就会发生越界访问,因为子类私有成员是不属于父类访问的,父类只能访问到子类中父类的那一部分
cout << dptr->_base << endl;
cout << dptr->_derive << endl;
}
}
int main()
{
Base b1;
Derive d1;
Func(&b1);
Func(&d1);
return 0;
}
下面是程序的运行结果。
下面的图解释了为什么当ptr指向父类的时候,会出现越界访问的问题,
dynamic_cast只能用于多态类型,如果不是多态类型,则不能使用dynamic_cast
下面回顾了一下静态绑定和动态绑定的知识点,其实静态绑定和动态绑定如果简单理解的话,你可以理解为一个进符号表,一个不进符号表,进符号表编译器则可以在编译期间确定调用的具体函数,不进符号表则需要在程序运行期间,动态查找具体需要调用的函数是什么。
补充话题1:
RTTI:Run-time Type identification的简称,即:运行时类型识别。
C 通过typeid运算符,dynamic_cast运算符,等支持RTTI。
而decltype不是真正的运行时类型信息(RTTI)。它只是编译时的类型推断。decltype 和 RTTI 虽然目的不同,但可以彼此补充,decltype 可以用于编译时类型检查和推断,避免使用RTTI的运行时开销,RTTI可以在运行时做更加动态的类型检查和转化,这是decltype无法实现的。
补充话题2:
常见面试题:C 中的四种类型转换分别是什么?谈谈四种类型转换的应用场景是什么?