《C++ PrimerPlus》 第十五章

第十五章 友元、异常和其他

15.1 友元

类并非只能拥有友元函数,也可以将类作为友元。在这种情况下,友元类的所有方法都可以访问原始类的私有成员和保护成员。哪些函数、成员函数或类为友元是由类定义的,而不能从外部强加友情。因此 ,尽管友元被授予从外部访问类的私有部分的权限,但它们并不与面向对象的编程思想相悖。

15.1.1 友元类

关于需要使用友元类的情况,可以想象电视机类和遥控器类之间的关系,首先,它们不是is-a,其次,也不是has-a,但是它们确实存在某个关系,这就是——友元。下面的语句使Remote成为友元类,友元声明可以位于公有、私有或保护部分,其所在的位置无关紧要。由于Remote类中访问了Tv类的成员,因此,编译器必须了解Tv类以后,才能处理Remote类,最简单的方法是首先定义Tv类,另一种方法是使用前向声明(forward delaration),这将稍后介绍。

1
2
3
4
5
class Tv{
...
friend class Remote;
...
};

以上代码,在电视机类里面声明遥控器类为友元类以后,遥控器类就可以访问电视机类的私有成员变量,而无需让电视机类修改自身的访问权限,就是说电视机对外界的访问权限是不变的,并没有破坏类的封装行。

15.1.2 友元成员函数

可以选择仅让特定的类成员变成另一个类的友元,而不必让整个类成员友元,但是这样做稍微有点麻烦,必须小心排列各种声明和定义的顺序。

1
2
3
class TV{
friend void Remote::set_chanel(TV &t, int c);
}

要使编译器能够处理上面的代码,它必须知道Remote的定义,否则,它无法知道Remote是类还是别的什么,也无法确定set_chanel是这个类的方法。而前面又说了,Tv应定义在Remote定义之前,这就导致了循环依赖问题。避开这种问题的方法是使用前向声明(forward declaration)。为此,需要在Remote定义的前面插入下面的语句:

1
class Tv;

这样,新的排列顺序如下:

1
2
3
4
5
6
7
class Tv;
class Remote {
...
};
class Tv{
...
};

注意,Tv类与Remote类的定义不可以调换,因为编译器在Tv类的声明者是看到Remote的一个方法被声明为Tv类的友元之前,应该先看到Remote类的声明和set_chanel()方法的声明。

15.1.3 其他友元关系

可以通过让类彼此成为对方的友元来实现互相影响对象的功能。即除了Remote是Tv的友元外,Tv还是Remote的友元。需要注意一点的是,对于使用Remote对象的Tv方法,其原型可在Remote类声明之前声明,但必须在Remote类声明之后定义,以便编译器有足够的信息来编译该方法。

15.1.4 共同的友元

需要使用友元的另一种情况是,函数需要访问两个类的私有数据。因此,可以将函数左右两个类的友元

15.2 嵌套类(内部类)

在C++中,可以将类声明放在另一个类中,在另一个类中声明的类被成为嵌套类(nested class),它通过提供新的类型类作用域来避免名称混乱。包含类的成员函数可以创建和使用被嵌套类的对象,而仅当声明位于公有部分,才能在包含类的外面使用嵌套类,而且必须使用作用域解析符。

对类进行嵌套与包含并不同。包含意味着将类对象作为另一个类的成员,而对类进行嵌套不会创建类成员,而是 定义了一种类型 ,可以在包含类的其他方法内声明该类型的变量,该类型仅在包含嵌套类声明的类中有效。

15.2.1 嵌套类和访问权限

  1. 作用域

  如果嵌套类是在另一个类的私有部分声明的,则只有这个类知道它。也就是说,只能通过这个类的成员来使用嵌套类,就像私有变量一样。而这个类的派生类,和外部的程序都无法访问到该嵌套类。

  如果嵌套类是在另一个类的保护部分声明的,则它对于后者及其派生类都是可见的,但是对于外部师姐是不可见的。

  如果嵌套类是在另一个类的公有部分声明的,则允许后者、后者的派生类以及外部世界访问它。并且,由于嵌套类的作用域为包含它的类,因此在外部世界使用它时,必须使用类限定符。

  嵌套结构和枚举的作用域与嵌套类相同。下表总结了嵌套类、结构和枚举的作用域特征

声明位置 包含它的类是否可以使用它 从包含它的类派生的类是否可以使用它 在外部是否可以使用
私有部分
保护部分
公有部分 是,通过类限定符使用
  1. 访问控制

   类可见后,起决定作用的将是访问控制。对嵌套类访问权的控制规则与对常规类相同。例如,在Queue类声明中声明了Node嵌套类,这并没有赋予Queue类任何对Node类的访问特权,也没有赋予Node类任何对Queue类的访问特权。因此,Queue类对象只能显式的访问Node对象的公有成员。

