1 友元

1.1 友元类

友元函数:不是类的成员函数,但是能够访问类的私有数据成员。

之前有个矛盾就是规定非成员函数不能直接访问类的私有数据,但是这会儿却可以,但那只是针对常规非成员函数而言,特殊的非成员函数就可以访问类的私有数据——友元函数。

友元函数就是这么特殊,虽然在类中声明,却不是类的成员函数,这是因为加了前缀friend。

友元不仅包含函数,类也可以当友元,也就是友元类。

友元类的所有方法都可以访问原始类的私有成员和保护成员。

也可以做更严格的限制,只将限定的成员函数指定为另一类的友元。

尽管友元被授予从外部访问类的私有部分的权限,但它们并不与面向对象的编程思想相违背。

接下来就是讨论友元类

假定要编写一个模拟电视机和遥控器的简单程序。

决定定义一个Tv类和一个Remote类,来分别表示电视机和遥控器。

很明显,这两个类之间应当存在某种关系,遥控器并非电视机,反之亦然,但是遥控器却可以改变电视机的状态。这样的关系可以将Remote类作为Tv类的一个友元来实现。

友元类声明位置无关紧要,可以声明在公有、私有、保护部分;

定义一个电视机类,用一些状态成员来表示电视机:

  • 开/关;
  • 频道设置;
  • 音量设置;
  • 有线电视or天线调节模式;
  • TV调谐或A/V输入。

tv.h文件,声明tv和remote的类

#ifndef TV_H_
#define TV_H_

class Tv
{
public:
    friend class Remote;
    enum{Off, On};
    enum{MinVal, MaxVal=20};
    enum{Antenna, Cable};
    enum{TV,DVD};
    
    Tv():state(s),volume(5),maxchannel(mc),channel(2),mode(Cable),input(TV){}
    void onoff() {state=(state == On)?Off:On;}
    bool ison() const {return state==On;}
    bool volup();
    bool voldown();
    void chanup();
    void chandown();
    void set_mode() {mode=(mode == Antenna)?Cable:Antenna;}
    void set_input() {input=(input == TV)?Cable:Antenna;}
    void settings() const;  //display all settings;
private:
    int state;    //on or off
    int volume;
    int maxchannel;
    int channel;  //current channel setting
    int mode;    //broadcast or cable
    int inputs;  //TV or DVD
};

class Remote  //Remote方法以TV的对象引用作为参数,这表明遥控器必须针对特定的电视机
{
private:
    int mode;   //controls TV or DVD;
public:
    Remote(int m=Tv::Tv): mode(m) {}
    bool volup(Tv & t) {return t.volup();} 
    bool voldown(Tv & t) {return t.voldown();}
    void onoff(Tv & t) {t.onoff();}
    void chanup(Tv & t) {t.chanup();}
    void chandown(Tv & t) {t.chandown();}
    void set_chan(Tv & t, int c) {t.channel = c;}
    void set_mode(Tv & t) {t.set_mode();}
    void set_input(Tv & t) {t.set_input();}
};
#endif

tv.cpp

#include <iostream>
#include "tv.h"

bool Tv::volup()
{
    if(volume<MaxVal)
    {
        volume++;
        return true;
    }
    else
        return false;
}

bool Tv::voldown()
{
    if(volume>MinVal)
    {
        volume--;
        return true;
    }
    else
        return false;
}

void Tv:chanup()
{
    if(channel<maxchannel)
        channel++;
    else
        channel=1;
}

void Tv::chandown()
{
    if(channel>1)
        channel--;
    else
        channel = maxchannel;
}

void Tv::Settings() const
{
    using std::cout;
    using std::endl;
    cout<<"TV is "<<(state==Off?"Off":"On")<<endl;
    if(state == On)
    {
        cout<<"Volume setting = "<<volume<<endl;
        cout<<"Channel setting = "<<channel<<endl;
        cout<<"Mode = "<<(mode == Antenna? "antenna":"cable")<<endl;
        cout<<"Input = "<<(mode == TV? "TV":"DVD")<<endl;
    }
}

use_tv.cpp

#include<iostream>
#include "tv.h"

int main()
{
    using std::cout;
    Tv s42;
    cout<<"Initial settings for 42\" TV:\n";
    s42.settings();
    s42.onoff();
    s42.chanup();
    cout<<"\nAdjusted settings for 42\" TV:\n";
    s42.chanup();
    cout<<"\nAdjusted settings for 42\" TV:\n";
    s42.settings();
    
    Remote grey;
    
    grey.set_chan(s42, 10);
    grey.volup(s42);
    grey.volup(s42);
    cout<<"\n42\" settings after using remote:\n";
    s42.settings();
    
    Tv s58(Tv::On);
    s58.set_mode();
    grey.set_chan(s58,28);
    cout<<"\n58\" settings:\n";
    s58.settings();
    return 0;
}

1.2 友元成员函数

