第十八章 用于大型程序的工具
18.1 异常处理
18.1.1 抛出异常
当执行一个throw时,跟在throw后面的语句将不再被执行。相反,程序的控制权从throw转移到与之匹配的catch模块。该catch可能是同一个函数中的局部catch,也可能位于直接或间接调用发生异常的函数的另一个函数中。控制权从一处转移到另一处,这有两个重要的含义:
- 沿着调用链的函数可能会提早退出
- 一旦程序块开始执行异常处理代码,则沿着调用链创建的对象将被销毁
栈展开
当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的catch子句。当throw出现在一个try语句块内时,检查与该try块关联的catch子句。如果找到了匹配的catch就使用这个catch处理异常。如果这一步没有找到匹配的catch且该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch子句。如果还是找不到匹配的catch,则退出当前的函数,在调用当前函数的外层函数中继续寻找。
如果抛出异常的函数的调用语句位于一个try语句块内,则检查与该try块关联的catch子句。如果找到了匹配的catch,就使用该catch处理异常。否则,如果该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch子句。如果仍然没有找到匹配的catch,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,依次类推。
上述过程被称为栈展开。
当找不到匹配的catch时,程序将调用标准库函数terminate,这个函数负责终止程序的执行过程。
栈展开过程中对象被自动销毁
如果异常发生在构造函数中,则当前的对象可能只构造了一部分。有的成员已经初始化了,而另外一些成员在异常发生前也许还没有初始化。即使某个对象只构造了一部分,我们也要确保已构造的成员能够被正确的销毁。
析构函数与异常
如果在析构时抛出了异常,那么很有可能部分资源没有被释放,基于此原因,我们规定,析构函数不能抛出自己不能处理的异常。
异常对象
异常对象是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型。而且如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。
当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。如果一条throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
18.1.2 捕获异常
catch子句的异常声明看起来像是只包含一个形参的函数形参列表。跟形参一样,如果catch无须访问抛出的表达式,则可以忽略捕获形参的名字。
声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。
查找匹配的处理代码
在搜寻catch语句的过程中,我们最终找到的catch未必是异常的最佳匹配。相反,挑选出来的应该是第一个与异常匹配的catch语句。因此,越是专门的catch越应该置于整个catch列表的前端。
与实参和形参的匹配规则相比,异常和catch异常声明的匹配规则受到更多限制。此时,绝大多数类型转换都不被允许,除了一些细小的差别之外,要求异常的类型和catch声明的类型是精确匹配的:
- 允许从非常量向常量的类型转换,也就是说,一条非常量对象的throw语句可以匹配一个接受常量引用的catch语句
- 允许从派生类向基类的类型转换
- 数组被转换成指向数组类型的指针,函数被转换成指向该函数类型的指针。
除此之外,包括标准算术类型转换和类类型转换在内,其他所有转换规则都不能在匹配catch的过程中使用。
重新抛出
有时,一个单独的catch语句不能完整地处理某个异常。在执行了某些矫正操作之后,当前的catch可能会决定由调用链更上一层的函数接着处理异常。一条catch语句通过重新抛出的操作将异常操作传递给另外一个catch语句。这里得重新抛出仍然是一条throw语句,只不过不包含任何表达式:
throw;
捕获所有异常的处理代码
为了一次性捕获所有异常,我们使用省略号作为异常声明,这样的处理代码称为捕获所有异常的处理代码,形如catch(…)
void mainp(){
try{
//这里的操作将引发并抛出一个异常
}catch(...){
//处理异常的某些操作
throw;
}
}
注意:catch(…)可以单独出现,也可以和其他catch一起出现,如果一起出现,则catch(…)应该放在最后面
18.1.3 函数try语句块与构造函数
当在构造函数的初始值列表中出现异常时,构造函数中的catch语句还未生效,所以无法进行异常的捕获。
此时我们应该使用函数try语句块的形式。函数try语句块使得一组catch语句既能处理构造函数体,也能处理构造函数的初始化过程。如下:
template<typename T>
Blob<T>::Blob(std::initializer_list<T> il) try:
data(std::make_shared<std::vector<T>>(il)){
//空函数体
}catch(const std::bad_alloc &e){
handle_out_of_memory(e);
}
注意:try出现在了构造函数初始值列表的冒号以及表示构造函数体的花括号之前。与这个try关联的catch既能处理构造函数体抛出的异常,也能处理成员初始化列表抛出的异常。
18.1.4 noexcept异常说明
如果能够事先知道函数不会抛出异常有两个好处:1.有助于简化调用该函数的代码;2.编译器可以执行一些特殊的优化。
c++11新标准,通过提供noexcept说明符指定某个函数不会抛出异常。其关键字noexcpt紧跟在函数参数列表后面,用于标识该函数不会抛出异常:
void recoup(int) noexcept;//不会抛出异常
void alloc(int); //可能抛出异常
noexcept说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。它应该出现在函数的尾置返回类型之前。
也可以在函数指针的声明和定义中指定noexcept。在typedef或类型别名中则不能出现noexcept。在成员函数中,noexcept说明符需要跟在const及引用限定符之后,而在final,override或虚函数的=0之前
违反异常说明
就算一个函数使用noexcept说明符,它也可以使用throw,或者调用可能抛出异常的其他函数。
因此,出现这样一种情况:尽管函数声明了它不会抛出异常,但实际上还是抛出了,一旦一个noexcept函数抛出异常,程序就会调用terminate以确保遵守不再运行时抛出异常的承诺。
上述过程对是否执行栈展开未作约定,因此,noexcept可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理异常。
异常说明符实参
noexcept说明符接受一个可选的实参,该实参必须能转换为bool类型:如果实参时true,则函数不会抛出异常;如果实参时false,则函数可能抛出异常
void recoup(int) noexcept(true);//不会抛出异常
void alloc(int) noexcept(false);//可能抛出异常
noexcept运算符
noexcept运算符是一个一元运算符,它返回一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和sizeof类似,noexcept也不会求其运算对象的值
void f() noexcept(noexcept(g()));//f和g的异常说明一样
异常说明与指针、虚函数和拷贝控制
函数指针及该指针所指的函数必须具有一致的异常说明。也就是说,如果我们为某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数。相反,如果我们显式或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数,即使是承诺了不抛出异常的函数也可以:
void (*pf1)(int) noexcept = recoup;
void (*pf2)(int) = recoup;
pf1 = alloc;// 错我:alloc可能抛出异常,但是pf1已经说明他不会抛出异常
pf2 = alloc;//正确,pf2和alloc都可能抛出异常
如果一个虚函数承诺了不会抛出异常,则后派生出来的虚函数也必须做同样的承诺;与之相反,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常
当编译器合成拷贝控制成员时,同时也会生成一个异常说明。如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员时noexcept的。如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是noexcept(false)。而且,如果我们定义了一个析构函数但是没有为他提供异常说明,则编译器将合成一个。合成的异常说明将与假设编译器为类合成析构函数时所得到的的异常说明一致。
18.1.5 异常类层次
标准库异常类,如下图
18.2 命名空间
18.2.1 命名空间定义
关键字namespace和命名空间的名字。如下:
namespace cplusplus_primer{
class Sales_data{};
Sales_data operator+(const Sales_data&,const Sales_data&);
class Query{};
class Query_base{};
}
和其他名字一样,命名空间的名字也必须在定义它的作用域内保持唯一。命名空间既可以定义全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。
注意:命名空间作用域后面无需分号
每个命名空间都是一个作用域
定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。位于该命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间。
命名空间可以是不连续的
如下:
namespace nsp{
}
可能是定义了一个名为nsp的新命名空间,也可能是为已经存在的命名空间添加一些新成员。如果之前没有名为nsp的命名空间定义,则上述代码创建一个新的命名空间;否则,上述代码打开已经存在的命名空间定义并为其添加一些新成员的声明。
这样可以如下写法:
-
命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中
-
命名空间成员的定义部分则置于另外的源文件中
模板特例化
模板特例化必须定义在原始模板所属的命名空间中。和其他命名空间名字类似,只要我们再命名空间中声明了特例化,就能在命名空间外部定义它:
namespace std{
template<> struct hash<Sales_data>;
}
template<> struct std::hash<Sales_data>{
size_t operator() (const Sales_data& s) const{
return hash<string>()(s.bookNo)^
hash<unsigned>()(s.units_sold)^
hash<double>()(s.revenue);
}
}
全局命名空间
全局作用域中定义的名字也就是定义在全局命名空间中。全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到了全局命名空间中
::member_name
表示一个全局命名空间中的一个成员
嵌套的命名空间
定义在其他命名空间中的命名空间,称为嵌套命名空间
嵌套的命名空间也是一个嵌套的作用域。它嵌套在外层命名空间的作用域中。嵌套的命名空间的名字遵循的规则与外层类似:内层命名空间声明的名字将隐藏外层命名空间声明的同名名字。在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间中的代码要想访问他必须在名字前面添加限定符。
内联命名空间
和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。也就是说,我们无须在内联命名空间的名字前面添加表示该命名空间的前缀,通过外层命名空间的名字就可以直接访问它:
inline namespace FifthEd{//内联
}
namespace FifthEd{//隐式内联
}
关键字inline必须出现在命名空间第一次定义的地方,后续再打开命名空间可以写,也可以不写inline
未命名的命名空间
未命名空间是指关键字namespace后紧跟花括号括起来的一系列声明语句。未命名的命名空间中定义的变量拥有静态声明周期:他们在第一次使用前创建,并且直到程序结束才销毁。
一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间相互无关。在这两个未命名的命名空间中定义相同的名字,并且这些定义表示的是不同的实体。如果一个头文件定义了未命名的空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同的实体。
未命名空间的命名空间中定义的名字的作用域于该命名空间所在的作用域相同。如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字有所区别:
int i;
namespace {
int i;
}
//二义性:i的定义即出现在全局作用域中,又出现在未命名的命名空间中
i = 10;
未命名的命名空间取代文件中的静态声明
在标准c++引入命名空间的概念之前,程序需要将名字声明为static以使得其对于整个文件有效。在文件中进行静态声明的做法是从c语言继承而来的。在c语言中,声明static的全局实体在其所在的文件外不可见
在文件中静态声明的做法已经被c++标准取消了,现在的做法是使用未命名的命名空间
18.2.2 使用命名空间
命名空间的别名
命名空间别名使得我们可以为命名空间的名字设定一个短得多的同义词。例如:
namespace cplusplus_primer{/**/}
namesapce primer = cplusplus_primer;
别名以关键字namespace开始,后面是别名使用的名字、=符号、命名空间原来的名字以及一个分号。
注意:一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价
using声明:扼要概述
using声明引入的名字遵循与过去一样的作用域:它的有效范围从using声明的地方开始,一直到using声明所在的作用域结束为止。
在此过程中,外层作用域的同名实体将被隐藏。
一条using声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域。
using指示
using指示以关键字using开始,后面是关键字namespace以及命名空间的名字。
如果这里所用的名字不是一个已经定义好的命名空间的名字,则程序将发生错误。using 指示可以出现在全局作用域、局部作用域、和命名空间作用域中。但是不能出现在类的作用中。
using指示使得某个特定的命名空间中所有的名字都可见。
using指示与作用域
using指示:具有将命名空间成员提升到包含命名空间本身和using指示的最近作用域的能力
因为,命名空间中会包含一些不能出现在局部作用域中的定义,因此,using指示一般被看做是出现在最近的外层作用域中。
例如:
namespace A{
int i,j;
}
void f(){
using namespace A;//把A中的名字注入全局作用域中
cout << i * j << endl;
}
namespace blip{
int i = 16,j = 15,k = 23;
}
int j = 0;
void manip(){
using namespace blip;
++i;//将blip::i设为17
++j;//二义性错误:是全局的j还是blip::j?
++::j;//正确
++blip::j;
int k = 97;
++k;//将当前局部的k变为98
}
头文件与using声明或指示
头文件如果在其顶层作用域中含有using声明或using指示,则会将名字注入到所有包含了该头文件的文件中。
通常情况下,头文件应该只负责定义接口部分的名字,而不定义实现部分的名字。因此,头文件最多只能在他的函数或命名空间内使用using指示或using声明
18.2.3 类、命名空间与作用域
namespace A{
int i;
namespace B{
int i;//隐藏A::i
int j;
int f1(){
int j;//隐藏A::B::j
return i;//返回B::i
}
}
int f2(){
return j;//错误j没有定义
}
int j = i;//使用A::i进行初始化
}
namespace A{
int i;
int k;
class C1{
public:
C1():i(0),j(0){}//初始化C1::i C1::j
int f1() {return k;}//返回A::k
int f2() {return h;}//h未定义
int f3();
private:
int i;//隐藏A::i
int j;
};
int h = i;
}
int A::C1::f3(){return h;}//返回A::h
实参相关的查找与类类型形参
std::string s;
std::cin >> s;
上面的调用等价于:
operator>>(std::cin,s);
对于命名空间中名字的隐藏规则来说一个重要的例外,它使得我们可以直接访问
输出运算符。这个例外就是:当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类型所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效
上面例子中,编译器发现operator>>的调用,首先在当前作用域中寻找合适的函数
接着查找输出语句的外层作用域。随后,因为>>表达式的形参是类类型,所以编译器还会查找cin和s的类所属的命名空间。也就是说,会查找,std命名空间
友元声明与实参相关的查找
namespace A{
class C{
//两个友元,在友元声明之外没有其他的声明
//这些函数隐式地成为了命名空间A的成员
friend void f2(); //除非另有声明,否则不会被找到
friend void f(const C&);//根据实参相关的查找规则可以被找到
};
}
int main(){
A::C cobj;
f(cobje);//正确:通过A::C中的友元声明找到A::f
f2();//错误:A::f2没有被声明
}
18.2.4 重载与命名空间
namespace NS{
class Quote{
};
void display(const Quote&){
}
}
class Bulk_item:public NS::Quote{};
int main(){
Bulk_item book1;
display(book1);
return 0;
}
我们传递给display的实参属于类类型Bulk_item,因此该调用语句的候选函数不仅应该在调用语句所在的作用域中查找,而且也应该在Bulk_item及其基类Quote所属的命名空间中查找。命名空间NS中声明的函数display(const Quote&)也将被添加到候选函数集中。
重载与using声明
using NS::print(int);//错误:不能指定形参列表
using NS::print;//正确:using声明只声明一个名字
一个using声明囊括了重载函数的所有版本以确保不违反命名空间的接口。
一个using声明引入的函数将重载该声明语句所属作用域已有的
其他同名函数。如果using声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。如果using声明所在的作用域中已经有一个函数与新引入的函数同名且形参列表相同,则该using声明将引发错误。除此之外,using声明将引入的名字添加到额外的重载实例,并最终扩充候选函数集的规模
重载与using指示
namespace libs_R_us{
extern void print(int);
extern void print(double);
}
void print(const std::string &);
using namespace libs_R_us;
void fooBar(int ival){
print("value:");//调用全局作用域的print(const string &)
print(ival);//调用libs_R_us::print(int)
}
与using声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用于的版本即可
跨越多个using指示的重载
namespace AW{
int print(int);
}
namespace Primer{
double print(double);
}
using namespace AW;
using namespace Primer;
long double print(long double);
int main(){
print(1);//调用AW::print(int)
print(3.1);//调用Primer::print(double);
return 0;
}
在全局作用域中,函数print的重载集合包括print(int); print(double)和print(long double);尽管他们属于不同的作用域中,但他们都属于main函数中print调用的候选函数集
18.3 多重继承和虚继承
多重继承:是指从多个直接基类中产生派生类的能力。
18.3.1 多重继承
在派生类的派生列表中可以包含多个基类:
class Bear:public ZooAnimal{
class Panda:public Bear,public Endangered{/*...*/};
对于派生类能够继承的基类个数,c++没有特殊规定:但是在某个给定的派生列表中,同一个基类只能出现一次
多重继承的派生类从每个基类中继承状态
在多重继承中,派生类对象包含有每个基类的子对象。
派生类构造函数初始化所有基类
//显式初始化所有的基类
Panda::Panda(std::string name,bool onExhibit):
Bear(name,onExhibit,"Panda"),
Endangered(Endangered:::critical){
}
//隐式地使用Bear的默认构造函数初始化Bear子对象
Panda::Panda():
Endangered(Endangered::critical){}
对于上面的例子初始化顺序如下:
- ZooAnimal是整个继承体系的最终基类,Bear是Panda的直接基类ZooAnimal是Bear的直接基类,所以首先初始化ZooAnimal
- 接下来初始化Panda的第一个直接基类Bear
- 然后初始化Panda的第二个直接基类Endangered
- 最后初始化Panda
其中基类的构造顺序与派生类列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。
继承的构造函数与多重继承
在c++11新标准中,允许派生类从他的一个或几个基类中继承构造函数。但是如果从多个基类中继承了相同的构造函数(即形参列表完全相同),则程序将产生错误:
struct Base1{
Base1() = default;
Base1(const std::string &);
Base1(std::shared_ptr<int>);
};
struct Base2{
Base2() = default;
Base2(const std::string &);
Base2(int);
};
struct D1:public Base1,public Base2{
using Base1::Base1;//从Base1继承构造函数
using Base2::Base2;//从Base2继承构造函数
};
如果一个类从他的多个基类中继承了相同的构造函数,则这个类必须为这个构造函数定义它自己的版本如下;
struct D2:public Base1,public Base2{
using Base1::Base1;
using Base2::Base2;
D2(const string &s):Base1(s),Base2(s){}
D2() = default;
};
析构函数与多重继承
析构函数只负责清除派生本身分配的资源,派生类的成员及基类都是自动销毁的
析构函数的调用顺序正好与构造函数相反
多重继承的派生类的拷贝与移动操作
与只有一个基类的继承一样,多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动或赋值操作。
只有当派生类使用了合成版本的拷贝、赋值或移动成员时,才会自动对其基类部分执行这些操作。在合成版本的拷贝控制成员中,每个基类分别使用自己的对应的成员隐式地完成构造、赋值或销毁等工作
18.3.2 类型转换与多个基类
此种情况与只有一个基类情况一样,如下:
void print(const Bear&);
void highlight(const Endangered &);
ostream& operator<<(ostream &,const ZooAnimal&);
Panda ying_yang("ying_yang");
print(ying_yang);//把一个Panda对象传递给一个Bear引用
highlight(ying_yang);//把一个Panda对象传递个一个Endangered的引用
cout << ying_yang << endl; //把一个Panda对象传递给一个ZooAnimal的引用
void print(const Bear&);
void print(const Endangered&);
Panda ying_yang("ying_yang"):
//二义性错误,因为将派生类转换到任意基类都一样好
print(ying_yang);
18.3.3 多重继承下的类作用域
在多重继承中,名字查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则该名字的使用将产生二义性。
18.3.4 虚继承
尽管在派生列表中,同一个基类只能出现一次,但实际上派生类可以多次继承同一个类。派生类可以通过它的两个直接基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另外一个基类再一次间接继承该类
在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程重出现多次,则派生类中将包含该类的多个子对象
有时我们并不希望同一个类中有多个子对象,此时可以使用虚继承。
虚继承的目的是令某个基类做出声明,承诺共享它的基类。其中,共享的基类子对象称为虚基类。
在这种情况下,不论虚基类在继承体系中出现多少次,在派生类中都只包含唯一一个共享的虚基类子对象
使用虚基类
class Raccoon:public virtual ZooAnimal{};
class Bear:virtual public ZooAnimal{};
class Panda:public Bear,public Raccoon,public Endangered{
};
virtual 说明符表明了一种愿望,即在后续的派生类当中共享虚基类的同一份实例
支持向基类的常规类型转换
不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作
18.3.5 构造函数与虚继承
在虚派生中,虚基类是由最底层的派生类初始化的。
下面是Panda的构造函数的
Panda::Panda(std::string name,bool onExhibit):
ZooAnimal(name,onExhibit,"Panda"),
Bear(name,onExhibit),
Raccoon(name,onExhibit),
Endangered(Endangered::critical),
sleeping_flag(false){}
虚基类ZooAnimal是由Panda进行直接构造的,而不是由他们的直接基类进行构造
虚继承的对象的构造方式
含有虚基类的对象的构造顺序与一般的顺序稍有区别:首先使用提供给最底层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化
例如:当我们创建Panda对象时:
-
首先使用Panda构造函数初始值列表中提供的初始值构造基类ZooAnimal部分
-
接下来构造Bera部分
-
然后构造Raccoon部分
-
然后构造第三个直接基类Endangered
-
最后构造Panda部分
如果Panda没有显式地初始化ZooAnimal基类,则ZooAnimal的默认构造函数将被调用。如果ZooAnimal没有默认构造函数,则代码发生错误
注意:虚基类总是先于非虚基类构造,与他们在继承体系中的次序和位置无关
析构函数与析构函数次序
一个类可以有多个虚基类。此时,这些虚的子对象按照他们在派生列表中出现的顺序从左向右依次构造。
合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。
同样,对象的销毁与构造顺序正好相反。
本章完