前言
C++的某些特性特别适合于处理超大规模问题,这些特性包括:异常处理、命名空间以及多重继承或虚继承。这章知识还是挺多挺复杂的,想了解更多详细知识,建议大家自行查看书籍,这里主要介绍一些细节问题。
最后,如果有什么理解不对的地方,希望大家不吝赐教,谢谢!
十五、用于大型程序的工具
当我们编写比较复杂的、小组和个人难以管理的系统时,异常处理、命名空间和多重继承这些特征最为有用。
大规模应用程序的特殊要求包括:
- 在独立开发的子系统之间协同处理错误的能力
- 使用各种库(可能包含独立开发的库)进行协同开发的能力
- 对比较复杂的应用概念建模的能力
异常处理
异常处理机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。异常使得我们能够将问题的检测与解决过程分离开来。程序的一部分负责检测问题的出现,然后解决该问题的任务传递给程序的另一部分。检测环节无须知道问题处理模块的所有细节,反之亦然。
要想有效地使用异常处理,必须首先了解当抛出异常时发生了什么,捕获异常时发生了什么,以及用来传递错误的对象的意义。
抛出异常
我们通过抛出一条表达式来引发一个异常。被抛出的表达式的类型以及当前的调用链共同决定了哪段处理代码将用来处理该异常。被选中的处理代码是在调用链中与抛出对象类型匹配的最近的处理代码。
当执行一个throw时,跟在throw后面的语句将不再执行。相反,程序的控制权从throw转移到与之匹配的catch模块。控制权从一处转移到另一处,这里有两个重要的含义:
- 沿着调用链的函数可能会提早退出
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁
因为跟在throw后面的语句将不再执行,所以throw语句的用法有点类似于return语句:它通常作为条件语句的一部分或者作为某个函数的最后(或者唯一)一条语句。
栈展开
当throw出现一个try语句块内时,检查与该try块关联的catch子句。如果找到了匹配的catch,就使用该catch处理异常。如果这一步没找到匹配的catch且该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch子句。如果还是找不到匹配的catch,则退出当前的函数,在调用当前函数的外层函数中继续寻找。
上述过程被称为栈展开过程。栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch子句为止;或者也可能一直没找到匹配的catch,则退出主函数后查找过程终止。
一个异常如果没有被捕获,则它将终止当前的程序。
栈展开过程中对象被自动销毁
在栈展开过程中,位于调用链上的语句块可能会提前退出。通常情况下,程序在这些块中创建了一些局部对象。如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。
如果异常发生在构造函数中,即使某个对象只构造了一部分,我们也要确保已构造的成员能被正确地销毁。
析构函数与异常
在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的。所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将终止。
异常对象
异常对象是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw语句中的表达式必须拥有完全类型。
当抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。因为很多情况下程序抛出的表达式类型来自于某个继承体系,如果一条throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。
捕获异常
catch子句中的异常声明看起来像是只包含一个形参的函数形参列表。像在形参列表中一样,如果catch无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。
声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。
异常声明的静态类型将决定catch语句所能执行的操作,如果catch的参数是基类类型,则catch无法使用派生类特有的任何成员。
通常情况下,如果catch接受的异常与某个继承体系有关,则最好将该catch的参数定义成引用类型。
查找匹配的处理代码
因为catch语句是按照其出现的顺序逐一进行匹配的,所以当程序使用具有继承关系的多个异常时必须对catch语句的顺序进行组织和管理,使得派生类异常的处理代码出现在基类异常的处理代码之前。
除了一些极细小的差别之外,要求异常的类型和catch声明的类型是精确匹配的:
- 允许从非常量向常量的类型转换
- 允许从派生类向基类的类型转换。
- 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。
重新抛出
有时,一个单独的catch语句不能完整地处理某个异常,这时一条catch语句通过重新抛出地操作将异常传递给另外一个catch语句,这里的重新抛出仍然是一条throw语句,只不过不包含任何表达式。
空的throw语句只能出现在catch语句或catch语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw语句,编译器将调用terminate。
捕获所有异常的处理代码
为了一次性捕获所有异常,我们使用省略号作为异常声明,这样的处理代码称为捕获所有异常的处理代码,形如catch(...)。一条捕获所有异常的语句可以与任意类型的异常匹配。
catch(...)既能单独出现,也能与其他几个catch语句一起出现。如果catch(...)与其他几个catch语句一起出现,则catch(...)必须在最后的位置。出现在捕获所有异常语句后面的catch语句将永远不会被匹配。
函数try语句块与构造函数
要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数try语句块的形式。函数try语句块使得一组catch语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。
关键字try出现在表示构造函数初始值列表的冒号以及表示构造函数体的花括号之前。与这个try关联的catch既能处理构造函数体抛出的异常,也能处理成员初始化列表抛出的异常。
如果在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。
noexcept异常说明
我们可以通过提供noexcept说明指定某个函数不会抛出异常。其形式是关键字noexcept紧跟在函数的参数列表后面,用以标识该函数不会抛出异常。
在成员函数中,noexcept说明符需要跟在const及引用限定符之后,而在final、override或虚函数的=0之前。
违反异常说明
如果一个函数在说明了noexcept的同时又含有throw语句或者调用了可能抛出异常的其他函数,编译器将顺利编译通过,并不会因为这种违反异常说明的情况而报错。
通常情况下,编译器不能也不必在编译时验证异常说明。
noexcept运算符
noexcept说明符的实参常常与noexcept运算符混合使用。noexcept运算符是一个一元运算符,它的返回值是一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和sizeof类似,noexcept也不会求其运算对象的值。
noexcept有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为noexcept异常说明的bool实参出现时,它是一个运算符。
异常说明与指针、虚函数和拷贝控制
函数指针以及该指针所指的函数必须具有一致的异常说明。如果我们为某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数。
异常类层次
类型exception仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为what的虚成员。类exception、bad_cast和bad_alloc定义了默认构造函数。类runtime_error和logic_error没有默认构造函数。
命名空间定义
一个命名空间的定义包含两部分:首先是关键字namespace,随后是命名空间的名字。在命名空间名字后面是一系列由花括号括起来的声明和定义。只要能出现在全局作用域中的声明就能置于命名空间内,主要包括:类、变量(及其初始化操作)、函数(及其定义)、模板和其他命名空间。
命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。
命名空间作用域后面无须分号。
每个命名空间都是一个作用域
因为不同命名空间的作用域不同,所以在不同命名空间内可以有相同的成员。
定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌套作用域中的任何单元访问。位于该命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间。
命名空间可以是不连续的
命名空间可以定义在几个不同的部分,这一点与其他作用域不太一样。若该命名空间不存在,则是定义一个新的命名空间,若存在,则是打开已存在的命名空间定义并为其添加一些新成员声明。
命名空间的组织方式类似于我们管理自定义类及函数的方式:
- 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件包含在使用了这些成员的文件中。
- 命名空间成员的定义部分则置于另外的源文件中。
在程序中某些实体只能定义一次:如非内联函数、静态数据成员、变量等。
定义多个类型不相关的命名空间应该使用单独的文件分别表示每个类型(或关联类型构成的集合)。
在通常情况下,我们不把#include放在命名空间内部,如果我们这么做了,隐含的意思是把头文件中所有的名字定义成该命名空间的成员。
定义命名空间成员
命名空间中定义的成员可以直接使用名字,无须前缀。也可以在命名空间的外部定义该命名的成员。命名空间对于名字的声明必须在作用域内,同时该名字的定义需要明确指出其所属的命名空间。
模板特例化
模板特例化必须定义在原始模板所属的命名空间中。和其他命名空间名字类似,只要我们在命名中声明了特例化,就能在命名空间外部定义它了。
全局命名空间
全局作用域中定义的名字也就是定义在全局命名空间中。 全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到全局命名空间中。作用域运算符同样可以用于全局作用域的成员,因为全局作用域是隐式的,所以它并没有名字,如 ::member_name表示全局命名空间中的一个成员。
嵌套的命名空间
嵌套的命名空间是指定义在其他命名空间中的命名空间。嵌套的命名空间同时是一个嵌套的作用域,它嵌套在外层命名空间的作用域中。内层命名空间声明的名字将隐藏外层命名空间的同名成员。
内联命名空间
和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。也就是说,我们无须在内联命名空间的名字前添加表示该命名空间的前缀,通过外层命名空间的名字就可以直接访问它。
定义内联命名空间的方式是在关键字namespace前添加关键字inline。关键字inline必须出现在命名空间第一次定义的地方,后续再打开命名空间的时候可以写inline,也可以不写。
未命名的命名空间
未命名的命名空间是指关键字namespace后紧跟花括号起来的一系列声明语句。未命名的空间中定义的变量拥有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。
和其他命名空间不同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。
未命名的命名空间取代文件中的静态声明
在C语言中,声明为static的全局实体在其所在的文件外不可见。而在C++中,直接使用未命名的命名空间即可。
使用命名空间成员
使用更为简便的方法使用命名空间的成员,有一种方法是using声明。还有命名空间的别名以及using指示等。
命名空间的别名
命名空间的别名声明以关键字namespace开始,后面是别名所用的名字、=符号、命名空间原来的名字以及一个分号。不能再命名空间还没定义前就声明别名,否则将产生错误。
命名空间的别名也可以指向一个嵌套的命名空间。
一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价。
using声明:扼要概述
一条using声明语句一次只引入命名空间的一个成员。它使得我们可以清楚地知道程序中所用地到底是哪个名字。
using声明引入的名字遵守与过去一样的作用域规则L:它的有效范围从using声明的地方开始,一直到using声明所在的作用域结束为止。
在类的作用域中,这样的声明语句只能指向基类成员。
using指示
using指示和using声明类似的地方是,我们可以使用命名空间名字的简写形式;和using声明不同的地方是,我们无法控制哪些名字是可见的,因为所有名字都是可见的。
using指示以关键字using开始,后面是关键字namespace以及命名空间的名字。不能出现在类的作用域中。
using指示与作用域
using声明的名字的作用域与using声明语句本身的作用域一致,从效果上看就好像using声明语句为命名空间的成员在当前作用域内创建一个别名一样。
它具有将命名空间成员提升到包含命名空间本身和using指示的最近作用域的能力。
头文件与using声明或指示
头文件如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含了该头文件的文件中。
提示:避免using指示
using指示一次性注入某个命名空间的所有名字,这种用法看似简单实则充满了风险:只使用一条语句就图如将命名空间中所有成员的名字变得可见了。如果应用程序使用了多个不同的库,而这些库中的名字通过using指示变得可见,则全局命名空间污染的问题将重新出现。
using声明效果会更好,using声明引起的二义性问题在声明处就能发现,无须等到使用名字的地方,这显然对检测并修改错误大有益处。
using指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using指示。
类、命名空间与作用域
由内向外一次查找每个外层作用域。外层作用域也可能是一个或多个嵌套的命名空间,直到最外层的全局命名空间查找过程终止。只有位于开放的块中且在使用点前声明的名字才被考虑。
重载与命名空间
命名空间对函数的匹配过程有两方面的影响,其中一个影响非常明显:using声明或using指示能将某些函数添加到候选函数集。另外一个影响则比较微妙。
与实参相关的查找与重载
我们将在每个实参类(以及实参类的基类)所属的命名空间中搜寻候选函数。在这些命名空间中所有被调用函数同名的函数都将被添加到候选集当中,即使其中某些函数在调用语句处不可见也是如此。
重载与using声明
using声明语句声明的是一个名字,而非一个特定的函数,即不能带形参列表。当我们为函数书写using声明时,该函数的所有版本都被引入到当前作用域中。
一个using声明囊括了重载函数的所有版本以确保不违反命名空间的接口。
一个using声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。如果using声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。using声明将为引入的名字添加额外的重载实例,并最终扩充候选函数集的规模。
重载与using指示
using指示将命名空间的成员提升到外层作用域中,如果命名空间的某个函数与该命名空间所属作用域的函数同名,则命名空间的函数将被添加到重载集合中。
与using声明不同的是,对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。
跨越多个using指示的重载
如果存在多个using指示,则来自每个命名空间的名字都会成为候选函数集的一部分。
多重继承与虚继承
多重继承是指从多个直接基类中产生派生类的能力。多重继承的派生类继承了所有父类的属性。
多重继承
在派生类的派生列表中可以包含多个基类,每个基类包含一个可选的访问说明符。如果说明符被忽略掉了,则关键字class对应的默认访问说明符是private,关键字struct对应的是public。
在某个给定的派生类列表中,同一个基类只能出现一次。
多重继承的派生类从每个基类中继承状态
在多重继承关系中,派生类的对象包含每个基类的子对象。
派生类构造函数初始化所有基类
构造一个派生类的对象将同时构造并初始化它的所有基类子对象。与从一个基类进行的派生类一样,多重继承的派生类的构造函数初始值也只能初始化它的直接基类。
继承的构造函数与多重继承
允许派生类从它的一个或几个基类中继承构造函数。但是如果从多个基类中继承了相同的构造函数(即形参列表完全相同),则程序将产生错误。
如果一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本。
析构函数与多重继承
派生类的析构函数只负责清楚派生类本身分配的资源,派生类的成员及基类都是自动销毁的,合成的析构函数体为空。
多重继承的派生类的拷贝与移动操作
多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动或赋值操作。只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对基类部分都执行这些操作。在合成的拷贝控制成员中,每个基类分别使用自己的对应成员隐式地完成构造、赋值或销毁等工作。
类型转化与多个基类
在只有一个基类的情况下,派生类的指针或引用能自动转换成一个可访问基类的指针或引用。多个基类的情况与之类似,我们可以令某个可访问基类的指针或引用直接指向一个派生类对象。
编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。
基于指针类型或引用类型的查找
与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员。
多重继承下的类作用域
在多重继承的情况下,相同的查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。先查找名字后进行类型检查。要避免潜在的二义性,最好的办法是在派生类中为该函数定义一个新版本。
虚继承
在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。
而虚继承机制的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
支持向基类的常规类型转换
不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。
虚基类成员的可见性
因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。如果虚基类的成员被一条派生路径覆盖,则我们仍然可以直接访问这个被覆盖的成员。但是如果成员被多余一个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。
构造函数与虚继承
在虚派生中,虚基类是由最底层的派生类初始化的。
虚继承的对象的构造方式
首先使用提供给最底层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。
虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
构造函数与析构函数的次序
一个类可以有多个虚基类,此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。