我们发现Remote友元类的大多数方法都是用Tv类的公有接口实现。这意味着这些方法并不是真正需要友元。

事实上唯一直接访问Tv成员的Remote方法是Remote::set_chan(),因此它是唯一需要作为友元的方法。

确实可以仅让特定的类成员成为另一类的友元。

这种做法稍微有点麻烦,必须小心排列各种声明和定义的顺序。

让Remote::set_chan()成为Tv类的友元的方法是,在Tv类声明中将其声明为友元:

class Tv
{
  friend void Remote::set_chan(Tv & t, int c);
}

要让编译器能够处理这条语句,它必须知道Remote的定义。否则,它无法知道Remote是一个类,而set_chan是一个类方法;

这就意味着要把Remote的定义放到Tv类定义之前。

但是Remote方法提到了Tv对象,而这就意味着Tv定义应当放在Remote定义之前。

这就产生了循环依赖的问题。要避免循环依赖关系,就要使用前向声明(forward declaration)。

解决方法如下:

class Tv;  //forward declaration  告诉编译器Tv是一个类
class Remote {...};  //然后再Remote中出现set_chan 方法时,知道其中Tv是一个类
class Tv {...};

这里补充一句,让整个Remote类成为友元并不需要前向声明,因为友元语句本身已经指出Remote是一个类;

friend class Remote;

但是能否像下面这样排列呢?

class Remote ;  //forward declaration
class Tv {...};
class Remote {...};

答案是不能,因为在编译器看到Tv类的声明中看到Remote的一个方法被声明为Tv类的友元之前,应先看到Remote类的声明和set_chan()方法的声明。

还有一个麻烦就是。Remote声明中包含了内联代码

void onoff(Tv & t) {t.onoff();}

由于这将调用Tv的一个方法,所以编译器此时必须已经看到了Tv类的声明。这样才能知道Tv有哪些方法,但正如看到的,该声明位于Remote声明的后面。

这种问题的解决方法是,使Remote声明中只包含方法声明,并将实际的定义放在Tv类之后。

class Tv;  //forward declaration
class Remote {...};  //Tv-using methods as prototypes only  只包含方法的声明
class Tv {...};
//put Remote method definitions here  定义在这里写

Remote方法的声明与下面类似

void onoff(Tv & t);

检查该原型时,编译器都需要知道Tv是一个类,而向前声明提供了这样的信息。

当编译器到达真正的方法定义时,它已经读取到了Tv类的声明,并拥有编译这些方法的所需信息。

通过在方法定义中使用inline关键字,仍然可以使其成为内联方法。

//这种友元成员函数的声明、定义顺序非常微妙,令人抓狂。很容易造成错误,一旦问题复杂起来,定位bug都很困难。难怪C++是个大坑。友元的存在就是其中一个大坑。把类的关系,函数的关系搞复杂了。

#ifndef TVFM_H_
#define TVFM_H_

class Tv;  //forward declaration

class Remote
{
public:
    enum State{Off,On};
    enum {MinVal, Maxval=20};
    enum {Antenna, Cable};
    enum {TV, DVD};
    
private:
    int mode;
    
public:
    Remote(int m = TV):mode(m) {}
    bool volup(Tv & t);
    bool voldown(Tv & t);
    bool onoff(Tv & t);
    bool chanup(Tv & t);
    bool chandown(Tv & t);
    void set_mode(Tv & t);
    void set_input(Tv & t);
    void set_chan(Tv & t, int c);
};

class Tv
{
public:
    friend void Remote::set_chan(Tv & t, int c);
    enum State{Off, On};
    enum {Minval, Maxval =20};
    enum {Antenna, Cable};
    enum {Tv, DVD};
    
    Tv(int s=Off, int mc=125):state(s),volume(5),maxchannel(mc),channel(2),mode(Cable),input(TV) {}
    void onoff() {state = (state==On)?Off:On;}
    bool ison() const {return state == On;}
    bool volup();
    bool voldown();
    void chanup();
    void chandown();
    void set_mode() {mode = (mode == Antenna)?Cable:Antenna;}
    void set_input() {input = (input == TV)?DVD:TV;}
    void settings() const;
    
private:
    int state;
    int volume;
    int channel;
    int maxchannel;
    int mode;
    int input;
};

//Remote methods as inline functions
inline bool Remote::volup(Tv & t) {return t.volup();}
inline bool Remote::voldown(Tv & t) {return t.voldown();}
inline void Remote::onoff(Tv & t) {t.onoff();}
inline void Remote::chanup(Tv & t) {t.chanup();}
inline void Remote::chandown(Tv & t) {t.chandown();}
inline void Remote::set_mode(Tv & t) {t.set_mode();}
inline void Remote::set_input(Tv & t) {t.set_input();}
inline void Remote::set_chan(Tv & t, int c) {t.channel = c;}

1.3 其他友元关系

友元和类的关系还可以更复杂。

