异常处理机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。
当执行一个
throw
时,跟在throw
后面的语句将不再被执行。相反,程序的控制权从throw
转移到与之匹配的catch
模块。该catch
可能是同一个函数中的局部catch
,也可能位于直接或间接调用了发生异常的函数的另一个函数中。此时:
- 沿着调用链的函数可能会提早退出。
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。
throw
的用法有点类似于return
语句:它通常作为条件语句的一部分或者作为某个函数的最后(或者唯一)一条语句。
栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的
catch
子句为止;或者也可能一直没找到匹配的catch
,则退出主函数后查找过程终止(terminate
)。
如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。编译器在销毁内置类型的对象时不需要做任何事情。
如果异常发生在构造函数中,即使某个对象只构造了一部分,也要确保已构造的成员能被正确地销毁。
类似的,异常也可能发生在数组或标准库容器的元素初始化过程中,此时应该确保已经构造的这部分元素被正确地销毁。
在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被终止(
terminate
)。因此,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try
语句块中,并且在析构函数内部得到处理。
在实际的编程过程中,因为析构函数仅仅是释放资源,所以它不太可能抛出异常。所有标准库类型都能确保它们的析构函数不会引发异常。
编译器使用异常抛出表达式来对异常对象进行拷贝初始化。如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。
当抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。如果一条throw
表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
如果
catch
无须访问抛出的表达式的话,则可以忽略捕获形参的名字。
声明的类型必须是完全类型,它可以是左值引用,但不能是右值引用。如果catch
的参数类型是非引用类型,则该参数是异常对象的一个副本;相反,如果参数是引用类型,则该参数是异常对象的一个别名。
如果catch
的参数是基类类型,则可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch
的参数是非引用类型,则异常对象将被切掉一部分。另一方面,如果catch
的参数是基类的引用,则该参数将以常规方式绑定到异常对象上。
需要注意的是,异常声明的静态类型将决定catch
语句所能执行的操作。如果catch
的参数是基类类型,则catch
无法使用派生类特有的任何成员。
通常情况下,如果catch
接受的异常与某个继承体系有关,则最好将该catch
的参数定义成引用类型。
越是专门的
catch
越应该置于整个catch
列表的前端,所以当程序使用具有继承关系的多个异常时,派生类异常的处理代码应该出现在基类异常的处理代码之前。
与实参和形参的匹配规则相比,异常和catch
异常声明的匹配规则受到更多限制:
- 允许从非常量向常量的类型转换,一条非常量对象的
throw
语句可以匹配一个接受常量引用的catch
语句。- 允许从派生类向基类的类型转换。
- 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。
有时,一个单独的
catch
不能完整地处理某个异常。在执行了某些校正操作之后,当前的catch
可能会决定由调用链更上一层的函数接着处理异常:
// 只能出现在catch语句或catch语句直接或间接调用的函数之内,如果在处理代码
// 之外的区域遇到了空throw语句,编译器将调用terminate。
throw;
很多时候,
catch
语句会改变其参数的内容。如果在改变了参数的内容后catch
语句重新抛出异常,则只有当catch
异常声明是引用类型时对参数所做的改变才会被保留并继续传播:
catch (my_error &eObj) { // 引用类型
eObj.status = errCodes::severeErr; // 修改了异常对象
throw; // 异常对象的status成员是severeErr
} catch (other_error eObj) { // 非引用类型
eObj.status = errCodes::badErr; // 只修改了异常对象的局部副本
throw; // 异常对象的status成员没有改变
}
有时希望不论抛出的异常是什么类型,程序都能统一捕获它们。
void manip() {
try {
// 这里的操作将引发并抛出一个异常
} catch(...) {
// 处理异常的某些特殊操作
throw;
}
}
catch(...)
通常与重新抛出语句一起使用,其中catch
执行当前局部能完成的工作,随后重新抛出异常。catch(...)
既能单独出现,也能与其他几个catch
语句一起出现,此时catch(...)
必须在最后的位置。
构造函数在进入其函数体之前首先执行初始值列表,因此,此时构造函数体内的
catch
语句无法处理相关的异常。所以,必须将构造函数写成函数try
语句块的形式:
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
语句块的一部分。该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。
在c++11新标准中,可以通过提供
noexcept
说明指定某个函数不会抛出异常:
void recoup(int) noexcept; // 不会抛出异常
void alloc(int); // 可能抛出异常
noexcept
说明要么出现在函数的所有声明语句和定义语句中,要么一次也不出现。- 该说明应该在函数的尾置返回类型之前。
- 可以在函数指针的声明和定义中指定
noexcept
。- 在
typedef
或类型别名中则不能出现noexcept
。- 在成员函数中,
noexcept
说明符需要跟在const
及引用限定符之后,而在final
、override
或虚函数的=0
之前。
编译器并不会在编译时检查
noexcept
说明。实际上,如果一个函数在说明了noexcept
的同时又含有throw
语句或者调用了可能抛出异常的其他函数,编译器将顺利通过,并不会因为这种违反异常说明的情况而报错:
void f() noexcept {
throw exception(); // 违反了异常说明
}
此时,程序会调用
terminate
以确保遵守不在运行时抛出异常的承诺。因此,noexcept
可以用在两种情况下:一是确认函数不会抛出异常,二是根本不知道该如何处理异常。
指明某个函数不会抛出异常可以令该函数的调用者不必再考虑如何处理异常。无论是函数确实不抛出异常,还是程序被终止,调用者都无须为此负责。
noexcept
说明符接受一个可选的实参,该实参必须能转换为bool
类型:如果实参是true
,则函数不会抛出异常;如果实参是false
,则函数可能抛出异常:
void recoup(int) noexcept(true); // recoup不会抛出异常
void alloc(int) noexcept(false); // alloc可能抛出异常
noexcept
说明符的实参常常与noexcept
运算符混合使用。
noexcept
运算符是一个一元运算符,返回值是一个bool
类型的右值常量表达式,用于表示给定的表达式是否会抛出异常,不会求其运算对象的值:
noexcept(recoup(i)) // 如果recoup不抛出异常则结果为true;否则结果为false。
// 更普通的形式是:
noexcept(e)
// 当e调用的所有函数都做了不抛出说明且e本身不含有throw语句时,上述表达式为true;
// 否则noexcept(e)返回false。
// 可以使用noexcept运算符得到如下的异常说明:
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都可能抛出异常。
如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常:
class Base {
public:
virtual double f1(double) noexcept; // 不会抛出异常
virtual int f2() noexcept(false); // 可能抛出异常
virtual void f3(); // 可能抛出异常
};
class Derived : public Base {
public:
double f1(double); // 错误:Base::f1承诺不会抛出异常。
int f2() noexcept(false); // 正确:与Base::f2的异常说明一致。
void f3() noexcept; // 正确:Derived的f3做了更严格的限定,这是允许的。
};
当编译器合成拷贝控制成员时,同时也生成一个异常说明:
- 如果对所有成员和基类的所有操作都承诺了不会抛出异常,则合成的成员是
noexcept
的。- 如果合成成员调用的任意一个函数可能抛出异常,则合成的成员是
noexcept(false)
。- 如果定义了一个析构函数但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明将与假设由编译器为类合成析构函数时所得的异常说明一致。
在这些类中,
what
负责返回用于初始化异常对象的信息。因为what
是虚函数,所以当捕获基类的引用时,对what
函数的调用将执行与异常对象动态类型对应的版本。
// 为某个书店应用程序设定的异常类
class out_of_stock : public std::runtime_error {
public:
explicit out_of_stock(const std::string &s) : std::runtime_error(s) {}
};
class isbn_mismatch : public std::logic_error {
public:
explicit isbn_mismatch(const std::string &s) : std::logic_error(s) {}
isbn_mismatch(const std::string &s, const std::string &lhs, const std::string &rhs) :
std::logic_error(s), left(lhs), right(rhs) {}
const std::string left, right;
};
// 如果参与加法的两个对象并非同一本书籍,则抛出一个异常。
Sales_data &Sales_data::operator+=(const Sales_data &rhs) {
if (isbn() != rhs.isbn()) {
throw isbn_mismatch("wrong isbns", isbn(), rhs.isbn());
}
units_sold += rhs.units_sold;
revenue += rhs.revenue;
return *this;
}
// 使用之前设定的书店程序异常类
Sales_data item1, item2, sum;
while (cin >> item1 >> item2) { // 读取两条交易信息
try {
sum = item1 + item2; // 计算它们的和
// 此处使用sum
} catch(const isbn_mismatch &e) {
cerr << e.what() << ": left isbn(" << e.left
<< ") right isbn(" << e.right << ")" << endl;
}
}
namespace cplusplus_primer {
// ...
} // 无须分号,与块类似。
命名空间的名字必须在定义它的作用域内保持唯一。
定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。位于该命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间:
cplusplus_primer::Query q = cplusplus_primer::Query("hello");
// 可能是定义了一个名为nsp的新命名空间,也可能是
// 为已经存在的命名空间添加一些新成员。
namespace nsp {
// ...
}
这种特性使得可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似于管理自定义类及函数的方法:
- 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中。
- 命名空间成员的定义部分则置于另外的源文件中。
在程序中某些实体只能定义一次:如非内联函数、静态数据成员、变量等,命名空间中定义的名字也需要满足这一要求,可以通过上面的方式组织命名空间并达到目的。这种接口和实现分离的机制确保所需的函数和其他名字只定义一次,而只要是用到这些实体的地方都能看到对于实体名字的声明。
// Sales_data.h
// #include应该出现在打开命名空间的操作之前,否则隐含的意思是
// 把头文件中所有的名字定义成该命名空间的成员。
#include
namespace cplusplus_primer {
class Sales_data { /* ... */ };
Sales_data operator+(const Sales_data &, const Sales_data &);
// Sales_data的其他接口函数的声明
}
// Sales_data.cpp
// 确保#include出现在打开命名空间的操作之前
#include "Sales_data.h"
namespace cplusplus_primer {
// Sales_data成员及重载运算符的定义,可以直接使用名字,此时无须前缀。
}
// user.cpp
#include "Sales_data.h"
int main() {
using cplusplus_primer::Sales_data;
Sales_data trans1, trans2;
// ...
return 0;
}
这种程序的组织方式提供了开发者和库用户所需的模块性。每个类仍组织在自己的接口和实现文件中,一个类的用户不必编译与其他类相关的名字。库的开发者可以分别实现每一个类,相互之间没有干扰。
可以在命名空间定义的外部定义该命名空间的成员,但是这样的定义必须出现在所属命名空间的外层空间中,而不能在一个不相关的作用域中。
// 命名空间之外定义的成员必须使用含有前缀的名字,一旦看到含有完整前缀的名字,
// 就可以确定该名字位于命名空间的作用域内,因此可以直接使用该命名空间的其他成员。
cplusplus_primer::Sales_data
cplusplus_primer::operator+(const Sales_data &lhs, const Sales_data &rhs) {
// ...
}
模板特例化必须定义在原始模板所属的命名空间中:
// 必须将模板特例化声明成std的成员
namespace std {
template<> struct hash<Sales_data>;
}
// 在std中添加了模板特例化的声明后,就可以在命名空间std的外部定义它了。
template<> struct std::hash<Sales_data> {
size_t operator()(const Sales_data &) const {
return hash<string>()(s.bookNo) ^
hash<unsigned>()(s.units_sold) ^
hash<double>()(s.revenue);
}
// 其他成员保持一致
}
C++11新标准引入,内联命名空间中的名字可以被外层命名空间直接使用。当应用程序的代码在一次发布和另一次发布之间发生了改变时,常常会用到内联命名空间。
// 必须出现在第一次定义的地方,后续可写可不写。
inline namespace FifthEd {
// 该命名空间表示第5版的代码
}
namespace FifthEd { // 隐式内联
class Query_base { /* ... */ };
// 其他与Query有关的声明
}
namespace FourthEd {
class Item_base { /* ... */ };
class Query_base { /* ... */ };
// 第4版用到的其他代码
}
// 因为FifthEd是内联的,所以形如cplusplus_primer::的代码
// 可以直接获得FifthEd的成员。如果想使用早起版本的代码,则
// 必须像其他嵌套的命名空间一样加上完整的外层命名空间名字。
namespace cplusplus_primer {
#include "FifthEd.h"
#include "FourthEd.h"
}
未命名的命名空间是指关键字
namespace
后紧跟花括号括起来的一系列声明语句。其中定义的变量拥有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。
一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关。因此,如果一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。
定义在未命名的命名空间中的名字可以直接使用,毕竟找不到什么名字来限定它们;同样的,也不能对未命名的命名空间的成员使用作用域运算符。
未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同。如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字有所区别:
int i; // i的全局声明
namespace {
int i;
}
// 二义性:i的定义既出现在全局作用域中,又出现在未嵌套的未命名的命名空间中。
i = 10;
namespace local {
namespace {
int i;
}
}
// 正确:定义在嵌套的未命名的命名空间中的i与全局作用域中的i不同。
local::i = 42;
未命名的命名空间取代文件中的静态声明:
在标准c++引入命名空间的概念之前,程序需要将名字声明成static
的以使得其对于整个文件有效。这样的做法是从c语言继承而来的。在c语言中,声明为static
的全局实体在其所在的文件外不可见。
在文件中进行静态声明的做法已经被c++标准取消了,现在的做法是使用未命名的命名空间。
// 不能在命名空间还没有定义前就声明别名。
// 别名也可以指向一个嵌套的命名空间。
namespace primer = cplusplus_primer;
一条
using
声明语句一次只引入命名空间的一个成员,可以清楚地知道程序中所用的到底是哪个名字。
using
声明的有效范围从声明的地方开始,一直到其所在的作用域结束为止。在此过程中,外层作用域的同名实体将被隐藏。
using
指示无法控制哪些名字是可见的,因为所有名字都是可见的。简写的名字从using
指示开始,一直到其所在的作用域结束都能使用。
using
声明的名字的作用域与语句本身的作用域一致,从效果上看就好像为命名空间的成员在当前作用域内创建了一个别名一样。
而using
指示具有将命名空间成员提升到包含命名空间本身和using
指示的最近作用域的能力。通常情况下,命名空间中会含有一些不能出现在局部作用域中的定义,因此,using
指示一般被看作是出现在最近的外层作用域中。
// 命名空间A和函数f定义在全局作用域中
namespace A [
int i, j;
}
void f() {
using namespace A; // 把A中的名字注入到全局作用域中
cout << i * j << endl; // 使用命名空间A中的i和j
// ...
}
namespace blip {
int i = 16, j = 15, k = 23;
// 其他声明
}
int j = 0; // 正确:blip的j隐藏在命名空间中。
void manip() {
// using指示,blip中的名字被添加到全局作用域中。
using namespace blip; // 如果使用了j,则将在::j和blip::j之间产生冲突。
++i; // 将blip::i设定为17
++j; // 二义性错误:是全局的j还是blip::j?
++::j; // 正确:将全局的j设定为1。
++blip::j; // 正确:将blip::j设定为16。
int k = 97; // 当前局部的k隐藏了blip::k
++k; // 将当前局部的k设定为98
}
头文件如果在其顶层作用域中含有
using
指示或声明,则会将名字注入到所有包含了该头文件的文件中。
通常情况下,头文件应该只负责定义接口部分的名字,而不定义实现部分的名字。因此,头文件最多只能在它的函数或命名空间内使用using
指示或using
声明。
避免
using
指示:
using
指示引发的二义性错误只有在使用了冲突名字的地方才能被发现;而**using
声明引起的二义性问题在声明处就能发现**。
using
指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using
指示。
对命名空间内部名字的查找遵循常规的查找规则:即由内向外依次查找每个外层作用域。只有位于开放的块中且在使用点之前声明的名字才被考虑:
namespace A {
int i;
namespace B {
int i; // 在B中隐藏了A::i
int j;
int f1() {
int j; // j是f1的局部变量,隐藏了A::B::j
return i; // 返回B::i
}
} // 命名空间B结束,此后B中定义的名字不再可见。
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; // 在C1中隐藏了A::i
int j;
};
int h = i; // 用A::i进行初始化
}
// 成员f3定义在C1和命名空间A的外部
int A::C1::f3() { return h; } // 正确:返回A::h。
std::string s;
// 不用std::限定符和using声明就可以调用operator>>
std::cin >> s;
// 等价于:
operator>>(std::cin, s);
// 首先在当前作用域中寻找合适的函数,接着查找输出语句的外层作用域。
// 随后,因为>>表达式的形参是类类型的,所以编译器还会查找cin和s的类
// 所属的命名空间,即std。
当给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间,对于传递类的引用或指针的调用同样有效。
因此,允许概念上作为类接口一部分的非成员函数无须单独的using
声明就能被程序使用。
通常情况下,如果在应用程序中定义了一个标准库中已有的名字,则:要么根据一般的重载规则确定某次调用应该执行函数的哪个版本;要么应用程序根本就不会执行函数的标准库版本。
由于标准库中move
和forward
都是模板函数,且都接受一个右值引用的函数形参(可以匹配任何类型),因此,如果应用程序也定义了一个接受单一形参的move
/forward
函数,则不管该形参是什么类型,都将与标准库的版本冲突。
因此,move
/forward
的名字冲突要比其他标准库函数的冲突频繁得多,所以建议最好使用带限定语的完整版本。
当类声明了一个友元时,该友元声明并没有使得友元本身可见。然而,一个另外的未声明的类或函数如果第一次出现在友元声明中,则认为它是最近的外层命名空间的成员:
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没有被声明。
}
对于接受类类型实参的函数来说,将在每个实参类(以及实参类的基类)所属的命名空间中搜寻候选函数。在这些命名空间中所有与被调函数同名的函数都将被添加到候选集当中,即使其中某些函数在调用语句处不可见也是如此:
namespace NS {
class Quote { /* ... */ };
void display(const Quote &) { /* ... */ }
}
// Bulk_item的基类声明在命名空间NS中
class Bulk_item : public NS::Quote { /* ... */ };
int main() {
Bulk_item book1;
display(book1);
return 0;
}
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指示把名字添加到print调用的候选函数集
using namespace libs_R_us;
// print调用此时的候选函数集包括:
// libs_R_us的print(int)
// libs_R_us的print(double)
// 显式声明的print(const std::string &)
void fooBar(int ival) {
print("Value: "); // 调用全局函数print(const std::string &)
print(ival); // 调用libs_R_us::print(int)
}
对于
using
指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要指明调用的是命名空间中的函数版本还是当前作用域的版本即可。
如果存在多个
using
指示,则来自每个命名空间的名字都会成为候选函数集的一部分:
namespace AW {
int print(int);
}
namespace Primer {
double print(double);
}
// using指示从不同的命名空间中创建了一个重载函数集合
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;
}
多重继承是指从多个直接基类产生派生类的能力。多重继承的派生类继承了所有父类的属性。
class Bear : public ZooAnimal { /* ... */ };
class Panda : public Bear, public Endangered { /* ... */ };
需要注意的是,在某个给定的派生列表中,同一个基类只能出现一次。
在多重继承关系中,派生类的对象包含有每个基类的子对象。
构造一个派生类的对象将同时构造并初始化它的所有基类子对象。多重继承的派生类的构造函数初始值也只能初始化它的直接基类:
// 显式地初始化所有基类
// 首先初始化ZooAnimal,其次是Bear,然后是Endangered,最后是Panda。
Panda::Panda(std::string name, bool onExhibit)
: Bear(name, onExhibit, "Panda"), Endangered(Endangered::critical) {}
// 隐式地使用Bear的默认构造函数初始化Bear子对象
Panda::Panda()
: Endangered(Endangered::critical) {}
基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。析构函数的调用顺序则与构造函数相反。
在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);
};
// D1试图从两个基类中都继承D1::D1(const string &),
// 如果不定义自己的版本将引发错误。
struct D1 : public Base1, public Base2 {
using Base1::Base1; // 从Base1继承构造函数
using Base2::Base2; // 从Base2继承构造函数
// D2必须自定义一个接受string的构造函数
D2(const string &s) : Base1(s), Base2(s) {}
D2 = default; // 一旦D2定义了它自己的构造函数,则必须出现。
};
与单继承一样,多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动或赋值操作。
只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分执行这些操作。在合成的拷贝控制成员中,每个基类分别使用自己的对应成员隐式地完成构造、赋值或销毁等工作。
Panda ying_yang("ying_yang");
// 将调用Bear的拷贝构造函数,后者又在执行自己的拷贝任务之前先调用
// ZooAnimal的拷贝构造函数。一旦ling_ling的Bear部分构造完成,接着
// 就会调用Endangered的拷贝构造函数来创建对象相应的部分。最后,执行
// Panda的拷贝构造函数。合成的移动构造函数/拷贝赋值运算符类似。
Panda ling_ling = ying_yang; // 使用拷贝构造函数
可以令某个可访问基类的指针或引用直接指向一个派生类对象:
// 接受Panda的基类引用的一系列操作
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的引用
编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。
对象、指针和引用的静态类型决定了能够使用哪些成员。
Bear *pb = new Panda("ying_yang");
pb->print(); // 正确:Panda::print()。
pb->cuddle(); // 错误:不属于Bear的接口。
pb->highlight(); // 错误:不属于Bear的接口。
delete pb; // 正确:Panda::~Panda()。
Endangered *pe = new Panda("ying_yang");
pe->print(); // 正确:Panda::print()。
pe->toes(); // 错误:不属于Endangered的接口。
pe->cuddle(); // 错误:不属于Endangered的接口。
pe->highlight(); // 正确:Panda::highlight()。
delete pe; // 正确:Panda::~Panda()。
在多重继承的情况下,查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。
对于一个派生类来说,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时必须明确指出它的版本。
// 如果ZooAnimal和Endangered都定义了名为max_weight的成员,并且
// Panda没有定义该成员,则下面的调用是错误的。此时,需要指出所调用
// 的版本:ZooAnimal::max_weight或者Endangered::max_weight。
double d = ying_yang.max_weight();
一种更复杂的情况是,有时即使派生类继承的两个函数形参列表不同也可能引发错误。此时,即使函数在一个类中是私有的,而在另一个类中是公有的或受保护的同样也可能发生错误。
和往常一样,先查找名字后进行类型检查。当编译器在两个作用域中同时发现了相同的成员时,将直接报告一个调用二义性的错误。
要想避免潜在的二义性,最好的办法是在派生类中为该函数定义一个新版本。
double Panda::max_weight() const {
return std::max(ZooAnimal::max_weight(), Endangered::max_weight());
}
尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类。
在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。
因此,对于形如iostream
这样的类显然是行不通的。一个iostream
对象肯定希望在同一个缓冲区中进行读写操作,也会要求条件状态能同时反映输入和输出操作的情况。假如在iostream
对象中真的包含了base_ios
的两份拷贝,则共享行为就无法实现了。
在c++中通过虚继承的机制解决这样的问题。虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。
在实际的编程过程中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。通常情况下,使用虚继承的类层次是由一个人或一个项目组一次性设计完成的。对于一个独立开发的类来说,很少需要基类中的某一个是虚基类,况且新基类的开发者也无法改变已存在的类体系。
在派生列表中添加关键字
virtual
,后续的派生类当中共享虚基类的同一份实例:
// 关键字public和virtual的顺序随意
class Raccoon : public virtual ZooAnimal { /* ... */ };
class Bear : virtual public ZooAnimal { /* ... */ };
// 如果某个类指定了虚基类,则该类的派生仍按常规方式进行:
class Panda : public Bear,
public Raccoon, public Endangered { /* ... */ };
// 不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。
void dance(const Bear &);
void rummage(const Raccoon &);
ostream &operator<<(ostream &, const ZooAnimal &);
Panda ying_yang;
dangce(ying_yang); // 正确:把一个Panda对象当成Bear传递。
rummage(ying_yang); // 正确:把一个Panda对象当成Raccoon传递。
cout << ying_yang; // 正确:把一个Panda对象当成ZooAnimal传递。
因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则仍然可以直接访问这个被覆盖的成员。但是如果成员被多余一个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。
在虚派生中,虚基类是由最低层的派生类初始化的。之所以这样设计,不妨假设当以普通规则处理初始化任务时会发生什么情况。在此例中,虚基类将会在多条继承路径上被重复初始化。
当然,继承体系中的每个类都可能在某个时刻成为最低层的派生类。只要能创建虚基类的派生类对象,该派生类的构造函数就必须初始化它的虚基类。
// 当创建一个Bear(或Raccoon)的对象时,它已经位于派生的最低层,
// 因此Bear(或Raccoon)的构造函数将直接初始化其ZooAnimal基类部分:
Bear:Bear(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Bear") {}
Raccoon::Raccoon(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Raccoon") {}
// 当创建一个Panda对象时,其位于派生的最低层并由它负责初始化共享的ZooAnimal基类部分。
// 即使ZooAnimal不是Panda的直接基类,Panda的构造函数也可以初始化ZooAnimal。
Panda::Panda(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Panda"),
Bear(name, onExhibit),
Raccoon(name, onExhibit),
Endangered(Endangered::critical),
sleeping_flag(false) {}
首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。
虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
一个类可以有多个虚基类。此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造:
class Character { /* ... */ };
class BookCharacter : public Character { /* ... */ };
class ToyAnimal { /* ... */ };
class TeddyBear : public BookCharacter,
public Bear,
public virtual ToyAnimal { /* ... */ };
编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。
// 创建一个TeddyBear对象需要按照如下次序调用这些构造函数:
ZooAnimal(); // Bear的虚基类
ToyAnimal(); // 直接虚基类
Character(); // 第一个非虚基类的间接基类
BookCharacter(); // 第二个直接非虚基类
Bear(); // 第二个直接非虚基类
TeddyBear(); // 最低层的派生类
合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。和往常一样,对象的销毁顺序与构造顺序正好相反。