当前位置: 首页 > 工具软件 > V2EX PLUS > 使用案例 >

C++ Primer Plus 学习笔记(十二)

唐博文
2023-12-01

第15章 友元、异常和其他

1. 友元

1.1. 友元类

友元类用来表示一种关系,类似于电视与遥控器,它俩并不属于 is-a 的关系,也不是 has-a 的关系,但是遥控器可以控制电视,这种情况下,遥控器就可以作为电视的友元类。

class TV 
{
private:
    int state;
    int volume;
    int channel;
    ...
public:
    friend class Remote;  // 友元类的声明
    enum { Off, On };
    enum { TV, DVD };
    bool volup() ;
    ...
};

class Remote 
{
private:
    int mode;
public:
    Remote(int m = TV::TV) : mode(m) {}
    bool volup(TV &t) { return t.volup(); }
    void set_chan(TV &t, int t) { t.channel = c; }
    ...
};

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

1.2. 友元成员函数

也可以选择只将 Remote 的某些方法作为 TV 的友元函数,而不是整个 Remote 作为友元类。这需要前置声明:

class TV;  // 前置声明 TV 类

class Remote 
{
private:
    int mode;
public:
    enum { TV, DVD };
    Remote(int m = TV:TV) : mode(m) {}
    bool volup(TV &t);
    void set_chan(TV &t, int t);
    ...
};

class TV 
{
private:
    int state;
    int volume;
    int channel;
    ...
public:
    friend void Remote::set_chan(TV &t, int c);  // 类某方法作为友元函数的声明
    enum { Off, On };
    enum { TV, DVD };
    bool volup() ;
    ...
};

inline bool Remote::volup(TV &t)
{
    return t.volup();
}

inline void Remote::set_chan(TV &t, int c)
{
    t.channel = c;
}

Remote 类的公有方法的参数几乎都是 TV 类对象,所以应该先声明 TV 类,但是 TV 类中有 Remote 类的友元函数,若不先声明 Remote 类的话,编译器并不知道 Remote 类的方法 set_chan(),解决这个矛盾的方法是前置声明 TV 类,这样就有了 TV 类,然后声明的 Remote 类就可以知道有 TV 类对象,最后声明的 TV 类的友元函数也找到了。

但是 Remote 类的方法要定义成内联函数,就需要在声明完 TV 类后,再定义,因为如果在 Remote 类内直接定义的话,当时并没有声明 TV 类的数据成员和方法,这样编译器找不到 TV 类的这些成员。内联函数是内部链接的,所以最好定义在头文件里。

1.3. 其他友元关系

Remote 类和 TV 可以互为友元类:

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

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

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

TV::buzz() 方法要在 Remote 类声明后定义,因为它的参数表明,它可能会用到 Remote 类的方法,因此在定义之前,需要先声明 Remote 类。

1.4. 共同的友元

有两个类 Pro 和 Ana,它们都有内部时钟,如果希望实现时钟同步:

class Ana;  // 前置声明

calss Pro
{
    friend void sync(Ana &a, const Pro & p);
    friend void sync(Pro & p, const Ana &a);
    ...
};

class Ana
{
    friend void sync(Ana &a, const Pro & p);
    friend void sync(Pro & p, const Ana &a);
    ...
};

void sync(Ana &a, const Pro & p)
{
    ...
}

void sync(Pro & p, const Ana &a)
{
    ...
}

2. 嵌套类

与包含类不同,嵌套类只是在类内定义一种类类型,并不创建类成员。包含嵌套类的类成员函数可以创建和使用嵌套类对象;当嵌套类定义在公有部分,可以在类外使用嵌套类。

class Queue
{
    class Node  // 嵌套类定义
    {
    public:
        Item item;
        Node * next;
        Node(const Item & i) : item(i), next(0) {}
    };
    ...
};

如果要在方法定义文件中定义 Node 构造函数,需要两次作用域解析运算符:

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

2.1. 作用域

嵌套类如果在私有部分,那么包含类的方法可以创建使用 Node 类对象,类外不行,包含类的派生类同样不能直接创建 Node 类对象。

如果在保护部分,则派生类方法可以创建 Node 类对象,类外不行。

如果在公有部分,则派生类和类外都可以直接创建 Node 类对象,不过类外要使用类限定符:

Queue::Node node;