举个例子,假设出现了交互式遥控器,交互式遥控器能够让您回答电视节目中的问题,如果回答错误,电视将在控制器上产生嗡嗡声。

这个例子的问题,可以使用新的友元关系来解决。我把它叫做相互的友情。

即一些Remote方法能够像前面那样访问Tv对象,而一些Tv方法也能影响Remote对象。

这可以通过让类彼此成为对方的友元来实现。即相互的友元

即除了Remote是Tv的友元外,Tv还是Remote的友元。

这里对于使用Remote对象的Tv方法,其原型可以再Remote方法类声明之前声明,但必须在Remote声明之后定义,以便编译器有足够的信息来编译该方法。

方法如下:(两个类互为友元类)

class Tv
{
frirend class Remote;
public:
  void buzz(Remote & r);
  ...
};

class Remote
{
friend class Tv;
public:
  void Bool volup(Tv & t) {t.volup();}
}

//buzz方法的声明和定义是分开的,定义要在Remote声明之后
//这是因为这个时候编译器编译该方法时,知道Remote指的是什么了;

inline void Tv::buzz(Remote & r)
{
...
}

1.4 共同的友元

需要使用友元的另一种情况是,函数需要访问两个类的私有数据。

从逻辑上看,这样的函数是每个类的成员函数,但是这是不可能的。

它可以是一个类的成员,同时是另一个类的友元,但有时将函数作为两个类的友元更加合理。

例如,假定有一个Probe类和一个Analyzer类,前者表示某种可编程的测量设备,后者表示某种可编程的分析设备。

这两个类中都有内部时钟,且希望它们能够同步,则应包含下述代码行:

class Analyzer   //forward declaration
class Probe
{
    friend void sync(Analyzer & a, const Probe & p);   // sync a to p
    friend void sync(Probe & p, const Analyzer & a);   // sync p to a
    ...
}

class Analyzer
{
    friend void sync(Analyzer & a, const Probe & p);   // sync a to p
    friend void sync(Probe & p, const Analyzer & a);   // sync p to a
    ...
}

//define the friend functions
inline void sync(Analyzer & a, const Probe & p)
{
...
}

inline void sync(Probe & p, const Analyzer & a)
{
...
}

前向声明使得编译器看到Probe类声明中的友元声明时,知道Analyzer是一种类型。

关于友元的总结

理解友元的产生和意义,首先要从类说起;

类对象的私有和保护数据成员只能通过类的公有方法访问。(这里暂时不讨论类的继承)

这样做的好处是提高了数据的安全性和封装性,接口简洁。

但是这种对数据的访问和修改的强限制手段,有时候会在某些场景下不方便。

有些情况可能需要直接去访问或修改类的私有数据成员。那么为了提高类修改私有数据的灵活性,引入友元的概念。

举个例子:假如你把一个人定义成朋友,那么这个朋友也获得了进出你家的资格,而不仅仅是你的家人。

但是你的朋友不是你的家人,是有别于你的家人。所以友元不是类的成员。

友元可以是函数,类的成员函数,甚至类;

其实还可以这样理解:把友元看成与类方法一样都是表达类接口的一种方式。

2 嵌套类

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

对类进行嵌套和包含并不同, 包含意味着将类对象作为另一个类的成员, 而对类进行嵌套不创建类成员, 而是定义了一种类型, 该类型仅在包含嵌套类声明的类中有效.

我们先看一个之前的一个例子, 自定义实现的简化版的队列:

class Queue
{
private:
	// 这里Node是一个嵌套的结构体定义
	// Item是一个别名, 详见之前的笔记
	struct Node {Item item; struct Node * next;}
	...
};

由于结构是一种其成员在默认情况下为共有的类, 所以Node实际上是一个嵌套类.

在之前的笔记中创建Node对象的方法是这么写的:

bool Queue::enqueue(const Item & item)
{
	if(isfull())
		return false;
	Node * add = new Node;
	add->item = item;
	add->next = NULL:
	...
}

现在我们将Node由struct改为嵌套类:

class Queue
{
	// 嵌套类
	class Node
	{
	public:
		Item item;
		Node * next;
		// 构造函数, 将item赋值为i, next指针设置为0, 也就是空值指针
		Node(const Item & i): item(i), next(0) {}
	};
	...
};

然后, 重新使用构造函数编写enqueue():

bool Queue::enqueue(const Item & item)
{
	if(isfull())
		return false;
	Node * add = new Node(item);
	...
}

这个例子中是在类声明中定义了构造函数, 假设想在方法文件中定义构造函数, 则定义必须指出Node类是在Queue类中定义的, 我们可以通过两次作用域解析运算符来完成:

Queue::Node::Node(const Item & i): item(i), next(0) {}

2.1 嵌套类和访问权限

首先.嵌套类的声明位置决定了嵌套类的作用域, 即它决定了程序的那些部分可以创建这种类的对象.

