C++ Primer 18 用于大型程序的工具

C++ Primer 18 用于大型程序的工具,第1张

用于大型程序的工具

大规模编程对程序设计语言的要求更好。大规模应用程序的特殊要求包括:

  • 在独立开发的子系统之间协同处理错误的能力。
  • 使用各种库(可能包含独立开发的库)进行协同开发的能力。
  • 对比较复杂的应用概念建模的能力。

异常处理、命名空间和多重继承,正好能满足上述要求。


1 异常处理

异常处理机制允许程序中独立开发的部分能够在运行时就出现的问题进行通信并做出相应的处理。异常使得我们能够将问题的检测与解决过程分离开来。程序的一部分负责检测问题的出现,然后解决该问题的任务传递给程序的另一部分。检测环节无须知道问题处理模块的所有细节,反之亦然。

1.1 抛出异常

在 C++ 语言中,我们通过 抛出 一条表达式来 引发 一个异常。被抛出的表达式的类型以及当前的调用链共同决定了哪段处理代码将被用来处理该异常。被选中的处理代码实在调用链中与抛出对象类型匹配的最近的处理代码。

当执行一个 throw 时,跟在 throw 后面的语句将不再被执行。相反,程序的控制权从 throw 转移到与之匹配的 catch 模块。控制权从一处转移到另一处,这有两个重要的含义:

  • 沿着调用链的函数可能会提早退出。
  • 一旦程序开始执行异常处理代码,则沿着调用链创建的对象将被销毁。

因为跟在 throw 后面的语句将不再执行,所以 throw 语句的用法有点类似于 return语句。

栈展开

当抛出一个异常后,程序暂停当前函数的执行过程并立即开始寻找与异常匹配的 catch 子句。当 throw 出现在一个 try 语句块 内时,检查与该 try 块关联的 catch 子句。如果找到了匹配的 catch ,就使用该 catch 处理异常。如果这一步没有找到匹配的 catch 且该 try 语句嵌套在其他 try 块中,则继续检查与外层 try 匹配的 catch 子句。如果还是找不到匹配的 catch ,则退出当前的函数,在调用当前函数的外层函数中继续寻址。

如果对抛出异常的函数的调用语句位于一个 try 语句块内,则检查与该 try 块关联的 catch 子句。如果找到了匹配的 catch ,就使用该 catch 处理异常。否则,如果该 try 语句嵌套在其他 try 块中,则继续检查与外层 try 匹配的 catch 子句。
如果仍没有找到匹配的 catch ,则退出当前这个主调函数,继续在调用了刚刚退出的这个函数的其他函数中寻找,以此类推。

上述过程被称为 栈展开 过程。栈展开过程沿着嵌套函数的调用链不断查找,直到找到了与异常匹配的 catch 子句为止;或者也可能一直没找到匹配的 catch ,则退出主函数后查找过程终止。当找不到 catch 时,程序调用标准库函数 terminate。
注: 如果一个异常没有被捕获,则他将种植当前的程序。

栈展开过程中对象被自动销毁

如果栈展开过程中退出了某个块,编译器将负责确保在这个块中创建的对象能被正确地销毁。如果某个局部对象的类型是类类型,则该对象的析构函数将被自动调用。
注: 如果异常发生前已经构造了一部分元素(异常发生在构造函数中),则应该确保这部分元素被正确地销毁。

析构函数与异常

析构函数在栈展开的过程中执行,这一事实影响着我们编写析构函数的方式。在栈展开的过程中,已经引发了异常但是我们还没有处理它。如果异常抛出后没有被正确捕获,则系统将调用 terminate 函数。

因此,处于栈展开可能使用析构函数的考虑,析构函数不应该抛出不能被它自身处理的异常。换句话说,如果析构函数需要执行某个可能抛出异常的 *** 作,则该 *** 作应该被放置在一个 try 语句块当中,并且在析构函数内部得到处理。
注: 在栈展开的过程中,运行类类型的局部对象的析构函数。因为这些析构函数是自动执行的,所以它们不应该抛出异常。一旦在栈展开的过程中析构函数抛出了异常,并且析构函数自身没能捕获到该异常,则程序将被种植。