嵌套类的作用域与类内枚举相同,使用方法也类似,下面是嵌套类、枚举、结构的作用域:

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

具体包含类对嵌套类的访问控制,看嵌套类的访问控制,即包含类方法创建的嵌套类对象,对嵌套类的访问同常规类一样,能访问公有数据和方法,私有和保护不能直接访问。

总之,嵌套类声明的位置决定了类的作用域或可见性(能否创建这个类对象),嵌套类的访问控制(公有、保护、私有、友元)决定对嵌套类成员的访问权限。

3. 异常

假设一个程序的功能是求两个数的调和平均数(倒数的平均数的倒数),当两个数互为相反数时,分母是0,这个时候程序会异常中断,异常处理可以作为一种工具预防这些情况。

3.1. 调用abort() 函数

#include <iostream>
#include <cstdlib>
double hmean(double a, double b);
using namespace std;

int main()
{
    double x, y, z;
    cout << "Enter two numbers: ";
    while (cin >> x >> y)
    {
        z = hmean(x, y);
        cout << "hmean is :" << z << endl;
        cout << "Enter two numbers<q to quit>: ";
    }
    
    return 0;
}

double hmean(double a, double b)
{
    if (a == -b)
    {
        cout << "not allow" << endl;
        std::abort();
    }
    else
        return 2.0 * a *b / (a + b);
}
        

abort() 函数位于头文件 cstdlib(或 stdlib.h)中,典型实现像标准错误流(cerr)发送消息 abnormal program termination(消息异常终止),终止后返回一个随具体实现而差异的值,告诉操作系统(若程序由另一个程序调用,则告诉父进程),处理失败。是否刷新缓冲区,取决于实现。也可以不用abort 使用 exit() ,但是后者不显示消息。

3.2. 异常机制

#include <iostream>
double hmean(double a, double b);
using namespace std;

int main()
{
    double x, y, z;
    cout << "Enter two numbers: ";
    while (cin >> x >> y)
    {
        try
        {
            z = hmean(x, y);
        }
        catch (const char * s)
        {
            cout << s << endl;
            cout << "Enter two numbers<q to quit>: ";
            continue;
        }
         cout << "hmean is :" << z << endl;
         cout << "Enter two numbers<q to quit>: ";    
    }
    
    return 0;
}

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

这种异常处理机制,由 try、catch 和 throw 三部分组成,try 块(花括号内部)内部表示可能引发异常的代码段,如果在 try 块外面才调用 hmean() 函数,则异常无法处理;catch 块类似 switch 块,根据 throw 后面的类型选择处理哪种异常,catch 块可以有多个,它的参数类似于函数定义时的参数,上述例程表明,throw 后面的是一个字符串,catch 的参数是字符串指针,抛出的字符串会赋值到这个指针上,进而处理这个异常。

throw 语句类似于返回语句,它也终止函数的执行,回退到包含 try 块的函数,上面的例程是 main() 函数,main 会寻找 catch 块中是否有处理 throw 类型的块。

当没有 try 块或处理这种类型的 catch 时,会调用 abort() 函数。

3.4. 将对象作为异常类型

class bad_mean
{
private:
    double v1;
    double v2;
public:
    bad_mean(double a = 0, double b = 0) : v1(a), v2(b) {}
    void msg();
};

void bad_mean::msg()
{
    std::cout << "hmean :" << v1 << ", " << v2
              << "invalid arguments: a = -b\n";
}

catch(bad_mean &b)
{
    b.msg();
    std::cout << "try again.\n";
    continue;
}

double hmean(double a, double b)
{
    if (a == -b)
    {
       throw bad_mean(a, b);
    }
    else
        return 2.0 * a *b / (a + b);
}

将例程中的函数进行如上更改,可以使 throw 一个类对象,而 catch 参数为该类对象,进而处理异常。

3.5. 异常规范和C++11

C++11 建议忽略异常规范,并新增了关键字 noexcept 指出函数不会引发异常:

double marm() noexcept;

3.6. 栈解退

程序在运行过程中,每次调用函数时,都会将调用函数指令的地址放到栈中,期间的自动变量也会放到栈中,函数调用完后,逐步释放栈顶,如果释放的是类对象,也会调用析构函数,直到调用指令的地址,然后继续执行程序。