15.2.2 模板中的嵌套

模板类可以正常使用嵌套类,不会带来额外的问题。

15.3 异常

程序有时会遇到运行阶段错误,导致程序无法正常的走下去。如,试图打开一个不可用的文件、请求过多的内存、遭遇不能接受的值等等。

C++的异常处理机制是一个相对较新的功能,有些老式编译器可能没有实现,有些编译器可能默认关闭这种特性,需要在选项中开启。比如“零除” 这种异常,很多新编译器通过生成一个表示无穷大的特殊浮点值来处理,cout将其显示为Inf、inf、INF等,有些编译器可能会直接崩溃。

15.3.1 调用abort()

abort()函数的原型位于头文件cstdlib(stdlib.h)中,其典型实现是向标准错误流发送消息abnormal program termination(程序异常终止),然后终止程序。它还返回一个随实现而异的值,告诉操作系统(或者父进程),处理失败。abort()是否刷新文件缓冲区(用于存储读写到文件中的数据的内存区域)取决于实现。如果愿意,也可以使用exit(),该函数刷新文件缓冲区,但不显示消息。

15.3.2 返回错误码

一种比异常终止更灵活的方法是,使用函数的返回值来指出问题。如果某些函数的任何数值返回都是有效的,那么可以增加一个指针参数或引用参数,来将返回值返回,同时将函数的返回值改成bool类型,来指出是否返回成功。

另一种方法使用一个全局变量。可能问题的函数可以在出现问题时将该全局变量设值为特定的值,而调用程序可以检查该变量。

15.3.3 异常机制

C++异常是对程序运行过程中发生的异常情况的一种响应。异常提供了将控制权从程序的一个部分传递到另一个部分的途径。对异常的处理有三个组成部分:

  • 引发异常
  • 使用处理程序捕获异常
  • 使用try块处理异常

throw语句实际上是跳转,即命令程序跳到另一条语句。throw关键字表示引发异常,紧随其后的值(例如字符串或对象)指出了异常的特征。

程序使用异常处理程序(exception handler)来捕获异常,异常处理程序位于要处理问题的程序中,catch关键字表示捕获异常。处理程序以关键字catch开头,随后是位于括号中的类型声明,他指出了异常处理程序要响应的异常类型。

catch关键字和异常类型用作标签,指出当异常被引发时,程序应跳到这个为止执行。异常处理程序也被称为catch块。

try块标识可能引起特定异常的代码块,他后面跟一个或多个catch块。try块是由关键字try只是的,关键字try的后面是一个由花括号括起的代码块,表明需要注意这些代码引发的异常。

在默认情况下,如果函数引发了异常,而没有try块或没有匹配的处理程序时,程序将调用abort()函数。(默认行为可修改)。

15.3.4 将对象用作异常类型

通常,引发异常的函数将传递一个对象。这样做的重要优点之一是,可以使用不同的异常类型来区分不同的函数在不同情况下引发的异常。另外,对象可以携带信息,程序员可以根据这些信息来确定引发异常的原因。

根据不同的对象类型,可以跟不同的catch块进行匹配,类型不匹配的catch块将跳过不执行。

15.3.5 异常规范和C++11

C++98新增了异常规范(exception specification)的功能,但是在C++11中却被摒弃了。

新增关键字noexcept指出函数不会引发异。

15.3.6 栈解退

C++中处理函数的调用和返回时,会让程序将调用函数的指令的地址(返回地址)放到栈中。当被调用的函数执行完毕后,程序将使用该地址来确定从哪里开始继续执行。另外,函数调用将参数放到栈中。在栈中,这些函数参数被视为自动变量。如果被调用的函数创建了新的自动变量,则这些变量也将被添加到栈中。如果被调用的函数调用了另一个函数,则后者的信息将被添加到栈中,以此类推。

现在假设函数由于出现异常(而不是由于返回)而终止,则程序也将释放栈中的内存,但不会在释放栈的第一个返回地址后停止,而是继续释放栈,直到找到一个位于try块中的返回地址。随后,控制权将转到块尾的异常处理程序,而不是函数调用后面的第一条语句。——这个过程被称为栈解退。

栈解退的意义在于:对于普通的函数返回来说,仅仅会调用该函数放在栈中的对象的析构函数,而throw语句则处理try块和throw之间整个函数调用序列放在栈中的对象。所以,如果没有栈解退这种特性,则引发异常后,对于中间函数调用放在栈中的自动类对象,其析构函数将不会被调用。

也就是说:程序进行栈解退以回到能够捕获异常的地方时,将释放栈中的自动存储型变量。如果变量是类对象,将为该对象调用析构函数。