异常对象

异常对象是一种特殊的对象,编译器使用异常抛出表达式来对异常对象进行拷贝初始化。因此,throw 语句中的表达式必须拥有完全类型。

异常对象位于由编译器管理的空间中,编译器确保无论最终调用的哪个 catch 子句都能访问该空间。当异常处理完毕后,异常对象被销毁。

当我们抛出一条表达式时,该表达式静态编译时类型决定了异常对象的类型。如果一条 throw 表达式解引用了一个基类指针,而该指针实际指向的是派生类对象,则抛出的对象将被切掉一部分,只有基类部分被抛出。
注: 抛出指针要求在任何对应的处理代码存在的地方,指针所指的对象都必须存在。

1.2 捕获异常

catch 子句 中的 异常声明 看起来像是只包含一个形参的函数形参列表。像在形参列表中一样,如果 catch 无须访问抛出的表达式的话,则我们可以忽略捕获形参的名字。

声明的类型决定了处理代码所能捕获的异常类型。这个类型必须是完全类型,它可以是左值引用,但不能是右值引用。

异常声明的静态类型将决定 catch 语句所能执行的 *** 作。如果 catch 的参数是基类类型,则 catch 无法使用派生类特有的任何成员。
注: 通常情况下,如果 catch 接受的异常与某个继承体系有关,则最好将该 catch 的参数定义成引用类型。

查找匹配的处理代码

在搜寻 catch 语句的过程中,我们最终找到的 catch 未必是异常的最佳匹配。相反,挑选出来的应该是第一个与异常匹配的 catch 语句。因此,越是专门的 catch 越应该置于整个 catch 列表的前端。因为 catch 语句是按照其出现顺序逐一进行匹配的。

与实参和形参的匹配规则相比,异常和 catch 异常声明的匹配规则受到更多限制。此时,绝大多数类型转换都不被允许,除了一些细小的差别之外,要求异常的类型和 catch 声明的类型是精确匹配的:

  • 允许从非常量向常量的类型转换,也就是说,一条非常量对象的 throw 语句可以匹配一个接受常量引用的 catch 语句。
  • 允许从派生类向基类的类型转换。
  • 数组被转换成指向数组类型的指针,函数被转换成指向该函数类型的指针。

除此之外,包括标准算术类型转换和类类型转换在内,其他所有转换规则都不能在匹配 catch 的过程中使用。
注: 如果在多个 catch 语句的类型之间存在着继承关系,则我们应该把继承链最低端的类放在前面,而继承链最顶端的类放在后面。

重新抛出

有时,一个单独的 catch 语句不能完整地处理某个异常。在执行了某些矫正 *** 作之后,当前的 catch 可能会决定由调用链更上一层的函数接着处理异常。一条 catch 语句通过重新抛出的 *** 作将异常 *** 作传递给另外一个 catch 语句。这里得重新抛出仍然是一条 throw 语句,只不过不包含任何表达式:

throw;

空的 throw 语句只能出现在 catch 语句或 catch 语句直接或间接调用的函数之内。如果在处理代码之外的区域遇到了空 throw 语句,编译器将调用 terminate。

一个重新抛出语句并不制定新的表达式,而是将当前的异常对象沿着调用链向上传递。如果在改变了参数的内容后 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(…) 通常与重新抛出语句一起使用,其中 catch 执行当前局部能完成的工作,随后重新抛出异常:

void mainp() {
    try {
        // 这里的 *** 作将引发并抛出一个异常
    } catch(...) {
        // 处理异常的某些 *** 作
        throw;
    }
}

注: 如果 catch(…) 与其他几个 catch 语句一起出现,则 catch(…) 必须在最后的位置。出现在捕获所有异常语句后面的 catch 语句将永远不会被匹配。

1.3 函数 try 语句块与构造函数

构造函数在进入其函数体之前首先执行初始值列表。因为在初始值列表抛出异常时,构造函数中的 try 语句还未生效,所以构造函数体内的 catch 语句无法处理构造函数初始值列表抛出的异常。

