C++ 可以处理的问题的范围十分广泛,从只需要一个程序员在几个小时内就能解决的小问题,到需要涉及到多个系统协作,有着百万行级别的代码量,并且需要几百个程序员在多年时间内参与的大问题(如:操作系统)。本书前面章节介绍的内容同时适合于所有这些跨度的问题。
语言还包括一些针对大的复杂系统而设计的特性。这些特性包括:异常处理(exception handling),名称空间(namespaces)和多重继承(multiple inheritance),这是本章将阐述的内容。
大型规模编程(Large-scale programming)对语言的要求要远高于小团队开发的系统对语言的要求。这些要求包括:
- 在独立开发的多个子系统间处理错误;
- 使用独立开发的库而不产生名称冲突;
- 对更加复杂的应用概念进行建模;
本章将讲述针对以上需求而设计的语言特性:异常、名称空间和多重继承。
异常处理(Exception handling)允许独立开发的程序部分可以在运行时的错误进行通信(communicate)和处理(handle)。异常使得我们可以分离问题的发现部分和问题的处理部分。程序的一部分可以发现问题,然后将解决问题的工作传递给程序的另一部分。检测部分不需要知道处理部分的细节,反之亦然。
有效的使用异常处理需要理解当异常抛出时发生了什么,当捕获时发生了什么,以及用来传递错误的对象的含义。
在 C++ 中,异常是通过抛出(throwing)一个表达式来引发(raised)的。抛出的表达式的类型以及当前的调用链决定了哪个处理器(handler)将处理此异常。选中的处理器是调用链中匹配抛出对象类型的最近的代码。抛出对象的类型和内容允许抛出部分给处理部分提供出错信息。
当 throw 被执行时,throw 之后的语句是不会执行的。相反,控制(control)将从 throw 转移到对应的 catch 处。catch 子句可能在同一个函数中,也可能在直接或间接调用了发生异常的函数的函数中。控制从一个地方转到另一个地方的事实有两个重要的暗示:
- 调用链上的所有函数调用将永久退出;
- 当进入一个处理器时,调用链上创建的对象将被销毁;
栈展开(Stack Unwinding)
当异常抛出时,当前函数的执行被中止并开始搜索匹配的 catch 子句。如果 throw 出现在一个 try 块中,那么与之相对应的 catch 子句将首先被检查,如果找到了匹配的 catch 子句,异常就被此 catch 所处理。否则,如果 try 被嵌套在另外一个 try 中,那么将继续搜索外层的 catch 子句。如果没有任何 catch 匹配此异常,那么当前函数将退出,并且继续搜索发起调用的函数。这样一直向上,称为栈展开,直到找到一个匹配异常的 catch 子句,或者在没有找到任何匹配的 catch 子句时 main 函数自己退出。
假如找到了一个匹配的 catch 子句,将执行 catch 中的代码,当其完成后,将执行其后的第一条非 catch 子句代码。如果没有找到任何匹配的 catch 子句,程序将退出。异常是必须处理的,因为异常的目的就是阻止程序继续按常规执行,如果不处理异常则程序会隐式调用 terminate 库函数来终止程序的执行。
没有被捕获的异常将终止程序的执行。
栈展开时对象将自动被销毁
在栈展开时,调用链中的语句块将会永久退出,通常语句块中将创建本地对象,而本地对象则在语句块退出时销毁。栈展开执行相同的逻辑:当一个语句块在栈展开时退出,编译器保证其中创建的对象被适当的销毁。如果本地对象是类类型,对象的析构函数将执行,如果是内置类型,那么将不执行任何操作直接销毁。
异常可能发生在构造函数中,那么对象可能处于部分构建(partially constructed)状态。其中一些成员已经被初始化了,但是另外一些成员在异常发生时还没有初始化。即便处于部分构建状态,编译器将保证已经构建的成员将被销毁。
同样,异常可能发生在数组或容器元素的初始化过程中,编译器将保证在异常发生前构建的元素将被销毁。
析构函数和异常
析构函数总是执行,而函数中释放资源的代码可能会被跳过影响着我们如何组织程序。如果一个代码块分配了资源,但是异常发生在释放资源的代码之前,那么释放资源的代码将不会执行。另一方面,由类类型对象分配的资源肯定会被析构函数释放。通过使用类来控制资源的分配,我们可以保证资源总是被合理的释放,而不管函数是正常结束还是由异常导致结束。
栈展开时将执行析构函数影响着我们如何写析构函数。在栈展开时,异常已经引发但是还没有被处理。如果栈展开过程中又抛出一个新的异常,而没有在抛出的函数中捕获的话就会调用 terminate 函数。由于析构函数会在栈展开中调用,析构函数不应该抛出任何它自己不处理的异常。也意味着如果析构函数调用了可能抛出异常的函数,需要将其放在 try 块中,并将异常处理掉。
在现实中,由于析构函数只释放资源,它不太可能会抛出异常。所有的标准库中的类型都保证其析构函数不会抛出异常。
**注意:**在栈展开中,析构函数抛出了异常但是并没有处理,那么程序将退出。
异常对象
编译器使用抛出表达式来拷贝复制一个特殊对象称为异常对象(exception object)。所以抛出的对象必须是完全类型(complete type),如果对象是类类型,那么其必须具有可访问的析构函数和可访问的拷贝或移动构造函数。如果对象是数组或者函数类型,那么其将被转型为对应的指针类型。
异常对象驻留于编译器管理内存空间中,当任何 catch 子句被调用时,这个异常对象就会被访问,这个异常对象将在异常被处理之后被销毁。
抛出本地对象的指针是错误的用法,因为在栈展开时本地对象会被销毁。
catch 子句中的异常声明(exception declaration)非常类似于只有一个参数的函数参数列表。当 catch 不需要访问抛出的异常对象时,异常声明中的名字可以省略。
异常声明中的类型决定了可以处理的异常,这个类型必须是完全类型,可以是左值引用但不能是右值引用。catch 子句非常类似于函数体。当进入 catch 子句时,异常声明中的参数将被初始化为异常对象,与函数参数一样,如果 catch 参数是非引用类型,那么 catch 参数是异常对象的拷贝;在 catch 中对参数做的任何改变都是针对本地拷贝而与异常对象本身没有任何关系。如果参数是引用类型,那么与任何别的引用参数是一样的,catch 参数是异常对象的另一个名字。对参数做的任何改变都会反映到异常对象上。
与函数参数一样,如果 catch 参数是基类类型,其可以被初始化为子类类型对象。如果 catch 参数是非引用类型,那么异常对象将被裁剪(sliced down),如果参数是基类类型的引用的话,那么参数将被绑定到异常对象上。
同样,异常声明的静态类型决定了 catch 可以执行的操作,如果 catch 参数是基类类型,那么 catch 就不能执行派生类类型的任何操作。
**最佳实践:**一个 catch 如果是处理通过继承关联起来的类型的异常对象时,应该将其声明为引用。
查找一个匹配的处理器
在查找匹配的 catch 时,找到的 catch 不需要是最匹配异常的那个,而是第一个匹配异常的那个。因而,在一串 catch 子句中,最具体的 catch 子句应该第一个出现。
由于 catch 子句是由其出现的顺序进行匹配的,使用具有继承关系的异常对象的程序必须将其 catch 子句进行排序使得处理派生类的处理器出现在处理基类的 catch 子句前。
异常匹配的规则比之函数参数匹配更加严格,绝大多数时候 catch 声明的异常类型必须与异常对象的类型完全一致,只有在极少数的情况下两者之间可以有差异:
- 从非 const 到 const 的转换是允许的,意味着抛出一个非 const 对象可以匹配一个声明为捕获 const 引用的 catch 子句;
- 从派生类到基类的转换是允许的;
- 数组可以转为其元素类型的指针;函数可以转为函数类型的指针;
其它的任何转型都是不允许的,特别指出的是不允许标准算术转型和类类型定义的转型。
重新抛出(Rethrow)
有时单个 catch 可能不能完全处理一个异常,它可以通过重新抛出将异常传递给上层的 catch 子句处理。重新抛出形如:throw;
就是 throw 后不跟随任何对象。空的 throw 只能出现在 catch 子句中,或者由 catch 调用的函数。如果一个空 throw 出现在非 catch 中,terminate 将被调用。
重新跑出异常不会指定对象,其当前的异常对象被传递到调用链的上层。通常 catch 会改变异常对象,这个改变后的异常对象只有被声明为引用时才会被上层 catch 子句观察到。
捕获所有的处理器(The Catch-All Handler)
可以通过 catch(...)
的方式来捕获所有的异常,这个称为 catch-all 处理器,这种处理器通常与重新抛出结合在一起,其做完任何可以做的本地工作然后重新抛出异常。如果将 catch(...)
与其它 catch 子句使用时应该放在最后的位置,其后的任何 catch 子句都不会被匹配到。
异常可能会出现在处理构造函数的初始化时,在进入构造函数体之前需要先执行构造函数初始化,在构造函数体中的 catch 不能捕获有初始化器抛出的异常,这是由于构造函数体中的 try 块在异常抛出时还没有起作用。
为了捕获构造函数初始化器中的异常,必须书写构造函数为函数级 try 语句块(function try block)。这种语句块允许我们将一系列 catch 与构造函数的初始化阶段(或析构函数的析构阶段)和构造函数体(或析构函数体)关联起来。如:
Blob(std::initializer_list<std::string> il) try :
data(std::make_shared<std::vector<std::string>>(il)) {
/* ... */
} catch (const std::bad_alloc &e) {
/* handle_out_of_memory(e); */
}
此时 try 关键字出现在开始构造函数初始化列表的冒号之前,这也在开始构造函数体的大括号之前。与之关联的 catch 子句可以捕获成员初始化中或构造函数体中抛出的异常。
值得注意的是出现在构造函数参数本身时发生的异常不会被函数级 try 语句块捕获,只有开始构造函数的初始化列表后的异常才能被捕获。捕获这种异常的职责是调用表达式的,需要有调用者来处理。
**注意:**书写函数级 try 块是处理构造函数初始化列表中抛出异常的唯一方法。
知道函数不会抛出任何异常对于程序员和编译器来说都是有好处的,程序员书写调用代码将更加简单,编译器则可以生成更加优化的机器码。
在新标准中,函数可以通过 noexcept 说明(noexcept specification),在函数参数列表后放置的 noexcept 关键字表示函数不会抛出异常:
void recoup(int) noexcept; // won't throw
void alloc(int); // might throw
我们说 recoup 做了不抛出异常说明(nothrowing specification)。
noexcept 说明必须出现在所有的声明和定义处或者不出现在任何一个上。并且放在尾后返回类型之前。同样,noexcept 需要放在函数指针的声明或定义处。不过不需要放在类型别名或 typedef 处。在成员函数中,noexcept 说明符放在 const 或引用限定符之后,在 final 、override 或虚函数的 = 0
之前。
违反异常说明
需要指出的是编译器并不会在编译时对 noexcept 说明进行检查,甚至编译器都不能拒绝函数体内包含 throw 语句或者调用可能会抛出异常的函数的语句进行 noexcept 声明,不过某些编译器会对此进行警告。
void f() noexcept
{
throw exception();
}
如果 noexcept 函数抛出了异常,编译器将调用 terminate 函数来结束程序执行,这就强制保证了 noexcept 函数不会抛出异常。这里没有特别指定是否会进行栈展开。所有 noexcept 使用的场景应该是如下两个场景:1. 对于函数确实不会抛出异常有充分的自信;2. 对于函数抛出异常,不知如何进行处理;
指明函数不会抛出异常保证调用者不需要处理异常,要么函数真的不会抛出异常,要么程序就直接结束了。
**警告:**编译器通常不能也不会在编译期检查异常说明。
向后兼容
早期版本的 C++ 的异常说明更加复杂,允许指定一个函数可能抛出的异常,但是现在几乎是没有什么人使用这种方式了,并且被废弃了。但是有一个方式是经常使用的就是:throw()
来表明函数不抛出任何异常;如:
void recoup(int) noexcept; // recoup doesn't throw
void recoup(int) throw(); // equivalent declaration
noexcept 说明的实参
noexcept 可以有一个可选的实参,必须是可以转换为布尔值的,如果为 false 的话就表示可能会抛出异常,true 则不会抛出。如:
void recoup(int) noexcept(true); // recoup won't throw
void alloc(int) noexcept(false); // alloc can throw
noexcept 操作符
noexcept 说明的实参经常是由 noexcept 操作符求值所得,noexcept 是一元操作符,返回的 bool 型的右值常量表达式,其求值发生在编译期所以不会对表达式求值,而是进行编译推导。这与 sizeof 是一样的。如:
void f() noexcept(noexcept(g())); // f has same exception specifier as g
其中 noexcept(e)
在 e 调用的所有函数都不抛出异常,并且 e 本身不抛出异常的情况下,返回 true,否则就是 false。
异常说明以及指针、虚函数、拷贝控制
将指针声明为只能指向不抛出异常的函数,可以赋值的函数必须是不抛出异常的。而如果将指针声明为可能会抛出异常,那么就无所谓了,任何符合的函数都可以赋值给这种指针。如:
void (*pf1)(int) noexcept = recoup;
void (*pf2)(int) = recoup;
pf1 = alloc; // error: alloc might throw but pf1 said it wouldn't
pf2 = alloc;
如果一个虚函数将自己声明为不会抛出异常,那么子类的覆盖函数必须同样不抛出异常。而基类虚函数可能会抛出异常,子类覆盖函数则可以更加严格的保证不抛出异常。如:
class Base {
public:
virtual double f1(double) noexcept;
virtual int f2() noexcept(false);
virtual void f3();
};
class Derived : public Base {
public:
double f1(double) override; // error: Base::f1 promises not to throw
int f2() noexcept(false) override;
void f3() noexcept override;
};
当编译器合成拷贝控制成员时,同样会合成异常说明符。如果合成成员的所有成员和基类对象的对应函数保证不会抛出异常,那么合成的成员不会抛出异常。否则其中任何一个函数可能会抛出异常,那么合成的就会抛出异常。并且,如果我们没有自己定义的析构函数提供异常说明,编译期会自动合成一个异常说明,这个合成的异常说明与合成的析构函数的异常说明一样。
exception 类本身只定义了拷贝构造函数、拷贝赋值操作符和虚析构函数以及虚函数成员 what,what 函数返回 C-字符串指针,并且保证不会抛出异常,这个字符串用于说明异常的错误信息。除此之外可以定义自己的异常类型,这些类型可以继承自 exception 也可以不是。甚至抛出的异常可以是内置类型。
大的项目中通常需要用到第三方库,这些库通常都会定义一个名称空间,没有定义名称空间而直接使用的名字会带来名称空间污染(namespace pollution),C 语言通过将库的名字作为函数等全局名字的前缀来避免此问题。C++ 则提供了名称空间,名称空间可以更有效的更好的管理名字。名称空间将全局名称空间进行切分,每个名称空间就一个作用域。
名称空间中包含一系列的声明和定义,这些声明和定义必须是可以出现在全局作用域中的:类、变量(以及初始值)、函数以及其定义、模板和其它名称空间。
与任何名字一样,名称空间的名字必须在其作用域中是唯一的。名称空间可以定义在全局作用域中或者在别的名称空间中,但不能在函数或者类中。
名称空间不以分号结束。
每个名称空间是一个作用域
每个名称空间中的每个名字都必须指向独一无二的实体,在不同的名称空间中的相同名字是不同的实体。同一个名称空间中的名字可以被直接访问(包括嵌套的作用域)。而在名称空间之外则需要用完全限定名来进行访问。
名称空间不需要连续
名称空间可以分离定义,在不同的文件或者在同一个文件的不同地方。比如:
namespace nsp {}
要么定义一个新的名称空间 nsp ,要么给已经存在的名称空间增加内容。如果 nsp 之前没有定义过,那么就是创建一个新的名称空间。否则就将定义增加到已经存在的名称空间中去。
名称空间的这种用法主要用来适配类定义以及函数的定义。名称空间中定义类和声明函数是接口的一部分,将被放在头文件中。而名称空间的成员实现则被放在源文件中。通过将接口和实现分离,可以保证名称空间中的名字只被定义以此,但是可以被声明多次。 如果一个名称空间中包含了多个不相关的类型,应该使用分离的文件来书写这些名称空间和每个类型。
需要注意的是,#include
必须出现在所有的名称空间之前,否则就是将所有被包含的文件中的名字在我们的名称空间中再次定义一次。
定义名称空间成员
在同一个名称空间中的成员之间相互通过非限定名称进行引用,也可以在名称空间外面定义成员,定义需要指定名字是属于哪个名称空间的。
cplusplus_primer::Sales_data
cplusplus_primer::operator+(const Sales_data& lhs, const Sales_data& rhs)
{
Sales_data ret(lhs);
}
这个定义的声明必须存在于对应的名称空间中,与定义类的成员函数一样,函数体是在名称空间中的,所以可以不加限定地使用名称空间中的名字。
模板特例
模板特例需要放在与原始模板相同的作用域中,与别的名字一样,模板特例可以在作用域中声明,然后在外面进行定义。
全局名称空间
在全局作用域中定义的名字被放在全局名称空间(global namespace)中,全局名称空间是隐式定义的,并且存在于每一个程序中。每个文件中定义在全局作用域中的名字都被放到了全局名称空间中。
引用全局名称空间中的名字需要使用 ::member_name
的方式。
嵌套的名称空间
定义在别的名称空间中的名称空间就是嵌套名称空间。嵌套名称空间是一个嵌套作用域,其作用域被嵌套在另外一个名称空间中。规则与嵌套作用域一样,定义在内部名称空间中的名字会隐藏外部名称空间中的名字,外部名称空间想要访问嵌套名称空间中的名字必须通过完全限定名称进行访问。
inline名称空间
新标准中引入了一种新的嵌套名称空间,inline namespace。与常规的嵌套名称空间不同的是,inline名称空间中的名字可以被其直接包含的名称空间直接,而不需要加以限定。如:
inline namespace FifthEd {}
inline namespace FifthEd {
class Query_base {};
}
inline 名称空间多用于从应用的一个版本迁移到另一个版本。
unnamed名称空间
匿名名称空间(unnamed namespace)由关键字 namespace 后直接跟名称空间定义,在匿名名称空间中定义的名字是 static 的,当第一次使用时存在,并且在程序结束时销毁。
匿名名称空间不会跨越多个文件,每个文件有自己的匿名名称空间,它们可以定义同名的名字但是不是同一个实体。不要在头文件中定义匿名名称空间。
匿名名称空间中的名字可以直接被使用,它们的作用域在其直接外部名称空间,所以需要其中的名字没有冲突。如:
namespace local {
namespace {
int i;
}
}
local::i = 42;
C++ 语言试图用匿名名称空间来替换 static 变量声明。
通过 using 声明(using declarations)、名称空间别名(namespace aliases)和 using 指令(using directives)来简化名称空间的使用。
名称空间别名
名称空间别名将一个较短的名字作为名称空间名字的别名。如:
namespace primer = cplusplus_primer;
名称空间别名可以表示一个嵌套的名称空间,如:
namespace Qlib = cplusplus_primer::QueryLib;
Qlib::Query q;
using 声明
using 声明在一次引入一个名称空间的成员。由 using 声明引入的名字遵循常规的作用域规则:从引入的地方可见直到作用域的结尾处结束。外部作用域中的相同名字被隐藏。在内部将其嵌套的作用域中可以不加限定的访问该名字,出了作用域就需要使用完全限定名字。
using 声明可以出现在全局、局部、名称空间和类作用域中。如果类作用域中则 using 声明只能针对基类成员。
using 指令
using 指令可以让名称空间中的所有名字都不加限定的进行访问。using 指令的形式是 using namespace NAMESPACE
, using 指令只能出现在全局、局部和名称空间作用域中,不能出现在类作用域中。
using 指令和作用域
由 using 指令导入的名字的作用域比 using 声明的更加复杂。using 声明将名字放在 using 声明所在的作用域中,就像定义了一个本地变量一样,如果前面有一个相同的名字就会报错。相反,using 指令将所有的名字提升到最近的名称空间中,这个名称空间同时包含 using 指令后的名称空间以及 using 指令本身所在的名称空间。如以下就是错误的:
namespace blip {
int i = 16, j = 15, k = 23;
}
int j = 0;
void mainp()
{
using namespace blip;
++i;
++j; // error: 不知道是 global j 还是 blip::j
++::j;
++blip::j;
int k = 97;
++k;
}
以允许 blip 中的名字与全局名称空间中的名字一样,但是如果想要引用这些名字的话就需要明确限定说明使用的是哪个名字,否则就会产生名字二义性。
头文件和 using 声明或 using 指令
头文件中至应该包含接口部分的名字,不应该包含任何实现部分的名字。因而,头文件不应该在函数或者名称空间外使用 using 声明或 using 指令。
应该尽可能少的使用 using 指令,而在需要的时候使用 using 声明。
名称空间中的名字查找一样是从内部作用域往外面不停查找,并且只查找外部作用域在前面声明的名字。名称空间中的类的成员函数中的名字先从成员函数中查找,再从类中查找,然后从所在的外围作用域中查找,最后才是从定义所在的地方进行查找。如:
namespace A {
int i;
int k;
class C1 {
public:
C1() : i(0), j(0) { }
int f1() { return k; }
int f2() { return h; } // error: h 还没有定义
int f3();
private:
int i;
int j;
};
int h = i; // 从 A::i 初始化
}
int A::C1::f3() { return h; }
这里需要注意的是 A::h 是在 f2 后定义的,所有无法通过编译,而 f3 是在 A::h 后定义的,所以可以访问。
由实参决定的查找(Argument-Dependent Lookup)和类类型参数
当传递一个类类型对象给函数时,编译器将在正常的作用域查找之外从实参的类定义的名称空间中查找。这个规则将会运用于类类型的引用和指针实参。如:std::string s; std::cin >> s;
在调用 >>
就不需要指定 std 名称空间,将会自动从 cin 或者 s 的名称空间从查找函数。
这个规则的意义在于不需要为概念上是类的接口,但不是类的成员函数,在使用时不需要单独的 using 声明。
查找 std::move 和 std::forward
由于 std::move
和 std::forward
的参数是右值引用,所以是可以匹配任何参数的。这样如何应用程序定义了别的 move 的话就会产生名字冲突(name collision)。所以在使用时尽可能地使用 std 进行限定。
友元声明和由实参决定的查找
如果一个未声明的类或函数第一次出现在友元声明中将被认为是定义在最接近的外围名称空间中,这与由实参决定的名称查找会产生意想不到的结果。如:
namesapce A {
class C {
friend void f2(); // won't be found
friend void f(const C&); // found by argument-dependent lookup
};
}
通过由实参决定的名称查找可以调用 f ,如:
int main()
{
A::C cobj;
f(cobj);
f2();
}
由于 f 的参数是类类型,而 f 是隐式声明在名称空间 A 中,所以 f 将被找到并被被调用。
由实参决定的名称查找和重载
带有类类型实参的函数查找函数名字时同时将在每个实参所在类及其基类的名称空间中查找此函数名字。这个规则同时会影响重载候选集。每个实参定义所在的名称空间都会被查找,所有这里面的同名函数都会被添加到候选集中。即便是这些函数在调用点看不到也会被添加到候选集中。如:
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 的基类所在的名称空间中进行查找同名函数。
重载和 using 声明
using 声明导入的是整个名字,而不是特定的函数。using 声明引入的函数可以对当前作用域中的同名函数进重载,而如何函数原型完全一样则会导致编译错误。
重载和 using 指令
using 指令将名称空间中的成员提升到最近的外围作用域中。如果名称空间中的成员与当前作用域中的名字同名,名称空间中的名字被添加到重载集合中。如果 using 指令引入的名称空间中的函数与当前作用域中的函数具有相同的原型,这不是一种错误。只要在调用时指定希望调用名称空间中的,还是当前作用域中的。
在多个 using 指令之间重载
如果一次性出现多个 using 指令,那么每个名称空间中的名字都会变成候选集中的一员。
C++ 是可以多重继承的(Multiple inheritance),意味着一个派生类可以多个直接基类。多重派生类(multiply derived class)继承其所有的父类的属性。尽管在概念上很简单,但是在细节上涉及到多个直接基类的时候会在设计层面和实现层面遇到许多问题。
多重继承的派生列表会包含多个基类。如:
class Panda : public Bear, public Endangered { /* ... */ }
每个基类都有一个可选的访问说明符,如果省略的话就提供默认的说明符,对于 class 是 private,对于 struct 是 public。与单一继承一样,派生列表中的类必须是已经定义的,并且不能是 final 的。语言并没有限制具体可以在派生列表中包含多少个类。每个基类只能在派生列表中出现一次。
多重派生类从每个基类中继承状态
在多重继承下,一个派生类对象将包含所有基类的子对象。如:Panda 类中包含了 Bear 和 Endangered 子对象,以及它自身定义的成员。
派生构造函数需要初始化所有基类
构建派生类对象需要构建和初始化其所有的直接基类子对象。如:
Panda::Panda(std::string name, bool onExhibit)
: Bear(name, onExhibit, "Panda"),
Endangered(Endangered::critical) { }
// 以下意味着 Bear 对象是默认初始化的
Panda::Panda()
: Endangered(Endangered::critical) { }
构造函数的初始化列表负责初始化所有的直接基类,可将参数传递个直接基类的构造函数作为实参。直接基类的初始化顺序它们出现在派生列表中的顺序。
继承构造函数和多重继承
在新标准下,可以用 using 声明的方式从一个或多个基类中继承构造函数,如果从多个基类中继承具有相同的签名的构造函数将导致编译错误。如果发生了这样的情况需要派生类重新定义此构造函数。如:
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;
using Base2::Base2;
// D1 must define its own constructor
D1(const std::string &s) : Base1(s), Base2(s) {}
// needed once D1 defines its own constructor
D1() = default;
};
析构函数和多重继承
多重继承的析构函数与单一继承的析构函数没有什么不同。析构函数本身只需要负责它自己的资源的释放,成员和基类的资源都由他们各自释放。如果没有定义析构函数,编译器会自动合成一个,其函数体依然是空的。析构的顺序与构造的顺序刚好是完全相反的,所以将先调用成员的析构函数(属于最底层的派生类),再依次以派生列表的相反顺序调用基类的析构函数。
多重派生类的拷贝和移动操作
与单继承一样,如果多重继承的子类如果要定义自己的拷贝、移动构造函数以及赋值操作符将必须拷贝、移动或赋值整个对象。只用派生类使用这些成员的合成版本时,基类才会自动进行拷贝、移动或赋值。在派生类的合成版本中会自动隐式使用基类的对应成员。
在多重继承下任何可访问的基类的指针或引用都可以绑定到派生对象上。事实上,编译器认为所有以上的转换是同样好的,意味着如下代码将是编译错误:
void print(const Bear&);
void print(const Endangered&);
Panda ying_yang("ying_yang");
print(ying_yang); // error: 二义性
基于指针或引用类型的查找
在多重继承中,对象、指针、引用的静态类型决定了使用哪个成员,即便是指向子类对象,其其它的基类的接口或者子类自己的接口亦是不可用的。
在单一继承下,派生类的作用域被嵌套在直接和间接基类中。名字的查找将沿着继承链一直往上,定义在派生类中的名字将屏蔽掉基类中的名字。在多重继承中,名称查找将同时在所有直接基类中查找,如果一个名字在多个基类中找到就被认为是具有二义性。即便是两个名字所代表的函数的函数原型不一样也是错误的,甚至两个名字其中一个不是函数也会产生二义性错误。与往常一样,名称查找发生在类型检查之前。
多重继承中继承相同名字是可以的,但是如果想要引用其中一个名字则需要指定哪个版本。最好的办法是在派生类中为这些可能产生二义性的名字重新定义一个函数。
一个类可能继承一个相同的基类多次,原因在于某些基类都继承自同一个基类。这种情况下将导致同一个基类有两个子对象。但是有时我们需要让这个相同的基类只有一个子对象。那我们通过虚继承(virtual inheritance)来解决此问题,共享的基类子对象成为虚基类(virtual base class),不管这个虚基类在继承链中出现了多少次,只有一个共享子对象。用法如下:
class Raccoon : public virtual ZooAnimal { };
class Bear : virtual public ZooAnimal { };
此处 ZooAnimal 是虚基类,virtual 告知愿意在接下来的继承中共享同一个基类对象,对于所使用的基类本身并没有什么特别的限制。
class Panda : public Bear, public Raccoon, public Endangered {};
Panda 对象中就只有一个 ZooAnimal 子对象了。
支持到基类的转换
不过一个基类是不是虚基类都可以用其指针或引用指向派生类对象。
虚基类成员的可见性
如果虚基类中的成员被其中一个路径上的派生子类对象覆盖而不是被所有路径上的派生子类对象覆盖的话,那么引用这个成员将是派生对象上的成员,如果所有路径都覆盖的话,那么就会产生二义性错误。这时最好的做法就是在底层的派生类对象中重新定义此成员。
在虚继承中,虚基类是由最后面的派生构造函数进行初始化,否则的话虚基类就可能在所有路径中被初始化,从而导致初始化多次。当继承体系中派生类如果可以独立被创建的话,它的构造函数也是最后面的派生构造函数,此时它也需要对虚基类进行初始化。在这种情况下将由最底层的派生类构造函数首先对虚基类子对象进行初始化,后面遇到的初始化虚基类子对象将会被忽略。然后再按照正常的派生列表顺序进行初始化其它基类。如:
Panda::Panda(std::string name, bool onExhibit) :
ZooAnimal(name, onExhibit, "Panda"),
Bear(name, onExhibit),
Raccoon(name, onExhibit),
Endangered(Endangered::critical)
{}
虚基类总是在非虚基类前被初始化,而不管它们出现在继承层级的哪个位置。
构造和析构顺序
如果一个类有多个虚基类,那么其顺序将按照出现在派生列表中的顺序进行初始化。如:
class Character {};
class BookCharacter : public Character {};
class ToyAnimal {};
class TeddyBear : public BookCharacter,
public Bear, public virtual ToyAnimal {};
将按照如下顺序进行初始化:
ZooAnimal(); // Bear 的虚基类
ToyAnimal(); // 直接虚基类
Character();
BookCharacter();
Bear();
TeddyBear();
对于拷贝和移动构造函数来说其顺序是一样的,合成的赋值操作符则是按照此顺序进行赋值的。而析构函数则以此反方向执行。
- 构建顺序(constructor order):在非虚继承的情况下,基类按照出现在派生列表中的顺序进行执行。在虚继承的情况下,虚基类将首先被构建,它们之间是按照出现在派生列表中的顺序进行构建的。只有最底层的派生类可以初始化这个虚基类子对象;出现在中间层次的类对其初始化的运算将会被忽略;
- 异常对象(exception object):用于 throw 和 catch 之间进行通讯的对象。此对象在 throw 时创建,是抛出表达式的拷贝,异常对象将存在直到匹配的处理器执行完,对象类型是抛出表达式的静态类型。
- 函数 try 块(function try block):使用来捕获构造初始化器中的异常。 try 关键字出现在开始初始化列表的冒号之前,并且 catch 子句出现在函数体的后面;
- 内联名称空间(inline namespace):在名称空间前加上 inline,成员将被放在外围的名称空间中;
- 名称空间(namespace):将库中的名字收集到一个单一作用域的机制,与其它的 C++ 作用域不一样,名称空间可以被定义几个分离的地方;
- noexcept 操作符:这个操作符返回一个 bool 来指示一个表达式是否会抛出异常,表达式本身不会被求值。结果是一个常量表达式,当为 true 时表示不抛出异常;
- noexcept 说明:用于告知函数是否为抛出异常的关键字,这个关键字后可以跟一个括号中的布尔值,当省略时或者布尔值为 true 时表示不会抛出异常。
- 不抛出异常说明(nonthrowing specification):函数不会抛出异常的说明,如果一个承诺不抛出异常的函数抛出了异常,将会调用 terminate;
- 匿名名称空间(unnamed namespace):没有名字的名称空间。匿名名称空间在每个文件中都有唯一的一个,并且可以直接访问而不需要作用域操作符。匿名名称空间中的名字在文件在是不可见的。