其次.嵌套类的共有部分, 保护部分和私有部分控制了对类成员的访问.

1.作用域 如果嵌套类是在另一个类的私有部分声明的, 则只有后者知道它. 前面的例子中Node就是这种情况, 虽然Node没有明确写是private的, 但是默认就是private的. 因此Queue成员可以使用Node对象和指向Node对象的指针, 但是程序的其他部分不知道存在Node类, 对于从Queue派生而来的类, Node也是不可见的, 因为派生类不能直接访问基类的私有部分.

如果嵌套类是在另一个类的保护部分声明的, 则它对于后者可见, 但是对于外部世界是不可见的. 但是这种情况中, 派生类是知道嵌套类的, 并可以直接创建这种类型的对象.

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

假设有如下的声明:

class Team
{
public:
	class Coach {...};
	...
};

如果要在Team外面创建Coach对象, 可以这么写:

Team::Coach forhire;

表15.1 嵌套类、结构和枚举的作用域特征

声名位置 包含他的类是否能使用 从包含它的类派生而来的类 在外部类
私有部分
保护部分
公有部分 是,通过限定符来使用

2.访问控制 对嵌套类的访问控制规则与常规类相同. 在Queue类声明中声明Node类并没有赋予Queue类的访问特权, 也没有赋予Node类任何对Queue类的访问特权. 因此Queue类对象只能显示的访问Node对象的公有成员。

类声明的位置决定了类的作用域或可见性, 类可见后, 访问控制规则(共有, 保护, 私有, 友元)将决定程序对嵌套类成员的访问权限。

2.2 模板中的嵌套

模板类中同样可以使用嵌套类

3 异常

异常是相对较新的C++功能,有些老式编译器可能没有实现。另外有些编译器可能默认关闭这一特性,需要使用编译器选项来打开它。

这里先讨论一个基本问题:

2.0 * x * y / (x+y)

如果y是x的负值,则上述公式将导致被零除。这是一种不允许的运算。很多新式编译器通过生产一个表示无穷大的特殊浮点值来处理。

例如Inf,inf,INF等。

3.1 调用abort()

对于这种问题,处理方式之一就是,如果其中一个参数是另一个参数的负值,则调用abort()函数。

Abort()函数的原型位于头文件cstdlib中,其典型实现是向标准错误流发送消息abnormal program termination(程序异常终止),然后终止程序。

它还返回一个随实现而异的值,告诉操作系统,实现失败。

abort()函数是否刷新文件缓冲区取决于实现。

如果愿意,也可以使用exit(),该函数刷新文件缓冲区,但不显示消息。

文件缓冲区:用于存储读写到文件的数据的内存区域。

#error1.cpp --using the abort() function
#include <iostream>
#include <cstdlib>
double hmean(double a, double b);

int main()
{
    double x,y,z;
    std::cout<<"Enter two numbers:";
    while()
    {
        z=hmean(x,y);
        std::cout<<"Harmonic mean of"<<x<<"and"<<y
               <<"is"<<z<<std:endl;
        std::cout<<"Enter next set of numbers<q to quit>:";

    }
    std::cout<<"Bye!\n";
    return 0;
}

double hmean(double a, double b)
{
    if(a == -b)
    {
        std::cout<<"untenable arguments to hmean()\n";
        std::abort();
    }
    return 2.0*a*b/(a+b);
}

为了避免异常终止,程序应在调用hmean()函数之前检查x和y的值。然而,依靠程序员来执行这种检查是不安全的。

3.2 返回错误码

一种比异常终止更灵活的办法是,使用函数的返回值来指出问题。

例如,ostream类的get(void)成员通常返回下一个输入字符的ASCII码,但到达文件尾时,将返回EOF。

对hmean()来说,这种方法不管用。任何数值都是有效的返回值,因此不存在可以指出问题的特殊值。

在这种情况下,可以使用指针参数或引用参数来将值返回给调用程序,并使用函数的返回值来指出成功还是失败。

iostream族重载»运算符使用了这个技术的变体。通过告知调用程序是成功了还是失败了,使得程序可以采取除异常终止程序之外的其他措施。

下列程序就是采用这种方法,它将hmean()的返回值重新定义为bool,让返回值指出成功了,还是失败了。另外还给该函数增加了第三个参数,用于提供答案。

//error2.cpp --returning an error code
#include<iostream>
#include<cfloat>   //(or float.h) for DBL_MAX

bool hmean(double a, double b, double * ans);

int main()
{
    double x, y, z;
    std::cout<<"Enter two numbers: ";
    while(std::cin>>x>>y)
    {
        if(hmean(x,y,&z))
            std::cout<<"Harmonic mean of "<<x<<" and "<< y
                   <<" is "<<z<<std::end;
        else
            std::cout<<"One value should not be the negative"
                  <<"of the other- try again.\n";
    }    
    std::cout<<"Bye!\n";
    return 0;
}