此时我们必须将构造函数写成 函数 try 语句块 的形式。函数 try 语句块使得一组 catch 语句既能处理构造函数体(或析构函数体),也能处理构造函数的初始化过程(或析构函数的析构过程)。如下:

template
Blob::Blob(std::initializer_list il) try :
    data(std::make_shared>(il)) {
        //空函数体
    } catch(const std::bad_alloc &e) {
        handle_out_of_memory(e);
    }

还有一种情况,在初始化构造函数的参数时也能发生异常,这样的异常不属于函数 try 语句块的一部分。函数 try 语句块只能处理构造函数开始执行后发生的异常。
注: 处理构造函数初始值异常的唯一方法是将构造函数写成函数 try 语句块。

1.4 noexcept 异常说明

预先知道函数不会抛出异常有很多好处。首先,有助于简化调用该函数的代码;其次,编译器可以执行一些特殊的优化,而这些优化 *** 作并不适用于可能出错的代码。

在 C++11 新标准中,通过提供 noexcept 说明符指定某个函数不会抛出异常。其关键字 noexcpt 紧跟在函数参数列表后面,用于标识该函数不会抛出异常:

void recoup(int) noexcept;  // 不会抛出异常
void alloc(int);  // 可能抛出异常

对于一个函数来说,noexcept 说明要么出现在该函数的所有声明语句和定义语句中,要么一次也不出现。

该说明应该在函数的尾置返回类型之前。也可以在函数指针的声明和定义中指定 noexcept 。 在 typedef 或类型别名中则不能出现 noexcept 。在成员函数中,noexcept 说明符需要跟在 const 及引用限定符之后,而在 final、override 或虚函数的 =0 之前。

违反异常说明

如果一个函数在说明了 noexcept 的同时有含有 throw 或者调用了可能抛出异常的其他函数,编译器也会顺利通过,并不会因为这种违反一场说明的情况而报错。

// 尽管该海曙明显违反了异常说明,但它仍然可以顺利编译通过
void f() noexcept {  // 承诺不会抛出异常
	thorw exception();  // 违反了异常说明
}

因此,可能出现这样一种情况:尽管函数声明了它不会抛出异常,但实际上还是抛出了。一旦一个 noexcept 函数抛出异常,程序就会调用 terminate 以确保遵守不在运行时抛出异常的承诺。上述过程对是否执行栈展开未作约定,因此,noexcept 可以用在两种情况下:一是我们确认函数不会抛出异常,二是我们根本不知道该如何处理异常。
注: 通常情况下,编译器不能也不必在编译时验证异常说明。

异常说明的实参

noexcept 说明符接受一个可选的实参,该实参必须能转换为 bool 类型:如果实参时 true,则函数不会抛出异常;如果实参是 false,则函数可能抛出异常:

void recoup(int) noexcept(true);  // 不会抛出异常
void alloc(int) noexcept(false);  // 可能抛出异常
noexcept 运算符

noexcept 说明符的实参常常与 noexcept 运算符 混合使用。noexcept 运算符是一个一元运算符,它的返回值是一个 bool 类型的右值常量表达式,用于表示给定的表达式是否会抛出异常。和 sizeof 类似,noexcept 也不会求其运算对象的值。

noexcept(recoup(i))  // 如果 recoup 不抛出异常则结果为 true;否则结果为 false

更普通的形式是:

noexcept(e)

当 e 调用的所有函数都做了不抛出说明且 e 本身不含有 throw 语句时,上述表达式为 true;否则返回 false。

我们可以使用 noexcept 运算符得到如下的异常说明:

void f() noexcept(noexcept(g()));  // f 和 g 的异常说明一样

注: noexcept 有两层含义:当跟在函数参数列表后面时它是异常说明符;而当作为 noexcept 异常说明的 bool 实参出现时,它是一个运算符。

异常说明与指针、虚函数和拷贝控制

尽管 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 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)。而且,如果我们定义了一个析构函数但是没有为它提供异常说明,则编译器将合成一个。合成的异常说明将与假设编译器为类合成析构函数时所得到的的异常说明一致。

1.5 异常类层次

标准库异常类构成了下图所示的继承体系:

类型 exception 仅仅定义了拷贝构造函数、复制构造函数、一个虚析构函数和一个名为 what 的虚函数。其中 what 函数返回一个 const chat*,该指针指向一个 null 结尾的字符数组,并且确保不会抛出异常。

类 exception 、 bad_cast 和 bad_alloc 定义了默认构造函数。类 runtime_error 和 logic_error 没有默认构造函数,但是有一个可以接受 C 风格字符串或标准库 string 类型实参的构造函数,这些实参负责提供关于错误的更多信息。这些类中,what 负责返回用于初始化异常对象的信息。因为 what 是虚函数,所以当我们捕获基类的引用时,对 what 函数的调用将执行与异常对象动态类型对应的版本。

实际应用中的异常类

实际应用通常会自定义 exception(或者 exception 的标准库派生类)的派生类以扩展其继承体系。这些面向应用的异常类表示了与应用相关的异常条件。

我们面向应用的异常类继承自标准异常类,和其他继承体系一样,异常类也可以看作按照关系组织的。层次越低,表示的异常情况越特殊。
注: 使用自定义异常类的方式与使用标准异常类的方式完全一样。


2 命名空间

大型程序往往会使用多个独立开发的库,这些库有会定义大量的全局名字,如类、函数和模板等。当应用程序用到多个供应商提供的库时,不可避免的会发生某些名字互相冲突的情况。多个库将名字放置在全局命名空间将引发命名空间污染。

命名空间 为防止名字冲突提供了可控的机制。命名空间分割了全局命名空间,其中每个命名空间是一个作用域。通过在某个命名空间中定义库的名字,库的作者(以及用户)可以避免全局名字固有的限制。

2.1 命名空间定义

一个命名空间的定义包含两个部分:首先是关键字 namespace,随后是命名空间的名字。在命名空间名字后面是一系列由花括号括起来的声明和定义。

只要能出现在全局作用域中的声明就能置于命名空间中,包括:类、变量(及其初始化 *** 作)、函数(及其定义)、模板和其他命名空间:

namespace cplusplus_primer {
    class Sales_data { /*   */ };
    Sales_data operator+(const Sales_data&, const Sales_data&);
    class Query {/*   */};
    class Query_base {/*   */};
}  // 命名空间结束后无须分号,这一点于块类似

和其他名字一样。命名空间的名字也必须在它的作用域内保持唯一。命名空间可以定义在全局作用域内,也可以定义在其他命名空间中,但是不能定义在函数或类的内部。
注: 命名空间作用域后面无须分号。

每个命名空间都是一个作用域

命名空间的每个名字都必须表示该空间内的唯一实体。因为不同命名空间的作用域不同,所以在不同命名空间内可以有相同名字的成员。

定义在某个命名空间中的名字可以被该命名空间内的其他成员直接访问,也可以被这些

cplusplus_primer::Query q = cplusplus_primer::Query("hello");
AddisonWeslry::Query q = AddisonWeslry::Query("hello");
命名空间可以是不连续的
namespace nsp {
	// 相关声明
}

可能是定义了一个名为 nsp 的新命名空间,也可能时为已经存在的命名空间添加一些新成员,

命名空间的定义可以不连续的特性使得我们可以将几个独立的接口和实现文件组成一个命名空间。此时,命名空间的组织方式类似我们管理自定义类及函数的方式:

  • 命名空间的一部分成员的作用是定义类,以及声明作为类接口的函数及对象,则这些成员应该置于头文件中,这些头文件将被包含在使用了这些成员的文件中。
  • 命名空间成员的定义部分则置于另外的源文件中。

在程序中某些实体只能定义一次:如非内联函数、静态数据成员、变量等,命名空间中定义的名字也需要满足这一要求。这种接口和实现分离的机制确保我们所需的函数和其他名字只定义一次,而只要是用到这些实体的地方都能看到对实体名字的声明。
注: 定义多个类型不想管的命名空间应该使用单独的文件分别表示每个类型(或关联类型构成的集合)。

模板特例化

模板特例化必须定义在原始模板所属的命名空间中。只要在命名空间中声明了特例化,就能在命名空间外部定义它:

// 我们必须将模板特例化成 std 的成员
namespace std {
    template <> struct hash;
}
 // 在 std 中添加了模板特例化后,就可以在命名空间 std 的外部定义它了
template<> struct std::hash {
    size_t operator()(const Sales_data &s) const {
        returen hash()(s.bookNo) ^
                hash()(s.units_sold) ^
                hash()(s.revenue);
    }
}
全局命名空间

全局作用域中定义的名字(即在所有类、函数及命名空间之外定义的名字)也就是定义在全局命名空间中。全局命名空间以隐式的方式声明,并且在所有程序中都存在。全局作用域中定义的名字被隐式的添加到全局命名空间中。

因为全局作用域是隐式的,所以它并没有名字。下面的形式

::member_name

表示一个全局命名空间中的一个成员。

嵌套的命名空间

嵌套的命名空间是指定义在其他命名空间中的命名空间:

namespace cplusplus_primer {
	namespace QueryLib {
		class Query { /* ... */ };
		// ...
	}
}

嵌套的命名空间也是一个嵌套的作用域。它嵌套在外层命名空间的作用域中。嵌套的命名空间的名字遵循的规则与外层类似:内层命名空间声明的名字将隐藏外层命名空间声明的同名名字。在嵌套的命名空间中定义的名字只在内层命名空间中有效,外层命名空间中的代码要想访问它必须在名字前面添加限定符:

cplusplus_primer::QueryLib::Query
内联命名空间

C++11 新标准引入了一种新的嵌套命名空间,称为内联命名空间。和普通的嵌套命名空间不同,内联命名空间中的名字可以被外层命名空间直接使用。也就是说,我们无须在内联命名空间的名字前面添加表示该命名空间的前缀,通过外层命名空间的名字就可以直接访问它。

定义内联命名空间的方式实在关键字 namespace 前添加关键字 inline:

inline namespace FifthEd {  // 第一次出现
	 // ...
}
 
namespace FifthEd {  // 隐式内联
    class Query_base { /*    */  }
}

关键字 inline 必须出现在命名空间第一次定义的地方,后续再打开命名空间可以写 inline,也可以不写。

当应用程序的代码在一次发布和另一次发布之间发生了改变时,常常会用到内联名名空间。

例如,我们可以把本书当前版本的所有代码都放在一个内联命名空间中,而之前版本的代码都放在要给非内联命名空间中。如果想使用当前版本代码可以直接调用,而使用之前版本代码则需要加上完整的外层命名空间的名字。

未命名的命名空间

未命名的命名空间是指关键字 namespace 后紧跟花括号括起来的一系列声明语句。未命名的命名空间中定义的变量拥有静态生命周期:它们在第一次使用前创建,并且直到程序结束才销毁。

一个未命名的命名空间可以在某个给定的文件内不连续,但不能跨越多个文件。每个文件定义自己的未命名的命名空间。
注: 和其他命名空间同,未命名的命名空间仅在特定的文件内部有效,其作用范围不会横跨多个不同的文件。

未命名的命名空间中定义的名字的作用域与该命名空间所在的作用域相同。如果未命名的命名空间定义在文件的最外层作用域中,则该命名空间中的名字一定要与全局作用域的名字有所区别:

int i;  // i 的全局声明
namespace {
    int i;
}
//二义性:i 的定义基础现在全局作用域中,又出现在未嵌套的未命名的命名空间中
i = 10;

在标准 C++ 引入命名空间的概念之前,程序需要将名字声明成 static 的以使得其对于整个文件是有效的。在 C 语言中,声明为 static 的全局实体在其所在的文件之外不可见。在文件中进行静态声明的做法已经被 C++ 标准取消了,现在的做法是使用未命名的命名空间。

2.2 使用命名空间成员 命名空间的别名

命名空间别名使得我们可以为命名空间的名字设定一个短得多的同义词。例如:

namespace cplusplus_primer { /* ... */ }
namesapce primer = cplusplus_primer;

命名空间的别名声明以关键字 namespace 开始,后面是别名使用的名字、=符号、命名空间原来的名字以及一个分号。
注1: 命名空间的别名也可以指向一个 嵌套的命名空间。
注2: 一个命名空间可以有好几个同义词或别名,所有别名都与命名空间原来的名字等价。

