C++Primer Plus学习记录(15)

180 阅读13分钟

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

15.1 友元

前一章介绍将友元函数用于类的扩展接口中,类并未只能拥有友元函数,也可以将类作为友元。此时,友元类的所有方法都可以访问原始类的私有成员和保护成员。 也可以只将特定的成员函数指定为另一个类的友元。友元提高类公有接口的灵活性。

1. 友元类

什么时候希望一个类成为另一个类的友元呢?

假设需要编写一个模拟电视机和遥控器的简单程序。定义一个Tv类和一个Remote类。很明显这两个类应当存在某种关系,但又不是公有继承的is-a关系也不是包含或私有继承的has-a关系。但遥控器可以改变电视机的状态,这表明应当将Remote类作为Tv类的一个友元。

friend class Remote; //使Remote成为友元类

友元声明可以位于公有、私有或保护部分

但在很多情况下,并不需要整个类作为友元类,只是需要类的某些公共方法,此时可以选择仅让特定的类成员成为另一个类的友元

    class Tv{
        friend void Remote::set_chann(Tv &t, int c);
        ...
    };
    
    /*
        要使编译器能够处理这条语句,它必须知道Remote的定义。这意味着应将Remote的定义放在Tv的定义前面。但同时Remote又提到了TV,而这又意味着Tv定义也需要位于Remoet之前。
        要避开这种**循环依赖**,需要使用***前向声明***。
    */
 //需要在Remote的定义前插入下面这个语句
 class Tv;
 //类的排列顺序如下
 class Tv; //forward declaration
 class Remote{...};
 class Tv{....};
 
 //是否可以像下面这样排列呢?
 class Remote;
 class Tv{...};
 class Remote{...};
 /*
 答案是不能,原因呢在于Tv类的声明中使用了Remote的一个方法被声明为Tv类的友元之前,应该先看到Remote类的声明和set_chan()方法的声明。  
 */
 
 /*
     另外一个问题是:在Remote声明中包含了内联代码,如
 */
 class Remote{
     ...
     void onoff(Tv &t){t.onoff();}
     ....
 };
 /*
     由于此时调用了Tv的方法,因此编译器此时必须已经看到Tv类的声明。但此时该声明位于Remote之后
     解决办法:使Remote声明中只包含方法声明,将实际的定义放在Tv类之后,这样排列顺序如下:
     
 */
 class Tvclass Remote{...};
 class Tv{...};
 //put Remote method definition here
 //put Tv method definition here

2. 其他友好关系

还存在一些其他友元和类的组合形式,如交互式遥控器让您能够回答电视节目中的问题,如果回答错误,电视将在遥控器中产生嗡嗡声。那么C++类该如何实现呢?

一些Remote类的方法将影响Tv对象,而一些Tv方法也能影响Remote对象。可以通过让类彼此成为对方的友元来实现,实现如下:

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

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

inline void Tv::buzz(Remote & r){
    ...
}
/**
    由于Remote的声明位于Tv声明的后面,所以可以在类声明中定义Remote::volup(),但Tv::buzz()方法必须在Tv声明的外部定义,使其位于Remote声明的后面
*/

3. 共同的友元

需要使用友元的另一种情况是:函数需要访问两个类的私有数据。从逻辑上看,这样的函数应是每个类的成员函数,但这是不可能的。它可以是一个类的成员,同时是另外一个类的友元,不过有时将函数作为两个类的友元更为合理。

假设:有一个Prob类和一个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  friend void sync(Analyzer& a, const Probe& p){
    ...
}
inline friend void sync(Probe& p, const Analyzer& a){
    ...
}

//上述代码中使用前向声明看到Probe类声明中的友元声明时,知道Analyzer是一种类型

15.2 嵌套类

在C++中,可以将类放在另一个类中。在另一个类中声明的类称为嵌套类。在嵌套类中,可以通过提供新的类型作用域来避免名称混乱。

  • 包含类的成员函数可以创建和使用被嵌套类的对象
  • 仅当声明位于公有部分时,才能在包含类的外面使用嵌套类
  • 必须使用作用域解析符

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

class Queue{
// 类的默认访问权限是private,所以Node是私有的
    class Node{
    public:
        Item item;
        Node* next;
        Node(cosnt Item& i):item(i),next(0){}
    };
    ....
};

1) 嵌套类和访问权限

  • 嵌套类的声明位置决定了嵌套类的作用域;
  • 嵌套类的公有部分、保护部分和私有部分控制了对类成员的访问。
  1. 作用域
  • 嵌套类在private:只有包含类本身知道它
  • 嵌套类在protect:派生类将能知道嵌套类
  • 嵌套类在public:外部可感知嵌套类,但需要使用类限定符
    总结如下:

