这一章在干嘛?

本章收拾 C++ 的”散装知识”,顺便讲三个较新的语言设施。友元部分从友元函数扩展到友元类、友元成员函数和嵌套类;异常部分讲 try/throw/catch 三件套如何优雅地处理运行期错误;RTTI(运行阶段类型识别)教你用 dynamic_casttypeid 在运行时辨认对象类型;最后是四个更安全的类型转换运算符,替代 C 风格的”万能强转”。

15.1 友元类:Tv 与 Remote

15.2 友元成员函数与互为友元

15.3 嵌套类

15.4 异常机制:throw、try 与 catch

15.5 用对象当异常

15.6 栈解退

15.7 异常的家族与注意点

15.8 RTTI:运行阶段类型识别

15.9 四个类型转换运算符

15.10 本章自测

15.1 友元类:Tv 与 Remote

友元函数之外,整个类也可以是另一个类的友元——友元类的任何方法都能访问原类的私有成员和保护成员。注意:友元关系是由类自己授予的friend 声明写在谁家里,谁说了算),不能从外部强加,所以它并不破坏封装的精神,只是让公有接口更灵活。

典型场景:电视机 Tv 与遥控器 Remote。遥控器不是电视机(is-a 不成立),也不含电视机(has-a 不成立),但遥控器能修改电视机的状态——友元关系正合适:

// tv.h -- Tv 和 Remote 类(节选)
class Tv
{
public:
    friend class Remote;   // Remote 可以访问 Tv 的私有部分
    enum {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 maxchannel;  // 最大频道数
    int channel;     // 当前频道
    int mode;        // 天线或有线
    int input;       // TV 或 DVD
};
 
class Remote
{
private:
    int mode;        // 控制电视还是 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(); }
};

多数 Remote 方法只借用 Tv 的公有接口,唯一直接摸私有成员的是 set_chan()(随机选台,Tv 自己没有对应方法)。Remote 方法都带一个 Tv & 参数——遥控器总要”对准”某台电视。友元声明可以放在类的 public、private 或 protected 任意区段,位置无所谓;由于 Remote 用到了 Tv,先定义 Tv(或用前置声明)即可。

通关标准:

能说清并演示:①友元类、友元成员函数的声明方法与前置声明的排布规则;②嵌套类与包含的区别;③try/throw/catch 三要素如何协作、栈解退时析构函数为何仍被调用;④dynamic_casttypeid 各自适用场景;⑤四个新式转换运算符分别管什么。

15.2 友元成员函数与互为友元

既然只有一个方法真正需要友元身份,可以更节制——只把 Remote::set_chan() 声明为 Tv 的友元:

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

麻烦在于声明顺序:编译器看到这条 friend 语句时,必须已经见过 Remote 的完整定义(知道 set_chan 是它的方法);而 Remote 的方法又要用 Tv 对象,需要先见到 Tv 的定义——循环依赖。解法是前置声明加”先只写原型”:

class Tv;                      // 前置声明
class Remote { /* 方法只写原型 */ };
class Tv { friend void Remote::set_chan(Tv & t, int c); /* …… */ };
// Remote 方法定义放在这里(可加 inline 保持内联)
inline void Remote::set_chan(Tv & t, int c) { t.channel = c; }

前置声明 class Tv; 告诉编译器”Tv 是个类”,足以编译 void onoff(Tv & t); 这样的原型;等编译器读到 Tv 的完整定义后,再编译 Remote 的方法定义就顺理成章。(把整个类声明为友元则不需要前置声明——friend class Remote; 本身就介绍了 Remote。)

进一步还可以互为友元:比如交互式遥控器与电视机互相影响。Tv 里声明 friend class Remote; 和方法 void buzz(Remote & r);;Remote 里声明 friend class Tv;。规则:原型可以提前写,用到对方成员的定义必须放在对方类声明之后

还有一种共同友元:一个函数要访问两个类的私有数据(如同步 Probe 和 Analyzer 两台设备的时钟),就让它同时做两个类的友元,两个类里都写 friend 声明。

类友元与成员友元的区别
▲ 图 15.1 之一:Remote 是 Tv 的友元类时,所有 Remote 方法都能访问 Tv 私有成员

类友元与成员友元的区别(续)
▲ 图 15.1 之二:友元类的访问范围覆盖全部方法

类友元与成员友元的区别(续)
▲ 图 15.1 之三:只把 set_chan() 声明为友元时,仅这一个方法能访问私有成员

15.3 嵌套类

把一个类的声明放在另一个类内部,就是嵌套类。它获得类作用域:外界想用就得通过作用域解析运算符(且声明须在 public 区)。注意嵌套不是包含:包含是”成员对象”,嵌套是”在类里定义一个只在局部可见的类型”——不创建任何数据成员。常见动机:为外层类做内部实现,顺便避免名字污染。

第 12 章的 Queue 就隐藏着一个嵌套类(struct 默认公有,本质就是类)。给它补上构造函数让节点创建即初始化:

class Queue
{
    // Node 是仅本类可用的嵌套类
    class Node
    {
    public:
        Item item;
        Node * next;
        Node(const Item & i) : item(i), next(0) { }  // 创建即初始化
    };
    // ……
};
bool Queue::enqueue(const Item & item)
{
    if (isfull())
        return false;
    Node * add = new Node(item);  // 构造函数代劳初始化,更简短更安全
    // ……
}

在类外定义嵌套类方法要两级作用域解析Queue::Node::Node(const Item & i) : item(i), next(0) {}

作用域与访问控制

嵌套类涉及两套独立的控制:声明位置决定谁看得到这个类型,类内的 public/private 决定对它的成员怎么访问。

声明位置对嵌套类可见对派生类可见对外界可见
private 区
protected 区
public 区是(需加类限定,如 Team::Coach forhire;

声明位置不授予任何访问特权:Queue 对 Node 并无特殊权力,反过来也一样。Queue 把 Node 的成员全设为 public 是可以的——因为 Node 本身藏在 private 区,外界根本看不见。

嵌套类放进模板毫无障碍。下面的队列模板里,Node 用泛型 Item 定义:QueueTP<double>QueueTP<char> 各有自己的 Node 类,互不冲突:

// queuetp.h -- 带嵌套类的队列模板(节选)
template <class 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;
public:
    QueueTP(int qs = Q_SIZE);
    ~QueueTP();
    bool isempty() const { return items == 0; }
    bool isfull() const { return items == qsize; }
    bool enqueue(const Item & item);
    bool dequeue(Item & item);
};

15.4 异常机制:throw、try 与 catch

程序运行会遇到异常情况:除零、打不开文件、内存不够……处理手段从原始到先进有三档,以”两数调和平均 2.0*x*y/(x+y)”(当 y = -x 时除零)为例:

  1. 调用 abort():检测到坏参数直接终止程序,用户没有挽回余地。
  2. 返回错误码:函数返回 bool 表示成败,结果用指针参数带回。灵活,但依赖调用者自觉检查返回值。
  3. 异常机制:错误发生时抛出异常,由调用方某处的处理代码接住——错误检查与错误处理彻底分离。

异常机制三要素:

  • throw:抛出异常,后跟一个值(字符串、对象均可)。它像一次跳跃——终止当前函数,向上回溯函数调用链。
  • try 块:标记”这段代码里的异常由后面的 catch 处理”。
  • catch 块(异常处理程序):catch (类型 形参) 开头,匹配对应类型的异常并处理。
// error3.cpp -- 使用异常
double hmean(double a, double b)
{
    if (a == -b)
        throw "bad hmean() arguments: a = -b not allowed";
    return 2.0 * a * b / (a + b);
}
 
int main()
{
    double x, y, z;
    while (std::cin >> x >> y)
    {
        try {                       // try 块开始
            z = hmean(x, y);
        }                           // try 块结束
        catch (const char * s)      // 异常处理程序
        {
            std::cout << s << std::endl;
            std::cout << "Enter a new pair of numbers: ";
            continue;
        }
        std::cout << "Harmonic mean of " << x << " and " << y
                  << " is " << z << std::endl;
    }
    return 0;
}

执行流程:try 块里没有异常就跳过 catch 继续往下;hmean(10, -10) 抛出字符串异常时,程序回溯到调用它的 try 块,发现 catch (const char * s) 类型匹配,把异常值赋给 s,执行处理块(这里 continue 回到循环开头,重新要数)。若 throw 发生却没有对应的 try/catch,程序默认调用 abort()

15.5 用对象当异常

抛字符串能用但太粗糙。惯例是抛类对象:不同异常类型可区分不同错误来源,对象还能携带数据(比如出错时的参数值):

// exc_mean.h -- 异常类
class bad_hmean
{
private:
    double v1;
    double v2;
public:
    bad_hmean(double a = 0, double b = 0) : v1(a), v2(b) {}
    void mesg();
};
inline void bad_hmean::mesg()
{
    std::cout << "hmean(" << v1 << ", " << v2 << "): "
              << "invalid arguments: a = -b\n";
}
 
class bad_gmean
{
public:
    double v1;
    double v2;
    bad_gmean(double a = 0, double b = 0) : v1(a), v2(b) {}
    const char * mesg() { return "gmean() arguments should be >= 0\n"; }
};

函数里 throw bad_hmean(a, b);,try 块后可以跟多个 catch 块,按类型各管一摊——bad_hmean 处理后 continue 重试,bad_gmean 处理后 break 退出,响应可以量身定做:

try {
    z = hmean(x, y);
    // ……
    cout << "Geometric mean of " << x << " and " << y
         << " is " << gmean(x, y) << endl;
}
catch (bad_hmean & bg)      // 先匹配这个
{
    bg.mesg();
    cout << "Try again.\n";
    continue;
}
catch (bad_gmean & hg)      // bad_gmean 落到这里
{
    cout << hg.mesg();
    cout << "Values used: " << hg.v1 << ", " << hg.v2 << endl;
    break;
}

顺带一提:C++98 曾有异常规范double marm(double) throw();),C++11 已弃用,不必再学它的用法;唯一保留的是 noexcept——声明”本函数不抛异常”,算程序员许下的承诺。