using 声明:扼要概述

一条 using 声明 语句一次只引入命名空间的一个成员。

using 声明引入的名字遵循与过去一样的作用域:它的有效范围从 using 声明的地方开始,一直到 using 声明所在的作用域结束为止。在此过程中,外层作用域的同名实体将被隐藏。

一条 using 声明语句可以出现在全局作用域、局部作用域、命名空间作用域以及类的作用域。

using 指示

using 指示以关键字 using 开始,后面是关键字 namespace 以及命名空间的名字。

using namespace std;

如果这里所用的名字不是一个已经定义好的命名空间的名字,则程序将发生错误。using 指示可以出现在全局作用域、局部作用域、和命名空间作用域中。但是不能出现在类的作用中。

using 指示使得某个特定的命名空间中所有的名字都可见。
注: 如果我们提供了一个对 std 等命名空间的 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
}
using 指示示例
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 声明。

2.3 类、命名空间与作用域

对于命名空间内部的名字的查找遵循常规的查找规则:即由内向外依次查找每个外层作用域。外层作用域也可能是一个或多个嵌套的命名空间,直到最外层的全局命名空间查找过程终止。只有位于开放的块中且再使用点之前声明的名字才被考虑:

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::cin >> s;

该调用等价于:

operator>>(std::cin, s);

operator>> 函数定义在标准库 string 中,string 又定义在命名空间 std 中。我们可以不用 std:: 限定符和 using 声明就可以调用 operator>>。

对于命名空间中名字的隐藏规则来说有一个重要的例外,它使得我们可以直接访问输出运算符:当我们给函数传递一个类类型的对象时,除了在常规的作用域查找外,还会查找实参类(以及实参类的基类)所属的命名空间。

在此例中,当编译器法相对 operator>> 的调用时,首先在当前作用域中寻找合适的函数,接着查找输出语句的外层作用域。随后,因为 >> 表达式的形参时类类型的,所以编译器还会查找 cin 和 s 的类所属的命名空间。也就是说,对于这个调用来说,编译器会查找定义了 istream 和 string 的命名空间 std。当在 std 中查找时,编译器找到了 string 的输出运算符函数。
注: 通常情况下,如果在应用程序中定义了一个标准库中已有的名字,将会出现以下两种情况中的一种:要么根据一般的重载规则确定某次调用应该执行函数的哪个版本,要么应用程序根本不执行函数的标准库版本。

友元声明与实参相关的查找

当类声明了一个友元时,该友元声明并没有使得友元本身可见。然而,一个另外的未声明的类或函数如果第一次出现在友元声明中,我们认为它是最近的外层命名空间的成员。这条规则和实参相关的查找规则结合在一起会产生意想不到的效果:

namespace A {
    class C {
        // 两个友元,在友元声明之外没有其他声明
        // 这些函数隐式地称为命名空间A的成员
        friend void f2();  // 除非另有声明,否则不会被找到
        friend void f(const C&);  // 根据实参的相关查找规则可以被找到
    };
}

此时,f 和 f2 都是命名空间 A 的成员。即使 f 不存在其他声明,我们也能通过实参相关的查找规则调用 f:

int main() {
    A::C cobj;
    f(cobj);  // 正确 通过在 A:: C中的友元声明找到 A::f
    f2();  // 错误 A::f2 没有被声明
}

因为 f 接受了一个类类型的实参,而且 f 在 C 所属的命名空间进行了隐式的声明,所以 f 能被找到。相反,因为 f2 没有形参,所以它无法被找到。

2.4 重载与命名空间

对于接受类类型实参的函数来说,其名字查找将在实参类所属的命名空间进行。这条规则对于我们如何确定候选函数集同样也有影响。我们将在每个实参类(以及实参类的基类)所属的命名空间搜寻候选函数。这些命名空间中所有与被调用函数同名的函数都将被添加到候选集当中,即使其中某些函数在调用语句处不可见也是如此:

namespace NS {
    class Quote { /* ... */ };
    void display(const Quote&) { /* ... */ } 
}
 // Bulk_item 的基类声明在命名空间 NS 中
class Bulk_item : public NS::Quote { /* ... */ };
int main() {
    Bulk_item bookl;
    display(bookl);
    return 0;
}

我们传递给 display 的实参属于类类型 Bulk_item,因此该调用语句的候选函数不仅应该在调用语句所在的作用域中查找,而且也应该在 Bulk_item 及其基类 Quote 所属的命名空间中查找。

重载与 using 声明

using 声明语句声明的是一个名字,而非特定函数:

using NS::print(int);  // 错误 不能指定形参列表
using NS::print;  // 正确 using 只声明一个名字

当我们为函数书写 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 调用此时的候选函数包括:
// print(int) 和 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 指示,则来自每个命名空间的名字都会成为候选函数集的一部分。


3 多重继承与虚继承

多重继承是指从多个直接基类中产生派生类的能力。多重继承的派生类继承了所有父类的属性。存在多个基类相互交织产生的细节可能会带来的错综复杂的设计问题和实现问题。

3.1 多重继承

在派生类的派生列表中可以包含多个基类:

class Bear : public ZooAnimal {
    /* ... */
};
 
class Panda : public Bear, public Endangered {
    /* ... */
};

和只有一个基类的继承一样,多重继承的派生列表也只能包含已经被定义过的类,而且这些类不能是 final 的。在某个给定的派生列表中,同一个基类只能出现一次。

派生类构造函数初始化所有基类

构造一个派生类的对象将同时构造并初始化它的所有基类子对象。与从一个基类进行的派生一样,多重继承的派生类的构造函数初始值也只能初始化它的直接基类:

// 显式初始化所有基类
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);
};
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 继承构造函数
};

如果一个类从它的多个基类中继承了相同的构造函数,则这个类必须为该构造函数定义它自己的版本:

struct 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 定义了自己的构造函数,则必须出现
};
析构函数与多重继承

和往常一样,派生类的析构函数只负责析构派生类自身分配的资源,派生类的成员及基类都是自动销毁的。合成的析构函数体为空。

析构函数的调用顺序正好与构造函数相反。

多重继承的派生类的拷贝与移动 *** 作

多重继承的派生类如果定义了自己的拷贝/赋值构造函数和赋值运算符,则必须在完整的对象上执行拷贝、移动和赋值 *** 作。

只有当派生类使用的是合成版本的拷贝、移动或赋值成员时,才会自动对其基类部分执行这些 *** 作。在合成的拷贝控制成员中,每个基类分布使用自己对应成员隐式的完成构造、赋值和销毁等工作。

3.2 类型转换与多个基类

在只有一个基类的情况下,派生类的指针或引用能自动转换成一个可访问基类的指针或引用。

多个基类的情况与之类似。我们可以令某个可访问基类的指针或引用直接指向一个派生类对象。但是编译器不会再派生类向基类的几种转换中进行比较和选择,因为在它看来转换到任意一种基类一样好。例如,存在下面的 print 重载形式:

void print(const Bear&);
void print(const Endangered&);

则通过 Panda 对象对不带前缀限定符的 print 函数进行调用将产生编译错误:

Panda ying_yang("ying_yang");
print(ying_yang);    //二义性错误
基于指针类型或引用类型的查找

与只有一个基类的继承一样,对象、指针和引用的静态类型决定了我们能够使用哪些成员。

如果我们使用一个 ZooAnimal 指针,则只有定义在 ZooAnimal 中的 *** 作是可以使用的,Panda 接口中的 Bear、Panda 和 Endangered 特有的部分都不可见。类似的,一个 Bear 类型的指针或引用只能访问 Bear 及 ZooAnimal 的成员。

3.3 多重继承下的类作用域

在只有一个基类的情况下,派生类的作用域嵌套在直接基类和间接基类的作用域中。查找过程沿着继承体系自底而上进行,直到找到所需的名字。派生类的名字将隐藏基类的同名成员。