image.png

  1. 访问控制

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

2)模板中的嵌套

与在普通类中嵌套类是一致的,看下面例子:

#ifndef QUEUETP_H_H
#define QUEUETP_H_H

template <typename Item>
class QueueTP{
private:
    enum{Q_SIZE = 10};
    class Node{
    public:
        Item item;
        Node* next;
        Node(const Item& i):item(i),next(0){}
    };
    Node* front;
    Node* rear;
    int items;
    const int qsize;
    QueueTP(const QueueTP &q):qsize(0){}
    QueueTP& operator=(const QueueTP &q){return *this;}
public:
    QueueTP(int qs = Q_SIZE);
    ~QueueTP();
};

15.3 异常

程序有时会遇到运行阶段错误,导致程序无法正常运行下去。如,打开一个不可用文件,请求过多内存,或遭遇不能容忍的值等。C++异常就是用来处理这些异常情况的工具。有些编译器默认关闭这种特性,需要读者自己开启。

//计算两个数的调和平均数
2.0 * X * Y/(X + Y)
//当X与Y互为相反数时,会出现被零除

1) 调用abort()

abort()函数的原型位于头文件cstdlib中,其典型实现是向标准错误流(即cerr使用的错误流)发送消息abnormal program termination,然后终止程序
返回一个随实现而异的值,告诉操作系统(如果程序是由另一个程序调用的,则告诉父进程),处理失败
abort()是否刷新文件缓冲区取决于实现(也可使用exit,该函数刷新文件缓冲区,但不显示消息)

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中调用abort终止程序,而不是先返回到到main

2) 返回错误码

一种比异常终止更灵活的方法。如,ostream类的get方法返回下一个输入字符的ASCII码,但到达文件尾时,将返回特殊值EOF

/**
    可使用指针参数或引用参数来将值返回给调用程序,并使函数的返回值来指出成功还是失败
*/

 bool hmean(double a, double b, double* ans){
     if(a == -b){
         *ans = DBL_MAX;
         return false;
     }else{
         *ans = 2.0 * a * b / (a + b)
         return true;
     }
 }
 /**
     另外一种方法,使用一个全局变量,在函数出现问题时将全局变量设置为特定的值,然后调用程序检查该值。
     传统的C语言中使用了一个全局变量名为error。
 */

3)异常机制

C++异常是对程序运行过程中发生的异常的一种响应,异常处理有3个组成部分:

  • 引发异常—— throw关键字
  • 捕获有处理程序的异常 —— 异常处理程序 catch捕获异常
  • 使用try块—— 存在异常的代码块
int main(){
    double x,y;
    std::cout << "Enter two numbers:";
    while(std::cin >> x >> y){
        try{
            z = hmean(x,y);
        }
        catch(const char *s){
            std::cout << s << std::endl;
            std::cout << "Enter a new pair of numbers:";
            continue;
        }
    }
    return 0;
}
//引发异常的代码
if (a == -b){
    throw "bad hmean() arguments";
}

出现异常时的程序流程如下所示: image.png

4)将对象用作异常类型

引发异常的函数将传递一个对象,这样的好处是:

  • 可以使用不同的异常类型来区分不同的函数在不同的情况下引发的异常;
  • 对象可以携带信息来确定引发异常的原因
  • catch块可以根据信息来决定采取的措施
class bad_hmean{
private:
    double v1;
    double v2;
public:
    bad_mean(int a = 0, int b = 0):v1(a),v2(b){}
    void mesg();
};

inline void bad_mean::mesg(){
    std::cout << "hmean(" << v1 << "," << v2 << "):"
    << "invalid arguments: a == -b \n";
}
//可以将一个bad_hmean对象初始化为传递给函数hmean()的值
if(a == -b)
    throw bad_mean(a,b);

也可使用异常规范对函数定义进行限定,指出它将引发哪些类型的异常。为此,可在函数定义后面加上异常规范,它由关键字throw和异常类型列表组成,异常类型列表被括在括号中。

   double hmean(double a, double b) throw(bad_mean);
   double hmean(double a, double b) throw(const char*, double);//引发char*或doubel异常
   double simple(double z)throw(); //不会抛出异常