15.6 栈解退

假设 try 块调用 means()means() 又调用 hmean(),而异常在 hmean() 里抛出——控制流会一路回溯到拥有 try 块的函数。沿途经历的过程叫栈解退(unwinding the stack)

函数正常调用时,返回地址、参数、自动变量都压在栈上;函数返回时栈顶释放。throw 终止函数时同样释放栈,但不停在第一个返回地址,而是连续释放,直到到达位于 try 块中的返回地址,然后把控制交给后面的 catch 块。关键是:这段回溯路上,所有自动存储的类对象析构函数都会被调用——这和正常 return 一致,而且回溯跨越多少层函数就清理多少层,内存不会漏。

// error5.cpp -- 栈解退演示(节选)
class demo   // 话痨类:构造析构都会喊一嗓子
{
public:
    demo(const std::string & str) { std::cout << "demo " << str << " created\n"; }
    ~demo() { /* 输出 "destroyed" */ }
};
 
double means(double a, double b)
{
    double am, hm, gm;
    demo d2("found in means()");     // 自动对象
    am = (a + b) / 2.0;
    try {
        hm = hmean(a, b);
        gm = gmean(a, b);
    }
    catch (bad_hmean & bg)           // 只接 bad_hmean
    {
        bg.mesg();
        std::cout << "Caught in means() \n";
        throw;                       // 重新抛出,交给上一层
    }
    d2.show();
    return (am + hm + gm) / 3.0;
}

样例输出里能看到:hmean 抛异常被 means() 接住又重新抛出时,d2 的析构函数照样执行(“demo found in means() destroyed”)——虽然 d2.show() 没执行,说明函数确实被提前终止了。裸 throw;(不带操作数)表示重新抛出同一个异常,交给上一层 try/catch。

throw 与 return 的区别
▲ 图 15.3 throw 一路回溯到 try 块,沿途调用各层自动对象的析构函数

另外要注意:throw 总是创建副本。就算 catch 用引用接收,它引用的也是被抛出对象的副本(因为原对象随函数终止而消失),这反而使得引用安全可用。

15.7 异常的家族与注意点

catch 的匹配顺序

引用的妙处:基类引用能捕获整族派生类异常。若异常类构成继承链 bad_1 → bad_2 → bad_3,异常会被第一个类型匹配的 catch 块捕获,所以应把最派生的类放前面:

try { duper(); }
catch (bad_3 & be) { /* … */ }   // 最派生的先写
catch (bad_2 & be) { /* … */ }
catch (bad_1 & be) { /* … */ }
catch (...)        { /* … */ }   // 省略号:接住剩下的一切

catch (...) 类似 switch 的 default,兜住未知类型。用对象(而非引用)接收基类异常会把派生部分”削掉”,虚方法也只走基类版本——所以接收一律用引用。

标准异常家族

<exception> 定义了基类 exception,其虚方法 what() 返回描述字符串。<stdexcept> 又定义两大族:

  • logic_error 族(可通过编程避免的逻辑错误):domain_error(定义域错误)、invalid_argument(非法实参)、length_error(长度超限)、out_of_bounds(下标越界)。
  • runtime_error 族(运行期难以预料的错误):range_erroroverflow_error(上溢)、underflow_error(下溢)。

自己写异常类时,从 std::logic_errorstd::runtime_error 派生并入族是良好习惯:

class bad_hmean : public std::exception
{
public:
    const char * what() { return "bad arguments to hmean()"; }
};
// 统一捕获:catch (std::exception & e) { cout << e.what() << endl; }

<new> 头文件的 bad_alloc 也是 exception 的派生类——new 失败时抛出它(老式 new 返回空指针,若要旧行为可用 new (std::nothrow) Big[10000];)。

异常可以嵌在类里、随类继承

// sales.h -- 异常与继承(节选)
class Sales
{
public:
    enum {MONTHS = 12};
    class bad_index : public std::logic_error   // 嵌套异常类
    {
    private:
        int bi;
    public:
        explicit bad_index(int ix,
            const std::string & s = "Index error in Sales object\n");
        int bi_val() const { return bi; }
    };
    virtual double operator[](int i) const;     // 越界就抛 bad_index
    // ……
};
 
class LabeledSales : public Sales
{
public:
    class nbad_index : public Sales::bad_index  // 继承父类的异常类
    {
    private:
        std::string lbl;
    public:
        nbad_index(const std::string & lb, int ix,
            const std::string & s = "Index error in LabeledSales object\n");
        const std::string & label_val() const { return lbl; }
    };
    // ……
};

实现里 Sales::operator[]() 越界时 throw bad_index(i);,LabeledSales 的版本抛 nbad_index(Label(), i);。catch 块先接 LabeledSales::nbad_index &(更派生、在前),再接 Sales::bad_index &——正好呼应上一节的顺序规则。

异常的两大险情与内存泄漏

  • 未捕获异常(没有 try 或没有匹配 catch):程序先调 terminate()(默认转 abort()),可用 set_terminate(myQuit); 注册自己的收尾函数。
  • 意外异常(不符合旧式异常规范):调用 unexpected()terminate()abort(),可用 set_unexpected() 定制,典型做法是重新抛出一个 std::bad_exception 让上层接住。

常见坑:throw 导致 delete 被跳过,内存泄漏

栈解退只清理自动变量string mesg(...) 这类对象会被正确析构;但 double * ar = new double[n]; 之后抛异常,指针 ar 本身被从栈上清除,delete [] ar; 被跳过——堆上的内存成了孤儿。补救:在函数内先 try/catch、catch 块里 delete [] ar; 后再 throw; 重新抛出;更省心的做法是用智能指针(第 16 章)托管。异常会增加程序体积、拖慢速度,也不宜滥用,该用在真正异常的情况。

15.8 RTTI:运行阶段类型识别

场景:基类指针指向族里某个对象,运行时才知道它到底是谁——RTTI 提供标准的辨认手段。三个组件:

  • dynamic_cast:回答”能否安全地把这个地址转成某类型指针”。能则返回地址,不能则返回空指针(对引用则抛 bad_cast 异常)。
  • typeid:返回 type_info 对象,标识对象的确切类型
  • type_info:存类型信息,重载了 ==/!=,有 name() 方法。

常见坑:RTTI 只对含虚函数的类有效

