C++语言的三大特性:异常处理、命名空间、多重继承。
1.异常处理
异常处理机制允许我们能够将问题的检测与解决过程分离开来。
1.1、抛出异常
在C++语言中,我们通过抛出一条表达式来引发一个异常。
当执行一个throw时,程序的控制权从throw转移到与之匹配的catch模块。
控制权的转移意味着一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。
当throw出现在一个try语句块内时,检查匹配顺序:
- 检查与该try块相关联的catch语句
- 继续检查与外层try匹配的catch子句
- 退出当前的函数,在调用当前函数的外层函数中继续寻找
- 执行terminate函数终止程序
上述匹配过程称为栈展开,栈展开过程沿着嵌套函数的调用链不断查找,直到匹配成功为止。
标准库函数terminate负责终止程序的执行过程。
在栈展开过程中,块退出后它的局部对象将随之销毁,若对象是类,则该类的析构函数将自动调用。
若析构函数需要执行某个可能抛出异常的操作,则该操作应放置在一个try语句块中。
异常对象是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。
由于异常对象会被编译器进行拷贝初始化,因此throw语句中的表达式必须拥有完全类型。
抛出一个指向局部对象的指针几乎是一个错误的行为,你不知道该局部对象是否还存在。
当我们抛出一个表达式时,该表达式的静态编译时类型决定了异常对象的类型。
1.2、捕获异常
catch子句中的异常声明类似于函数的形参列表,若catch无须访问抛出的表达式,则可忽略捕获形参的名字。
声明的类型决定了处理代码所能捕获的异常类型,该类型必须是完全类型,不能是右值引用。
当进入一个catch语句后,通过异常对象初始化异常声明中的参数。
若catch的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。
异常声明的静态类型将决定catch语句所能执行的操作。
通常情况下,若catch接受的异常与某个继承体系相关,则最好将该catch的参数定义为引用类型。
在搜索catch语句的过程中,最终找到的catch未必是异常的最佳匹配(第一个与异常匹配的catch语句)。
异常和catch异常声明的匹配规则:
- 允许从非常量向常量的类型转换:接受常量引用的catch语句能匹配非常量对象的throw对象。
- 允许从派生类向基类的类型转换。
- 数组被转换成指向数组类型的指针,函数被转换成指向该函数类型的指针。
如果在多个catch语句的类型之间存在着继承关系,则应将继承链最低端的类放在前面,继承链最顶端的类放在后面(catch语句从最低派生类到最高派生类型排序)。
在执行了某些校正操作之后,当前的catch可能会通过重新抛出操作将异常传递给调用链更上一层的函数接着处理异常。
重新抛出操作仍是一条throw语句,但不包含任何表达式,将异常抛出当前catch块。
空的throw语句只能出现在catch语句中或catch语句直接或间接调用的函数之内。
只有当catch异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播。
catch(my_error &oj){oj.status = errcodes::severs; //修改了异常对象throw; //异常对象的status成员是severs
}
为了一次性捕获所有异常,使用省略号来作为异常声明,这样的处理代码称为捕获所有异常。
try{
}
catch(...){
}
若catch(...)与其他catch语句一起出现,则catch(...)必须在最后的位置。
1.3、函数try语句与构造函数
要想处理构造函数初始值抛出的异常,需将构造函数写成函数try语句块(函数测试块)。
template<typename T> blob<T>::blob(std::initializer_list<T> il) try :
data(std::make_shared<std::vector<T>>(il)) {
}catch(...) {...}
1.4、noexcept异常声明
对于用户以及编译器来说,知道函数不会抛出异常有助于简化代码和执行某些特殊的优化操作。
在C++新标准中,可以通过提供noexcept说明来指定某个函数不会抛出异常。
void recoup(int) noexcept;
对于一个函数来说,noexcept说明应出现在该函数的所有声明语句和定义语句中,或一次都不出现。
noexcept不能出现在typedef或类型别名中。
在成员函数中,noexcept说明符需要跟在const及引用限定符之后。
编译器并不会在编译时检查noexcept说明。
尽管函数声明了它不会抛出异常,但实际上还是抛出了,那么程序将会调用terminate来遵守规则
若函数被设计为是throw()的,则意味着该函数将不会抛出异常。
void recoup(int) noexcept;
void recoup(int) throw(); //与上式等价的声明
noexcept说明符接受一个可选的实参,该实参必须能转换为bool类型。
void recoup(int) noexcept(true); //函数不会抛出异常
void recoup(int) noexcept(false); //函数可能会抛出异常
noexcept运算符是一个一元运算符,它的返回值是一个bool类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。
noexcept(recoup(i)); //若recoup不抛出异常则结果为true,反之则为false
noexcept(e); //若e调用的函数都做了不抛出说明且e本身不含有throw语句时,返回true
noexcept有两层含义:当跟在函数参数列表后时它是异常说明符,而当作noexcept异常说明的实参出现时,它是一个运算符。
函数指针及该指针所指的函数必须具有一致的异常说明。
void recoup(int) noexcept;
void (*pf)(int) noexcept = recoup; //正确
若一个虚函数使用noexcept说明,则后续派生出来的虚函数也必须做出相同的操作。
若基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常
class base{
public:virtual double f1(double) noexcept;virtual int f2();
};
class derived : public base{double f1(double); //错误,base::f1不会抛出异常int f2() noexcept; //正确,derived的f2做了更严格的限定
当编译器合成拷贝控制成员时,同时也生成一个异常说明。
1.5、异常类层次
标准库异常类的继承体系如下图所示:
类型exception定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为what的虚成员。
类型runtime_error和logic_error没有默认构造函数,但是有一个接受C风格字符串或string类型实参的构造函数,这些实参负责提供关于错误的更多信息。
what成员负责返回用于初始化异常对象的信息,在catch异常后用于提取异常基本信息的函数。
实际的应用程序通常会自定义exception的派生类来扩展其继承体系。
与其他继承体系一致,异常类的层次越低,表示的异常情况就越特殊。
2.命名空间
大型程序往往使用多个独立开发的库,库中又定义了大量的全局名字,这就不可避免地发生了名字相冲突。
命名空间为防止名字冲突提供了更加可控的机制:命名空间分割了全局命名空间,每个命名空间是一个作用域。
2.1、命名空间的定义
一个命名空间的定义使用到关键字namespace和命名空间的名称。
namespace cplus{ //新的命名空间class sales_data{...};
}
命名空间的名字必须在定义它的作用域内保持一致。
命名空间既可以定义在全局作用域内,也可以定义在其他命名空间内,但不能定义在函数或类内
不同命名空间内可以有相同名字的成员。
定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。
命名空间允许是不连续的,可以为已存在的命名空间添加新成员。
命名空间的定义可以不连续的特性使得我们可以将几个独立的接口和实现文件组成一个命名空间。
命名空间的组织方式:
- 命名空间的一部分成员的作用是定义类、声明作为类接口的函数及对象,则这些成员应置于头文件中。
- 命名空间成员的定义部分则置于另外的源文件中。
在通常情况下,我们不把#include放在命名空间内部,防止将头文件中所有的名字定义成该命名空间的成员。
可以在命名空间定义的外部定义该命名空间的成员,但命名空间对于名字的声明必须在作用域内。
该名字的定义需要明确指出其所属的命名空间。
尽管命名空间的成员可以定义在命名空间内部,但定义必须出现在所属命名空间的外层空间中。
只要我们在命名空间中声明了模板特例化,就能在命名空间的外部定义它。
namespace std{template<> struct hash<sales_data>; //在命名空间中声明
}template<> struct std::hash<sales_data> {...}; //在命名空间外定义
全局作用域中定义的名字被隐式地添加到全局命名空间中。
作用域运算符可以用于全局作用域的成员。
::member_name; //作用域是隐式的
嵌套的命名空间是指定义在其他命名空间中的命名空间。
namespace cplus{namespace cpluss{......
}
}
内层命名空间声明的名字将隐藏外层命名空间声明的同名成员。
在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间的成员若想访问它必须在名字前添加限定符。
内联命名空间中的名字可以被外层命名空间直接使用。
关键字inline必须出现在命名空间第一次定义的地方,后续再打开空间时无须再写inline关键字。
inline namespace fifth{......
}namespace fifth{......
}
未命名的命名空间中定义的变量拥有静态生命周期。
一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。
namespace {......
}
定义在未命名的命名空间中的名字可以直接使用,但我们不能对未命名的命名空间的成员使用作用域运算符。
若未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字有所区别,否则将出现二义性问题。
int i;
namespace{int i;
}i = 10; //二义性问题
一个未命名的命名空间也能嵌套在其他命名空间中,此时,未命名的命名空间中的成员可以通过外层命名空间的名字来访问。
namespace local{namespace {int i;}
}local::i = 42; //正确
2.2、使用命名空间成员
命名空间的别名使得我们可以为命名空间的名字设定一个同义词。
namespace cplusplus {...};
namespace primer = cplusplus; //使用关键字namespace来定义命名空间的别名
不能在命名空间还没有定义前就声明别名,否则将产生错误。
命名空间的别名可以指向一个嵌套的命名空间。
namespace qlib = cplusplus::querylib;
一个命名空间可以有好几个同义词或别名。
一条using声明语句一次只引入命名空间的一个成员。
using指示可以使得某个特定的命名空间中所用的名字都可见。
using指示可以出现在全局作用域、局部作用域和命名空间作用域中,但不能出现类的作用域中。
using指示具有将命名空间成员提升到包含命名空间本身和using指示的最近作用域的能力。
namespace ab{int i;double j;
}void f()
{using namespace ab; //把ab中的名字注入到全局作用域中cout << i*j << endl;
}
当命名空间被注入到它的外层作用域之后,有可能会发生名字冲突问题。
头文件如果在其顶层作用域中含有using指示或using声明,则会将名字注入到所有包含该头文件的文件中。
2.3、类、命名空间与作用域
对命名空间内部名字查找遵守由内向外依次查找每个外层作用域。
当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参所属的命名空间。
查找规则允许概念上作为类接口一部分的非成员函数无须单独的using声明就能被程序使用。
std::string s;
operator>>(std::cin,s):
//operator函数定义在标准库string中,string又定义在命名空间std中
//不用std::限定符和using声明就可以调用operator>>
在函数模板中,右值引用形参能匹配任何类型。
一个未声明的类或函数若第一次出现在友元声明中,则我们认为它是最近的外层命名空间的成员。
namespace ab{class c{//两个友元,在友元声明外没有其他声明,则这些函数被隐式地成为命名空间ab的成员friend void f2(): //使用时除非另有声明,否则不会被找到friend void f(const c&); //查找实参的命名空间可以被找到};
}int main()
{ ab::c ob;f(ob); //正确,通过ob可以找到f函数f2(); //错误
}
2.4、重载与命名空间
对于接受类类型实参的函数来说,其名字查找将在实参类所属的命名空间中进行。
using声明语句声明的是一个名字,而非一个特定的函数。
当我们为函数书写using声明时,该函数的所有版本都被引入到当前作用域中。
一个using声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。
using指示将命名空间的函数提升到外层作用域中,函数被添加到重载集合中。
对于using指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。
3.多重继承与虚继承
多重继承是指从多个直接基类中产生派生类的能力,多重继承的派生类继承了所有父类的属性。
3.1、多重继承
在派生类的派生列表中可以包含多个基类:
class panda : public bear, public endangered {......};
对于派生类能够继承的基类个数,C++并没有明确的规定,但是在一个给定的派生列表中,同一个基类只能出现一次。
在多重继承关系中,派生类的对象包含有每个基类的子对象。
构造一个派生类的对象将同时构造并初始化他的所有基类子对象。
派生类的构造函数初始值列表将实参分别传递给每个直接基类,基类的构造顺序与派生列表中基类的出现顺序保持一致。
panda::panda(std::string name,bool hibit) :
bear(name,hibit,"panda"),endangerd(endangered::critical) {}
在C++新标准中,允许派生类从它的一个或几个基类中继承构造函数,但若从多个基类中继承了相同的构造函数,则程序将产生错误。
struct base1{base1() = default;base1(const std::stirng&);
};struct base2{base2() = default;base2(const std::string&);
};struct ab : public base1,public base2{using base1::base1; //从base1继承构造函数using base2::base2; //从base2继承构造函数
//但是发生错误,ab试图从两个基类中继承相同构造函数base(const std::string&)
}
若一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本。
析构函数的调用顺序与构造函数相反。
3.2、类型转换与多个基类
在只有一个基类的情况下,派生类的指针或引用能自动转换成一个可访问基类的指针或引用。
void print(const bear&);
panda ying("ying_yang");
print(ying); //把一个panda对象传递给一个bear的引用
编译器不会在派生类向基类的几种转换中进行比较和选择,因为这几种转换都是等价的。
对象、指针和引用的静态类型决定了我们能够使用哪些成员。
3.3、多重继承下的类作用域
在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中。
在多重继承的情况下,相同的查找过程在所有直接基类中同时进行。
当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名的成员。此时,不加前缀限定符直接使用该名字将引发二义性。
上述情况是完全合法的,派生仅仅是产生了潜在的二义性,只要不调用该函数就能避免二义性错误。
由于编译器是先查找名字后再进行类型检查,因此有时派生类继承的两个函数形参列表不同也有可能发生二义性错误。
要想避免上述情况的潜在二义性,最好还是在派生类中为该函数定义一个新版本。
3.4、虚继承
在默认情况下,派生类中含有继承链上每个类对应的子部分,若某个类在派生过程多次出现,则派生类中将包含该类的多个子对象。
C++的虚继承机制能有效地解决上述问题,虚继承能令某个类做出声明,承诺共享它的基类。
虚继承机制是继承方式,不是某个特殊基类。
在虚继承机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一的共享的虚基类子对象。
虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
我指定虚基类的方式是在派生列表中添加关键字virtual:
class bear : public virtual zooanimal {......};
//zooanimal是bear的虚基类
virtual说明符表明希望在后续的派生类当中共享虚基类的同一份实例。
不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。
void dance(const bear&);
panda ping_ying;
dance(ping_ying); //正确,把一个panda对象当成bear传递
因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。
当成员被多于一个基类覆盖时,直接访问该成员将产生二义性错误。
3.5、构造函数与虚继承
在虚派生中,虚基类是由最低层的派生类初始化的,这能有效避免虚基类被重复初始化。
含有虚基类的对象的构造顺序使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接着按照直接基类在派生列表中出现的次序依次对其进行初始化。
虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
一个类可以有多个虚基类,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。