bool mean(double a, double b, double * ans)
{
    if(a == -b)
    {
        *ans =DBL_MAX;
        return false;
    }

    else
    { 
        *ans = 2.0 * a * b / (a+b);
        return true;
    }
}

这个程序设计避免了错误输入导致的恶果,让用户能够继续输入。

当然,设计确实依靠用户检查函数的返回值。这项工作不是程序员经常做的。

第三个参数可以是指针或引用。很多程序员都倾向于使用指针,因为这样可以明显看出是哪个参数用于提供答案。

另外一种在某个地方存储返回条件的方法是使用一个全局变量。

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

3.3 异常机制

下面介绍如何使用异常机制来处理错误。

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

对异常的处理有3个组成部分:

  • 引发异常 —-throw
  • 使用处理程序捕获异常 —- catch
  • 使用try块。 —- try

程序在出现问题时将引发异常,例如之前的hmean(),使之引发异常,而不是调用abort()函数。

throw()语句实际上是跳转,即命令程序跳到另一条语句。

throw关键字表示引发异常,紧随其后的值指出来异常的特征。

程序使用异常处理程序(exception handler)来捕获异常,异常处理程序位于要处理问题的程序中。catch关键字表示捕获异常。处理程序以catch关键字开头,随后是位于括号中的类型声明,它指出了异常处理程序要响应的异常类型;然后是用花括号括起来的代码段,指出要采取的措施。

catch关键字和异常类型用作标签,指出当异常被引发时,程序应跳到这个位置执行。

异常处理程序也被称为catch块。

try块标识其中特定的异常可能被激活的代码块,它后面跟一个或多个catch块。try块是由关键字try指示的。

关键字try的后面是一个由花括号括起来的代码块,表明需要注意这些代码引发的异常。

了解这三个关键字是如何协同工作的,最简单的方法是看下面一个例子:

// error3.cpp -- using an exception
#include <iostream>
double hmean(double a, double b);

int main()
{
    double x, y, z;

    std::cout << "Enter two numbers: ";
    while (std::cin >> x >> y)
    {
        try {                   // start of try block
            z = hmean(x,y);
        }                       // end of try block
        catch (const char * s)  // start of exception handler
        {
            std::cout << s << std::endl;
            std::cout << "Enter a new pair of numbers: ";
            continue;
        }                       // end of handler
        std::cout << "Harmonic mean of " << x << " and " << y
            << " is " << z << std::endl;
        std::cout << "Enter next set of numbers <q to quit>: ";
    }
    std::cout << "Bye!\n";
    return 0;
}

double hmean(double a, double b)
{
    if (a == -b)
        throw "bad hmean() arguments: a = -b not allowed";
    return 2.0 * a * b / (a + b); 
}

上面那个程序,如果在try块中调用hmean(),将无法处理异常。

引发异常的语句:throw “bad hmean() arguments; a= -b not allowed”; 这个语句异常类型是字符串。

执行throw语句,类似于执行返回语句,因为它也将终止函数的执行;

但throw不是讲控制权返回给调用程序,而是将程序沿函数调用序列后退,直到找到包含try块的函数。

catch块类似于函数定义,但并不是函数定义。关键字catch表明这是一个处理程序,而 char * s 表明该处理程序与字符串的异常匹配。

s与函数参数定义极其类似,因为匹配的引发将被赋给s。

另外,当异常与该处理程序匹配时,程序将执行括号中的代码。

执行完try块后,如果没有引发任何异常,将跳过后面的catch块,直接执行处理程序后面的第一条语句。

整个过程为:

  1. 程序在try块中调用hmean()
  2. hmean()引发异常,将从而执行catch块,并将异常字符串赋给s
  3. catch块返回到while循环的开始位置

这里可能有个疑问,如果函数引发了异常,而没有try块或者没有匹配的处理程序时,将会发生什么情况。

在默认情况下,程序最终会调用abort()函数,但可以修改这种行为。

3.4 将对象用作异常类型

通常,引发异常的函数将传递一个对象。这样做的重要优点之一就是,可以利用不同的异常类型来区分不同的函数在不同的情况下引发的异常。

对象可以携带信息,程序员可以根据这些信息来确定异常的原因。

同时,catch块可以根据这些信息来决定采取什么样的措施。

下面是针对函数引发的异常而提供的一种设计:

class bad_hmean
{
private:
    double v1;
    double v2;

public:
    bad_hmean(int a =0, int b=0):v1(a), v2(b) {}
    void mesg();
};

inline void bad_hmean::mesg()
{
    std::cout<<"hmean("<<v1<<","<<v2<<"):"
           <<"invalid arguments: a = -b\n";

}

可以将bad_hmean对象初始化为传递给含税hmean()的值,而方法mesg()可用于报告问题。

函数hmean()可以使用如下代码:

if (a == -b)
    throw bad_hmean(a,b);

上述代码调用构造函数bad_hmean(),以初始化对象,使其存储参数值。

