18.1 异常处理
- 异常处理(exception handling)机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。
18.1.1 抛出异常
在 C++ 语言中,我们通过抛出(throwing)一条表达式来引发(raised)一个异常。被抛出的表达式的类型以及当前的调用链共同决定了哪段处理代码(handler)将被用来处理该异常。被选中的处理代码是在调用链中与抛出对象类型匹配的最近的处理代码。其中,根据抛出对象的类型和内容,程序的异常抛出部分将会告知异常处理部分到底发生了什么错误。
当执行一个
throw
时,跟在throw
后面的语句将不再被执行。相反,程序的控制权从throw
转移到与之匹配的catch
模块。该catch
可能是同一个函数中的局部catch
,也可能位于直接或间接调用了发生异常的函数的另一个函数中。控制权从一处转移到另一处,这有两个重要的含义:- 沿着调用链的函数可能会提早退出。
- 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。
因为跟在
throw
后面的语句将不再被执行,所以throw
语句的用法有点类似于return
语句:它通常作为条件语句的一部分或者作为某个函数的最后(或者唯一)一条语句。当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的
catch
子句。当throw
出现在一个try
语句块(try block)内时,检查与该try
块关联的catch
子句。如果找到了匹配的catch
,就使用该catch
处理异常。如果这一步没找到匹配的catch
且该try
语句嵌套在其他try
块中,则继续检查与外层try
匹配的catch
子句。如果还是找不到匹配的catch
,则退出当前的函数,在调用当前函数的外层函数中继续寻找。如果对抛出异常的函数的调用语句位于一个
try
语句块内,则检查与该try
块关联的catch
子句。如果找到了匹配的catch
,就使用该catch
处理异常。否则,如果该try
语句嵌套在其他try
块中,则继续检查与外层try
匹配的catch
子句。如果仍然没有找到匹配的catch
,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,以此类推。上述过程被称为栈展开(stack unwinding)过程。栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的
catch
子句为止;或者也可能一直没找到匹配的catch
,则退出主函数后查找过程终止。假设找到了一个匹配的
catch
子句,则程序进入该子句并执行其中的代码。当执行完这个catch
子句后,找到与try
块关联的最后一个catch
子句之后的点,并从这里继续执行。如果没找到匹配的
catch
子句,程序将退出。因为异常通常被认为是妨碍程序正常执行的事件,所以一旦引发了某个异常,就不能对它置之不理。当找不到匹配的catch
时,程序将调用标准库函数terminate
,顾名思义,terminate
负责终止程序的执行过程。Note:一个异常如果没有被捕获,则它将终止当前的程序。
在栈展开过程中,位于调用链上的语句块可能会提前退出。通常情况下,程序在这些块中创建了一些局部对象。我们已经知道,块退出后它的局部对象也将随之销毁,这条规则对于栈展开过程同样适用。如果在栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。与往常一样,编译器在销毁内置类型的对象时不需要做任何事情。
如果异常发生在构造函数中,则当前的对象可能只构造了一部分。有的成员已经初始化了,而另外一些成员在异常发生前也许还没有初始化。即使某个对象只构造了一部分,我们也要确保已构造的成员能被正确地销毁。
类似的,异常也可能发生在数组或标准库容器的元素初始化过程中。与之前类似,如果在异常发生前已经构造了一部分元素,则我们应该确保这部分元素被正确地销毁。
析构函数总是会被执行的,但是函数中负责释放资源的代码却可能被跳过,如果一个块分配了资源,并且在负责释放这些资源的代码前面发生了异常,则释放资源的代码将不会被执行。 另一方面,类对象分配的资源将由类的析构函数负责释放。因此,如果我们使用类来控制资源的分配,就能确保无论函数正常结束还是遭遇异常,资源都能被正确地释放。
析构函数在栈展开的过程中执行。在栈展开的过程中,已经引发了异常但是我们还没有处理它。如果异常抛出后没有被正确捕获,则系统将调用
terminate
函数。因此,出于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能抛出异常的操作,则该操作应该被放置在一个try
语句块当中,并且在析构函数内部得到处理。在实际的编程过程中,因为析构函数仅仅是释放资源,所以它不太可能抛出异常。所有标准库类型都能确保它们的析构函数不会引发异常。
WARNING: 在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被终止。
异常对象(exception object)是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,
throw
语句中的表达式必须拥有完全类型。而且如果该表达式是类类型的话,则相应的类必须含有一个可访问的析构函数和一个可访问的拷贝或移动构造函数。如果该表达式是数组类型或函数类型,则表达式将被转换成与之对应的指针类型。异常对象位于由编译器管理的空间中,编译器确保无论最终调用的是哪个
catch
子句都能访问该空间。当异常处理完毕后,异常对象被销毁。如我们所知,当一个异常被抛出时,沿着调用链的块将依次退出直至找到与异常匹配的处理代码。如果退出了某个块,则同时释放块中局部对象使用的内存。因此,抛出一个指向局部对象的指针几乎肯定是一种错误的行为。出于同样的原因,从函数中返回指向局部对象的指针也是错误的。如果指针所指的对象位于某个块中,而该块在
catch
语句之前就已经退出了,则意味着在执行catch
语句之前局部对象已经被销毁了。当我们抛出一条表达式时,该表达式的静态编译时类型决定了异常对象的类型,很多情况下程序抛出的表达式类型来自于某个继承体系。如果一条
throw
表达式解引用一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。WARNING: 抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。
18.1.2 捕获异常
catch
子句(catch clause)中的异常声明(exception declaration)看起来像是只包含一个形参的函数形参列表。像在形参列表中一样,如果catch
无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。
当进入一个
catch
语句后,通过异常对象初始化异常声明中的参数。和函数的参数类似,如果catch
的参数类型是非引用类型,则该参数是异常对象的一个副本,在catch
语句内改变该参数实际上改变的是局部副本而非异常对象本身;相反,如果参数是引用类型,则和其他引用参数一样,该参数是异常对象的一个别名,此时改变参数也就是改变异常对象。catch
的参数还有一个特性也与函数的参数非常类似:如果catch
的参数是基类类型,则我们可以使用其派生类类型的异常对象对其进行初始化。此时,如果catch
的参数是非引用类型,则异常对象将被切掉一部分,这与将派生类对象以值传递的方式传给一个普通函数差不多。另一方面,如果catch
的参数是基类的引用,则该参数将以常规方式绑定到异常对象上。最后一点需要注意的是,异常声明的静态类型将决定
catch
语句所能执行的操作。如果catch
的参数是基类类型,则catch
无法使用派生类特有的任何成员。《Effective C++》第三版“条款 20:宁以 pass-by-reference-to-const 替换 pass-by-value”也阐述了派生类对象以传值方式传给一个函数的基类形参时存在的对象切割问题。——博主注
Best Practices: 通常情况下,如果
catch
接受的异常与某个继承体系有关,则最好将该catch
的参数定义成引用类型。在搜寻
catch
语句的过程中,我们最终找到的catch
未必是异常的最佳匹配。相反,挑选出来的应该是第一个与异常匹配的catch
语句。因此,越是专门的catch
越应该置于整个catch
列表的前端。因为
catch
语句是按照其出现的顺序逐一进行匹配的,所以当程序使用具有继承关系的多个异常时必须对catch
语句的顺序进行组织和管理,使得派生类异常的处理代码出现在基类异常的处理代码之前。与实参和形参的匹配规则相比,异常和
catch
异常声明的匹配规则受到更多限制。此时,绝大多数类型转换都不被允许,除了一些极细小的差别之外,要求异常的类型和catch
声明的类型是精确匹配的:- 允许从非常量向常量的类型转换,也就是说,一条非常量对象的
throw
语句可以匹配一个接受常量引用的catch
语句。 - 允许从派生类向基类的类型转换。
- 数组被转换成指向数组(元素)类型的指针,函数被转换成指向该函数类型的指针。
除此之外,包括标准算术类型转换和类类型转换在内,其他所有转换规则都不能在匹配
catch
的过程中使用。Note:如果在多个
catch
语句的类型之间存在着继承关系,则我们应该把继承链最底端的类(most derived type)放在前面,而将继承链最顶端的类(least derived type)放在后面。- 允许从非常量向常量的类型转换,也就是说,一条非常量对象的
有时,一个单独的
catch
语句不能完整地处理某个异常。在执行了某些校正操作之后,当前的catch
可能会决定由调用链更上一层的函数接着处理异常。一条catch
语句通过重新抛出(rethrowing)的操作将异常传递给另外一个catch
语句。这里的重新抛出仍然是一条throw
语句,只不过不包含任何表达式:1
throw;
空的
throw
语句只能出现在catch
语句或catch
语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空throw
语句,编译器将调用terminate
。一个重新抛出语句并不指定新的表达式,而是将当前的异常对象沿着调用链向上传递。
很多时候,
catch
语句会改变其参数的内容。如果在改变了参数的内容后catch
语句重新抛出异常,则只有当catch
异常声明是引用类型时我们对参数所做的改变才会被保留并继续传播:1
2
3
4
5
6
7
8
9
10catch (my_error &eObj) // 引用类型
{
eobj.status = errCodes::severeErr; // 修改了异常对象
throw; // 异常对象的 status 成员是 severeErr
}
catch (other_error eObj) // 非引用类型
{
eObj.status = errCodes::badErr; // 只修改了异常对象的局部副本
throw; // 异常对象的 status 成员没有改变
}有时我们希望不论抛出的异常是什么类型,程序都能统一捕获它们。为了一次性捕获所有异常,我们使用省略号作为异常声明,这样的处理代码称为捕获所有异常(catch-all)的处理代码,形如
catch(...)
。一条捕获所有异常的语句可以与任意类型的异常匹配。catch(...)
通常与重新抛出语句一起使用,其中catch
执行当前局部能完成的工作,随后重新抛出异常:1
2
3
4
5
6
7
8
9
10
11
12void manip()
{
try
{
// 这里的操作将引发并抛出一个异常 catch
}
catch (...)
{
// 处理异常的某些特殊操作
throw;
}
}catch(...)
既能单独出现,也能与其他几个catch
语句一起出现。Note: 如果
catch(...)
与其他几个catch
语句一起出现,则catch(...)
必须在最后的位置。出现在捕获所有异常语句后面的catch
语句将永远不会被匹配。
18.1.3 函数 try 语句块与构造函数
构造函数在进入其函数体之前首先执行初始值列表。因为在初始值列表抛出异常时构造函数体内的
try
语句块还未生效,所以构造函数体内的catch
语句无法处理构造函数初始值列表抛出的异常。要想处理构造函数初始值抛出的异常,我们必须将构造函数写成函数
try
语句块(也称为函数测试块,function try block)的形式。函数try
语句块使得一组catch
语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程):1
2
3
4
5
6
7
8
9
10template <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
出现在表示构造函数初始值列表的冒号以及表示构造函数体(此例为空)的花括号之前。与这个try
关联的catch
既能处理构造函数体抛出的异常,也能处理成员初始化列表抛出的异常。构造函数初始化列表先于构造函数体执行,因此构造函数初始化列表抛出的异常无法被构造函数体内的一般的
try...catch...
语句捕获,需要将构造函数写成函数try
语句块的形式。——博主注在初始化构造函数的参数时也可能发生异常,这样的异常不属于函数
try
语句块的一部分。函数try
语句块只能处理构造函数开始执行后发生的异常。和其他函数调用一样,如果在参数初始化的过程中发生了异常,则该异常属于调用表达式的一部分,并将在调用者所在的上下文中处理。Note:处理构造函数初始值异常的唯一方法是将构造函数写成函数
try
语句块。
18.1.4 noexcept 异常说明
首先,知道函数不会抛出异常有助于简化调用该函数的代码:其次,如果编译器确认函数不会抛出异常,它就能执行某些特殊的优化操作,而这些优化操作并不适用于可能出错的代码。
在 C++11 新标准中,我们可以通过提供
noexcept
说明(noexcept specification)指定某个函数不会抛出异常。其形式是关键字noexcept
紧跟在函数的参数列表后面,用以标识该函数不会抛出异常:1
2void recoup(int) noexcept; // 不会抛出异常
void alloc(int); // 可能抛出异常我们说
recoup
做了不抛出说明(nonthrowing specification)。对于一个函数来说,
noexcept
说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。该说明应该在函数的尾置返回类型之前。我们也可以在函数指针的声明和定义中指定noexcept
。在typedef
或类型别名中则不能出现noexcept
。在成员函数中,noexcept
说明符需要跟在const
及引用限定符之后,而在final
、override
或虚函数的=0
之前。编译器并不会在编译时检查
noexcept
说明。实际上,如果一个函数在说明了noexcept
的同时又含有throw
语句或者调用了可能抛出异常的其他函数,编译器将顺利编译通过,并不会因为这种违反异常说明的情况而报错(不排除个别编译器会对这种用法提出警告):1
2
3
4
5// 尽管该函数明显违反了异常说明,但它仍然可以顺利编译通过
void f() noexcept // 承诺不会抛出异常
{
throw exception(); // 违反了异常说明
}因此可能出现这样一种情况:尽管函数声明了它不会抛出异常,但实际上还是抛出了。一旦一个
noexcept
函数抛出了异常,程序就会调用terminate
以确保遵守不在运行时抛出异常的承诺。上述过程对是否执行栈展开未作约定,因此noexcept
可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理异常。指明某个函数不会抛出异常可以令该函数的调用者不必再考虑如何处理异常。无论是函数确实不抛出异常,还是程序被终止,调用者都无须为此负责。
WARNING: 通常情况下,编译器不能也不必在编译时验证异常说明。
向后兼容:异常说明。 早期的 C++ 版本设计了一套更加详细的异常说明方案,该方案使得我们可以指定某个函数可能抛出的异常类型。函数可以指定一个关键字
throw
,在后面跟上括号括起来的异常类型列表。throw
说明符所在的位置与新版本 C++ 中noexcept
所在的位置相同。
上述使用throw
的异常说明方案在 C++11 新版本中已经被取消了。然而尽管如此,它还有一个重要的用处。如果函数被设计为是throw()
的,则意味着该函数将不会抛出异常:1
2void recoup(int) noexcept; // recoup 不会抛出异常
void recoup(int) throw(); // 等价的声明上面的两条声明语句是等价的,它们都承诺
recoup
不会抛出异常。noexcept
说明符接受一个可选的实参,该实参必须能转换为bool
类型:如果实参是true
,则函数不会抛出异常;如果实参是false
,则函数可能抛出异常:1
2void recoup(int) noexcept(true); // recoup 不会抛出异常
void alloc(int) noexcept(false); // alloc 可能抛出异常noexcept
说明符的实参常常与noexcept
运算符(noexcept operator)混合使用。noexcept
运算符是一个一元运算符,它的返回值是一个bool
类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和sizeof
类似,noexcept
也不会求其运算对象的值。例如,因为我们声明
recoup
时使用了noexcept
说明符,所以下面的表达式的返回值为true
:1
noexcept(recoup(i)) // 如果 recoup 不抛出异常则结果为 true;否则结果为 false
更普通的形式是:
1
noexcept(e)
当
e
调用的所有函数都做了不抛出说明且e
本身不含有throw
语句时,上述表达式为true
;否则noexcept(e)
返回false
。我们可以使用
noexcept
运算符得到如下的异常说明:1
void f() noexcept(noexcept(g())); // f 和 g 的异常说明一致
如果函数
g
承诺了不会抛出异常,则f
也不会抛出异常;如果g
没有异常说明符,或者g
虽然有异常说明符但是允许抛出异常,则f
也可能抛出异常。Note:
noexcept
有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为noexcept
异常说明的bool
实参出现时,它是一个运算符。尽管
noexcept
说明符不属于函数类型的一部分,但是函数的异常说明仍然会影响函数的使用。函数指针及该指针所指的函数必须具有一致的异常说明。 也就是说,如果我们为某个指针做了不抛出异常的声明,则该指针将只能指向不抛出异常的函数。相反,如果我们显式或隐式地说明了指针可能抛出异常,则该指针可以指向任何函数,即使是承诺了不抛出异常的函数也可以:
1
2
3
4
5
6// recoup 和 pf1 都承诺不会抛出异常
void (*pf1)(int) noexcept = recoup;
// 正确:recoup 不会抛出异常,pf2 可能抛出异常,二者之间互不干扰
void (*pf2)(int) = recoup;
pf1 = alloc; // 错误:alloc 可能抛出异常,但是 pf1 已经说明了它不会抛出异常
pf2 = alloc; // 正确:pf2 和 alloc 都可能抛出异常如果一个虚函数承诺了它不会抛出异常,则后续派生出来的虚函数也必须做出同样的承诺;与之相反,如果基类的虚函数允许抛出异常,则派生类的对应函数既可以允许抛出异常,也可以不允许抛出异常:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15class 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)
。而且,如果我们定义了一个析构函数但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明将与假设由编译器为类合成析构函数时所得的异常说明一致。
18.1.5 异常类层次
标准库异常类构成了如下图所示的继承体系:
类型
exception
仅仅定义了拷贝构造函数、拷贝赋值运算符、一个虚析构函数和一个名为what
的虚成员。其中what
函数返回一个const char*
,该指针指向一个以 un11 结尾的字符数组,并且确保不会抛出任何异常。类
exception
、bad_cast
和bad_alloc
定义了默认构造函数。类runtime_error
和logic_error
没有默认构造函数,但是有一个可以接受 C 风格字符串或者标准库string
类型实参的构造函数,这些实参负责提供关于错误的更多信息。在这些类中,what
负责返回用于初始化异常对象的信息。因为what
是虚函数,所以当我们捕获基类的引用时,对what
函数的调用将执行与异常对象动态类型对应的版本。和其他继承体系一样,异常类也可以看作按照层次关系组织的。层次越低,表示的异常情况就越特殊。例如,在异常类继承体系中位于最顶层的通常是
exception
,exception
表示的含义是某处出错了,至于错误的细节则未作描述。继承体系的第二层将
exception
划分为两个大的类别:运行时错误和逻辑错误。运行时错误表示的是只有在程序运行时才能检测到的错误;而逻辑错误一般指的是我们可以在程序代码中发现的错误。
18.2 命名空间
大型程序往往会使用多个独立开发的库,这些库又会定义大量的全局名字,如类、函数和模板等。多个库将名字放置在全局命名空间中将引发命名空间污染(namespace pollution)。
命名空间(namespace)为防止名字冲突提供了更加可控的机制。命名空间分割了全局命名空间,其中每个命名空间是一个作用域。
18.2.1 命名空间定义
一个命名空间的定义包含两部分:首先是关键字
namespace
,随后是命名空间的名字。在命名空间名字后面是一系列由花括号括起来的声明和定义。只要能出现在全局作用域中的声明就能置于命名空间内,主要包括:类、变量(及其初始化操作)、函数(及其定义)、模板和其他命名空间。和其他名字一样,命名空间的名字也必须在定义它的作用域内保持唯一。命名空间既可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。
Note: 命名空间作用域后面无须分号。
因为不同命名空间的作用域不同,所以在不同命名空间内可以有相同名字的成员。
定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些成员内嵌作用域中的任何单位访问。位于该命名空间之外的代码则必须明确指出所用的名字属于哪个命名空间。
命名空间可以定义在几个不同的部分,这一点与其他作用域不太一样。编写如下的命名空间定义:
1
2
3namespace nsp {
// 相关声明
}可能是定义了一个名为
nsp
的新命名空间,也可能是为已经存在的命名空间添加一些新成员。如果之前没有名为nsp
的命名空间定义,则上述代码创建一个新的命名空间;否则,上述代码打开已经存在的命名空间定义并为其添加一些新成员的声明。命名空间的定义可以不连续的特性使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似于我们管理自定义类及函数的方式:
- 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中。
- 命名空间成员的定义部分则置于另外的源文件中。
在程序中某些实体只能定义一次:如非内联函数、静态数据成员、变量等,命名空间中定义的名字也需要满足这一要求,我们可以通过上面的方式组织命名空间并达到目的。这种接口和实现分离的机制确保我们所需的函数和其他名字只定义一次,而只要是用到这些实体的地方都能看到对于实体名字的声明。
Best Practices: 定义多个类型不相关的命名空间应该使用单独的文件分别表示每个类型(或关联类型构成的集合)。
在通常情况下,我们不把
#include
放在命名空间内部。 如果我们这么做了,隐含的意思是把头文件中所有的名字定义成该命名空间的成员。假定作用域中存在合适的声明语句,则命名空间中的代码可以使用同一命名空间定义的名字的简写形式。
也可以在命名空间定义的外部定义该命名空间的成员。命名空间对于名字的声明必须在作用域内,同时该名字的定义需要明确指出其所属的命名空间。
和定义在类外部的类成员一样,一旦看到含有完整前缀的名字,我们就可以确定该名字位于命名空间的作用域内。
模板特例化必须定义在原始模板所属的命名空间中。和其他命名空间名字类似,只要我们在命名空间中声明了特例化,就能在命名空间外部定义它了:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16// 我们必须将模板特例化声明成 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)中。全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式地添加到全局命名空间中。
作用域运算符同样可以用于全局作用域的成员,因为全局作用域是隐式的,所以它并没有名字。下面的形式
1
::member_name
表示全局命名空间中的一个成员。
嵌套的命名空间是指定义在其他命名空间中的命名空间。嵌套的命名空间同时是一个嵌套的作用域,它嵌套在外层命名空间的作用域中。嵌套的命名空间中的名字遵循的规则与往常类似:内层命名空间声明的名字将隐藏外层命名空间声明的同名成员。在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间中的代码要想访问它必须在名字前添加限定符。例如,在嵌套的命名空间
QueryLib
中声明的类名是1
cplusplus_primer::QueryLib::Query
C++11 新标准引入了一种新的嵌套命名空间,称为内联命名空间(inline namespace)。和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。也就是说,我们无须在内联命名空间的名字前添加表示该命名空间的前缀,通过外层命名空间的名字就可以直接访问它。
定义内联命名空间的方式是在关键字
namespace
前添加关键字inline
:1
2
3
4
5
6
7
8
9
10inline namespace FifthEd {
// 该命名空间表示 C++ Primer 第 5 版的代码
}
namespace FifthEd { // 隐式内联
class Query_base
{
/* ... */
// 其他与 Query 有关的声明
};
} // namespace FifthEd关键字
inline
必须出现在命名空间第一次定义的地方,后续再打开命名空间的时候可以写inline
,也可以不写。当应用程序的代码在一次发布和另一次发布之间发生了改变时,常常会用到内联命名空间。例如,我们可以把本书当前版本的所有代码都放在一个内联命名空间中,而之前版本的代码都放在一个非内联命名空间中:
1
2
3
4
5
6
7
8
9
10
11namespace FourthEd {
class Item_base
{
/* ... */
};
class Query_base
{
/* ... */
};
// C++ Primer 第 4 版用到的其他代码
} // namespace FourthEd命名空间
cplusplus_primer
将同时使用这两个命名空间。例如,假定每个命名空间都定义在同名的头文件中,则我们可以把命名空间cplusplus_primer
定义成如下形式:1
2
3
4namespace cplusplus_primer {
} // namespace cplusplus_primer因为
FifthEd
是内联的,所以形如cplusplus_primer::
的代码可以直接获得FifthEd
的成员。如果我们想使用早期版本的代码,则必须像其他嵌套的命名空间一样加上完整的外层命名空间名字,比如cplusplus_primer::FourthEd::Query_base
。未命名的命名空间(unnamed namespace)是指关键字
namespace
后紧跟花括号括起来的一系列声明语句。未命名的命名空间中定义的变量拥有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。一个未命名的命名空间可以在某个给定的文件内不连续,但是不能跨越多个文件。 每个文件定义自己的未命名的命名空间,如果两个文件都含有未命名的命名空间,则这两个空间互相无关。在这两个未命名的命名空间中可以定义相同的名字,并且这些定义表示的是不同实体。如果一个头文件定义了未命名的命名空间,则该命名空间中定义的名字将在每个包含了该头文件的文件中对应不同实体。
Note:和其他命名空间不同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。
定义在未命名的命名空间中的名字可以直接使用,毕竟我们找不到什么命名空间的名字来限定它们;同样的,我们也不能对未命名的命名空间的成员使用作用域运算符。
未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同。如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域中的名字有所区别:
1
2
3
4
5
6
7int i; // i 的全局声明
namespace {
int i;
}
// 二义性:i 的定义既出现在全局作用域中,又出现在未嵌套的未命名的命名空间中
i = 10;其他情况下,未命名的命名空间中的成员都属于正确的程序实体。和所有命名空间类似,一个未命名的命名空间也能嵌套在其他命名空间当中。此时,未命名的命名空间中的成员可以通过外层命名空间的名字来访问:
1
2
3
4
5
6
7namespace local {
namespace {
int i;
}
} // namespace local
// 正确:定义在嵌套的未命名的命名空间中的 i 与全局作用域中的 i 不同
local::i = 42;未命名的命名空间取代文件中的静态声明。 在标准 C++ 引入命名空间的概念之前,程序需要将名字声明成
static
的以使得其对于整个文件有效。在文件中进行静态声明的做法是从 C 语言继承而来的。在 C 语言中,声明为static
的全局实体在其所在的文件外不可见。WARNING: 在文件中进行静态声明的做法已经被 C++ 标准取消了,现在的做法是使用未命名的命名空间。
18.2.2 使用命名空间成员
命名空间的别名(namespace alias)使得我们可以为命名空间的名字设定一个短得多的同义词。例如,一个很长的命名空间的名字形如
1
2
3namespace cplusplus_primer {
/*...*/
};我们可以为其设定一个短得多的同义词:
1
namespace primer = cplusplus_primer;
命名空间的别名声明以关键字
namespace
开始,后面是别名所用的名字、=
符号、命名空间原来的名字以及一个分号。不能在命名空间还没有定义前就声明别名,否则将产生错误。命名空间的别名也可以指向一个嵌套的命名空间:
1
2namespace Qlib = cplusplus_primer::QueryLib;
Qlib::Query q;Note: 一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价。
一条
using
声明(using declaration)语句一次只引入命名空间的一个成员。它使得我们可以清楚地知道程序中所用的到底是哪个名字。using
声明引入的名字遵守与过去一样的作用域规则:它的有效范围从using
声明的地方开始,一直到using
声明所在的作用域结束为止。在此过程中,外层作用域的同名实体将被隐藏。未加限定的名字只能在using
声明所在的作用域以及其内层作用域中使用。在有效作用域结束后,我们就必须使用完整的经过限定的名字了。一条
using
声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域中。在类的作用域中,这样的声明语句只能指向基类成员。using
指示(using directive)和using
声明类似的地方是,我们可以使用命名空间名字的简写形式;和using
声明不同的地方是,我们无法控制哪些名字是可见的,因为所有名字都是可见的。using
指示以关键字using
开始,后面是关键字namespace
以及命名空间的名字。如果这里所用的名字不是一个已经定义好的命名空间的名字,则程序将发生错误。using
指示可以出现在全局作用域、局部作用域和命名空间作用域中,但是不能出现在类的作用域中。using
指示使得某个特定的命名空间中所有的名字都可见,这样我们就无须再为它们添加任何前缀限定符了。简写的名字从using
指示开始,一直到using
指示所在的作用域结束都能使用。WARNING:如果我们提供了一个对
std
等命名空间的using
指示而未做任何特殊控制的话,将重新引入由于使用了多个库而造成的名字冲突问题。using
指示引入的名字的作用域远比using
声明引入的名字的作用域复杂。如我们所知,using
声明的名字的作用域与using
声明语句本身的作用域一致,从效果上看就好像using
声明语句为命名空间的成员在当前作用域内创建了一个别名一样。using
指示所做的绝非声明别名这么简单。相反,它具有将命名空间成员提升到包含命名空间本身和using
指示的最近作用域的能力。using
声明和using
指示在作用域上的区别直接决定了它们工作方式的不同。对于using
声明来说,我们只是简单地令名字在局部作用域内有效。相反,using
指示是令整个命名空间的所有内容变得有效。通常情况下,命名空间中会含有一些不能出现在局部作用域中的定义,因此,using
指示一般被看作是出现在最近的外层作用域中。在最简单的情况下,假定我们有一个命名空间
A
和一个函数f
,它们都定义在全局作用域中。如果f
含有一个对A
的using
指示,则在f
看来,A
中的名字仿佛是出现在全局作用域中f
之前的位置一样:1
2
3
4
5
6
7
8
9
10// 命名空间 A 和函数 f 定义在全局作用域中
namespace A {
int i, j;
}
void f()
{
using namespace A; // 把 A 中的名字注入到全局作用域中
cout << i * j << endl; // 使用命名空间 A 中的 i 和 j
// ...
}一个简单的示例:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16namespace blip {
int i = 16, j = 15, k = 23;
// 其他声明
} // namespace blip
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
}manip
的using
指示使得程序可以直接访问blip
的所有名字,也就是说,manip
的代码可以使用blip
中名字的简写形式。blip
的成员看起来好像是定义在blip
和manip
所在的作用域一样。假定manip
定义在全局作用域中,则blip
的成员也好像是定义在全局作用域中一样。当命名空间被注入到它的外层作用域之后,很有可能该命名空间中定义的名字会与其外层作用域中的成员冲突。例如在
manip
中,blip
的成员j
就与全局作用域中的j
产生了冲突。这种冲突是允许存在的,但是要想使用冲突的名字,我们就必须明确指出名字的版本。manip
中所有未加限定的j
都会产生二义性错误。为了使用像这样的名字,我们必须使用作用域运算符来明确指出所需的版本。我们使用
::j
来表示定义在全局作用域中的j
,而使用blip::j
来表示定义在blip
中的j
。因为
manip
的作用域和命名空间的作用域不同,所以manip
内部的声明可以隐藏命名空间中的某些成员名字。例如,局部变量k
隐藏了命名空间的成员blip::k
。在manip
内使用k
不存在二义性,它指的就是局部变量k
。头文件如果在其顶层作用域中含有
using
指示或using
声明,则会将名字注入到所有包含了该头文件的文件中。 通常情况下,头文件应该只负责定义接口部分的名字,而不定义实现部分的名字。因此,头文件最多只能在它的函数或命名空间内使用using
指示或using
声明。提示:避免
using
指示。using
指示一次性注入某个命名空间的所有名字,这种用法看似简单实则充满了风险:只使用一条语句就突然将命名空间中所有成员的名字变得可见了。如果应用程序使用了多个不同的库,而这些库中的名字通过using
指示变得可见,则全局命名空间污染的问题将重新出现。
而且,当引入库的新版本后,正在工作的程序很可能会编译失败。如果新版本引入了一个与应用程序正在使用的名字冲突的名字,就会出现这个问题。
另一个风险是由using
指示引发的二义性错误只有在使用了冲突名字的地方才能被发现。这种延后的检测意味着可能在特定库引入很久之后才爆发冲突。直到程序开始使用该库的新部分后,之前一直未被检测到的错误才会出现。
相比于使用using
指示,在程序中对命名空间的每个成员分别使用using
声明效果更好,这么做可以减少注入到命名空间中的名字数量。using
声明引起的二义性问题在声明处就能发现,无须等到使用名字的地方,这显然对检测并修改错误大有益处。Tip:
using
指示也并非一无是处,例如在命名空间本身的实现文件中就可以使用using
指示。
18.2.3 类、命名空间与作用域
对命名空间内部名字的查找遵循常规的查找规则:即由内向外依次查找每个外层作用域。 外层作用域也可能是一个或多个嵌套的命名空间,直到最外层的全局命名空间查找过程终止。只有位于开放的块中且在使用点之前声明的名字才被考虑:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19namespace 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
}
} // namespace B
// 命名空间 B 结束,此后 B 中定义的名字不再可见
int f2()
{
return j; // 错误:j 没有被定义
}
int j = i; // 用 A::i 进行初始化
} // namespace A对于位于命名空间中的类来说,常规的查找规则仍然适用:当成员函数使用某个名字时,首先在该成员中进行查找,然后在类中查找(包括基类),接着在外层作用域中查找,这时一个或几个外层作用域可能就是命名空间:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19namespace 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 进行初始化
} // namespace A
// 成员 f3 定义在 C1 和命名空间 A 的外部
int A::C1::f3() { return h; } // 正确:返回 A::h除了类内部出现的成员函数定义之外,总是向上查找作用域。名字必须先声明后使用,因此
f2
的return
语句无法通过编译。该语句试图使用命名空间A
的名字h
,但此时h
尚未定义。如果h
在A
中定义的位置位于C1
的定义之前,则上述语句将合法。类似的,因为f3
的定义位于A::h
之后,所以f3
对于h
的使用是合法的。Tip: 可以从函数的限定名推断出查找名字时检查作用域的次序,限定名以相反次序指出被查找的作用域。
限定符
A::C1::f3
指出了查找类作用域和命名空间作用域的相反次序。首先查找函数f3
的作用域,然后查找外层类C1
的作用域,最后检查命名空间A
的作用域以及包含着f3
定义的作用域。考虑下面的程序:
1
2std::string s;
std::cin >> s;该调用等价于:
1
operator>>(std::cin, s);
operator>>
函数定义在标准库string
中,string
又定义在命名空间std
中。但是我们不用std::
限定符和using
声明就可以调用operator>>
。对于命名空间中名字的隐藏规则来说有一个重要的例外,它使得我们可以直接访问输出运算符。这个例外是,当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外还会查找实参类所属的命名空间。这一例外对于传递类的引用或指针的调用同样有效。
在此例中,当编译器发现对
operator>>
的调用时,首先在当前作用域中寻找合适的函数,接着查找输出语句的外层作用域。随后,因为>>
表达式的形参是类类型的,所以编译器还会查找cin
和s
的类所属的命名空间。也就是说,对于这个调用来说,编译器会查找定义了istream
和string
的命名空间std
。当在std
中查找时,编译器找到了string
的输出运算符函数。查找规则的这个例外允许概念上作为类接口一部分的非成员函数无须单独的
using
声明就能被程序使用。假如该例外不存在,则我们将不得不为输出运算符专门提供一个using
声明:1
using std::operator>>; // 要想使用 cin >> s 就必须有该 using 声明
或者使用函数调用的形式以把命名空间的信息包含进来:
1
std::operator>>(std::cin, s); // 正确:显式地使用 std::>>
通常情况下,如果在应用程序中定义了一个标准库中已有的名字,则将出现以下两种情况中的一种:要么根据一般的重载规则确定某次调用应该执行函数的哪个版本;要么应用程序根本就不会执行函数的标准库版本。
标准库
move
和forward
函数都是模板函数,在标准库的定义中它们都接受一个右值引用的函数形参。 如我们所知,在函数模板中,右值引用形参可以匹配任何类型。如果我们的应用程序也定义了一个接受单一形参的move
函数,则不管该形参是什么类型,应用程序的move
函数都将与标准库的版本冲突。forward
函数也是如此。因此,move
(以及forward
)的名字冲突要比其他标准库函数的冲突频繁得多,建议最好使用它们的带限定语的完整版本。当类声明了一个友元时,该友元声明并没有使得友元本身可见。然而,一个另外的未声明的类或函数如果第一次出现在友元声明中,则我们认为它是最近的外层命名空间的成员。这条规则与实参相关的查找规则结合在一起将产生意想不到的效果:
1
2
3
4
5
6
7
8
9namespace A {
class C
{
// 两个友元,在友元声明之外没有其他的声明
// 这些函数隐式地成为命名空间 A 的成员
friend void f2(); // 除非另有声明,否则不会被找到
friend void f(const C &); // 根据实参相关的查找规则可以被找到
};
} // namespace A此时,
f
和f2
都是命名空间A
的成员。即使f
不存在其他声明,我们也能通过实参相关的查找规则调用f
:1
2
3
4
5
6int main()
{
A::C cobj;
f(cobj); // 正确:通过在 A::C 中的友元声明找到 A::f
f2(); // 错误:A::f2 没有被声明
}因为
f
接受一个类类型的实参,而且f
在C
所属的命名空间进行了隐式的声明,所以f
能被找到。相反,因为f2
没有形参,所以它无法被找到。练习 18.18: 已知有下面的
swap
的典型定义,当mem1
是一个string
时程序使用swap
的哪个版本?如果mem1
是int
呢?说明在这两种情况下名字查找的过程。1
2
3
4
5
6void swap(T v1, T v2)
{
using std::swap;
swap(v1.mem1, v2.mem1);
// 交换类型 T 的其他成员
}答:
mem1
是string
时,使用std::string::swap
;mem1
时int
时,使用std::swap
。
18.2.4 重载与命名空间
命名空间对函数的匹配过程有两方面的影响。其中一个影响非常明显:
using
声明或using
指示能将某些函数添加到候选函数集。对于接受类类型实参的函数来说,其名字查找将在实参类所属的命名空间中进行。这条规则对于我们如何确定候选函数集同样也有影响。我们将在每个实参类(以及实参类的基类)所属的命名空间中搜寻候选函数。在这些命名空间中所有与被调用函数同名的函数都将被添加到候选集当中,即使其中某些函数在调用语句处不可见也是如此:
1
2
3
4
5
6
7
8
9
10
11
12namespace NS {
class Quote { /* ... */ };
void display(const Quote &) { /* ... */ }
} // namespace NS
// Bulk_item 的基类声明在命名空间 NS 中
class Bulk_item : public NS::Quote { /* ... */ };
int main()
{
Bulk_item book1;
display(book1);
return 0;
}我们传递给
display
的实参属于类类型Bulk_item
,因此该调用语句的候选函数不仅应该在调用语句所在的作用域中查找,而且也应该在Bulk_item
及其基类Quote
所属的命名空间中查找。命名空间NS
中声明的函数display(const Quote&)
也将被添加到候选函数集当中。要想理解
using
声明与重载之间的交互关系,必须首先明确一条:using
声明语句声明的是一个名字,而非一个特定的函数:1
2using NS::print(int); // 错误:不能指定形参列表
using NS::print; // 正确:using 声明只声明一个名字当我们为函数书写
using
声明时,该函数的所有版本都被引入到当前作用域中。一个
using
声明囊括了重载函数的所有版本以确保不违反命名空间的接口。 库的作者为某项任务提供了好几个不同的函数,允许用户选择性地忽略重载函数中的一部分但不是全部有可能导致意想不到的程序行为。一个
using
声明引入的函数将重载该声明语句所属作用域中已有的其他同名函数。如果using
声明出现在局部作用域中,则引入的名字将隐藏外层作用域的相关声明。如果using
声明所在的作用域中已经有一个函数与新引入的函数同名且形参列表相同,则该using
声明将引发错误。除此之外,using
声明将为引入的名字添加额外的重载实例,并最终扩充候选函数集的规模。using
指示将命名空间的成员提升到外层作用域中,如果命名空间的某个函数与该命名空间所属作用域的函数同名,则命名空间的函数将被添加到重载集合中:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17namespace libs_R_us {
extern void print(int);
extern void print(double);
} // namespace libs_R_us
// 普通的声明
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 string &)
print(ival); // 调用 libs_R_us::print(int)
}与
using
声明不同的是,对于using
指示来说,引入一个与已有函数形参列表完全相同的函数并不会产生错误。此时,只要我们指明调用的是命名空间中的函数版本还是当前作用域的版本即可。如果存在多个
using
指示,则来自每个命名空间的名字都会成为候选函数集的一部分:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16namespace 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;
}在全局作用域中,函数
print
的重载集合包括print(int)
、print(double)
和print(long double)
,尽管它们的声明位于不同作用域中,但它们都属于main
函数中print
调用的候选函数集。练习 18.20: 在下面的代码中,确定哪个函数与
compute
调用匹配。列出所有候选函数和可行函数,对于每个可行函数的实参与形参的匹配过程来说,发生了哪种类型转换?1
2
3
4
5
6
7
8
9namespace primerLib {
void compute();
void compute(const void *);
} // namespace primerLib
using primerLib::compute;
void compute(int);
void compute(double, double = 3.4);
void compute(char *, char * = 0);
void f() { compute(0); }如果将
using
声明置于f
函数中compute
的调用点之前将发生什么情况?重新回答之前的那些问题。答:
primerLib
和全局作用域中的五个compute
函数都是候选函数,其中:void primerLib::compute()
不可行;void primerLib::compute(const void *)
可行,0
被转换为const void *
;void compute(int)
可行,为最优匹配;void compute(double, double = 3.4)
可行,int
被转换为double
;void compute(char*, char* = 0)
可行,int
被转换为char*
。如果将
using
声明置于f
函数中compute
的调用点之前,则全局作用域中的三个compute
函数被屏蔽,不可见,primerLib
中的两个compute
函数是候选函数,其中:void primerLib::compute()
不可行;void primerLib::compute(const void *)
可行,0
被转换为const void *
。
18.3 多重继承与虚继承
- 多重继承(multiple inheritance)是指从多个直接基类中,产生派生类的能力。多重继承的派生类继承了所有父类的属性。
18.3.1 多重继承
在派生类的派生列表中可以包含多个基类:
1
2
3
4
5
6
7
8class Bear : public ZooAnimal
{
/* ... */
};
class Panda : public Bear, public Endangered
{
/* ... */
};每个基类包含一个可选的访问说明符。一如往常,如果访问说明符被忽略掉了,则关键字
class
对应的默认访问说明符是private
,关键字struct
对应的是public
。和只有一个基类的继承一样,多重继承的派生列表也只能包含已经被定义过的类,而且这些类不能是
final
的。对于派生类能够继承的基类个数,C++ 没有进行特殊规定;但是在某个给定的派生列表中,同一个基类只能出现一次。在多重继承关系中,派生类的对象包含有每个基类的子对象。如下图所示,在
Panda
对象中含有一个Bear
部分(其中又含有一个ZooAnimal
部分)、一个Endangered
部分以及在Panda
中声明的非静态数据成员。构造一个派生类的对象将同时构造并初始化它的所有基类子对象。与从一个基类进行的派生一样,多重继承的派生类的构造函数初始值也只能初始化它的直接基类:
1
2
3
4
5
6
7
8// 显式地初始化所有基类
Panda::Panda(std::string name, bool onExhibit)
: Bear(name, onExhibit, "Panda"),
Endangered(Endangered::critical)
{
}
// 隐式地使用 Bear 的默认构造函数初始化 Bear 子对象
Panda::Panda() : Endangered(Endangered::critical) {}派生类的构造函数初始值列表将实参分别传递给每个直接基类。其中基类的构造顺序与派生列表中基类的出现顺序保持一致,而与派生类构造函数初始值列表中基类的顺序无关。一个
Panda
对象按照如下次序进行初始化:ZooAnimal
是整个继承体系的最终基类,Bear
是Panda
的直接基类,ZooAnimal
是Bear
的基类,所以首先初始化ZooAnimal
。- 接下来初始化
Panda
的第一个直接基类Bear
。 - 然后初始化
Panda
的第二个直接基类Endangered
。 - 最后初始化
Panda
。
在 C++11 新标准中,允许派生类从它的一个或几个基类中继承构造函数。但是如果从多个基类中继承了相同的构造函数(即形参列表完全相同),则程序将产生错误:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19struct 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 继承构造函数
};如果一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本:
1
2
3
4
5
6
7
8struct D2 : 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
、~Endangered
、~Bear
和~ZooAnimal
。与只有一个基类的继承一样,多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动或赋值操作。只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分执行这些操作。在合成的拷贝控制成员中,每个基类分别使用自己的对应成员隐式地完成构造、赋值或销毁等工作。
练习 18.22: 已知存在如下所示的类的继承体系,其中每个类都定义了一个默认构造函数:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28class A
{
// ...
};
class B : public A
{
// ...
};
class C : public B
{
// ...
};
class X
{
// ...
};
class Y
{
// ...
};
class Z : public X, public Y
{
// ...
};
class MI : public C, public z
{
// ...
};对于下面的定义来说,构造函数的执行顺序是怎样的?
1
MI mi;
答: A,B,C,X,Y,Z,MI。
18.3.2 类型转换与多个基类
在只有一个基类的情况下,派生类的指针或引用能自动转换成一个可访问基类的指针或引用。多个基类的情况与之类似。我们可以令某个可访问基类的指针或引用直接指向一个派生类对象。 例如,一个
ZooAnimal
、Bear
或Endangered
类型的指针或引用可以绑定到Panda
对象上:1
2
3
4
5
6
7
8// 接受 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 的引用编译器不会在派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类都一样好。 例如,如果存在如下所示的
print
重载形式:1
2void print(const Bear &);
void print(const Endangered &);则通过
Panda
对象对不带前缀限定符的print
函数进行调用将产生编译错误:1
2Panda ying_yang("ying_yang");
print(ying_yang); // 二义性错误与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员。 如果我们使用一个
ZooAnimal
指针,则只有定义在ZooAnimal
中的操作是可以使用的,Panda
接口中的Bear
、Panda
和Endangered
特有的部分都不可见。类似的,一个Bear
类型的指针或引用只能访问Bear
及ZooAnimal
的成员,一个Endangered
的指针或引用只能访问Endangered
的成员。举个例子,已知我们的类已经定义了下表列出的虚函数,考虑下面的这些函数调用:
1
2
3
4
5Bear *pb = new Panda("ying_yang");
pb->print(); // 正确:Panda::print(),多态调用
pb->cuddle(); // 错误:不属于 Bear 的接口
pb->highlight(); // 错误:不属于 Bear 的接口
delete pb; // 正确:Panda::~Panda(),多态调用当我们通过
Endangered
的指针或引用访问一个Panda
对象时,Panda
接口中Panda
特有的部分以及属于Bear
的部分都是不可见的:1
2
3
4
5
6Endangered *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 中定义的虚函数 函数 含有自定义版本的类 print ZooAnimal::ZooAnimal Bear::Bear Endangered::Endangered Panda::Panda highlight Endangered::Endangered Panda::Panda toes Bear::Bear Panda::Panda cuddle Panda::Panda 析构函数 ZooAnimal::ZooAnimal Endangered::Endangered 练习 18.25: 假设我们有两个基类
Base1
和Base2
,它们各自定义了一个名为print
的虚成员和一个虚析构函数。从这两个基类中我们派生出下面的类,它们都重新定义了print
函数:1
2
3
4
5
6
7
8
9
10
11
12class D1 : public Base1
{
/* ... */
};
class D2 : public Base2
{
/* ... */
};
class MI : public D1, public D2
{
/* ... */
};通过下面的指针,指出在每个调用中分别使用了哪个函数:
1
2
3
4
5
6
7Base1 *pb1 = new MI;
Base2 *pb2 = new MI;
D1 *pd1 = new MI;
D2 *pd2 = new MI;
(a) pb1->print(); (b) pd1->print(); (c) pd2->print();
(d) delete pb2; (e) delete pd1; (f) delete pd2;答:(a)、(b)、(c):
MI::print
;(d)、(e)、(f):~MI
,~D2
,~Base2
,~D1
,~Base1
。
18.3.3 多重继承下的类作用域
在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中。查找过程沿着继承体系自底向上进行,直到找到所需的名字。派生类的名字将隐藏基类的同名成员。
在多重继承的情况下,相同的查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对该名字的使用将具有二义性。
在我们的例子中,如果我们通过
Panda
的对象、指针或引用使用了某个名字,则程序会并行地在Endangered
和Bear
/ZooAnimal
这两棵子树中查找该名字。如果名字在超过一棵子树中被找到,则该名字的使用具有二义性。对于一个派生类来说,从它的几个基类中分别继承名字相同的成员是完全合法的,只不过在使用这个名字时必须明确指出它的版本。WARNING:当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名成员的情况。此时,不加前缀限定符直接使用该名字将引发二义性。
例如,如果
ZooAnimal
和Endangered
都定义了名为max_weight
的成员,并且Panda
没有定义该成员,则下面的调用是错误的:1
double d = ying_yang.max_weight();
Panda
在派生的过程中拥有了两个名为max_weight
的成员,这是完全合法的。派生仅仅是产生了潜在的二义性,只要Panda
对象不调用max_weight
函数就能避免二义性错误。另外,如果每次调用max_weight
时都指出所调用的版本(ZooAnimal::max_weight
或者Endangered::max_weight
),也不会发生二义性。只有当要调用哪个函数含糊不清时程序才会出错。在上面的例子中,派生类继承的两个
max_weight
会产生二义性,这一点显而易见。一种更复杂的情况是,有时即使派生类继承的两个函数形参列表不同也可能发生错误。此外,即使max_weight
在一个类中是私有的,而在另一个类中是公有的或受保护的同样也可能发生错误。最后一种情况,假如max_weight
定义在Bear
中而非ZooAnimal
中,上面的程序仍然是错误的。和往常一样,先查找名字后进行类型检查。当编译器在两个作用域中同时发现了max_weight
时,将直接报告一个调用二义性的错误。要想避免潜在的二义性,最好的办法是在派生类中为该函数定义一个新版本。 例如,我们可以为
Panda
定义一个max_weight
函数从而解决二义性问题:1
2
3
4double Panda::max_weight() const
{
return std::max(ZooAnimal::max_weight(), Endangered::max_weight());
}
18.3.4 虚继承
尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类。派生类可以通过它的两个直接基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另一个基类再一次间接继承该类。
举个例子,IO 标准库的
istream
和ostream
分别继承了一个共同的名为base_ios
的抽象基类。该抽象基类负责保存流的缓冲内容并管理流的条件状态。iostream
是另外一个类,它从istream
和ostream
直接继承而来,可以同时读写流的内容。因为istream
和ostream
都继承自base_ios
,所以iostream
继承了base_ios
两次,一次是通过istream
,另一次是通过ostream
。在默认情况下,派生类中含有继承链上每个类对应的子部分。如果某个类在派生过程中出现了多次,则派生类中将包含该类的多个子对象。
这种默认的情况对某些形如
iostream
的类显然是行不通的。一个iostream
对象肯定希望在同一个缓冲区中进行读写操作,也会要求条件状态能同时反映输入和输出操作的情况。假如在iostream
对象中真的包含了base_ios
的两份拷贝,则上述的共享行为就无法实现了。在 C++ 语言中我们通过虚继承(virtual inheritance)的机制解决上述问题。虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类(virtual base class)。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
我们可以对
Panda
类进行修改,令其同时继承Bear
和Raccoon
。此时,为了避免赋予Panda
两份ZooAnimal
的子对象,我们将Bear
和Raccoon
继承ZooAnimal
的方式定义为虚继承。观察这个新的继承体系,我们将发现虚继承的一个不太直观的特征:必须在虚派生的真实需求出现前就已经完成虚派生的操作。例如在我们的类中,当我们定义
Panda
时才出现了对虚派生的需求,但是如果Bear
和Raccoon
不是从ZooAnimal
虚派生得到的,那么Panda
的设计者就显得不太幸运了。在实际的编程过程中,位于中间层次的基类将其继承声明为虚继承一般不会带来什么问题。
Note:虚派生只影响从指定了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。
我们指定虚基类的方式是在派生列表中添加关键字
virtual
:1
2
3
4
5
6
7
8
9// 关键字 public 和 virtual 的顺序随意
class Raccoon : public virtual ZooAnimal
{
/* ... */
};
class Bear : virtual public ZooAnimal
{
/* ... */
};通过上面的代码我们将
ZooAnimal
定义为Raccoon
和Bear
的虚基类。virtual
说明符表明了一种愿望,即在后续的派生类当中共享虚基类的同一份实例。至于什么样的类能够作为虚基类并没有特殊规定。如果某个类指定了虚基类,则该类的派生仍按常规方式进行:
1
2
3class Panda : public Bear, public Raccoon, public Endangered
{
};Panda
通过Raccoon
和Bear
继承了ZooAnimal
,因为Raccoon
和Bear
继承ZooAnimal
的方式都是虚继承,所以在Panda
中只有一个ZooAnimal
基类部分。(如果Raccoon
和Bear
中,一个是虚继承自ZooAnimal
,而另一个是常规继承自ZooAnimal
呢?——博主注)不论基类是不是虚基类,派生类对象都能被可访问基类的指针或引用操作。 例如,下面这些从
Panda
向基类的类型转换都是合法的:1
2
3
4
5
6
7void 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 传递因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,并且不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则我们仍然可以直接访问这个被覆盖的成员。但是如果成员被多余一个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。
例如,假定类
B
定义了一个名为x
的成员,D1
和D2
都是从B
虚继承得到的,D
继承了D1
和D2
,则在D
的作用域中,x
通过D
的两个基类都是可见的。如果我们通过D
的对象使用x
,有三种可能性:- 如果在
D1
和D2
中都没有x
的定义,则x
将被解析为B
的成员,此时不存在二义性,一个D
的对象只含有x
的一个实例。 - 如果
x
是B
的成员,同时是D1
和D2
中某一个的成员,则同样没有二义性,派生类的x
比共享虚基类B
的x
优先级更高。 - 如果在
D1
和D2
中都有x
的定义,则直接访问x
将产生二义性问题。
与非虚的多重继承体系一样,解决这种二义性问题最好的方法是在派生类中为成员自定义新的实例。
- 如果在
练习 18.28: 已知存在如下的继承体系,在
VMI
类的内部哪些继承而来的成员无须前缀限定符就能直接访问?哪些必须有限定符才能访问?说明你的原因。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26struct Base
{
void bar(int); // 默认情况下是公有的
protected:
int ival;
};
struct Derived1 : virtual public Base
{
void bar(char); // 默认情况下是公有的
void foo(char);
protected:
char cval;
};
struct Derived2 : virtual public Base
{
void foo(int); // 默认情况下是公有的
protected:
int ival;
char cval;
};
class VMI : public Derived1, public Derived2
{
};答:
bar
和ival
可以不加限定地访问:bar
在共享基类Base
和派生类Derived1
中都存在,但这只是在一条派生路径上,特定派生类实例的优先级高于共享基类实例,所以在VMI
类内部不加限定地访问bar
,访问到的是Derived1
中的bar
实例。ival
在共享基类Base
和派生类Derived2
中都存在,同理,在VMI
类内部不加限定地访问ival
,访问到的是Derived2
中的ival
实例。foo
和cval
需要限定:二者在Derived1
和Derived2
中都存在,Derived1
和Derived2
均为Base
的派生类,访问优先级相同,所以,在VMI
类内不加限定地访问foo
和cval
存在二义性。
18.3.5 构造函数与虚继承
在虚派生中,虚基类是由最低层的派生类初始化的。 以我们的程序为例,当创建
Panda
对象时,由Panda
的构造函数独自控制ZooAnimal
的初始化过程。为了理解这一规则,我们不妨假设当以普通规则处理初始化任务时会发生什么情况。在此例中,虚基类将会在多条继承路径上被重复初始化。以
ZooAnimal
为例,如果应用普通规则,则Raccoon
和Bear
都会试图初始化Panda
对象的ZooAnimal
部分。当然,继承体系中的每个类都可能在某个时刻成为“最低层的派生类”。只要我们能创建虚基类的派生类对象,该派生类的构造函数就必须初始化它的虚基类。例如在我们的继承体系中,当创建一个
Bear
(或Raccoon
)的对象时,它已经位于派生的最低层,因此Bear
(或Raccoon
)的构造函数将直接初始化其ZooAnimal
基类部分:1
2
3
4
5
6
7
8Bear::Bear(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Bear")
{
}
Raccoon::Raccoon(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Raccoon")
{
}而当创建一个
Panda
对象时,Panda
位于派生的最低层并由它负责初始化共享的ZooAnimal
基类部分。即使ZooAnimal
不是Panda
的直接基类,Panda
的构造函数也可以初始化ZooAnimal
:1
2
3
4
5
6
7
8Panda::Panda(std::string name, bool onExhibit)
: ZooAnimal(name, onExhibit, "Panda"),
Bear(name, onExhibit),
Raccoon(name, onExhibit),
Endangered(Endangered::critical),
sleeping_flag(false)
{
}含有虚基类的对象的构造顺序与一般的顺序稍有区别:首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类子部分,接下来按照直接基类在派生列表中出现的次序依次对其进行初始化。
例如,当我们创建一个 Panda 对象时:
- 首先使用
Panda
的构造函数初始值列表中提供的初始值构造虚基类ZooAnimal
部分。 - 接下来构造
Bear
部分。 - 然后构造
Raccoon
部分。 - 然后构造第三个直接基类
Endangered
。 - 最后构造
Panda
部分。
如果
Panda
没有显式地初始化ZooAnimal
基类,则ZooAnimal
的默认构造函数将被调用。如果ZooAnimal
没有默认构造函数,则代码将发生错误。- 首先使用
Note:虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。
一个类可以有多个虚基类。此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。 例如,在下面这个稍显杂乱的
TeddyBear
派生关系中有两个虚基类:ToyAnimal
是直接虚基类,ZooAnimal
是Bear
的虚基类:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16class Character
{
/* ... */
};
class BookCharacter : public Character
{
/* ... */
};
class ToyAnimal
{
/* ... */
};
class TeddyBear : public BookCharacter, public Bear, public virtual ToyAnimal
{
/* ... */
};编译器按照直接基类的声明顺序对其依次进行检查,以确定其中是否含有虚基类。如果有,则先构造虚基类,然后按照声明的顺序逐一构造其他非虚基类。 因此,要想创建一个
TeddyBear
对象,需要按照如下次序调用这些构造函数:1
2
3
4
5
6ZooAnimal(); // Bear 的虚基类
ToyAnimal(); // 直接虚基类
Character(); // 第一个非虚基类的间接基类
BookCharacter(); // 第一个直接非虚基类
Bear(); // 第二个直接非虚基类
TeddyBear(); // 最低层的派生类合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。和往常一样,对象的销毁顺序与构造顺序正好相反,首先销毁
TeddyBear
部分,最后销毁ZooAnimal
部分。练习 18.29: 已知有如下所示的类继承关系:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24class Class
{
// ...
};
class Base : public Class
{
// ...
};
class D1 : virtual public Base
{
// ...
};
class D2 : virtual public Base
{
// ...
};
class MI : public D1, public D2
{
// ...
};
class Final : public MI, public Class
{
// ...
};(a)当作用于一个
Final
对象时,构造函数和析构函数的执行次序分别是什么?答: 优先构造虚基类部分,所以构造函数执行次序为
Class
,Base
,D1
,D2
,MI
,Class
,Final
;析构函数执行顺序相反。(b)在一个
Final
对象中有几个Base
部分?几个Class
部分?答: 一个
Base
部分,两个Class
部分。(c)下面的哪些赋值运算将造成编译错误?
1
2Base *pb; Class *pc; MI *pmi; D2 *pd2;
(a) pb = new Class; (b) pc = new Final; (c) pmi = pb; (d) pd2 = pmi;答: (a)错误,
Class
是Base
的基类,不能将基类指针转换为派生类指针;(b)错误,Final
中有两个Class
部分,即Class
是Final
的二义基类;(c)错误,Base
是MI
的基类,不能将基类指针转换为派生类指针;(d)正确。
小结
异常处理使得我们可以将程序的错误检测部分与错误处理部分分隔开来。当程序抛出一个异常时,当前正在执行的函数暂时中止,开始查找最邻近的与异常匹配的
catch
语句。作为异常处理的一部分,如果查找catch
语句的过程中退出了某些函数,则函数中定义的局部变量也随之销毁。从概念上来说,多重继承非常简单:一个派生类可以从多个直接基类继承而来。在派生类对象中既包含派生类部分,也包含与每个基类对应的基类部分。 虽然看起来很简单,但实际上多重继承的细节非常复杂。特别是对多个基类的继承可能会引入新的名字冲突,并造成来自于基类部分的名字的二义性问题。
如果一个类是从多个基类直接继承而来的,那么有可能这些基类本身又其享了另一个基类。在这种情况下,中间类可以选择使用虚继承,从而声明愿意与层次中虚继承同一基类的其他类共享虚基类。用这种方法,后代派生类中将只有一个共享虚基类的副本。