栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch子句为止:或者也可能一直没找到匹配的catch,则退出主函数后查找过程终止。
当执行完catch子句后,找到与try块关联的最后一个catch子句之后的点,并从这里继续执行。
一个异常如果没有被捕获,则它将终止当前的程序
块退出后它的局部对象也将随之销毁,这条规则对于栈展开过程同样适用。如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。
在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。
编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型。必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。
异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个catch子句都能访问该空间。当异常处理完毕后,异常对象被销毁。
当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。
如果catch的参数类型是非引用类型,则该参数是异常对象的一个副本;如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名,此时改变参数也就是改变异常对象。
如果catch的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch的参数是非引用类型,则异常对象将被切掉一部分。
异常声明的静态类型将决定catch语句所能执行的操作。如果catch的参数是基类类型,则catch无法使用派生类特有的任何成员。
通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型。
异常的类型和catch声明的类型是精确匹配的(throw中的实参类型转换到catch中的形参类型):
如果在多个catch语句的类型之间存在着继承关系,则我们应该把继承链最低端的类放在前面,而将继承链最顶端的类放在后面。
一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递。
throw;
空的throw语句只能出现在catch语句或catch语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate。
很多时候,catch语句会改变其参数的内容,只有当catch异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播。
catch(...)
为捕获所有异常的处理代码。catch(...)
通常与重新抛出语句一起使用。
如果catch(...)
与其他几个catch语句一起出现,则catch(...)
必须在最后的位置。出现在捕获所有异常语句后面的catch语句将永远不会被匹配。
函数try语句块:
template <typename T> Blob<T>::Blob(std::initalizer_list<T> il) try : data(std::make_shared<std::vector<T>>(il)) { /* 空函数体 */ } catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
处理构造函数初始值异常的唯一方法是将构造函数写成函数try语句块。如果在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。
对于一个函数来说,noexcept
说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。该说明应该在函数的尾置返回类型之前。我们也可以在函数指针的声明和定义中指定noexcept
。在typedef
或类型别名中则不能出现noexcept
。在成员函数中,noexcept
说明符需要跟在const
及引用限定符之后,而在final
、override
或虚函数的=0
之前。
一旦一个noexcept
函数抛出了异常,程序就会调用terminate
以确保遵守不在运行时抛出异常的承诺。因此,noexcept
可以用在两种情况下,一是我们确认函数不会抛出异常;二是我们根本不知道该如何处理异常。
通常情况下,编译器不能也不必在编译时验证异常说明。
等价的异常说明:
void recoup(int) noexcept; // recoup不会抛出异常 void recoup(int) throw(); // 等价的声明
noexcept
说明符接受一个可选的实参,该实参必须能转换为bool类型:如果实参是true,则函数不会抛出异常;如果实参是false,则函数可能抛出异常。
void recoup(int) noexcept(true); // recoup不会抛出异常 void alloc(int) noexcept(false); // alloc可能抛出异常
noexcept运算符是一个一元运算符,它的返回值是一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和sizeof
类似,noexcept
也不会求其运算对象的值。
noexcept(e);
当e调用的所有函数都做了不抛出说明且e本身不含有throw语句时,上述表达式为true;否则noexcept(e)
返回false。
noexcept
有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为noexcept
异常说明的bool实参出现时,它是一个运算符。
void f() noexcept(noexcept(g())); // f和g的异常说明一致
函数指针及该指针所指的函数必须具有一致的异常说明。也就是说,如果我们为某个指针做了不抛出异常的说明,则该指针将只能指向不抛出异常的函数。相反,如果我们显示或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数,即使是承诺了不抛出异常的函数也可以。
// recoup和pf1都承诺不会抛出异常 void (*pf1)(int) noexcept = recoup; // 正确:recoup不会抛出异常,pf2可能抛出异常,二者之间互不干扰 void (*pf2)(int) = recoup; pf1 = alloc; // 错误:alloc可能抛出异常,但是pf1已经说明了它不会抛出异常 pf2 = alloc; // 正确:pf2和alloc都可能抛出异常
如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常。
当编译器合成拷贝控制成员时,同时也生成一个异常说明。如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员是noexcept
的。如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是noexcept(false)
。而且,如果我们定义了一个析构函数但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明将与假设由编译器为类合成析构函数时所得的异常说明一致。
类exception
、bad_cast
和bad_alloc
定义了默认构造函数。类runtime_error
和logic_error
没有默认构造函数,但是有一个可以接受C风格字符串或者标准库string类型实参的构造函数,这些实参负责提供关于错误的更多信息。在这些类中,what负责返回用于初始化异常对象的信息。因为what是虚函数,所以当我们捕获基类的引用时,对what函数的调用将执行与异常对象动态类型对应的版本。
继承体系的第二层将exception
划分为两个大的类别:运行时错误和逻辑错误。运行时错误表示的是只有在程序运行时才能检测到的错误;而逻辑错误一般指的是我们可以在程序代码中发现的错误。
命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。
命名空间作用域后面无须分号。
每个命名空间都是一个作用域:和其它作用域类似,命名空间中的每个名字都必须表示该空间内的唯一实体。因为不同命名空间的作用域不同,所以在不同命名空间内可以有相同名字的成员。定义在某个命名空间中的名字可以被该命名空间内的其它成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。位于该命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间。
命名空间可以是不连续的:命名空间可以定义在几个不同的部分,这一点与其它作用域不太一样。命名空间的定义可以不连续的特性使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似于我们管理自定义类及函数的方式:
定义多个类型不相关的命名空间应该使用单独的文件分别表示每个类型(或关联类型构成的集合)。
在通常情况下,我们不把#include
放在命名空间内部。如果我们这么做了,隐含的意思是把头文件中所有的名字定义成该命名空间的成员。
命名空间中定义的成员可以直接使用名字,此时无须前缀;命名空间之外定义的成员必须使用含有前缀的名字。
尽管命名空间的成员可以定义在命名空间外部,但是这样的定义必须出现在所属命名空间的外层空间中。即不能在一个不相关的作用域中定义这个运算符。
模板特例化必须定义在原始模板所属的命名空间中。和其它命名空间名字类似,只要我们在命名空间中声明了特例化,就能在命名空间外部定义它了。
全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到全局命名空间中。
::member_name;
表示全局命名空间中的一个成员。
嵌套命名空间:内层命名空间声明的名字将隐藏外层命名空间声明的同名成员。在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间中的代码要想访问它必须在名字前添加限定符。
内联命名空间中的名字可以被外层命名空间直接使用。定义内联命名空间的方式是在关键字namespace
前添加关键字inline
。关键字inline
必须出现在命名空间第一次定义的地方,后续再打开命名空间的时候可以写inline
,也可以不写。
当应用程序的代码在一次发布和另一次发布之间发生了改变时,常常会用到内联命名空间。
未命名的命名空间中定义的变量拥有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。
一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。
和其它命名空间不同,未命名的命名空间仅在特定的文件内有效,其作用范围不会横跨多个不同的文件。
未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同。如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字有所区别。
使用未命名的命名空间取代文件中的静态声明。
不能在命名空间还没有定义前就声明别名,否则将产生错误。
一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价。
一条using声明(using declaration)语句一次只引入命名空间的一个成员。它的有效范围从using声明的地方开始,一直到using声明所在的作用域结束为止。
一条using声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员。
using指示使命名空间中的所有名字都是可见的。简写的名字从using指示开始,一直到using指示所在的作用域结束都能使用。
using指示可以出现在全局作用域、局部作用域和命名空间作用域中,但是不能出现在类的作用域中。
using声明的名字的作用域与using声明语句本身的作用域一致,从效果看就好像using声明语句为命名空间的成员在当前作用域内创建了一个别名一样。
using指示具有将命名空间成员提升到包含(命名空间本身和using指示)的最近作用域的能力(断句)。通常情况下,命名空间中会含有一些不能出现在局部作用域中的定义,因此,using指示一般被看作是出现在最近的外层作用域中。
当命名空间被注入到它的外层作用域之后,很有可能该命名空间中定义的名字会与其外层作用域中的成员冲突。这种冲突是允许存在的,但是要想使用冲突的名字,我们就必须明确指出名字的版本。
头文件如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含了该头文件的文件中。通常情况下,头文件应该只负责定义接口部分的名字,而不定义实现部分的名字。因此,头文件最多只能在它的函数或命名空间内使用using指示或using声明。
可以从函数的限定名推断出查找名字时检查作用域的次序,限定名以相反次序指出被查找的作用域。例:限定符A::C1::f3指出了查找类作用域和命名空间作用域的相反次序。首先查找函数f3的作用域,然后查找外层类C1的作用域,最后检查命名空间A的作用域以及包含着f3定义的作用域。
对于命名空间中名字的隐藏规则来说有一个重要的例外。这个例外是,当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。查找规则的这个例外运行概念上作为类接口一部分的非成员函数无须单独的using声明就能被程序使用。
一个另外的未声明的类或函数如果第一次出现在友元声明中,则我们认为它是最近的外层命名空间的成员。
namespace A { class C { // 两个友元,在友元声明之外没有其他的声明 // 这些函数隐式地成为命名空间A的成员 friend void f2(); // 除非另有声明,否则不会被找到 friend void f(const C&); // 根据实参相关的查找规则可以被找到 }; } int main() { A::C cobj; f(cobj); // 正确:通过在A::C中的友元声明找到A::f f2(); // 错误:A::f2没有被声明 }
因为f接受一个类类型的实参,而且f在C所属的命名空间进行了隐式的声明,所以f能被找到。相反,因为f2没有形参,所以它无法被找到。
对于接受类类型实参的函数来说,其名字查找将在实参类所属的命名空间中进行。这条规则对于我们如何确定重载函数的候选函数集同样也有影响。我们将在每个实参类(以及实参类的基类)所属的命名空间中搜寻候选函数。
using声明语句声明的是一个名字,而非一个特定的函数。
using指示将命名空间的成员提升到外层作用域中,如果命名空间的某个函数与该命名空间所属作用域的函数同名,则命名空间的函数将被添加到重载集合中。
与using声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数将不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。
如果存在多个using指示,则来自每个命名空间的名字都会成为候选函数集的一部分。
如果访问说明符被忽略掉了,则关键字class对应的默认访问说明符是private,关键字struct对应的是public。
多重继承的派生列表也只能包含已经被定义过的类,而且这些类不能是final的。在某个给定的派生列表中,同一个基类只能出现一次。
基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。
C++11允许派生类从它的一个或几个基类中继承构造函数。但是如果从多个基类中继承了相同的构造函数(及形参列表完全相同),则程序将产生错误。
如果一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本。
编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。例如,如果存在如下所示的print重载形式:
void print(const Bear&); void print(const Endangered&);
通过Panda对象对不带前缀限定符的print函数进行调用将产生编译错误。
与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员。
在多重继承的情况下,相同的查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。
对于一个派生类来说,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时必须明确指出它的版本,否则将引发二义性。只有当要调用哪个函数含糊不清时程序才会出错。
(?)一种更复杂的情况是,有时即使派生类继承的两个函数参数列表不同也可能发生错误。此外,即使 max_weight 在一个基类中是私有的,而在另一个类中是公有的或者受保护的,同样也可能发生错误。最后一种情况,假如 max_weight 定义在 Bear 而非 ZooAnimal中,程序仍然是错误的。
要想避免二义性问题,最好的办法就是在派生类中为该函数定义一个新版本
虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
关键字public和virtual的顺序随意。
因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则仍然可以直接访问这个被覆盖的成员。但是如果成员被多于一个基类覆盖,则一般情况下派生类必须为该成员定义一个新的版本。
假定类 B 定义了一个名为 x 的成员,D1 和 D2 都是从 B 继承得到的,D 继承了 D1 和 D2,则在D 的作用域中,x 通过 D 的两个基类都是可见的。如果我们通过D的对象使用 x,有三种可能性:
与非虚的多重继承体系一样,解决这种二义性问题最好的方法是在派生类中为成员自定义新的实例。
在派生类中,虚基类是由最低层的派生类初始化的。
首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。
虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
一个类可以有多个虚基类,此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。
编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其它非虚基类。
虚基类的初始值如果出现在中间基类中,则这些初始值将被忽略。
合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。和往常一样,对象的销毁顺序与构造顺序正好相反。