15.3.7 其他异常特性

虽然throw-catch机制类似于函数返回机制,但还是有些不同之处:

  • 函数fun()中的返回语句将控制权返回到调用fun()的函数,但throw语句将控制权向上返回到第一个包含能够捕获相应异常的try-catch组合。
  • 引发异常时编译器总是创建一个临时拷贝,即使异常规范和catch块中指定的是引用。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class problem{...};
...
void super() throw(problem){
...
if( oh_no ){
problem oops;
throw oops;
}
}
...

try{
super();
}catch(problem &p){ //这里p虽然声明为引用,但是p指向的是oops的副本而不是oops本身,这是件好事,因为函数`super()`执行完毕后,oops将不复存在。
//既然如此,为何还要特意声明为引用?因为引用还有另一个重要特征:基类引用可以执行派生类对象。
//这有一个很大的用法在于,假设有一个异常类层次结构,并要分别处理不同的异常类型,则使用基类引用将能够捕获任何异常对象;
//而使用派生类对象只能捕获它所属类及从这个类派生而来的类的对象。引发的异常对象将被第一个与之匹配的catch块捕获。
//这意味着catch块的排列顺序应该与派生顺序相反。也就是要将捕获派生类的catch放前面,捕获基类的catch放后面
//statements
}

15.3.8 exception类

