这一章在干嘛?
本章收拾 C++ 的”散装知识”,顺便讲三个较新的语言设施。友元部分从友元函数扩展到友元类、友元成员函数和嵌套类;异常部分讲 try/throw/catch 三件套如何优雅地处理运行期错误;RTTI(运行阶段类型识别)教你用
dynamic_cast和typeid在运行时辨认对象类型;最后是四个更安全的类型转换运算符,替代 C 风格的”万能强转”。
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_cast与typeid各自适用场景;⑤四个新式转换运算符分别管什么。
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 时除零)为例:
- 调用 abort():检测到坏参数直接终止程序,用户没有挽回余地。
- 返回错误码:函数返回 bool 表示成败,结果用指针参数带回。灵活,但依赖调用者自觉检查返回值。
- 异常机制:错误发生时抛出异常,由调用方某处的处理代码接住——错误检查与错误处理彻底分离。
异常机制三要素:
- 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。

▲ 图 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_error、overflow_error(上溢)、underflow_error(下溢)。
自己写异常类时,从 std::logic_error 或 std::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:

▲ 图 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_cast | dynamic_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 本章自测
自测:throw 和 return 有什么本质区别?
return 只把控制交回直接调用者;throw 则带着异常值一路回溯调用链,直到找到第一个能匹配该类型的 try/catch 组合,沿途释放栈并调用所有自动对象的析构函数(栈解退)。另外 throw 总是创建异常对象的副本,catch 用引用接住它。
自测:异常类继承链 bad_1 → bad_2 → bad_3,catch 块应该按什么顺序排?为什么?
从最派生到最基类:先
catch (bad_3 &),再catch (bad_2 &),最后catch (bad_1 &)。因为异常被第一个类型匹配的块捕获,基类引用能匹配本类及所有派生类对象——若基类放前面,派生类异常会被它半路截走。
自测:
dynamic_cast<Superb *>(pg)与typeid(*pg) == typeid(Superb)判断结果何时不同?pg 指向 Magnificent 对象时:dynamic_cast 成功(Magnificent 是 Superb 的派生类,“是某种 Superb”),但 typeid 比较为 false(类型不恰好相等)。dynamic_cast 问”能否安全当作 Superb 用”,typeid 问”是否恰好就是这个类型”——多数场景前者更有用。
自测:栈解退为什么能让 string 对象正确析构,却救不了 new 出来的内存?
栈解退释放的是自动存储变量:string 是栈上的对象,析构函数被自动调用。而
new分配的内存不在栈上,栈解退只清掉指针变量本身,函数末尾的delete []被跳过,堆内存泄漏。解决:catch 里先 delete 再重新抛出,或改用智能指针。
自测:想临时修改一个"平时只读"的变量,该用哪个转换运算符?有什么限制?
用
const_cast:它只增删 const/volatile,若还试图改变类型本身就是编译错误——比 C 风格强转安全。但限制是:如果变量本身声明为 const(不只是通过 const 指针访问),修改它属于未定义行为,编译器可能根本不会让改动生效。