上述代码完成两项工作:

  • 告诉编译器,该函数引发哪些类型的异常。若函数引发了其他类型的异常,程序最终将调用abort()函数,对这种越权做出反应
  • 使用异常规范将提醒阅读该原型的人,该函数引发异常,应提供try块和处理程序
  • 对于引发多种异常的函数,可提供一个由逗号分隔的异常类型列表。
  • 如果异常规范中的括号内为空,则表明该函数不会引发异常

5)堆栈解退

假设try块没有直接调用引发异常的函数,而是调用了对引发异常的函数进行调用的函数,则程序流程将从引发异常的函数跳到包含try块和处理程序的函数。—— 这涉及到堆栈解退
堆栈解退的过程:

  1. 函数出现异常而终止,程序将释放堆栈中的内存
  2. 堆栈持续释放知道找到一个位于try块中的返回地址
  3. 控制权将转到块尾的异常处理程序,而不是函数调用后面的第一条语句
    引发机制的一个非常重要的特性是:和函数返回一样,对于堆栈中的自动类对象,类的析构函数将被调用
    函数返回仅仅处理该函数放在堆栈中的对象,而throw语句则处理try块和throw之间整个函数调用序列放在堆栈中的对象。

image.png

6)其他异常特性

  1. 引发异常时编译器总是创建一个临时拷贝,即使异常规范和catch块中指定的是引用
class problem{....};
void super() throw (problem){
    ...
    if(oh_no){
        problems oops;
        throw oops;
        ....
    }
    ....
}
....
try{
    super();
}
catch(problem &p){
    .....
}
/**
p将指向oops的拷贝而不是oops本身。这是因为super()执行完毕后,oops将不复存在。
*/
  1. 引用的另外一个特征:基类引用可以执行派生类对象。一组通过继承关联起来的异常类型,则在异常规范中只需要列出一个基类,它将与任何派生类对象匹配
  2. 基类引用能捕获任何异常对象;而使用派生类对象只能捕获他所属类及从这个类派生而来的类的对象。引发的异常对象将被第一个与之匹配的catch块捕获。这意味着catch块的排列顺序应该与派生顺序相反
class bad_1{...};
class bad_2:public bad_1{...};
class bad_3:public bad_2{...};
void super()throw(bad_1){
    ...
    if(oh_no)
        throw bad_1();
    if(rats)
        throw bad_2();
    if(drat)
        throw bad_3();
}
....

try{
    super();
}catch(bad_3 &be){
...
}catch(bad_2 &be){
....
}catch(bad_1 &be){
....
}
/*
    假设将bad_1放在前面,他将不捕获bad_1,bad_2,bad_3
*/

当不知道被调用的函数可能引发哪些异常。这时,仍可以捕获异常,可使用省略号来表示异常类型,从而捕获任何异常。

catch(...){
...
}
//一般情况下,将捕获所有异常的catch块放在最后面,类似switch中的default

7)exception类

C++异常的主要目的是为设计容错程序提供语言级支持,即异常使得在程序设计中包含错误处理功能更容易,以免事后采取一些严格的错误处理方式。

新的C++编译器将异常合并到语言中。为支持该语言,exception头文件定义了exception类,C++可以把他作为其他异常类的基类。

#include<exception>
class bad_hmean: public std::exception{
public:
    const char * what(){
        return "bad arguments to hmean()";
    }
    ....
};
class bad_gmean: public std::exception{
public:
    const char* what(){
        return "bad arguments to gmean()";
    }
 ....
};

//如果不想以不同的方式处理这些派生而来的异常,可以在同一个基类处理程序中捕获它们
try{
...
}catch(std::exception &e){
    cout << e.what() << endl;
    ...
}
  1. stdexcept异常类 头文件stdexcept定义了其他几个异常类。该文件定义了logic_error和runtime_error类,它们都以公有方式从exception派生而来。
class logic_error:public exception{
public:
    explicit logic_error(const string& what_arg);
    ...
};
class domain_error:public logic_error{
public:
    explicit domain_error(const string& what_arg);
    ...
};
  • 异常系列logic_error描述类典型的逻辑错误,每个类的名称指出类它用于报告的错误类型:
    a) domain_error
    b) invalid_argument
    c) length_error
    d) out_of_bounds
  • runtime_error异常系列描述类可能在运行期间发生但难以预计和防范的错误,每个类的名称指出了它用于报错的错误类型:
    a) range_error
    b) overflow_error
    c) underflow_error
    一般而言,logic_error系列异常表明存在可以通过编程修复的问题,而runtime_error系列异常表明存在无法避免的问题。所有错误类有相同的常规特征,它们之间的主要区别在于:不同的类可以单独处理每种异常;继承关系又能统一处理这些异常。