而当调用的函数出现异常而终止时,栈并不返回到第一个调用指令地址上,而是回退到 try 块中的第一个调用指令地址上,然后程序控制权会转移到异常处理块上,而不是调用指令后的下一条指令。这个过程叫做栈解退。栈解退的特性是与函数返回一样,也会在回退到 try 的过程中释放途经的自动变量等,是对象的也会调用析构函数,与函数返回不同的是,函数返回只会释放函数调用中,放在栈中的自动变量,而栈解退会释放 throw 和 try 之间整个函数调用序列放在栈中的对象。异常的栈解退,是throw 后栈会返回到能 catch 到这个异常的位置,并释放过程中的对象。

3.7. 其他异常特性

throw—catch 是这样的机制:throw 将控制权返回到能够捕获这种异常的 catch 语句,即当有嵌套式的 try—catch—throw 结构时,内部 throw 的对象,内部的 catch 没有对应的情况下,会返回到上一层的 catch,直到有捕获这种异常的 catch,若没有对应的 catch,程序将异常中断。

throw 异常时,编译器总会创建这个异常的副本,即使 catch 块指定的是引用,它也是指向的这个副本的引用。这样的好处是引发异常的块调用完毕后,异常对象会被释放。catch 选择指向引用的另一个好处是,若 throw 的异常包括基类和派生类的话,catch 也可以捕获。注意,当 throw 的对象是派生类时,会被第一个对应的 catch 捕获,因此 catch 的排列顺序最好与派生顺序相反,即捕获基类对象的 catch 放在最后。

当一个调用另一个函数的函数可能会引发异常,但是你并不知道异常的类型,可以:

catch (...)  // 使用省略号
{
    // statement
}

用省略号来表示异常类型,从而捕获任何异常,这类似于 switch 对应的 default。若多个catch,catch(...) 放最后。

3.8. exception类

exception头文件(exception.h 或 except.h) 定义了 exception 类,可以将这个类作为其他异常的基类,它有一个虚成员函数 what() ,它返回一个字符串,该字符串随实现而定,因此可以在派生类中重新定义它:

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

try
{
    ...
}

catch(std::exception & e)
{
    cout << e.what() << endl;
    ...
}

3.10. 异常何时会迷失方向

当 throw 的对象没有对应的 catch 捕获时,发生意外异常,默认情况下,程序异常终止;或者当异常不是在 try 块中引发的,则异常称为未捕获异常,将引起程序异常终止。可以通过修改程序对意外异常和未捕获异常的反应。

程序并不会立刻终止,而是先调用函数 terminate()。默认情况下,terminat() 函数调用 abort() 函数。可以通过调用 set_terminat() 修改 terminat() 调用的函数,set_terminat() 声明在 exception 头文件中:

typedef void (*teminate_handler) ();
terminate_handler set_terminate(terminate_handler f) throw();  // C++ 98
terminate_handler set_terminate(terminate_handler f) noexcept;  // C++ 11
void terminate();  // C++ 98
void terminate() noexcept;  // C++ 11

typedef 语句使得 terminate_handler 成为了一个指向没有返回值和参数的函数的指针。set_terminate() 的参数是,没有返回值和参数的函数的名称(或地址),若多次调用 set_terminate()函数,则terminat() 调用的函数时最后一次调用 set_terminate() 设置的函数:

void myQuit()
{
    cout << "Terminating due to uncaught exception.\n";
    exit(5);
}

set_terminate(myQuit);

这样在发生未捕获异常时,terminate 函数会调用 myQuit 函数,打印log。

当程序发生意外异常时,程序会调用 unexcepted() 函数,这个函数将调用 terminat() 函数,后者默认情况下调用 abort() 函数。同样有 set_unexcepted() 函数设置 unexcepted 函数的行为。

typedef void (*unexcepted_handler) ();
unexcepted_handler set_unexcepted(unexcepted_handler f) throw();  // C++ 98
unexcepted_handler set_unexcepted(unexcepted_handler f) noexcept;  // C++ 11
void unexcepted();  // C++ 98
void unexcepted() noexcept;  // C+0x