在多重继承的情况下,相同的查找过程在所有直接基类中同时进行。如果名字在多个基类中都被找到,则对改名字的使用将具有二义性。
注: 当一个类拥有多个基类时,有可能出现派生类从两个或更多基类中继承了同名成员的情况。此时,不加前缀限定符直接使用该名字将引发二义性。

例如,如果 ZooAnimal 和 Endangered 都定义了名为 max_weight 的成员,并且 Panda 没有定义该成员,则下面的调用是错误的:

double d = ying_yang.max_weight();

要想避免潜在的二义性,最好的办法是在派生类中为该函数定义了一个新版本:

double Panda::max_weight() const {
    return std::max(ZooAnimal::max_weight(), Endangered::man_weight());
}
3.4 虚继承

尽管在派生列表中同一个基类只能出现一次,但实际上派生类可以多次继承同一个类。派生类可以通过它的两个之间基类分别继承同一个间接基类,也可以直接继承某个基类,然后通过另一个基类再一次间接继承该类。

在默认情况下,派生类中含有继承链上每个类对应的字部分。如果某个类在派生过程中出现多次,则派生类将包含该类的多个子对象。

在 C++ 语言中我们通过虚继承机制解决多次继承的多个子对象问题。虚继承的目的是令某个类做出声明,承诺愿意共享它的基类。其中,共享的基类子对象称为虚基类。在这种机制下,不论虚基类在继承体系中出现了多少次,在派生类中都只包含唯一一个共享的虚基类子对象。
注: 虚派生只影响了虚基类的派生类中进一步派生出的类,它不会影响派生类本身。

使用虚基类

我们指定虚基类的方式实在派生列表中添加关键字 virtual,关键字 public 和 virtual 的顺序随意:

class Raccoon : public virtual ZooAnimal { /* ... */ };
class Bear : virtual public ZooAnimal { /* ... */ };

virtual 说明符白哦明了一种愿望,即在后续的派生类当中共享虚基类的同一份实例。如果某个类指定了虚基类,则该类的派生仍按常规方式进行:

class Panda : public Bear, public Raccoon, public Endangered {
    /* ... */
};
支持向基类的常规类型转换

不论基类还是虚基类,派生类对象都能被可访问基类的指针的或引用 *** 作。

虚基类成员的可见性

因为在每个共享的虚基类中只有唯一一个共享的子对象,所以该基类的成员可以被直接访问,不会产生二义性。此外,如果虚基类的成员只被一条派生路径覆盖,则我们仍然可以直接访问这个被覆盖的成员。但如果成员被多个基类覆盖,则一般情况下派生类必须为该成员自定义一个新的版本。

例如,假定类 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 将产生二义性问题。

与非虚的多重继承体系一样,解决这种二义性问题最好的方法是在派生类中尾成员自定义新的实例。

3.5 构造函数与虚继承

在虚派生中,虚基类是由最低层的派生类初始化的。以我们的程序为例,当创建 Panda 对象时,由 Panda 的构造函数肚子控制 ZooAnimal 的初始化过程。

虚继承的对象的构造方式

含有虚基类的对象的构造顺序和一般的顺序稍有区别:首先使用提供给最低层派生类构造函数的初始值初始化该对象的虚基类字部分,接下来按照直接基类在派生列表中出现的次序依次初始化。

例如,当我们创建一个 Panda 对象时:

  1. 首先使用 Panda 的构造函数初始值列表中提供的初始值构造虚基类 ZooAnimal 部分。
  2. 接下来构造 Bear 部分。
  3. 然后构造 Raccoon 部分。
  4. 然后构造第三个直接基类 Endangered。
  5. 最后构造 Panda 部分。

注: 虚基类总是先于非虚基类构造,与它们在继承体系中的次序和位置无关。

构造函数与析构函数的次序

一个类可以有多个虚基类。此时,这些虚的子对象按照它们在派生列表中出现的顺序从左向右依次构造。合成的拷贝和移动构造函数按照完全相同的顺序执行,合成的赋值运算符中的成员也按照该顺序赋值。和王朝一样,对象的销毁顺序与构造顺序正好相反。

欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/langs/722957.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2022-04-26
下一篇 2022-04-26

发表评论

登录后才能评论

评论列表(0条)

保存