在C++语言中,我们通过抛出throwing
一条表达式来引发raised
一个异常。当执行一个throw
时,跟在throw
后面的语句将不再被执行。相反,程序的控制权从throw
转移到与之匹配的catch
模块。
throw
类似于return
,其后面的代码不会再被执行。
如果对抛出异常的函数的调用语句位于一个try语句内,则检查与该try块关联的catch子句。如果找到了匹配的catch,就使用该catch处理异常。否则,如果该try语句嵌套在其他try块中,则继续检查与外层try匹配的catch子句。如果仍然没有找到匹配的catch,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,以此类推。
上述过程被称为栈展开stack unwinding
。
栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的catch子句为止,或者也可能一直没有找到匹配的catch,则退出主函数后查找过程终止。
如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。与往常一样,编译器在销毁内置类型的对象时不需要做任何事情。
如果异常发生在构造函数中,则当前的对象可能只构造了一部分(有些成员已经初始化了,另一些成员在异常发生前也许还没有初始化)。即使某个对象只构造了一部分,我们也要确保已构造的成员能被正确地销毁。
析构函数总是会被执行的,但是函数中负责释放资源的代码 却可能被跳过。
如果一个块分配了资源,并且在负责释放这些资源的代码前面发生了异常,则释放资源的代码不会被执行。
因此我们使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正常释放。
由于栈展开可能使用析构函数,因此析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能正常抛出异常的操作,则该操作也应该被放置在一个try语句块当中,并且在析构函数内部得到处理。(在实际的编程过程中,因为析构函数仅仅是释放资源,所有他不太可能抛出异常,所有标准库类型都能确保它们的析构函数不会抛出异常)。
在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序被终止。
有时一个单独的catch语句不能完整地处理某个异常。一条catch语句通过重新抛出的操作将异常传递给另一个catch语句。这里的重新抛出仍然是一条throw语句,只不过不包含任何表达式:
// 空的throw语句只能在catch语句或catch语句或catch语句直接直接或间接调用的函数之外
// 如果在处理代码之外的区域遇到了空thrrow语句, 编译器将调用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成员不会被改变
}
有时候我们希望不论抛出的异常是什么类型,程序都能统一捕获它们。catch(...)
通常与重新抛出语句一起使用,其中catch执行当前局部能完成的工作,随后重新抛出异常。
void mainp() {
try {
// 这里的操作将引发并抛出一个异常
}
catch (...) {
// 处理异常的某些特殊操作
throw;
}
}
catch(...)
既能单独出现,也能与其他几个catch语句一起出现。如果catch(...)
与其他几个catch语句一起出现,则catch(...)
必须在最后的位置。出现在捕获所有异常语句后面的catch语句将永远不会被匹配。
要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数try语句块。函数try语句使得一组catch语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化该过程(或析构函数的析构过程):
template <typename T>
Blob<T>::Blob(std::initializer_list<T> il) try :
// 与这个try关联的catch既能处理构造函数体抛出的异常, 也能处理成员的初始化列表抛出的异常
data(std::make_shared<std::vector<T>>(il)) {
/* 空函数体 */
} catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
需要注意在初始化构造函数的参数时也可能发生异常,这样的异常不属于函数try语句块的一部分,函数try语句块只能处理构造函数开始执行后发生的异常。与其他函数调用一样,如果在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。
处理构造函数初始值异常的唯一方法是将构造函数写成函数try语句块。
对于用户和编译器来说,预先直到某个函数不会抛出异常显然大有裨益。首先直到函数不会抛出异常有助于简化调用该函数的代码;其次如果编译器确认函数不会抛出异常,它就能执行某些特殊的优化操作,而这些优化操作并不适用与可能出错的代码。
在C++11新标准中,我们可以通过提供noexcept说明指定某个函数不会抛出异常:
void recoup(int) noexcept; // 不会抛出异常
对于一个函数来说,
noexcept
说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。
违反异常说明:
// 尽管该函数明显违反了异常说明,但是它仍然可以顺利编译通过
void f() noexcept // 承诺不会抛出异常
{
throw exception(); // 违反了异常说明
}
一旦一个noexcept函数抛出了异常,程序就会调用terminate以确保遵守不在运行时抛出异常的承诺。因此noexcept可以用于两种情况:
标准库异常类构造了如下继承体系:
exception
├── bad_cast
├── runtime_error
| ├── overflow_error
| ├── underflow_error
| ├── range_error
├── logic_error
| ├── domain_error
| ├── invalid_argument
| ├── out_of_range
| ├── length_error
├── bad_alloc
我们也可以使用自己的异常类,抛出isbn_mismatch异常:
// 如果参加加法的两个对象不是同一书籍,则抛出一个异常
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
分割了全局命名空间,其中每个命名空间是一个作用域。
同其他作用域类似,命名空间中的每个名字都必须表示该空间内的唯一实体。因为不同命名空间的作用域不同,所以在不同命名空间内可以有相同名字的成员。
模板特例化必须定义在原始模板所属的命名空间中,和其他命名空间名字类似,只要我们在命名空间中声明了特例化,就能在命名空间外部定义它了:
// 我们必须将模板特例化声明成std的成员
namespace std {
template <> struct hash<Sales_data>;
}
// 在std中添加了模板特例化的声明后,我们就可以在命名空间std的外部定义它了
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);
}
};
全局作用域中定义的名字(即在所有类、函数以及命名空间之外定义的名字)也就是定义在全局命名空间global namespace
中。全局作用域是隐式的,所以它并没有名字,下面的形式表示全局命名空间中一个成员:
::member_name
namespace cplusplus_primer {
namespace QueryLib {
class Query { /*...*/ };
// ...
}
// ...
}
// 调用方式
cplusplus_primer::QueryLib::Query
C++11新标准引入了一种新的嵌套命名空间,称为内联命名空间inline namespace
。内联命名空间可以被外层命名空间直接使用。定义内联命名空间的方式是在关键字namespace
前添加关键字inline
:
// inline必须出现在命名空间第一次出现的地方
inline namespace FifthEd {
// ...
}
// 后续再打开命名空间的时候可以写inline也可以不写
namespace FifthEd { // 隐式内敛
// ...
}
当应用程序的代码在一次发布和另一次发布之间发生改变时,常使用内联命名空间。例如我们把本书当前版本的所有代码放在一个内联命名空间中,而之前版本的代码都放在一个非内联命名空间中:
namespace FourthEd {
class Query_base { /*...*/ };
// 本书第4版用到的其他代码
}
// 命名空间cplusplus_primer将同时使用这两个命名空间
namespace cplusplus_primer {
#include "FifthEd.h"
#include "FoutthEd.h"
}
因为FifthEd是内联的,所以形如cplusplus_primer::
的代码可以直接获得FifthEd的成员,如果我们想用到早期版本的代码,则必须像其他嵌套的命名空间一样加上完整的外层命名空间名字:
cplusplus_primer::FourthEd::Query_base
关键字namespace
后紧跟花括号括起来的一系列声明语句是未命名的命名空间unnamed namespace
。未命名的命名空间中定义的变量具有静态生命周期:它们在第一次使用前被创建,直到程序结束时才销毁。
每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关。在这两个未命名的命名空间里面可以定义相同的名字,并且这些定义表示的是不同实体。如果一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。
和其他命名空间不同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。
未命名的命名空间取代文件中的静态声明: 在标准C++引入命名空间的概念之前,程序需要将名字声明成static的以使其对于整个文件有效。在文件中进行静态声明的做法是从C语言继承而来的。在C语言中,声明为static的全局实体在其所在的文件外不可见。 在文件中进行静态声明的做法已经被C++标准取消了,现在的做法是使用未命名的命名空间。
namespace primer = cplusplus_primer;
// 命名空间的别名也可以指向一个嵌套的命名空间
namespace Qlib = cplusplus_primer::QueryLib;
头文件如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含该头文件的文件中。通常情况下,头文件应该只负责定义接口部分的名字,而不定义实现部分的名字。因此头文件最多只能在它的函数或命名空间中使用using指示或using声明。
using指示一次性注入某个命名空间中的所有名字,这种用法充满风险:命名空间中所有的成员变得可见了。相比于使用using指示,在程序中对命名空间的每个成员分别使用using声明效果更好,这样可以减少注入到命名空间中的名字数量。using指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用。
using声明语句声明的是一个名字,而非一个特定的函数:
using NS::print(int); // 错误: 不能指定形参列表
using NS::print; // 正确: using声明只声明一个名字
我们为函数书写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 { /*...*/ };
在多重继承关系中,派生类的对象包含每个基类的子对象:
Panda对象的概念结构.png
// 显式地初始化所有基类
Panda::Panda(std::string name, bool ohExhibit)
: Bear(name, ohExhibit, "Panda"),
Endangered(Endagered::critical) { }
// 隐式地使用Bear的默认构造函数初始化Bear子对象
Panda::Panda()
: Endangered(Endagered::critical) { }
其中基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。一个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);
};
// 错误: 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), Base(2) { }
D2() = default; // D2一旦定义了它自己的构造函数, 则必须出现
};
派生类的析构函数只负责清除派生类本身分配的资源,派生类的成员以及基类都是自动销毁的。合成的析构函数体为空。析构函数的调用顺序正好与构造函数相反,在上面的例子中析构函数的调用函数是:
~Panda ~Endangered ~Bear ~ZooAnimal
与只有一个基类的继承一样,多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动或赋值操作。
只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分执行这些操作。在合成的拷贝控制成员中,每个基类分别使用自己对应成员隐式地完成构造、赋值或销毁等工作。
比如Panda合成版本的拷贝构造函数:
合成的移动构造函数、拷贝赋值运算符的工作机理类似。
在只有一个基类的情况下,派生类的指针或者引用能自动转换成一个可访问基类的指针或者引用。
在上面的例子中,我们令某个可访问基类的指针或引用直接指向一个派生类对象,例如一个ZooAnimal、Bear或Endangered类型的指针或引用可以绑定到Panda对象上:
// 接收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的引用
注意编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。要注意避免二义性错误:
void print(const Bear&);
void print(const Endangered&);
Panda ying_yang("ying_yang");
print(ying_yang); // 二义性操作, print函数需要带上前缀限定符
与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员。举个例子,比如我们在不同类中定义了如下的虚函数:
函数 | 含有自定义版本的类 |
---|---|
ZooAnimal::ZooAnimal<br />Bear::Bear<br />Endangered::Endangered<br />Panda::Panda | |
highlight | Endangered::Endangered<br />Panda::Panda |
toes | Bear::Bear<br />Panda::Panda |
cuddle | Panda::Panda |
析构函数 | ZooAnimal::ZooAnimal<br />Endangered::Endangered |
考虑一下如下调用:
Bear *pb = new Panda("ying_yang");
pb->print(); // 正确: Panda::print()
pb->cuddle(); // 错误: 不属于Bear的接口
pb->highlignt(); // 错误: 不属于Bear的接口
delete pb; // 正确: Panda::~Panda()
当我们通过Endangered的指针或者引用访问一个Panda对象时,Panda接口中Panda特有的部分以及属于Bear部分是不可见的:
Endangered *pe = new Panda("ying_yang");
pe->print(); // 正确: Panda::print()
pe->toes(); // 错误: 不属于Endangered的接口
pe->cuddle(); // 错误: 不属于Endangered的接口
pe->highlignt(); // 正确: Panda::highlight()
delete pb; // 正确: Panda::~Panda()
在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中。查找过程沿着继承体系自底向上进行,直到找到所需的名字。派生类的名字将隐藏基类的同名成员。
在多重继承的情况下,相同的查找过程在所有直接基类中同时进行,如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。对于一个派生类而言,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时必须明确指出它的版本。
当一个类具有多个基类时,有可能出现派生类从两个或者多个基类中继承了同名成员的情况。此时不加前缀限定符直接使用该名字将引发二义性。
要想避免潜在的二义性,最好的办法是在派生类中为该函数定义一个新版本。例如:
double Panda::max_weight() const
{
return std::max(ZooAnimal::max_weight(),
Endangered::max_weight());
}
尽管在派生类列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类:
在默认情况下,派生类含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。
这种情况对于形如iostream的类显然是行不通的。一个iostream对象肯定希望在同一个缓冲区中进行读写操作,也会要求条件状态能同时反映输入和输出操作的情况。假如iostream对象中真的包含base_ios的两份拷贝,则上述的共享行为就无法实现了。
在C++中我们通过虚继承的机制解决问题。虚继承的目的是令某个类作出声明,承诺愿意共享它的基类。这种机制下,无论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
我们令Panda类同时继承Bear和Raccoon。为了避免赋予Panda两份ZooAnimal子对象,我们将Bear和Raccoon继承ZooAnimal的方式改成虚继承。新的继承体系如下图:
Panda的继承体系.png
虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
我们指定虚基类的方式是在派生列表中添加关键字virtual:
// 关键字public和virtual的顺序随意
class Raccoon : public virtual ZooAnimal { /*...*/ };
class Bear : virtual public ZooAnimal { /*...*/ };
如果某个类指定了虚基类,则该类的派生仍然按常规方式进行:
class Panda : public Bear,
public Raccoon, public Endangered {
};
不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。例如下面这些从Panda向基类的类型转换都是合法的:
void dance(const Bear&);
void rummage(const Raccoon&);
ostream& operator<<(ostream&, const ZooAnimal&);
Panda ying_yang;
dance(ying_yang); // 正确: 把一个Panda对象当成Bear传递
rummage(ying_yang); // 正确: 把一个Panda对象当成Raccoon传递
cout << ying_yang; // 正确: 把一个Panda对象当成ZooAnimal传递
在虚派生中,虚基类是由最低层的派生类初始化的。以我们的程序为例,当创建Panda对象时,由Panda的构造函数独自控制ZooAnimal的初始化过程。在此例中,虚基类将会在多条继承路径上被重复初始化。以ZooAnimal为例,如果应用普通规则,则Raccoon和Bear都会试图初始化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, onExihibit, "Raccoon") { }
而当创建一个Panda对象时,Panda位于派生的最底层并由它负责初始化共享的ZooAnimal基类部分。即使ZooAnimal不是Panda的直接基类,Panda的构造函数也可以初始化ZooAnimal:
Panda::Panda(std::string name, bool onExihibit)
: ZooAnimal(name, onExihibit, "Panda"),
Bear(name, onExihibit),
Raccoon(name, onExihibit),
Endangered(Endangered::critical),
sleeping_flag(false) { }
当我们创建一个Panda对象时,初始化顺序:
如果Panda没有显式地初始化ZooAnimal基类,则ZooAnimal的默认构造函数会被调用。如果ZooAnimal没有默认构造函数,那么代码将发生错误。
虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
一个类可以有很多虚基类,这些虚的子对象按照它们在派生列表中出现的顺序从左往右依次构造。例如:
class Character { /*...*/ };
class BookCharacter : public Character { /*...*/ };
class ToyAnimal { /*...*/ };
class TeddyBear : Public BookCharacter,
Public Bear, public vritual ToyAnimal
{ /*...*/ };
编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。按照如下次序调用构造函数:
ZooAnimal(); // Bear的虚基类
ToyAnimal(); // 直接虚基类
Character(); // 第一个非虚基类的间接基类
BookCharacter(); // 第一个直接非虚基类
Bear(); // 第二个直接非虚基类
TeddyBear(); // 最底层的派生类
合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。和往常一样,对象的销毁顺序和构造顺序正好相反,首先销毁TeddyBear部分,最后销毁ZooAnimal部分。