通过 set_unexcepted 函数,可以选择调用 terminate 函数、abort() 函数或者 exit() 函数终止程序,也可以选择重新抛出异常来与原 catct 匹配,继续程序。当选择后者时,如果新引发的异常也与 catch 不匹配,且异常规范中没有包括 std::bad_exception 类型,则程序会调用 terminate() 函数。std::bad_exception 是从 exception 类派生来的,声明位于头文件 exception 中;如果新异常与 catch 不匹配,但是包含了 std::bad_exception 类型,则不匹配的类型会被 std::bad_exception 异常取代。

可以这样做来捕获所有异常(无论是预期的异常还是意外异常):

#include <exception>
using namespace std;

void myUnexcepted()
{
    throw std::bad_exception();  // or just throw
}

set_unexcepted(myUnexcepted);

try
{
    ...
}

catch(out_of_bounds & ex)
{
    ...
}

catct(bad_exception & ex)
{
    ...
}

3.11. 有关异常的注意事项

异常处理应该在设计程序时就加入,而不是以后再添加,这会增加程序代码并降低程序运行速度。异常规范不适用于模板,以为模板引发的异常可能不同的具体化都不一样。而且在动态内存分配时异常可能会出现问题:

void test1(int n)
{
    string mesg("I'm trapped in an endless loop");
    ...
    if (oh_no)
        throw exception();
    ...
    return;
}

当出现异常时,程序终止,但由于栈解退,在释放 mesg 对象时,会调用 string 的析构函数,释放string 类的 new;

void test2(int n)
{
    double * ar = new double [n];
    ...
    if (oh_no)
        throw exception();
    ...

    delete [] ar;
    return;
}

当引发异常时,栈解退时,将直接释放 ar。但函数在 delete 之前终止,指针消失了,这块内存被泄露了。这种情况可以在 catch 块中增加一些清理代码:

void test2(int n)
{
    double * ar = new double [n];
    ...
    if (oh_no)
        throw exception();
    ...

    catch (exception & ex)
    {
        delete [] ar;
        throw;
    }

    delete [] ar;
    return;
}

这将增加疏忽和产生其他错误的机会。也可以通过智能指针来解决。

4. RTTI

RTTI指运行阶段类型识别( Runtime Type Identification) 的简称。

4.1. RTTI 的用途

当有一个基类派生出了多个类,则基类指针可以指向所有的派生类,当有一个这样的函数:处理一些信息后,选择一个类,创建该类的对象,并返回它的地址,这个地址当然可以赋给基类指针,但是如何知道这个地址指向的是哪个派生类呢?

我们要确定该地址指向哪个派生类的原因是,如果我们需要调用一个虚函数时,当然可以不知道是哪个派生类,但是如我们需要调用一个派生类的方法不是继承来的时,就需要知道具体是哪个派生类了;或者在调试代码时,想追踪生成的对象。

4.2. RTTI 的工作原理

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

1. dynamic_cast 运算符将使用一个基类指针来生成一个指向派生类的指针;否则,返回空指针。

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

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

RTTI 只能用在包含虚函数的类层次结构,因为只有这种类层次用基类指针指向派生类调用方法才更有意义。

4.2.1. dynamic_cast 运算符

该运算符负责回答:是否可以安全的将对象地址赋给特定类型的指针,这个问题:

class Grand 
{
private:
    int hold;
public:
    Grand(int h = 0) : hold(h) {}
    virtual void speak() const { cout << "I'm a grand class!\n"; }
};

class Superb : public Grand 
{
public:
    Superb(int h = 0) : Grand(h) {}
    void speak() const { cout << "I'm a superb class!\n"; }
    virtual void say() const { cout << "I hold superb value: " << h << endl; }
};

class Magn : public Superb 
{
private:
    char ch;
public:
    Magn(int h = 0, c = 'A') : Superb(h), ch(c) {}
    void speak() const { cout << "I'm a Magn class!\n"; }
    void say() const { cout << "I hold magn value: " << h 
                            << endl; }
};

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

上述代码表示:指针 pg 的类型是否能够安全的转换为 Superb * ,成功则返回对象,否则,返回空指针。

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

dynamic_cast<Type *>(pt);  // 否则,结果为空指针

当有程序为:

#include <iostream>
#include <cstdlib>
#include <ctime>
#include <typeinfo>
using namespace std;
Grand * Getone();