接下来看一个新的例子:

添加了一个新的异常类bad_gmean,类bad_gmean中的函数gmean()计算两个数的几何平均值,即乘积的平方根。

bad_hmean()和bad_gmean()使用的技术不一样,

bad_gmean()使用的是公有方法和公有数据,该方法返回一个C-风格字符串;

3.5 异常规范和C++11

异常规范的理念看似有前途,但实际的使用效果并不好。

忽视异常规范之前,您至少应该知道它是什么样的,如下所示:

double harm(double a) throw(bad_thing);  //may throw bad_thing exception
double harm(double a) throw();    //doesn't throw an exception

其中throw()部分就是异常规范,它可能出现在函数原型和函数定义中,可包含类型列表,也可不包含。

异常规范的另一个作用是,让编译器添加执行运行阶段检查的代码,检查是否违反了异常规范。

这很难检查;

marm()可能不会引发异常,但它可能调用一个函数,而这个函数调用的另一个函数引发了异常。

另外,您给函数编写代码时它不会引发异常,但库更新后它却会引发异常。

总之,编程社区达成的意见是不要使用这个功能。

3.6 栈解退

假设try块没有直接调用引发异常的函数B,而是调用了对引发异常的函数B进行调用的函数A,即A调用了B。

则程序流程将从引发异常的函数B跳到包含try块和处理程序的函数。这涉及到栈解退。

首先要了解C++是如何处理函数调用和返回的。C++通常通过将信息放到栈中来处理函数调用。

具体来说,程序将调用函数的指令和地址(返回地址)放到栈中。

当被调用的函数执行完毕后,程序将使用该地址来决定从哪里开始继续执行。

另外函数调用将函数A参数放到栈中。在栈中这些函数参数被视为自动变量。

如果被调用函数B创建了新的自动变量,则这些变量也将被添加到栈中。

如果被调用函数B调用了另一个函数C,则函数C的信息也将被添加到栈中。以此类推;

当函数C结束时,程序流程将跳到该函数C被调用时存储的地址(返回地址)处,同时栈顶的元素被释放。

因此函数都通常返回到调用它的函数处,同时每个函数都在结束时释放器自动变量。

如果自动变量是类对象,则类的析构函数(如果有的话)也将被调用。

现在假设函数由于出现异常(而不是由于return)而终止,则程序也将释放栈中的内存。

但不会在释放栈的第一个返回地址后停止,而是继续释放栈,直到找到一个位于try块中的返回地址。

随后控制权将转移到位于try块尾的异常处理程序,而不是函数调用后面的第一条语句。

这个过程就被称为栈解退。

和函数返回一样,对于栈中的自动类对象,类的析构函数将被调用。

然而函数返回仅仅处理该函数放在栈中的对象。

而throw语句则处理try块和throw之间整个函数调用序列放在栈中的对象。

3.7 其他异常特性

throw-catch机制类似于函数参数和函数返回返回机制,但还是有些不同:

  1. 函数func()中的返回语句将控制权返回到调用func()的函数,但throw语句将控制权向上返回到第一个这样的函数:包含能够捕获相应异常的try-catch组合。

  2. 引发异常时,编译器总是创建一个临时拷贝,即使异常规范和catch块中指定的是引用。

class problem {...};
...
void super() throw(problem)
{
	...
	if (1)
	{
		problem opps; //construct object
		throw opps; // throw it
		//or use this func
		throw problem(); //construct and throw default problem object 
	}
	...
}
 
try
{
	super();
};
catch(problem & p)
{
	//statements
}

此处p将指向opp的副本,而不是p本身。这是件好事,因为函数super()执行完毕后,opps将不复存在。

同时此处catch捕捉异常对象的引用还有一个重要特征:基类引用可以执行派生类对象。假设有一组通过继承关联起来的异常类型,则在异常规范中只需列出一个基类引用,它将于任何派生类对象匹配。由于这个特性,当如果有一个异常类继承层次结构,应当这样排列catch块:将捕获位于层次结构最下面的异常类的catch语句放在最前面,将捕获基类异常的catch语句放在最后面。

也可以创建捕获对象而不是引用的处理程序。在catch语句中使用基类对象时,将捕获所有的派生类对象,但派生特性将被剥去,因此将统一使用虚方法的基类版本。

可以在不知道异常类型的情况下,捕获异常,方法是:使用省略号来表示异常类型,从而捕获任何异常。

catch(...){//statements}

如果知道一些异常的类型,可以将捕获所有异常的catch块放在最后面,这一点优点类似于switch语句中的default:

try
{
	super();
};
catch(bad_case1 &be)
{//...}
catch(bad_case2 &be)
{//...}
catch(bad_case3 &be)
{//...}
catch(...)
{//statements}

3.8 exception类

exception头文件定义了exception类,C++可以把它用作其他异常的基类。代码可以引发exception异常,也可以将exception类作为基类。

有一个名为what()的虚拟成员函数,它返回一个字符串,该字符串的特征随实现而异。然而,由于这是一个虚方法,因此可以再exception派生类中重新定义它。

  1. stdexcept异常类

定义了,logic_error和runtime_error类,他们都是从exception类公有派生出来的。

这些类的构造函数接受一个string对象作为参数,作为方法what()的返回数据。

logic_error公有派生出4个类:

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

runtime_error公有派生出3个类:

  • range_error;
  • overflow_error;
  • underflow_error;

他们之间的主要区别在于:可以根据不同的类名分别处理每种异常。另一方面,由于是继承关系也可以一起处理它们(如果愿意的话)。

  1. bad_alloc异常和new

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

  1. 空指针和new

很多代码都是在new失败时返回空指针时编写的。

为处理new的变化,有些编译器提供了一个标记(开关),让用户选择所需的行为。

当前,C++标准提供了一种在失败时返回空指针的new,其用法如下

int * pi = new (std::nothrow) int;
int * pa = new (std::nowthrow) int[500];

使用这种new,可将上面程序的核心代码修改如下:

Big * pb;
pb = new(std::nothrow) Big [10000];
if(pb ==0)
{
    cout<<"Could not allocate memory. Bye.\n";
    exit(EXIT_FAILURE);
}

3.9 异常、类和继承

异常、类和继承以3种方式向关联:

  1. 可以像C++库那样,从一个异常类派生出另一个;

  2. 可以在类定义中嵌套异常类声明,来组合异常;

  3. 这种嵌套声明本身可以被继承,还可以用作基类。

3.10 异常何时会迷失方向

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

如果它是在带异常规范的函数中引发的,则必须与规范列表中的某种异常匹配,否则称为意外异常。

如果异常不是在函数中引发的(或者函数没有异常规范),则必须捕获它。如果没有捕获,则异常被称为未捕获异常。

总之,如果要捕获所有的异常(不管事预期异常还是意外异常),则可以这样做:

首先确保异常头文件的声明可用:

#include <exception>
using namespace std

然后设计一个替代函数,将异常转换为bad_exception异常,该函数的原型如下:

void myUnexpected()
{
	throw std::bad_exception();
}

接下来在程序的开始位置,将意外异常操作指定为调用该函数:

set_unexpected(myUnexpected);

最后,将bad_exception类型包括在异常规范中,并添加如下catch块序列:

double Argh(double,double)throw(out_of_range,bad_exception);
...
try
{
	x = Argh(a,b);
}
catch (out_of_range& e)
{
	...
}
catch (bad_exception& e)
{
	...
}

3.11 有关异常的处理事项

4 RTTI

4.1 RTTI的用途

假如,有一个类层次结构,其中的类都是从同一个基类派生而来的,利用多态性,则基类指针或者引用可以指向任意一个派生类对象。但如何知道指针指向的是哪种对象呢?可能有三种情况:

  1. 该类层次结构中所有的成员都拥有虚函数,则基类指针可以根据所指对象的类型,调用相应派生类的方法。

  2. 派生对象可能包含不是继承而来的方法,这种情况下,只有某些类型的对象可以使用该方法。

  3. 也可能是出于调试的目的,想跟踪生成的对象的类型。

对于后两种情况,RTTI提供解决方案。

4.2 RTTI工作原理

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

  1. 如果可能的话,dynamic_cast运算符将使用一个指向基类的指针来生成一个指向派生类的指针;否则该运算符返回0—空指针。

  2. typeid运算符返回一个指出对象的类型的值。

  3. type_info结构存储了有关特定类型的信息。

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

  1. dynamic_cast运算符

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

通常,如果指向的对象(*pt)的类型为Type或者是从Type直接或者间接派生而来的类型,则下面的表达式将指针pt转换为类型为Type类型的指针:

dynamic_cast<Type *> (pt)

否则,结果为0,即空指针。

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

#include <typeinfo>
...
try
{
	Superb & rs = dynamic_cast<Superb &> (rg);
}
catch (bad_cast &)
{
	...
};
  1. typeid运算符和type_info类

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

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

typeid运算符返回一个type_info对象的引用,其中,type_info是在头文件type_info中定义的一个类。type_info重载了==和!=运算符,以便可以使用这些运算符来对类型进行判断。例如,如果pg执行的是一个Superb对象,则下述表达式的结果为bool值true,否则为false:

typeid(Superb) == typeid(*pg)

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

type_info类实现随厂商而异,但包含一个name()成员,该函数返回一个随实现而异的字符串:通常(但并非一定)是类名。例如,下面的语句显示指针pg指向的对象所属类定义的字符串:

cout::typeid(*pg).name()<<"\n";

5 类型转换运算符

四种类型转换运算符,他们的语法相同:

  • dynamic_cast,语法:dynamic_cast (expression)
  • const_cast,语法:const_cast (expression)
  • static_cast,语法:static_cast (expression)
  • reinterpret_cast,语法:reinterpret_cast (expression)

5.1 dynamic_cast运算符

dynamic_cast运算符在前面已经介绍过了,假设High和Low是两个类,而ph和pl的类型分别为High *和Low *。

则仅当Low是High的可访问基类时,下面的语句才将一个Low*指针赋给pl:

pl = dynamic_cast<Low *> ph;

该运算符的用途是,使得能够在类层次结构中进行向上转换(由于是is-a关系,这种转换是安全的),而不允许其他转换。

(1)其他三种都是编译时完成的,dynamic_cast是运行时处理的,运行时要进行类型检查。

(2)不能用于内置的基本数据类型的强制转换。

(3)dynamic_cast转换如果成功的话返回的是指向类的指针或引用,转换失败的话则会返回NULL。

(4)使用dynamic_cast进行转换的,基类中一定要有虚函数,否则编译不通过。

基类中需要检测有虚函数的原因:类中存在虚函数,就说明它有想要让基类指针或引用指向派生类对象的情况,此时转换才有意义。

这是由于运行时类型检查需要运行时类型信息,而这个信息存储在类的虚函数表(关于虚函数表的概念,详细可见<Inside c++ object model>)中,只有定义了虚函数的类才有虚函数表。

(5)在类的转换时,在类层次间进行上行转换时,dynamic_cast和static_cast的效果是一样的。在进行下行转换时,dynamic_cast具有类型检查的功能,比static_cast更安全。

向上转换,即为子类指针指向父类指针(一般不会出问题);

向下转换,即将父类指针转化子类指针。向下转换的成功与否还与将要转换的类型有关,即要转换的指针指向的对象的实际类型与转换以后的对象类型一定要相同,否则转换失败。

在C++中,编译期的类型转换有可能会在运行时出现错误,特别是涉及到类对象的指针或引用操作时,更容易产生错误。Dynamic_cast操作符则可以在运行期对可能产生问题的类型转换进行测试。

5.2 const_cast运算符

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

如果类型的其他方面也被修改,则上述类型的转换将出错。

也就是说除了const和volatile特征可以不同外,type_name和expression的类型必须相同。

再次假设High和Low是两个类:

High bar;
const High * pbar = &bar;
...

High * pb = const_cast<High *> (pbar);                     //valid
const  Low * pl = const_cast<const Low *> (pbar);   //invalid

第一个类型转换使得*pb成为一个可用于修改bar对象值的指针,它删除const标签。

第二个类型转换是非法的,因为它同时尝试将类型从const High*改为const Low*

提供该运算符的原因是,有时候可能需要这样一个值,它在大多数时候是常量,而有时又是可修改的。

在这种情况下,可以将这个值声明为const,并在需要修改它的时候,使用const_cast

需注意const_cast可以修改指向一个值的指针,但不可以修改const值。

void change(const int * pt)
{
	int * pc;
	pc = const_cast<int *>(pt);
	*pc = 100;
}
 
int main()
{
    int pop1 = 38383;
    const int pop2 = 2000;
    
    change(&pop1);
    change(&pop2);
 
    return 0;
}

在change()中,指针pt被声明为const int *,因此不能用来修改指向的int。指针pc删除了const特诊,因此可以用来修改指针指向的值,但仅当指向的不是const时才行,因此,pc可用于修改pop1的值,但不能修改pop2的值。

5.3 static_cast运算符

static_cast运算符仅当type_name可被隐式转换为expression所属的类型或expression可被隐式转换为type_name所属的类型时,上述转换才是合法的,否则将会出错。

假设High是Low的基类,而Pond是一个无关的类,则从High到Low的转换,从Low到High的转换都是合法的,而从Low到Pond的转换时不允许的。

High bar;
Low blow;
...
High *pb = static_cast<High *>(&blow); //valid
Low *pl = static_cast<Low *>(&bar); //valid
Pond *pb = static_cast<Pond *>(&blow); //invalid,Pond unrelated

它主要有如下几种用法:

  • 用于类层次结构中基类和派生类之间指针或引用的转换 进行上行转换(把派生类的指针或引用转换成基类表示)是安全的 进行下行转换(把基类的指针或引用转换为派生类表示),由于没有动态类型检查,所以是不安全的
  • 用于基本数据类型之间的转换,如把int转换成char。这种转换的安全也要开发人员来保证
  • 把空指针转换成目标类型的空指针
  • 把任何类型的表达式转换为void类型 注意:static_cast不能转换掉expression的const、volitale或者__unaligned属性。

5.4 reinterpret_cast运算符

reinterpret_cast用于天生危险的类型转换,主要有三种强制转换用途:改变指针或引用的类型、将指针或引用转换为一个足够长度的整形、将整型转换为指针或引用类型。

reinterpret_cast不支持所有类型的转换,如不能将指针类型转换为更小的整型或浮点型不能将函数指针转换为数据指针,反之亦然。