只有”会把派生类地址赋给基类指针”的继承体系才需要 RTTI,而这只有含虚函数的体系才合理。编译器还可能默认关闭 RTTI 选项,编译通过但运行出错时先检查这个开关。

dynamic_cast<Superb *>(pg) 判断的不是”pg 指向谁”,而是”能否把 pg 安全地当作 Superb * 用”——只要对象是 Superb 或其派生类(如 Magnificent)就成功,指向纯 Grand 对象就返回 0:

Grand 类家族
▲ 图 15.4 Grand 类家族:dynamic_cast 和虚函数在三层继承上的协作

// rtti1.cpp -- dynamic_cast 演示(节选)
class Grand  { public: virtual void Speak() const; /* …… */ };
class Superb : public Grand { public: virtual void Say() const; /* …… */ };
class Magnificent : public Superb { /* …… */ };
 
Grand * pg;
Superb * ps;
for (int i = 0; i < 5; i++)
{
    pg = GetOne();               // 随机生成三种对象之一,返回 Grand *
    pg->Speak();                 // 虚方法:自动选对版本
    if (ps = dynamic_cast<Superb *>(pg))  // 能转成 Superb * 吗?
        ps->Say();               // 成功(非空)才调用 Say()
}

赋值表达式的值就是左值,所以 if (ps = dynamic_cast<...>) 一行完成”尝试转换 + 判空”。对引用使用时没有”空引用”可表示失败,改为抛异常,需要 try/catch 包裹:

#include <typeinfo>  // for bad_cast
try {
    Superb & rs = dynamic_cast<Superb &>(rg);
    // ……
}
catch (bad_cast &) { /* 转换失败的处理 */ }

typeid 则回答”是不是恰好这个类型”:

typeid(Magnificent) == typeid(*pg)   // pg 恰好指向 Magnificent 才为 true
cout << "Now processing type " << typeid(*pg).name() << ".\n";  // 调试利器

注意 typeid(*pg) 用的是对象——若写 typeid(pg) 比较的就是指针类型了。pg 为空指针时抛 bad_typeid 异常。name() 的返回值依实现而定(有的编译器返回 5Grand 这类修饰名)。

不要滥用 RTTI

能用虚函数就别用 typeid。用 if-else 链逐个 typeid 判断再强转的写法,每加一个新派生类都要改代码;而 pg->Speak()if (ps = dynamic_cast<Superb *>(pg)) ps->Say(); 对任何新增派生类自动适用。看到一长串基于 typeid 的 if-else,就该反思是不是该用虚函数了。

15.9 四个类型转换运算符

C 风格强转 (Junk *) (&d) 几乎什么都允许,错误意图也能编译通过。C++ 提供四个各司其职的运算符,写明意图,还让编译器帮你把关:

运算符用途语法
dynamic_cast类层次内的安全向上/向下转换(需虚函数),失败得空指针/抛 bad_castdynamic_cast<Low *>(ph)
const_cast只增删 const / volatile,改类型即出错const_cast<High *>(pbar)
static_cast允许”存在隐式双向通道”的转换:上下行转换、数值转换等static_cast<Low *>(&bar)
reinterpret_cast底层危险转换:指针↔足够大的整数等,不可移植reinterpret_cast<dat *>(&value)

几个细节:

  • const_cast 换来的”可写”不是免死金牌:对象本身声明为 const 时尝试修改,行为未定义。下面的程序里 pop1 被改了、pop2 纹丝不动:
    void change(const int * pt, int n)
    {
        int * pc;
        pc = const_cast<int *>(pt);   // 去掉指针的 const
        *pc += n;
    }
    // change(&pop1, -103) 生效;change(&pop2, -103)(pop2 是 const int)无效
  • static_cast 的合法性标准是”类型可以隐式地互转”:Low→High 上行可以,High→Low 下行因反方向能隐式进行所以也允许,Low→Pond(毫不相干)不允许。
  • reinterpret_cast 典型用途是底层、依赖实现细节的编程(比如把 long 指针重解释为结构体指针查看字节),不能去掉 const、不能把指针转成更小的整数或浮点数、函数指针与数据指针互转也不行。
  • C++ 保留的 C 风格强转能力被限制为”以上四者的组合”,比 C 里更收敛。

15.10 本章自测