int main()
{
    srand(time(0));
    Grand * pg;
    Superb * ps;
    
    for (int i = 0; i < 5; i++)
    {
        pg = Getone();
        pg->speak();
        if ( ps = dynamic_cast<Superb *>(pg))
            ps->say();
        if (typeid(Magn) == typeid(*pg))
            cout << "Yes, you are Magn.\n";
    }

    return 0;
}

Grand * Getone()
{
    Grand * p;
    switch(rand() % 3)
    {
        case 0 : p = new Grand(rand() % 100);
                    break;
        case 1 : p = new Superb(rand() % 100);
                    break;
        case 2 : p = new Magn(rand() % 100, 'A' + rand() % 26);
                    break;
    }
    return p;
}

​

当 pg 指向可以转换为 Superb 的对象时,就会调用虚方法 say()。

dynamic_cast 也可以用于引用,但是由于没有于空指针对应的引用值,因此无法用特殊的引用值表示失败。当引用的请求不正确时,dynamic_cast 将引发类型为 bad_cast 的异常,这种异常也是由 exception 类派生出来的,它在头文件 typeinfo 中定义,因此可以这样用:

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

catch(bad_cast &)
{
    ...
}

4.2.2. typeid 运算符和 type_info 类

typeid 运算符能够确定两个对象是否为同种类型,可以接受两种参数:类名;结果为对象的表达式。

它返回一个 type_info 对象的引用,type_info 是在头文件 typeinfo (以前为 typeinfo.h)中定义的一个类。该类重载了 == 和 != 运算符,以便用于比较。如上面例程里的 typeid(Magn) == typeid(*pg)

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

type_info 类的实现一般由厂商自己实现,但包含一个 name() 成员,该方法返回一个随实现而异的字符串,一般是类的名字:

cout << "Now processing type: " << typeid(*pg).name() << endl;

5. 类型转换运算符

由于 C 语言的类型转换太过松散,C++ 采取了 4 个更严格的类型转换运算符:dynamic_cast;const_cast;static_cast;reinterpret_cast。

const_cast 运算符执行于一种类型转换,即 const 或 volatile,语法为:

const_cast<type-name>(expression);

其中 expression 与 type-name 的类型必须相同,只有 const 或者 volatile 可以不同,即:

High bar;
const High * pbar = &bar;

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

上述例子,将 pbar 转换为非 const 指针,并将地址赋给了 pb,使得 *pb 可以修改 bar 的值;而 pbar 与 Low * 类型不同,因此不行。这个运算符是为了应对这样的情况:有一个值,它大部分时间是常量,但是有时也会需要修改。

而通用的 C 语言的类型转换,也可以修改 const,但是也可以修改类型,使用 const_cast 运算符更安全。但有的时候,const_cast 修改的结果是不确定的:

void change(const int * pt, int n);
int main()
{
    int p1 = 38383;
    const int p2 = 2000;
    
    cout << p1 << endl;
    cout << p2 << endl;
    change(&p1, -103);
    change(&p2, -103);
    cout << p1 << endl;
    cout << p2 << endl;

    return 0;
}

void change(const int * pt, int n)
{
    int * pc;
    pc = const_cast<int *> pt;
    *pc += n;
}

这种情况下,因为 change 的参数是 const 指针,因此当 p2 传入函数中时,可能被编译器阻止修改 p2 的值,这样两次的输出,可能只有 p1 的值改变了。

static_cast 运算符的用法:

static_cast <type-name> (expression);

这个运算符表示:仅当 type-name 所属类型可以隐性转换为 expression 类型,或者 expression 所属类型可以隐性转换为 type-name 类型时,转换才是合法的,否则将出错。假设 Low 是 High 的派生类,Pond 是一个无关类,则运用该运算符,Low 到 High 和 High 到 Low 都是合法的,而 Low 或 High 转换为 Pond 是非法的:

High bar;
Low blow;

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

reinterpret_cast 运算符用于天生危险的类型转换,它的用法:

reinterpret_cast <type-name> (expression);

struct dat {short a; short b;};
long value = 0xA224B118;

dat * pd = reinterpret_cast<dat *> (&value);
cout << hex << pd->a;  // 输出value的前两个字节

该运算符并非支持所有的类型转换。如,可以将指针类型转换为足以储存指针表示的整型,但是不能将指针转化为更小的整型或浮点型,也不能将函数指针转换为数据指针,反之也不行。

 类似资料: