@taqikema
2018-04-13T16:11:16.000000Z
字数 11438
阅读 1509
C++Primer
学习记录
异常处理
命名空间
多重继承
异常处理机制可以将负责逻辑业务的核心代码(try)与负责处理意外错误情况的代码(catch)分离开来,使程序员只用关心自己的逻辑代码。
当执行一个 throw时,跟在 throw后面的语句将不再被执行,相反程序的控制权从 throw转移到与之匹配的 catch模块。该 catch可能是同一个函数中的局部 catch,也可能位于直接或间接的用了发生异常的函数的另一个函数中。控制权从一处转移到另一处,这有两个重要的含义:
当抛出一个异常后,程序暂停当前函数的执行过程,并立即开始寻找与异常匹配的 catch子句。其寻找过程是一个栈展开的过程,沿着调用链的逆序寻找。如下图所示。
在栈展开过程中,位于调用链上的语句会可能会提前退出,而此时可能已经创建了一些局部对象。在块退出后,编译器将负责确保在这个块中创建的对象能被正确的销毁。如果局部对象的类型是类类型,则该对象的析构函数将被自动调用。与往常一样,编译器在销毁内置类型的对象时不需要做任何事情。
析构函数总是会被执行的,但是函数中负责释放资源的代码却可能被跳过。
异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个 catch子句都能访问该空间。当异常处理完毕后,异常对象将被销毁。
在栈展开过程中,如果退出了某个块,则同时释放块中局部对象使用的内存。因此抛出一个指向局部对象的指针几乎肯定是一种错误的行为。
当抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型。如果一条 throw表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
catch子句中的异常声明看起来与形参列表有些相似。同样,如果 catch无须访问抛出的表达式的话,则可以忽略捕获形参的名字。
try {
// 使用 C++标准库
} catch (const runtime_error &re) {
// ...
} catch (exception) { // 忽略形参名字
// ...
}
和函数的参数类似,当进入一个 catch语句后,通过异常对象初始化异常声明中的参数。
搜寻匹配 catch语句过程中,寻找的是第一个与异常匹配的 catch语句,是按照其出现的顺序逐一进行匹配的,当程序使用具有继承关系的多个异常时,要注意令派生类异常的处理代码出现在基类异常的处理代码之前。
对于异常和 catch异常声明的匹配规则,绝大多数类型转换都是不被允许的,除了一些极小的细微差别以外。除了下列允许的类型转换外,包括标准算术类型转换和类类型转换在内的其他所有转换规则都不能在匹配过程中使用。
通过一条空的 throw语句,可以将异常重新抛出,将异常传递给另一个 catch语句。一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递。
很多时候, catch
语句会改变其参数的内容。如果在改变了参数的内容后 catch
语句重新抛出异常,则只有当 catch
语句是引用类型时,我们对参数所做的改变才会被保留并继续传播。
catch (my_err &eObj) { // 引用类型
eObj.status = errCodes::servereErr; // 修改了异常对象
throw; // 异常对象的 status成员是 servereErr
} catch (other_error eObj) { // 非引用类型
eObj.status = errCodes::servereErr; // 只修改了异常对象的局部副本
throw; // 异常对象的 status成员没有改变
}
使用 catch(...)
语句,可以捕获所有异常,与任意类型的异常匹配。如果 catch(...)
语句与其他几个 catch语句一起出现,则该语句必须在最后的位置。出现在捕获所有异常语句后面的 catch语句将永远不会被匹配。
构造函数在进入其函数体之前会首先执行初始值列表。因为在初始值列表抛出异常时,构造函数体内的 try块还未生效,所以构造函数体内的 catch语句无法处理构造函数初始值列表抛出的异常。此时,可以将构造函数写成函数 try语句块
的形式,使得对应的 catch语句技能处理构造函数体,也能处理构造函数的初始化过程。其形式如下:
template <typename T>
Blob<T>::Blob() try :
data(std::make_shared<std::vector<T>>(i1)) {
/* 空函数体 */
} catch(const std::bad_alloc &e) { handle_out_of_memory(e); }
在初始化构造函数的参数时也可能发生异常,不过该异常属于调用表达式的一部分,将在调用者所在的上下文中进行处理。
对于用户及编译器来说,预先知道某个函数不会抛出异常是有好处的。首先,知道函数不会抛出异常,有助于减化调用该函数的代码;其次,如果编译器确认函数不会抛出异常,它就能执行某些特殊的优化操作。通过使用 noexcept说明符
可以指定某个函数不会抛出异常。
对于一个函数来说,noexcept说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。在成员函数中,noexcept说明符需要跟在 const及引用限定符之后,而在 final、override或虚函数的=0之前。
通常情况下,编译器不能也不必在编译时验证异常说明。实际上,如果在一个函数中声明了 noexcept的同时又含 throw语句,或者调用了可能抛出异常的其他函数,编译器仍将顺利编译通过。此时程序会调用 terminate,以确保遵守不在运行时抛出异常的承诺。
// 尽管函数明显违反了异常说明,但它仍然可以顺利编译通过
void f() noexcept // 承诺不会抛出异常
{
throw exception(); // 违反了异常说明
}
noexcept说明符接受一个可选的实参,来说明函数是否会抛出异常。
void recoup(int) noexcept(true); // recoup不会抛出异常
void alloc(int) noexcept(false); // alloc可能抛出异常
noexcept有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为 noexcept异常说明的 bool实参出现时,它是一个运算符,返回值是一个 bool类型的右值常量表达式,用于表示是否会抛出异常。
noexcept(recoup(i)) // 如果 recoup不抛出异常,则结果为 true;否则为 false
void f() noexcept( noexcept(g(i)) ); // f和 g的异常说明一致
尽管 noexcept说明符不属于函数类型的一部分,但是函数的异常说明仍然会影响函数的使用。
函数指针及该指针所指的函数必须具有一致的异常说明。
// 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 fl(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做了更严格的限定
};
命名空间分割了全局命名空间,其中每个命名空间是一个作用域。
namespace nsp {
// 相关声明
}
定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。而位于该命名空间之外的代码,则必须明确的指出所用的名字属于哪个命名空间。
cplusplus::Query q = cplusplus::Query("hello");
命名空间可以定义在几个不同的部分,这一点与其他作用域不太一样。第一条中的命名空间的定义形式,可能是定义了一个名为 nsp的命名空间,也可能是为已经存在的命名空间添加一些新的成员。
命名空间的定义可以不连续的特性,使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时命名空间的组织方式类似于我们管理自定义类及函数的方式:
通过使用上述接口与实现分离的机制,我们可以将cplusplus_primer库定义在几个不同的文件中。Sales_ data类的声明及其函数将置于 Sales_data.h头文件中,其实现文件是 Sales_data.cc。程序如果想使用所定义的库,只需要包含必要的头文件即可。有一点需要注意,在通常情况下,不把#include
放在命名空间内部。
// --- Sales_data.h ---
// #include应该出现在打开命名空间的操作之前
#include <string>
namespace cplusplus_primer {
class Sales_data { /* ... */};
Sales_data operator+(const Sales_data&, const Sales_data&);
// Sales_data的其他接口函数的声明
}
// --- Sales_data.cc ---
// 确保 #include出现在打开命名空间的操作之前
#include "Sales_data.h"
namespace cplusplus_primer {
// Sales_data成员及重载运算符的定义
}
// --- user.cc ---
// Sales_data.h头文件的名字位于命名空间 cplusplus_primer中
#include "Sales_data.h"
int main()
using cplusplus_primer::Sales_data;
Sales_data transl, trans2;
// ...
return 0;
在命名空间中声明完某个成员后,可以在命名空间的外部定义该成员。但是这样的定义必须出现在所属命名空间的外层空间中,不能在一个不相关的作用域中进行定义。
模板特例化必须定义在原始模板所属的命名空间中,在命名空间中声明了特例化后,就能在命名空间的外部定义它了。
// 我们必须将模板特例化声明成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); }
// 其他成员与之前的版本一致
}
全局作用域中定义的名字(即在所有类、函数及命名空间之外定义的名字),也就是定义在全局命名空间中。全局命名空间,以隐式的方式声明,并且在所有的程序中都存在。使用 ::member_name
这种形式,可以表示全局命名空间中的一个成员。
在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间中的代码要想访问它必须在名字前添加限定符,如右所示,out_nsp::in_nsp::member_name
。
内联命名空间是C++11新标准引入的一种新的嵌套命名空间,和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。定义内联命名空间的方式是在关键字 namespace
前添加关键字 inline
,关键字 inline
必须出现在命名空间第一次定义的地方,后续再打开命名空间的时候可以写 inline
,也可以不写。
inline namespace FifthEd {
// 该命名空间表示本书第 5 版的代码
}
namespace FifthEd { // 隐式内联
/* ... */
}
当应用程序的代码在一次发布和另一次发布之间发生了改变时,常常会用到内联命名空间。例如,可以把当前版本的所有代码都放在一个内联命名空间中,而之前版本的代码都放在一个非内联命名空间中。命名空间 cplusplus_primer
将同时使用这两个命名空间,并且假定每个命名空间都定义在同名的头文件中,则命名空间 cplusplus_primer
, 可以定义成如下形式。形如 cplusplus_primer::
的代码可以直接获得 FifthEd
的成员,想要使用较早期版本的代码则只需加上完整的内层命名空间的名字,如 cplusplus_primer::FourthEd::Item_base
。
namespace FourthEd {
class Item_base { /* ... */ };
// 本书第 4 版用到的其他代码
}
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 = 10;
命名空间的别名,使得我们可以为命空间的名字设定另一个短得多的同义词。如 namespace cp = cplusplus_primer;
using声明一次只引入命名空间的一个成员,有效范围从声明的地方开始,一直到声明所在的作用域结束为止。在此过程中外层作用域的同名实体将被隐藏。using声明可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员。
using指示一次性注入某个命名空间的所有名字,using指示可以出现在全局作用域、局部作用域和命名空间作用域中,但是不能出现在类的作用域中。using指示所注入的名字一般被看作是出现在最近的外层作用域中。
对于如下代码,分别在“位置1”和“位置2”的地方,使用 using声明或 using指示时, manip中的 3个名字实际所指示的对象或所在作用域如下表所示。
namespace Exercise {
int ivar = 0;
double dvar = 0;
const int limit = 1000;
}
int ivar = 0;
// 位置1
void manip() {
//位置2
double dvar = 3.1416;
int iobj = limit + 1;
++ivar;
谨慎使用 using指示。
当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。对于下式,operator>>函数定义在标准库 string中,string又定义在命名空间 std中。但是我们不用 std::限定符和 using声明就可以调用 operator>>。这是因为,当编译器发现对 operator>>的调用时,先在当前作用域中寻找合适的函数,接着查找输出语句的外层作用域。随后,因为 >>表达式的形参是类型的,所以编译器还会查找 cin和 s的类所属的命名空间。
std::string s;
operator>>(std::cin, s);
通常情况下,如果在应用程序中定义了一个标准库中已有的名字,则将出现以下两种情况中的一种:要么根据一般的重载规则确定某次调用应该执行函数的哪个版本;要么应用程序根本就不会执行函数的标准库版本。而对于 move和 forward函数,其本身执行的是非常特殊的类型操作,应用程序专门修改函数原有的行为概率非常小,大多都是需要使用函数的标准库版本,所以此时最好书写成 std::move而非 move的形式。
一个另外的未声明的类或函数如果第一次出现在友元声明中,则我们认为它是最近的外层命名空间的成员。这条规则与实参相关的查找规则结合在一起,将产生意想不到的效果。因为 f接受一个类类型的实参,而且 f在 C所属的命名空间进行了隐式的声明,所以 f能被找到。相反,因为 f2没有形参,所以它无法被找到。
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中
// 与实参相关的查找,即使没有使用 using说明,也将相关函数变为可见
class Bulk_item : public NS::Quote { /* ... */ };
int main() {
Bulk_item bookl;
display(bookl);
return 0;
}
using声明语句声明的是一个名字,而非一个特定的函数,该函数的所有版本都被引入到当前作用域中。一个 using声明将重载该声明语句所属作用域中已有的其他同名函数。如果 using声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。如果using声明所在的作用域中已经有一个函数与新引入的函数同名且形参列表相同,则该using声明将引发错误。
using NS::print(int); // 错误,不能指定形参列表
using NS::print; // 正确,using声明只声明一个名字
using指示也会将命名空间的函数添加到重载集合中,与 using声明不同的是,对于 using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。只要我们指明调用的是命名空间中的函数版本,还是当前作用域的版本即可。
多重继承的派生类继承了所有父类的属性。派生类的对象包含有每个基类的子对象。基类的构造顺序与派生类列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。
class Bear : public ZooAnimal { /* ... */};
class Pand : public Bear, public Endangered { /* ... */};
上述代码中,Panda对象的概念结构如下图所示:
在 C++11新标准中,允许派生类从它的一个或几个基类中继承构造函数,但是如果从多个基类中继承了相同的构造函数(即形参列表完全相同),则程序将产生错误。此时这个类必须为该构造函数定义它自己的版本。
struct Base1 {
Base1() = default;
Base1(const string&);
Base1(shared_ptr<int>);
};
struct Base2 {
Base2() = default;
Base2(const string&);
Base2(int);
};
// 错误,D1试图从两个基类中都继承 D1::D1(const string&)
struct D1 : public Base1, public Base2 {
using Base1::Base1; // 从 Base1中继承构造函数
using Base2::Base2; // 从 Base2中继承构造函数
};
// 正确的写法如下,派生类要定义该形式的、自己版本的构造函数
// 另外,因为自己显式定义了构造函数,还必须同时定义一个默认构造函数
struct D2 : public Base1, public Base2 {
using Base1::Base1; // 从 Base1中继承构造函数
using Base2::Base2; // 从 Base2中继承构造函数
D2(const string&s) : Base1(s), Base2(s) {}
D2() = default;
};
多重继承时,拷贝控制成员的行为与单个派生一样。
多重继承时,可以令某个可访问基类的指针或引用直接指向一个派生类对象,但是编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。
void print(const Bear&);
void print(const Endangered&);
// 通过 Panda对象调用上述函数,将产生二义性错误
Panda ying_yang("ying_yang");
print(ying_yang);
与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员。
在多重继承的情况下,名字查找过程仍然是沿着继承体系自底向上进行,只是会在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。对于一个派生类来说,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时,必须明确指出它的版本。而想要避免潜在的二义性,最好的办法是在设计派生类时,为该名字定义一个新的版本。
// 假定 ZooAnimal和 Endangered都定义了名为 max_weight的成员函数
// 以下调用将会报错
double d = ying_yang.max_weight();
// 调用时指出所调用的版本则不会引发二义性
ZooAnimal::max_weight();
// 或者
Endangered::max_weight();
// 最好的方法是在派生类中定义一个新版本
double Panda::max_weight() const
{
return max(ZooAnimal::max_weight(),
Endangered::max_weight());
}
尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类派生类。可以通过它的两个直接基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另一个基类,然后再一次间接继承该类。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。这在某些情形会出现问题,使用虚继承机制可以解决上述问题,在这种机制下,不论虚继承在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
虚继承一个特征就是要在虚派生的真实需求出现之前就已经完成虚派生的操作。
因为在每个共享的虚基类中只有唯一一个共享的对象,所以该基类的成员可以被直接访问,并且不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则我们仍然可以直接访问这个被覆盖的成员。但是如果成员被多于一个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。
对于下图中的继承体系,假定 B定义了一个名为 x的成员,通过 D的对象使用 x,有三种可能性。
在虚派生中,虚基类是由最低层的派生类初始化的。含有虚基类的对象的构造顺序与一般的顺序稍有区别:首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。
虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造,然后按照声明的顺序逐一构造其他非虚基类。对象的销毁顺序与构造顺序正好相反。