try{
...
}catch(out_of_bounds &oe){
...
}catch(logic_error &oe){
....
}catch(exception &oe){
....
}

//当系统定义的异常类无法满足需求时,可从系统异常类派生出一个自定义类
  1. bad_alloc和new
    在C++中对处理使用new时可能出现的内存分配问题,提供了两种可供选择的方式:
  • 当new无法满足内存请求时返回一个空指针
  • 引发bad_alloc异常,在头文件new中包含了bad_alloc类的声明

8)异常、类和继承

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

  • 可以从一个异常类派生出另一个
  • 可以在类定义中嵌套异常类声明来组合异常
  • 嵌套声明本身可被继承,且也可作为基类

9)异常何时会迷失方向

  1. 如果它是在带异常规范的函数中引发的,则必须与规范列表中的某种异常匹配,否则称为意外异常
  2. 如果异常不是在函数中引发的或者函数没有异常规范,则它必须被捕获,否则称为未捕获异常

未捕获异常不会导致程序立刻异常终止。相反,程序将首先调用函数terminate()。在默认情况下terminate()调用abort()函数。可以指定terminate()应调用的函数,调用set_terminate()函数设置,这两个函数都在头文件exception中声明。

typedef void(*terminate_handler)();
terminate_handler set_terminate(terminate_handler f)throw();
void terminate();

typedef使terminate_handler成为这样一种类型的名称:指向没有参数和返回值的函数的指针 set_terminate()函数将不带任何参数且返回类型为void的函数的名称作为参数,并且返回该函数的地址。terminate()调用最后一次set_terminate()调用设置的函数。

原则上,异常规范应包含函数调用的其他函数引发的异常。如,Argh()函数调用了Duh()函数,而后者可能引发retort对象异常,则Argh()和Duh()的异常规范中都应包含retort。

当发生意外异常时,程序将调用unexcepted()函数,这个函数将调用terminate(),类似terminate(),unexcepted函数也有一个set_excepted()函数,包含在头文件exception中:

typedef void(*unexcepted_handler)();
unexcepted_handler set_unexcepted(unexcepted_handler f) throw();
void unexcepted();

set_unexcepted()的函数行为受到更严格的限制,具体如下:

  • 通过调用terminate()(默认行为)、abort()或exit()来终止程序
  • 引发异常
    引发异常的结果取决于unexcepted_handler函数所引发的异常以及引发意外异常的函数的异常规范:
  • 如果新引发的异常与原来的异常规范匹配,则程序将从那里开始进行正常处理,即寻找与新引发的异常匹配的catch块。基本上,这种方法将用预期的异常取代意外异常。
  • 如果新引发的异常与原来的异常规范不匹配,且异常规范中没有包括std::bad_exception类型,则程序将调用terminate()。bad_exception是从exception派生而来的,其声明位于头文件exception中
  • 如果新引发的异常与原来的异常规范不匹配,且原来的异常规范中包含了std::bad_exception类型,则不匹配的异常将被std::bad_exception异常所取代。
//如果要捕获所有的异常,则可以这样做:
//1. 确保异常头文件的声明可用
#include<exception>
using namespace std;
//2. 设计一个替代函数,将意外异常转换为bad_exception异常,函数原型如下:
void myUnexpected(){
    throw std::bad_exception();
}
//3. 接下来在程序的开始位置,将意外异常操作指定为调用该函数
set_unexpected(myUnexpected);

//4. 将bad_excpetion类型包括在异常规范中,并添加如下catch块序列:
double Argh(double, double) throw(out_of_bounds, bad_exception);
...
try{
    x = Argh(a,b);
}catch(out_of_bounds &ex){
...
}catch(bad_exception &ex){
...
}

10) 异常的注意事项

程序员应在设计程序时就加入异常处理功能,而不是以后再添加。但这样做存在以下缺点:

  • 使用异常会增加程序代码,降低程序的运行速度
  • 异常规范不适用于模板,因为模板函数引发的异常可能随特定的具体化而异
  • 异常和动态内存分配不能总是协同工作

进一步讨论动态分配和异常,如下:

    void test1(int n){
        string mesg("I'm trapped in an endless loop");
        ...
        if(oh_no)
            throw exception();
         ...
        return;
    }
    /*
        string类采用动态分配。通常,当函数结束时,将为mesg调用string的析构函数,虽然throw语句过早地终止了函数,但它仍然使得析构函数被调用,这要归功于堆栈解退。
    */
    