较新的C++编译器将异常合并到语言中,并在exception头文件(以前为exception.hexcept.h定义了exception类,C++可以把它用作其他异常类的基类。

C++库定义了很多基于exceptin的异常类型

  1. stdexcept异常类

  头文件stdexcept定义了其他几个异常类,首先,该文件定义了logic_errorruntime_error类,它们都是以公有方式从exception派生而来的。这两个新类被用作两个派生类系列的基类。

  异常类系列logic_error描述了典型的逻辑错误:

  • domain_error;
  • invalid_argument;
  • length_error;
  • out_of_bounds.

  runtime_error异常类系列描述了可能在运行期间发生但难以预计和防范的错误:

  • range_error;
  • overflow_error;
  • underflow_error.

  一般,logic_error系列异常表明存在可以通过编程修复的问题,而runtime_error系列异常表明存在无法避免的问题。

  如果上述库类不能满足需求,则应该从logicruntime异常类中进行派生(而不是从exception),以确保派生出来的异常类可以归入同一个继承层次结构中。

  1. bad_alloc异常和new

  对于使用new导致的内存分配问题,C++的最新处理方式是让new引发bad_alloc异常。头文件new包含bad_alloc类的声明,他是从exception类公有派生出来的。但在以前,当无法分配请求的内存量时,new返回一个空指针。

  1. 空指针和new

  老代码的逻辑是根据new返回的指针是否为空来判断是否失败的,为兼容这种情况,C++标准提供了一种在失败时返回空指针的new,如下所示:

1
2
3
4
5
6
7

Big* pb;
pb = new(std::nothrow) Big[10000];
if(pb==0){
cout<<"error";
exit(EXIT_FAILURE);1
}

15.3.9 异常、类和继承

异常、类和继承以三种方式相互关联:

  • 像C++标准库一样,从一个异常类派生出另一个
  • 在类定义中嵌套异常类声明,从而将异常类组合到类中去
  • 上面的嵌套声明通过继承传给子类

15.3.10 异常何时会迷失方向

异常被引发后,在两种情况下会导致问题:

  • 如果异常是在带异常规范的函数中引发的(C++11虽然摈弃了异常规范,但仍有人使用),则必须与规范列表中的某种异常匹配,否则称为意外异常(unexpected exception)。在默认情况下,程序会异常终止。
  • 如果异常不是在函数中引发的(或者函数没有异常规范),则必须捕获该异常,如果没被捕获,则被称为未捕获异常(uncaught exception)。在默认情况下,程序会异常终止。

可以对以上默认情况进行修改:

未捕获异常:未捕获异常不会导致程序理科异常终止。相反,程序将首先调用函数terminate()。在默认情况下,terminate()调用abort()函数。可以使用set_terminate()函数指定terminate()应调用的函数来修改其默认行为:

1
2
3
4
5
6
7

void my_quit(){
cout<<"quit";
exit(5); // 退出状态值设为5
}
...
set_terminate(my_quit);

意外异常:通过给函数指定异常规范,可以让函数的用户知道要捕获哪些异常,如下所示:

1
2
3
4
5
6
7
double Argn(double , double ) throw (out_of_bounds);

try{
x = Argn(a,b);
}catch(out_of_bounds & ex){
...
}

C++11摒弃它的原因之一是:异常规范机制处理起来比较麻烦。p640

在意外异常发生时,将调用unexpected()函数,这个函数将调用terminate(),后者在默认情况下调用abort()。 C++提供了一个set_unexpected ()函数,但限制更严格。p640

15.3.11 有关异常的注意事项

从前面关于如何使用异常的讨论可知,应在设计程序时就加入异常处理功能,而不是以后再添加。

但是这样做会增加代码量,同时异常和动态内存分配并非总能协同工作。

一句话:异常处理很复杂

15.4 RTTI

RTTI:运行阶段类型识别(RunTime Type Identification)

这是一项比较新的特性,一些旧的C++编译器不支持,还有一些编译器提供了开关RTTI的设值。

15.4.1 RTTI的用途

RTTI旨在为程序在运行阶段确定对象的类型提供一种标准方式。

15.4.2 RTTI的工作原理

C++有三个支持RTTI的元素:

  • 如果可能的话,dynamic_cast运算符将使用一个只想基类的指针来生成一个只想派生类的指针;否则,该运算符返回0——空指针
  • typeid运算符返回一个指出对象的类型的值
  • type_info结构存储了有关特定类型的信息

RTTI只适用于包含虚函数的类: 只能将RTTI用于包含虚函数的类层次结构,原因在于只有对于这种类层次结构,才应该将派生类对象的地址赋给基类指针。

  1. dynamic_cast运算符

   该运算符是最常用的RTTI组件,

  • 它不能回答“指针指向的是哪类对象”这样的问题,
  • 但能够回答“是否可以安全地将对象的地址赋给特定类型的指针”这样的问题。

   与知道“是哪类对象”相比,知道“类型转换是否安全”更通用,也更有用。主要是因为,通常项知道类型的原因在于:知道类型后,就可以知道调用特定的方法是否安全。 而要调用方法,类型并不一定要完全匹配,而可以是定义了方法的虚拟版本的基类类型。

   用法:

ym
1
Superb* pm = dynamic_cast<Superb *>(pg);

   通常,如果指向的对象(*pt)的类型为Type或从Type直接间接派生而来的类型,则下面的表达式将指针pt转换为Type类型的指针,并作为结果赋给ps,否则ps结果为0,即空指针:

1
ps = dynamic_cast<Type *>(pt)

   也可以将dynamic_cast用于引用,其用法稍微有点不同:没有与空指针对应的引用值,因此无法使用特殊的引用值来指针失败。当请求不正确时,dynamic_cast将引发类型为bad_cast的异常,这种异常是从exception类派生而来的,在头文件typeinfo中定义,可以像下面这样使用:

1
2
3
4
5
6
7
#include <typeinfo>
...
try{
Superb & rs = dynamic_cast<Superb&>(rg);
}catch(bad_cast&){
...
}

  1. typeid运算符和type_info类

  typeid运算符使得能够确定两个对象是否为同种类型。它与sizeof有些相像,可以接受两种参数:

  • 类名
  • 结果为对象的表达式

  typeid运算符返回一个对type_info对象的引用,其中,type_info是头文件typeinfo中定义的一个类。type_info类重载了==!=运算符,可以进行类型间的比较。下面的代码判断pg指向的是否是一个Magnificent对象:

1
typeid(Magnificent) == typeid(* pg); //只会判断指针的类型,当基类指针指向子类时,得到的也是基类的类型

  如果pg是一个空指针,程序将引发bad_typeid异常。该异常类型是从exception类派生而来的,是在头文件typeinfo中声明的。

  1. 误用RTTI的例子

如果在扩展的if else语句系列使用了typeid,则应高了是否应该是否虚函数和dynamic_cast

15.5 类型转换运算符

C语言中的类型转换运算符太过松散,因此,在C++中,提供了更严格的限制允许的类型转换,并添加4个类型转换运算符,使转换过程更规范:

  • dynamic_cast
  • const_cast
  • static_cast
  • reinterpret_cast

const_cast 运算符用于执行只有一种用途的类型转换,即改变值为constvolatile,其语法与dynamic_cast运算符相同。

1
const_cast <type-name> (expression)

如果类型的其他方面也被修改,则上述类型转换将出错。也就是说,除了constvolatile特征可以不同外,type-nameexpression的类型必须相同。

提供该运算符的原因是,有时候可能需要这样一个值,它在大多数时候是常量,而有时有事可以修改的。此时就可以声明为const,并在需要的时候使用const_cast

static_cast运算符的语法与其他类型转换运算符相同:

1
static_cast <type-name> (expression)

仅当type-name可被隐式转换成expression所属的类型或expression可被隐式转换为type-name所属的类型时,上述转换才是合法的,否则将出错。

reinterpret_cast运算符用于天生危险的类型转换。