//在看下面的test2
void test2(int n){
    double *ar = new double[n];
    ... 
    if (oh_no)
        throw exception();
    ...
    delete []ar;
    return ;
}
/*
当堆栈解退时,delete语句被跳过,因此,ar指向的内存块并未被释放,且不可访问,导致了内存泄漏。
解决办法:
    在catch块中包含一些清理代码,然后重新引发异常
*/

void test3(int n){
   double *ar = new double[n];
   ... 
   try{
       if(oh_no)
           throw exception();
   }catch(exception &ex){
       delete [] ar;
       throw;
   }
   ...
   delete []ar;
   return ;
}

15.4 RTTI

RTTI是运行阶段类型识别(Runtime Type Identification)的简称。这是新添加到C++中的特性之一,很多老式实现不支持。RTTI旨在为程序在运行阶段确定对象的类型提供一种标准方式。

1)RTTI的用途

为何需要知道类型?

  • 希望在继承中调用类方法的正确版本,若该函数说类层次结构中所有成员都拥有的虚函数,则并不真正需要知道对象的类型。但派生对象可能包含不是继承而来的方法,此时,只有某些类型的对象可以使用该方法。
  • 出于调试的目的,想跟踪生成的对象的类型

2)RTTI的工作原理

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

  • dynamic_cast操作符将使用一个指向基类的指针来生成一个指向派生类的指针;否则,该操作符返回0-空指针
  • typeid操作符返回一个指出对象的类型的值
  • type_info结构存储了有关特定类型的信息

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

  1. dynamic_cast操作符
    dynamic_cast操作符不能回答“指针指向的是哪类对象”的问题,但能够回答"是否可以安全地将对象的地址赋给特定类型的指针"这样的问题。
class Grand{//has virtual methods};
class Superb: public Grand{...};
class Magnificent: public Superb{...};
//定义下面的指针
Grand *pg = new Grand;
Grand *ps = new Superb;
Grand *pm = new Magnificent;
//类型转换
Magnificent* p1 = (Magnificent*)pm; //1
Magnificent* p2 = (Magnificent*)pg; //2
Superb* p3 = (Magnificent*) pm;  //3

哪些是安全的? 根据类声明,它们可能都是安全的,但只有那些指针类型与对象的类型(或对象的直接或间接基类的类型)相同的类型转换才一定是安全的。

  • 1是安全的。因为Magnificent之间的转换是安全的
  • 2是不安全的。因为将基类对象的地址赋值给派生类的指针。程序将期望基类对象有派生类的特征,而通常是不可能的。
  • 3是安全的。它将派生对象的地址赋给基类指针。

注意:与问题"指针指向的是哪种类型的对象"相比,问题“类型转换是否安全”更通用,也更有用。通常想知道类型的原因在于:

  • 知道类型后,就可以知道调用特定的方法是否安全
  • 要调用方法,类型并不一定要完全匹配,而是可以是定义了方法的虚拟版本的基类类型。
//dynamic_cast的句法:
Superb *pm = dynamic_cast<Superb *>(pg);

**通常,如果指向的对象(*pt)的类型为Type或者是从Type直接或间接派生而来的类型,则表达式: dynamic_cast<Type *>(pt); 将指针pt转换为Type类型的指针;否则,结果为0,即空指针。**
也可将dynamic_cast用于引用:没有与空指针对应的引用值,因此无法使用特殊的引用值来指示失败。当请求不正确时,dynamic_cast将引发类型为bad_cast的异常,该异常从exception派生而来。在头文件typeinfo中定义:

#include<typeinfo> 
...
try{
    Superb &rs = dynamic_cast<Superb &>(rg);
    ...
}catch(bad_cast &){
    ...
}

2) typeid操作符和type_info类

typeid操作符使得能够确定两个对象是否为同种类型,可以接受两种参数:

  • 类名
  • 结果为对象的表达式
    typeid操作符返回一个对type_info对象的引用。
    type_info定义在头文件typeinfoz中,且重载了==和!=操作符,以便进行类型比较。
typeid(Magnificent) == typeid(*pg)
//当pg为空指针时,程序将引发bad_typeid异常。
//typeinfo包含一个name()成员
cout<<"Now processing type " << typeid(*pg).name() << ".\n";//将显示指针为pg指向的对象所属的类定义的字符串

提示:如果发现在扩展的if else语句系列中使用了typeid,则应考虑是否应该使用虚函数和dynamic_cast。

15.5 类型转换操作符

C++中有4个类型转换操作符:

  • dynamic_cast
  • const_cast
  • static_cast
  • reinterpret_cast

详细介绍见:blog.csdn.net/kezunhai/ar…