15.1 友元

通常,类的 privateprotected 成员只能被该类的成员函数访问。这是 C++ 封装性的体现,有助于保护数据和隐藏实现细节。然而,在某些特殊情况下,允许特定的外部函数或类访问一个类的私有或保护成员会非常方便。C++ 提供了友元 (friend) 机制来实现这种受控的访问。

友元可以是:

  • 友元函数 (Friend Function): 一个非成员函数被声明为某个类的友元。
  • 友元类 (Friend Class): 一个类被声明为另一个类的友元。
  • 友元成员函数 (Friend Member Function): 某个类的成员函数被声明为另一个类的友元。

声明友元:

在需要授予访问权限的类(我们称之为宿主类)的定义内部,使用 friend 关键字来声明友元。

1
2
3
4
5
6
7
8
9
10
11
class HostClass {
friend ReturnType friendFunctionName(parameters); // 声明友元函数
friend class FriendClassName; // 声明友元类
friend ReturnType AnotherClass::memberFuncName(parameters); // 声明友元成员函数
private:
int privateData;
protected:
int protectedData;
public:
// ...
};

重要特性:

  • 访问权限: 友元函数或友元类(及其所有成员函数)可以访问宿主类的所有成员,包括 privateprotected 成员。
  • 非传递性: 友元关系不是传递的。如果类 A 是类 B 的友元,类 B 是类 C 的友元,这并不意味着类 A 是类 C 的友元。
  • 非对称性: 友元关系不是对称的。如果类 A 是类 B 的友元,这并不意味着类 B 是类 A 的友元。
  • 声明位置: friend 声明可以放在类定义的 public, protected, 或 private 部分,效果是相同的。通常习惯放在类定义的开始或结束处。

15.1.1 友元类

当一个类被声明为另一个类的友元时,这个友元类的所有成员函数都可以访问宿主类的私有和保护成员。

示例: 假设有一个 Tv 类(电视)和一个 Remote 类(遥控器)。遥控器需要能够直接调整电视的状态(如频道、音量),即使这些状态是私有的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
// tv.h -- Tv and Remote classes
#ifndef TV_H_
#define TV_H_

class Tv; // 前向声明 Tv 类,因为 Remote 会用到它

class Remote {
public:
enum State { Off, On };
enum { MinVal, MaxVal = 20 };
enum { Antenna, Cable };
enum { TV, DVD }; // 假设遥控器也可以控制 DVD
private:
int mode; // 控制 TV 还是 DVD
public:
Remote(int m = TV) : mode(m) {}
// 遥控器的方法,需要访问 Tv 的私有成员
bool volup(Tv & t); // 引用 Tv 对象
bool voldown(Tv & t);
void onoff(Tv & t);
void chanup(Tv & t);
void chandown(Tv & t);
void set_mode(Tv & t);
void set_input(Tv & t);
void set_chan(Tv & t, int c);
};

class Tv {
private:
int state; // On or Off
int volume; // assumed to be digitized
int maxchannel; // maximum number of channels
int channel; // current channel setting
int mode; // Antenna or Cable
int input; // TV or DVD
public:
// 将 Remote 类声明为 Tv 类的友元
friend class Remote;

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; // display all settings
};

// Remote 方法的实现 (需要看到 Tv 的完整定义)
// 通常放在 .cpp 文件中,或者在 Tv 定义之后

inline bool Remote::volup(Tv & t) { return t.volup(); } // 调用 Tv 的公有方法
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(); }
// set_chan 需要直接访问 Tv 的私有成员 channel
inline void Remote::set_chan(Tv & t, int c) {
// 因为 Remote 是 Tv 的友元,可以直接访问 t.channel
t.channel = c;
}

// Tv 方法的实现 (部分)
inline bool Tv::volup() {
if (volume < MaxVal) {
volume++;
return true;
} else return false;
}
inline bool Tv::voldown() {
if (volume > MinVal) {
volume--;
return true;
} else return false;
}
inline void Tv::chanup() {
if (channel < maxchannel) channel++;
else channel = 1;
}
inline void Tv::chandown() {
if (channel > 1) channel--;
else channel = maxchannel;
}
// ... settings() 实现需要 iostream ...

#endif // TV_H_

// use_tv.cpp -- 使用 Tv 和 Remote
#include <iostream>
#include "tv.h"

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

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.settings();

Remote grey; // 创建遥控器
grey.set_chan(s42, 10); // 遥控器设置频道 (调用友元可访问的私有成员)
grey.volup(s42); // 遥控器增加音量
grey.volup(s42);
cout << "\nSettings after using remote:\n";
s42.settings();

Tv s58(Tv::On); // 创建一个已打开的电视
s58.set_mode(); // 切换模式
grey.set_chan(s58, 28); // 遥控器控制另一台电视
cout << "\nSettings for 58\" TV:\n";
s58.settings();

return 0;
}

在这个例子中,Remote 类被声明为 Tv 的友元,因此 Remote 的成员函数(如 set_chan)可以直接访问 Tv 对象的私有成员 channel

15.1.2 友元成员函数

有时,我们不需要让整个类成为友元,只需要让某个类的特定成员函数成为另一个类的友元。

声明语法:

1
2
3
4
5
6
class HostClass {
// ...
// 声明 OtherClass 的 memberFunc 为友元
friend ReturnType OtherClass::memberFunc(parameters);
// ...
};

编译顺序和前向声明:

声明友元成员函数时需要特别注意编译顺序和前向声明:

  1. 定义提供友元成员函数的类 (OtherClass): 编译器需要先知道 OtherClass 的完整定义,才能处理其中的 memberFunc
  2. 定义宿主类 (HostClass):HostClass 中声明 OtherClass::memberFunc 为友元。
  3. 定义友元成员函数 (OtherClass::memberFunc): 这个函数的实现需要看到 HostClass 的完整定义,因为它需要访问 HostClass 的私有/保护成员。

这通常需要使用**前向声明 (Forward Declaration)**。

示例:Remote::set_chan 成为 Tv 的友元,而不是整个 Remote 类。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
// tvfm.h -- Tv and Remote classes using a friend member
#ifndef TVFM_H_
#define TVFM_H_

class Tv; // *** 1. 前向声明 Tv ***

class Remote {
// ... (Remote 定义同前) ...
public:
// ...
void set_chan(Tv & t, int c); // 声明 set_chan
// ...
};

class Tv {
// ... (Tv 定义同前) ...
public:
// *** 2. 将 Remote::set_chan 声明为友元 ***
friend void Remote::set_chan(Tv & t, int c);
// ...
};

// *** 3. 定义 Remote::set_chan (需要看到 Tv 的完整定义) ***
inline void Remote::set_chan(Tv & t, int c) {
t.channel = c; // 现在可以访问 Tv 的私有成员 channel
}

// ... (其他 Remote 和 Tv 的内联方法定义) ...

#endif // TVFM_H_

在这个修改后的版本中,只有 Remote::set_chan 函数可以访问 Tv 的私有成员,而 Remote 的其他成员函数则不能(除非它们只调用 Tv 的公有方法)。这提供了比友元类更精细的访问控制。

15.1.3 其他友元关系

相互友元 (Mutual Friends): 两个类可以互为友元。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
class ClassB; // 前向声明

class ClassA {
friend class ClassB; // B 是 A 的友元
private:
int dataA;
};

class ClassB {
friend class ClassA; // A 是 B 的友元
private:
int dataB;
public:
void processA(ClassA& a) {
a.dataA = 10; // B 可以访问 A 的私有成员
}
};

// ClassA 的成员函数实现需要看到 ClassB 的完整定义
// void ClassA::processB(ClassB& b) { b.dataB = 20; }

将友元函数放在何处:

  • 如果友元函数只访问类的公有接口,它可以是普通非成员函数。
  • 如果友元函数需要访问类的私有/保护成员,它必须被声明为友元。
  • 如果一个函数需要访问两个不同类的私有/保护成员,那么它需要被这两个类都声明为友元。

15.1.4 共同的友元

一个函数可以是多个类的友元。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
class ClassC; // 前向声明

class ClassD {
friend void sharedFriend(const ClassC& c, const ClassD& d);
private:
int dataD;
};

class ClassC {
friend void sharedFriend(const ClassC& c, const ClassD& d);
private:
int dataC;
};

// 共同友元的实现
void sharedFriend(const ClassC& c, const ClassD& d) {
std::cout << "C data: " << c.dataC << ", D data: " << d.dataD << std::endl;
}

sharedFriend 函数可以同时访问 ClassCClassD 的私有成员。

使用友元的时机:

友元破坏了类的封装性,因为它允许外部代码直接访问内部实现细节。因此,应该谨慎使用友元。

  • 何时考虑使用:
    • 重载运算符: 尤其是需要访问两个不同类对象内部数据(如 operator<< 输出流操作符)或左操作数不是类对象的情况。
    • 紧密协作的类: 当两个或多个类在概念上紧密耦合,需要高效地共享信息时(如 TvRemote)。
    • 底层实现: 在某些底层库或框架中,为了性能或实现特定功能可能需要友元。
  • 替代方案: 在使用友元之前,考虑是否可以通过扩展类的公有接口(添加访问器或功能函数)来满足需求。

友元提供了一种绕过访问控制的机制,但应作为最后的手段,而不是常规设计工具。

15.2 嵌套类

C++ 允许在一个类中定义另一个类,这种在类内部定义的类称为嵌套类 (Nested Class)内部类 (Inner Class)**。包含嵌套类的类称为外围类 (Enclosing Class)** 或**外部类 (Outer Class)**。

目的:

嵌套类主要用于实现与外围类紧密相关的辅助类或数据结构,有助于将实现细节封装在外围类内部,提高代码的组织性和局部性。例如,链表或树结构的节点 (Node) 通常只为特定的容器类服务,将其嵌套在容器类内部就很自然。

语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
class EnclosingClass {
private:
// 嵌套类可以有自己的访问控制
class NestedClassPrivate {
// ...
};
protected:
class NestedClassProtected {
// ...
};
public:
class NestedClassPublic {
private:
int nestedData;
EnclosingClass* enclosingPtr; // 可以持有外围类指针
public:
NestedClassPublic(EnclosingClass* enc = nullptr) : enclosingPtr(enc) {}
void accessEnclosing(EnclosingClass& enc);
int getData() const { return nestedData; }
};

private:
int enclosingData;
NestedClassPrivate ncp; // 可以创建嵌套类对象作为成员
public:
EnclosingClass(int data = 0) : enclosingData(data) {}
void useNested(NestedClassPublic& ncp);
int getData() const { return enclosingData; }
};

// 嵌套类成员函数的定义
void EnclosingClass::NestedClassPublic::accessEnclosing(EnclosingClass& enc) {
// 嵌套类可以访问外围类的所有成员 (通过对象/指针/引用)
std::cout << "Accessing enclosing data: " << enc.enclosingData << std::endl;
// enc.ncp; // 可以访问外围类的私有成员对象
}

// 外围类成员函数的定义
void EnclosingClass::useNested(NestedClassPublic& ncp) {
// 外围类可以访问嵌套类的 public 成员
std::cout << "Using nested data: " << ncp.getData() << std::endl;
// std::cout << ncp.nestedData; // 错误!不能直接访问嵌套类的 private 成员
}

15.2.1 嵌套类和访问权限

嵌套类的访问权限遵循以下规则:

  1. 作用域:

    • 嵌套类的名称作用域仅限于其外围类。在外部引用嵌套类时,必须使用外围类的名称和作用域解析运算符 (::),例如 EnclosingClass::NestedClassPublic
    • 嵌套类的声明位置(public, protected, private)决定了外部代码是否以及如何能够引用该嵌套类类型本身。
      • public: 外部代码可以使用 EnclosingClass::NestedClassPublic
      • protected: 只有 EnclosingClass 及其派生类可以使用 EnclosingClass::NestedClassProtected
      • private: 只有 EnclosingClass 内部可以使用 NestedClassPrivate
  2. 嵌套类对外围类的访问:

    • 嵌套类的成员函数可以访问外围类的所有成员(public, protected, private),包括类型名、静态成员、枚举常量等。
    • 重要: 嵌套类访问外围类的非静态成员时,必须通过外围类的对象、指针或引用来进行。嵌套类对象本身包含一个指向其外围类对象的隐式指针(不像 Java 的内部类)。
  3. 外围类对嵌套类的访问:

    • 外围类的成员函数可以创建嵌套类的对象。
    • 外围类对其嵌套类的成员的访问权限,遵循嵌套类自身的访问控制规则(public, protected, private)。仅仅因为一个类是嵌套的,并意味着外围类可以无视其访问控制。外围类不能直接访问嵌套类的 private 成员(除非外围类是嵌套类的友元)。

示例:链式队列中的 Node 嵌套类

回顾第 12 章的队列模拟,Queue 类内部定义了一个 Node 结构。这就是一个典型的嵌套类(或结构)应用。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
// queue.h (部分)
#ifndef QUEUE_H_
#define QUEUE_H_

class Customer; // 前向声明
typedef Customer Item;

class Queue {
private:
// 嵌套结构 Node (作用域在 Queue 内)
// 设为 private,因为它是 Queue 的实现细节
struct Node {
Item item;
Node * next;
};
enum { Q_SIZE = 10 };

Node * front; // 指向 Node 对象的指针
Node * rear;
int items;
const int qsize;
// ... (禁止赋值) ...
public:
Queue(int qs = Q_SIZE);
~Queue();
bool isempty() const;
bool isfull() const;
int queuecount() const;
bool enqueue(const Item &item); // Queue 的方法可以创建和访问 Node 对象
bool dequeue(Item &item);
};
#endif

// queue.cpp (部分)
#include "queue.h"
// ... Customer 定义 ...

Queue::Queue(int qs) : qsize(qs) {
front = rear = nullptr;
items = 0;
}

Queue::~Queue() {
Node * temp; // 可以声明 Node 类型的指针
while (front != nullptr) {
temp = front;
front = front->next; // 可以访问 Node 的成员 (因为 Node 是 Queue 的嵌套类,且成员默认 public)
delete temp;
}
}

bool Queue::enqueue(const Item &item) {
if (isfull())
return false;
Node * add = new Node; // 可以创建 Node 对象
add->item = item; // 可以访问 Node 的成员
add->next = nullptr;
items++;
if (front == nullptr)
front = add;
else
rear->next = add;
rear = add;
return true;
}
// ... dequeue 实现类似 ...

在这个例子中:

  • Node 的作用域仅限于 Queue 类。外部代码不能直接使用 Node 类型。
  • Queue 的成员函数(如构造函数、析构函数、enqueue)可以自由地创建 Node 对象,并访问其成员 itemnext(因为 struct 成员默认是 public 的,并且 NodeQueue 的作用域内)。

15.2.2 模板中的嵌套

嵌套类也可以在类模板中定义。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
template <typename T>
class OuterTemplate {
private:
T outerData;

public:
// 嵌套类
class Nested {
private:
int nestedData;
public:
// 嵌套类的成员函数可以访问外围模板类的成员
// (需要通过外围类对象)
void processOuter(OuterTemplate<T>& outer) {
std::cout << "Outer data: " << outer.outerData << std::endl;
// outer.outerData = someValue; // 可以修改
}
};

private:
Nested nestedObj; // 外围类可以包含嵌套类对象

public:
OuterTemplate(const T& data) : outerData(data) {}

void useNested() {
nestedObj.processOuter(*this);
}
};

// 使用
int main() {
OuterTemplate<double> ot(3.14);
ot.useNested(); // 输出 Outer data: 3.14

// 引用嵌套类类型需要外围类模板实例化
OuterTemplate<double>::Nested nestedInstance;
// nestedInstance.processOuter(ot); // 也可以直接调用

return 0;
}
  • 当外围类是模板时,嵌套类的定义通常也依赖于外围类的模板参数(如 Nested 可以访问 outerData,其类型为 T)。
  • 在外部引用嵌套类类型时,需要指定外围模板类的具体实例化类型,例如 OuterTemplate<double>::Nested

总结:

  • 嵌套类是在另一个类(外围类)内部定义的类。
  • 嵌套类的作用域局限于外围类。
  • 嵌套类可以访问外围类的所有成员(通过对象、指针或引用)。
  • 外围类访问嵌套类成员时,受嵌套类自身访问控制的限制。
  • 嵌套类常用于实现与外围类紧密相关的辅助数据结构或功能,有助于封装实现细节。
  • 嵌套类可以出现在普通类和类模板中。

15.3 异常

程序在运行时可能会遇到各种错误或意外情况,例如:

  • 用户输入无效数据。
  • 试图打开一个不存在的文件。
  • 内存分配失败 (new 失败)。
  • 运算错误(如除以零)。

处理这些问题对于编写健壮的程序至关重要。C++ 提供了异常处理 (Exception Handling) 机制,作为一种强大而灵活的错误处理方式。

在介绍异常机制之前,先看看一些传统的错误处理方法及其局限性。

15.3.1 调用 abort()

最简单粗暴的方式是,当程序检测到无法处理的错误时,调用 abort() 函数(定义在 <cstdlib><stdlib.h> 中)。abort() 会向操作系统发送一个异常终止信号(如 abnormal program termination),立即停止程序的执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include <iostream>
#include <cstdlib> // for abort()

double hmean(double a, double b) {
if (a == -b) {
std::cerr << "Error: hmean() arguments a = -b not allowed.\n";
std::abort(); // 终止程序
}
return 2.0 * a * b / (a + b);
}

int main() {
double result = hmean(10.0, -10.0);
// 这行不会执行
std::cout << "Result: " << result << std::endl;
return 0;
}

缺点:

  • 程序突然终止,用户可能不知道原因。
  • 没有机会进行清理工作(如保存数据、关闭文件、释放资源)。

15.3.2 返回错误码

一种更常见的做法是让函数在出错时返回一个特殊的错误码(例如 0, -1, falsenullptr),调用者负责检查这个返回值并采取相应措施。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#include <iostream>
#include <cerrno> // for errno (C-style error reporting)

// 返回 -1 表示错误
int process_data(int input) {
if (input < 0) {
errno = EDOM; // 设置全局错误码 (Domain Error)
return -1; // 返回错误码
}
// ... process data ...
return 0; // 返回 0 表示成功
}

int main() {
if (process_data(-5) == -1) {
if (errno == EDOM) {
std::cerr << "Error: Invalid input detected.\n";
} else {
std::cerr << "An unknown error occurred.\n";
}
// ... 处理错误 ...
} else {
std::cout << "Processing successful.\n";
}
return 0;
}

缺点:

  • 调用者必须检查: 调用者很容易忘记检查错误码,导致错误被忽略。
  • 错误码混淆: 函数的正常返回值可能与错误码冲突。
  • 错误信息有限: 单个错误码可能不足以描述错误的具体原因。
  • 错误传递复杂: 在深层嵌套的函数调用中,每一层都需要检查并向上传递错误码,使代码冗长且容易出错。

15.3.3 异常机制

C++ 异常处理提供了一种更结构化、更强大的错误处理方式,它将错误检测(在发生错误的地方)与错误处理(在能够处理该错误的地方)分离开来。

它主要涉及三个关键字:

  1. throw: 当函数检测到无法处理的错误时,使用 throw 关键字引发 (throw)抛出 (raise) 一个异常。throw 后面跟着一个表达式,该表达式的值(称为异常对象)的类型决定了异常的类型。
  2. try: 将可能引发异常的代码块(包括函数调用)放在 try 块中。try 关键字后面跟着一个花括号 {} 包围的代码块。
  3. catch: 紧跟在 try 块之后,使用一个或多个 catch 块来捕获 (catch)处理 (handle) 异常。每个 catch 块指定它能处理的异常类型。

基本流程:

  1. 程序执行进入 try 块。
  2. 如果在 try 块中的代码(或其调用的任何函数)执行了 throw 语句,一个异常被引发。
  3. 程序立即跳出当前的 try 块(以及从 try 块开始到 throw 点之间的所有函数调用栈),开始查找匹配的 catch 块。
  4. 程序按顺序检查紧跟在 try 块后面的 catch 块。
  5. 如果找到一个 catch 块,其声明的异常类型与抛出的异常对象类型匹配(或者是其基类,或者是 catch(...)),则执行该 catch 块中的代码。
  6. 执行完匹配的 catch 块后,程序继续执行该 catch 块之后的代码(除非 catch 块本身又抛出异常或终止程序)。
  7. 如果在当前 try...catch 结构中没有找到匹配的 catch 块,异常会继续向外层传播,查找包含当前 try 块的更外层 try 块对应的 catch 块。
  8. 如果异常一直传播到 main 函数之外(即没有在任何地方被捕获),程序通常会调用 std::terminate() 异常终止。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
#include <iostream>
#include <stdexcept> // 包含标准异常类,如 std::runtime_error

double hmean_exc(double a, double b) {
if (a == -b) {
// 抛出一个 runtime_error 类型的异常对象
throw std::runtime_error("hmean(): invalid arguments: a = -b");
}
return 2.0 * a * b / (a + b);
}

int main() {
double x, y, z;
std::cout << "Enter two numbers: ";
while (std::cin >> x >> y) {
try { // ---- try block starts ----
z = hmean_exc(x, y);
std::cout << "Harmonic mean of " << x << " and " << y
<< " is " << z << std::endl;
} // ---- try block ends ----
catch (const std::runtime_error & e) { // ---- catch block starts ----
// 捕获 runtime_error 类型的异常 (及其派生类)
std::cerr << "Error caught: " << e.what() << std::endl; // what() 返回错误信息字符串
std::cout << "Enter a new pair of numbers: ";
continue; // 继续下一次循环输入
} // ---- catch block ends ----

std::cout << "Enter next set of numbers <q to quit>: ";
}
std::cout << "Bye!\n";
return 0;
}

优点:

  • 分离错误处理: 将错误处理代码与正常逻辑分开,使代码更清晰。
  • 强制处理 (某种程度上): 未捕获的异常通常会导致程序终止,迫使开发者考虑错误处理。
  • 自动传播: 异常会自动沿着调用栈向上传播,直到找到合适的处理程序,无需在每层函数手动传递错误码。
  • 类型安全: 可以根据异常对象的类型来区分不同的错误,并进行相应的处理。
  • 资源清理: 结合 RAII(资源获取即初始化),异常处理可以确保在异常发生时自动释放资源(通过栈解退时调用局部对象的析构函数)。

15.3.4 将对象用作异常类型

throw 语句可以抛出任何类型的表达式结果,包括基本类型(如 int, const char*)或类类型的对象。

强烈建议使用类类型的对象作为异常类型,原因如下:

  • 携带更多信息: 对象可以包含多个数据成员,携带关于错误的更丰富信息(错误码、错误描述、发生位置等)。
  • 类型层次: 可以利用类的继承关系来组织异常类型。catch 块可以捕获基类类型的异常,从而处理该基类及其所有派生类的异常。这允许我们编写更通用的错误处理代码。

示例:自定义异常类

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
#include <iostream>
#include <string>
#include <cmath> // for sqrt

// 自定义异常基类 (可以继承自 std::exception)
class MathError {
public:
virtual void report() const { std::cerr << "Math error\n"; }
virtual ~MathError() {} // 虚析构函数很重要
};

// 派生异常类:无效参数
class BadArgument : public MathError {
private:
double arg1;
double arg2; // 可能有多个参数
std::string funcName;
public:
BadArgument(double a1, double a2, const std::string& fname)
: arg1(a1), arg2(a2), funcName(fname) {}
virtual void report() const override {
std::cerr << "Bad arguments to function " << funcName << "(): "
<< arg1 << ", " << arg2 << std::endl;
}
};

// 派生异常类:定义域错误
class DomainError : public MathError {
private:
double value;
std::string funcName;
public:
DomainError(double val, const std::string& fname)
: value(val), funcName(fname) {}
virtual void report() const override {
std::cerr << "Domain error in function " << funcName << "(): "
<< "Invalid value " << value << std::endl;
}
};

// 函数可能抛出异常
double my_sqrt(double x) {
if (x < 0.0) {
throw DomainError(x, "my_sqrt"); // 抛出 DomainError 对象
}
return std::sqrt(x);
}

double hmean_exc2(double a, double b) {
if (a == -b) {
throw BadArgument(a, b, "hmean_exc2"); // 抛出 BadArgument 对象
}
return 2.0 * a * b / (a + b);
}


int main() {
double a = 10.0, b = -10.0;
double c = -5.0;

try {
std::cout << "Calculating hmean...\n";
double result1 = hmean_exc2(a, b);
std::cout << "Hmean result: " << result1 << std::endl; // 不会执行

std::cout << "Calculating sqrt...\n";
double result2 = my_sqrt(c);
std::cout << "Sqrt result: " << result2 << std::endl; // 不会执行
}
// catch 块的顺序很重要:派生类应在基类之前
catch (const BadArgument& bae) {
std::cerr << "Caught BadArgument: ";
bae.report();
}
catch (const DomainError& de) {
std::cerr << "Caught DomainError: ";
de.report();
}
catch (const MathError& me) { // 捕获所有 MathError 及其派生类
std::cerr << "Caught generic MathError: ";
me.report();
}
catch (...) { // 捕获任何其他类型的异常
std::cerr << "Caught an unknown exception!\n";
}

std::cout << "Program continues after catch.\n";
return 0;
}

捕获顺序: 当使用继承层次结构的异常类时,catch 块的顺序非常重要。应该将派生类的 catch 块放在基类 catch 块之前。否则,基类 catch 块会首先捕获到派生类异常,导致派生类的特定处理逻辑无法执行。

按引用捕获: 推荐使用 const 引用 (const ExceptionType& e) 来捕获异常。

  • 避免对象**切片 (slicing)**:如果按值捕获基类异常,当抛出的是派生类对象时,派生类特有的部分会丢失。
  • 避免复制开销。
  • 使用 const 引用表明处理程序不打算修改捕获到的异常对象。

15.3.5 异常规范和 C++11 (throw(), noexcept)

早期 C++ 允许使用异常规范 (Exception Specifications) 来声明函数可能抛出哪些类型的异常。

1
2
3
void func1() throw(BadArgument, DomainError); // 可能抛出 BadArgument 或 DomainError
void func2() throw(); // 保证不抛出任何异常
void func3(); // 可能抛出任何类型的异常 (默认)

问题: 异常规范在实践中被证明效果不佳且难以维护。

  • 编译器通常只在运行时检查,而不是编译时。
  • 如果函数抛出了未在规范中列出的异常,程序的行为是调用 std::unexpected(),默认情况下它会调用 std::terminate()
  • 模板代码难以使用异常规范。
  • 维护成本高,修改底层函数可能需要更新整个调用链的异常规范。

C++11 的改变:

  • 废弃 throw(...) 规范: C++11 废弃了除 throw() 之外的动态异常规范。
  • 引入 noexcept: C++11 引入了 noexcept 说明符,用于明确表示函数保证不抛出任何异常
    • void func_no_throw() noexcept; // 保证不抛出
    • void func_maybe_throw(); // 可能抛出 (同 C++98 默认)
    • noexcept 还可以带一个常量表达式参数:noexcept(expression)。如果表达式为 true,则函数保证不抛出;如果为 false,则可能抛出。
  • throw() 等价于 noexcept(true): 空的 throw() 规范在 C++11 中被视为等同于 noexcept(true)
  • 违反 noexcept: 如果一个声明为 noexcept 的函数实际上抛出了异常,程序会调用 std::terminate(),而不是进行栈解退来查找处理程序。这使得 noexcept 成为一个更强的保证,编译器可以利用它进行优化。

建议:

  • 不要使用 C++11 废弃的动态异常规范 throw(Type1, Type2)
  • 如果函数确实能保证不抛出异常,或者即使抛出也应视为严重错误导致程序终止,请使用 noexcept。这对移动构造函数、移动赋值运算符和析构函数尤其重要,因为标准库的某些操作(如 std::vector 重新分配内存)依赖于这些操作的 noexcept 保证来提供强异常安全保证。
  • 如果函数可能抛出异常,省略 noexcept (或使用 noexcept(false))。

15.3.6 栈解退 (Stack Unwinding)

当异常被抛出时,程序会暂停当前函数的执行,并开始沿着函数调用链反向查找匹配的 catch 块。在这个过程中,已经执行完毕的函数(从 try 块开始到 throw 点)会依次退出,这个过程称为**栈解退 (Stack Unwinding)**。

关键点: 在栈解退过程中,函数调用栈上创建的局部对象会按照其构造相反的顺序被销毁,即它们的析构函数会被自动调用

这就是 RAII (Resource Acquisition Is Initialization) 模式与异常处理协同工作的关键。如果资源(如动态内存、文件句柄、锁)由局部对象(如智能指针 std::unique_ptr, 文件流 std::ofstream, 锁守卫 std::lock_guard)在其生命周期内管理,那么即使发生异常,当栈解退导致这些局部对象被销毁时,它们的析构函数也会被调用,从而确保资源被正确释放。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include <memory> // for std::unique_ptr
#include <stdexcept>

void process_resource() {
std::unique_ptr<int> p_res(new int(10)); // RAII: 资源由 unique_ptr 管理

// ... 使用 p_res ...

if (/* some error condition */ true) {
throw std::runtime_error("Something went wrong in process_resource");
}

// ... 更多代码 ...

} // 如果正常结束,p_res 在这里销毁,释放内存
// 如果抛出异常,栈解退时 p_res 也会被销毁,释放内存

void caller() {
try {
process_resource();
} catch (const std::runtime_error& e) {
// 处理异常
}
}

注意: 如果在栈解退过程中,某个对象的析构函数自身抛出了异常,而此时已经有一个异常正在处理中,程序会调用 std::terminate()。因此,析构函数应该避免抛出异常(通常应声明为 noexcept)。

15.3.7 其他异常特性

  • 重新抛出异常 (throw;):catch 块内部,可以使用不带任何操作数的 throw; 语句将当前捕获到的异常重新抛出,交由外层的 catch 块处理。这允许一个 catch 块执行部分处理(如记录日志),然后将异常传递给更高层进行进一步处理。

    1
    2
    3
    4
    5
    6
    7
    8
    catch (const MyException& e) {
    log_error(e.what()); // 记录错误
    if (can_handle_partially(e)) {
    // ... 部分处理 ...
    } else {
    throw; // 重新抛出原始异常,让外层处理
    }
    }
  • 捕获所有异常 (catch(...)): catch(...) 可以捕获任何类型的异常。它通常放在所有其他 catch 块的最后,用于进行最终的清理或记录未知错误。在 catch(...) 块内部无法知道捕获到的异常的具体类型和信息(除非重新抛出给外层)。

    1
    2
    3
    4
    5
    catch (...) {
    std::cerr << "Caught an unexpected exception type!\n";
    // 可能进行一些通用清理
    // throw; // 可以重新抛出,如果外层可能知道如何处理
    }

15.3.8 exception 类

C++ 标准库在 <exception> 头文件中定义了一个标准的异常基类 std::exception。标准库抛出的许多异常(如 std::bad_alloc, std::bad_cast, std::runtime_error, std::logic_error 等)都直接或间接从此类派生。

std::exception 提供了一个重要的虚成员函数:

  • virtual const char* what() const noexcept;
    • 返回一个描述异常的 C 风格字符串。派生类通常会覆盖此方法以提供更具体的错误信息。

建议自定义的异常类也继承自 std::exception 或其派生类,这样可以更容易地与标准库异常和通用的异常处理代码集成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
#include <stdexcept> // 包含 runtime_error, logic_error 等
#include <string>

class MyLogicError : public std::logic_error {
public:
explicit MyLogicError(const std::string& what_arg)
: std::logic_error(what_arg) {}
// what() 会继承 std::logic_error 的实现,返回构造时传入的字符串
};

int main() {
try {
throw MyLogicError("Custom logic error occurred");
}
catch (const std::exception& e) { // 捕获所有标准异常及派生类
std::cerr << "Caught standard exception: " << e.what() << std::endl;
}
return 0;
}

标准异常类层次结构(部分):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
std::exception
├── std::logic_error
│ ├── std::domain_error
│ ├── std::invalid_argument
│ ├── std::length_error
│ └── std::out_of_range
├── std::runtime_error
│ ├── std::overflow_error
│ ├── std::underflow_error
│ ├── std::range_error
│ └── std::system_error (C++11)
├── std::bad_alloc (new 失败)
├── std::bad_cast (dynamic_cast 到引用失败)
├── std::bad_typeid (typeid 用于空指针)
└── std::bad_exception (处理 unexpected() 时可能抛出)
...

15.3.9 异常、类和继承

异常处理机制与类和继承良好地交互:

  • 类对象作为异常: 如前所述,推荐使用类对象。
  • 继承层次: 可以捕获基类异常来处理派生类异常。
  • 构造函数中的异常: 如果构造函数在对象完全构造好之前抛出异常,该对象的析构函数不会被调用。但是,已经完全构造好的成员对象和基类子对象的析构函数会按照构造相反的顺序被调用(栈解退的一部分)。因此,构造函数需要特别注意资源管理,最好使用 RAII 成员(如智能指针)来避免资源泄漏。
  • 析构函数中的异常: 析构函数不应该抛出异常。如果析构函数在栈解退过程中抛出异常,会导致 std::terminate()。如果析构函数内部的操作可能失败,应将失败信息记录下来或提供其他查询方式,而不是抛出异常。

15.3.10 异常何时会迷失方向 (Uncaught Exceptions)

如果一个异常被抛出后,沿着调用栈一直传播到 main 函数之外都没有被任何 catch 块捕获,这个异常就称为**未捕获异常 (Uncaught Exception)**。

当发生未捕获异常时,C++ 运行时系统会调用 std::terminate() 函数(定义在 <exception> 中)。std::terminate() 的默认行为是调用 std::abort(),导致程序异常终止。

可以通过 std::set_terminate() 函数来注册一个自定义的终止处理程序,在 std::terminate() 被调用时执行一些自定义操作(如记录日志),但这个自定义处理程序最终也必须终止程序(例如通过调用 abort()exit()),不能返回。

15.3.11 有关异常的注意事项

  • 性能: 异常处理机制(特别是 throw 和栈解退)通常比返回错误码有更高的运行时开销。因此,异常应该用于处理异常情况,而不是用于正常的程序流程控制。对于预期会频繁发生的“错误”(如用户输入格式错误),可能使用错误码或其他方式更合适。
  • 异常安全 (Exception Safety): 编写在发生异常时仍能保持正确状态(例如,不泄漏资源、保持数据一致性)的代码非常重要。RAII 是实现异常安全的关键技术。函数通常追求以下几种异常安全保证级别(从弱到强):
    1. 基本保证 (Basic Guarantee): 操作失败时,对象保持在某个有效状态(不一定和操作前相同),没有资源泄漏。
    2. 强保证 (Strong Guarantee): 操作失败时,对象的状态回滚到操作开始之前的状态(事务性)。
    3. 不抛出保证 (Nothrow Guarantee): 操作保证不会抛出任何异常 (noexcept)。
  • 析构函数: 绝对不要让析构函数抛出异常。
  • 构造函数: 谨慎处理构造函数中的异常,使用 RAII 管理资源。
  • 何时使用: 异常最适合处理那些阻止函数完成其预期任务的、不常见的错误情况,特别是当错误发生在深层嵌套调用中,需要将错误信息传递给高层调用者时。

异常处理是 C++ 中一个强大的错误处理工具,但也需要谨慎使用,并结合 RAII 等技术来确保程序的健壮性和资源的正确管理。

15.4 RTTI(运行时类型识别)

RTTIRuntime Type Identification 的缩写,即运行时类型识别。它是 C++ 的一项机制,允许程序在运行时发现和使用对象的实际类型信息

通常,我们通过基类指针或引用来操作派生类对象(多态)。在这种情况下,代码只关心对象是否符合基类定义的接口。但有时,我们可能需要知道指针或引用实际指向的对象的确切派生类型,以便执行该派生类特有的操作。RTTI 就是为了解决这类问题而设计的。

RTTI 主要通过以下三个元素实现:

  1. dynamic_cast<> 运算符: 用于在类层次结构中进行**安全的向下转换 (Downcasting)**。它可以将基类指针或引用转换为派生类指针或引用,并在转换无效时提供明确的失败指示。
  2. typeid() 运算符: 返回一个指向 std::type_info 对象的引用,该对象包含了关于操作数类型的信息(如类型名称)。
  3. std::type_info 类: 存储特定类型信息的类。其具体内容因编译器而异,但标准保证它有一个 name() 方法返回类型的名称字符串,并可以比较两个 type_info 对象是否相等。

注意: RTTI 主要用于具有虚函数的类层次结构(即多态类)。对于没有虚函数的类,RTTI 的功能会受到限制(特别是 dynamic_cast 对指针的操作和 typeid 对解引用指针的操作)。编译器通常通过虚函数表 (vtable) 来存储 RTTI 所需的信息。

15.4.1 RTTI 的用途

RTTI 主要用于以下场景:

  • 安全的向下转换: 当你有一个基类指针或引用,并且你怀疑它可能指向某个特定的派生类对象,你想安全地将其转换为该派生类指针或引用,以便调用派生类特有的方法。
  • 类型相关的特殊处理: 根据对象的实际类型执行不同的逻辑分支。

替代方案: 应该优先考虑使用虚函数来实现多态行为。如果不同的派生类需要以不同的方式执行某个操作,通常更好的设计是定义一个虚函数,让每个派生类提供自己的实现,而不是使用 RTTI 来判断类型并手动调用不同的代码。RTTI 往往被视为一种最后的手段,或者在特定框架(如图形界面库中的事件处理)中可能更常用。过度使用 RTTI 可能表明类设计存在问题。

15.4.2 RTTI 的工作原理

1. dynamic_cast<> 运算符

dynamic_cast 用于在继承层次结构中进行类型转换,特别是在运行时检查转换的有效性。

语法:

  • 指针转换: dynamic_cast<TargetType*>(source_pointer)
  • 引用转换: dynamic_cast<TargetType&>(source_reference)

行为:

  • 向上转换 (Upcasting): 将派生类指针/引用转换为基类指针/引用。dynamic_cast 可以执行此操作,但通常不需要,因为隐式转换即可。
  • 向下转换 (Downcasting): 将基类指针/引用转换为派生类指针/引用。这是 dynamic_cast 的主要用途。
    • 前提: 基类必须包含至少一个虚函数(即是多态基类),dynamic_cast 才能执行运行时的向下转换检查。如果基类没有虚函数,dynamic_cast 用于向下转换时通常会导致编译错误(或行为类似 static_cast,不安全)。
    • 指针转换:
      • 如果 source_pointer 确实指向一个 TargetType 类型的对象(或者是 TargetType 的派生类对象),则转换成功,返回指向该对象的 TargetType* 指针。
      • 如果 source_pointer 指向的对象不是 TargetType 类型(或其派生类),或者 source_pointernullptr,则转换失败,返回 **nullptr**。
    • 引用转换:
      • 如果 source_reference 确实引用一个 TargetType 类型的对象(或者是 TargetType 的派生类对象),则转换成功,返回对该对象的 TargetType& 引用。
      • 如果 source_reference 引用的对象不是 TargetType 类型(或其派生类),则转换失败,抛出 std::bad_cast 异常(定义在 <typeinfo> 中)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
#include <iostream>
#include <cstdlib> // for rand(), srand()
#include <ctime> // for time()
#include <typeinfo> // for bad_cast
#include <stdexcept> // for exception

class Grand {
private:
int hold;
public:
Grand(int h = 0) : hold(h) {}
virtual void Speak() const { std::cout << "I am a grand class!\n"; }
virtual int Value() const { return hold; }
virtual ~Grand() {} // 虚析构函数
};

class Superb : public Grand {
public:
Superb(int h = 0) : Grand(h) {}
void Speak() const override { std::cout << "I am a superb class!!\n"; }
virtual void Say() const { std::cout << "I hold the superb value of " << Value() << "!\n"; }
};

class Magnificent : public Superb {
private:
char ch;
public:
Magnificent(int h = 0, char c = 'A') : Superb(h), ch(c) {}
void Speak() const override { std::cout << "I am a magnificent class!!!\n"; }
void Say() const override { std::cout << "I hold the magnificent value of " << Value() << " and the character " << ch << "!\n"; }
};

// 函数:随机生成一个指向 Grand, Superb 或 Magnificent 对象的指针
Grand * GetOne() {
Grand * p = nullptr;
switch (std::rand() % 3) {
case 0: p = new Grand(std::rand() % 100); break;
case 1: p = new Superb(std::rand() % 100); break;
case 2: p = new Magnificent(std::rand() % 100, 'A' + std::rand() % 26); break;
}
return p;
}

int main() {
std::srand(std::time(0));
Grand * pg;
Superb * ps;
Magnificent * pm;

for (int i = 0; i < 5; i++) {
pg = GetOne(); // 获取一个随机类型的对象指针
std::cout << "Now processing type #" << i+1 << std::endl;
pg->Speak(); // 调用虚函数,总是正确的

// 尝试向下转换为 Superb*
ps = dynamic_cast<Superb *>(pg);
if (ps != nullptr) { // 检查转换是否成功
ps->Say(); // 如果是 Superb 或 Magnificent,可以调用 Say()
} else {
std::cout << " (Not a Superb or derived type)\n";
}

// 尝试向下转换为 Magnificent*
pm = dynamic_cast<Magnificent *>(pg);
if (pm != nullptr) {
// 如果是 Magnificent,可以调用 Say() (这里会调用 Magnificent 的版本)
// pm->Say(); // 可以调用,但上面 Superb* 的 Say() 已经调用过了
std::cout << " (Also a Magnificent type)\n";
}

// 尝试使用引用转换 (如果失败会抛异常)
try {
Superb & rs = dynamic_cast<Superb &>(*pg); // 尝试转换为 Superb 引用
std::cout << " Reference cast to Superb successful.\n";
// rs.Say(); // 可以调用
} catch (const std::bad_cast & bc) {
std::cout << " Reference cast to Superb failed: " << bc.what() << std::endl;
}

delete pg; // 清理内存 (需要虚析构函数)
std::cout << "--------------------\n";
}
return 0;
}

2. typeid() 运算符

typeid 运算符返回一个 const std::type_info& 对象,表示其操作数的类型。

语法:

  • typeid(expression): 返回表达式 expression运行时类型信息(如果表达式是解引用的多态指针或引用)。如果表达式不是多态类型,则返回其静态类型信息。
  • typeid(type-name): 返回类型 type-name 的类型信息。

std::type_info 类:

  • name(): 返回一个实现定义的 C 风格字符串,表示类型的名称。名称的具体格式(例如是否包含命名空间、是否经过“修饰”/mangled)没有统一标准。
  • operator==: 可以比较两个 type_info 对象是否代表同一类型。
  • operator!=: 可以比较两个 type_info 对象是否代表不同类型。
  • before(): 用于确定一个类型在编译器的内部排序顺序中是否位于另一个类型之前(用途较少)。

注意:

  • 要使用 typeidtype_info,需要包含 <typeinfo> 头文件。
  • 如果 typeid 的操作数是一个**空指针 (nullptr)**,它会抛出 std::bad_typeid 异常。
  • 如果 typeid 用于非多态类型(没有虚函数)的指针解引用,它返回的是指针的静态类型信息,而不是实际指向对象的类型信息。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
#include <iostream>
#include <typeinfo> // for typeid, type_info
#include <cstdlib>
#include <ctime>

// 使用上面定义的 Grand, Superb, Magnificent 类和 GetOne() 函数

int main() {
std::srand(std::time(0));
Grand * pg = nullptr;

for (int i = 0; i < 5; i++) {
pg = GetOne();
std::cout << "Pointer pg type (static): " << typeid(pg).name() << std::endl; // 指针本身的类型
if (pg != nullptr) {
// 对指针解引用,获取运行时类型信息 (因为 Grand 有虚函数)
std::cout << "Object pointed to by pg (runtime): " << typeid(*pg).name() << std::endl;

// 比较类型信息
if (typeid(*pg) == typeid(Grand)) {
std::cout << " It's exactly a Grand object.\n";
} else if (typeid(*pg) == typeid(Superb)) {
std::cout << " It's exactly a Superb object.\n";
} else if (typeid(*pg) == typeid(Magnificent)) {
std::cout << " It's exactly a Magnificent object.\n";
}

// 检查是否是某种类型或其派生类 (通常 dynamic_cast 更适合)
// 注意:typeid 比较的是精确类型,不会考虑继承关系
if (dynamic_cast<Superb*>(pg)) { // 检查是否是 Superb 或其派生类
std::cout << " It's a Superb or derived from Superb.\n";
}

delete pg;
pg = nullptr;
}
std::cout << "--------------------\n";
}

// 对空指针使用 typeid 会抛异常
try {
std::cout << "Type of *nullptr: " << typeid(*pg).name() << std::endl;
} catch (const std::bad_typeid& bt) {
std::cerr << "Caught bad_typeid exception: " << bt.what() << std::endl;
}

return 0;
}

dynamic_cast vs typeid:

  • dynamic_cast 主要用于安全的类型转换,并检查对象是否属于某个类型或其派生类。
  • typeid 主要用于获取对象的精确类型信息并进行比较。它不直接用于类型转换。

在需要根据类型执行不同操作时,如果这些操作可以通过虚函数实现,则优先使用虚函数。如果必须进行向下转换,dynamic_cast 通常是比 typeid 结合 static_cast 更安全的选择。

RTTI 和编译器选项:

某些编译器可能提供禁用 RTTI 的选项,以减少代码大小或运行时开销。如果禁用了 RTTI,dynamic_casttypeid 的行为可能会改变或导致编译错误。

15.5 类型转换运算符

C++ 继承了 C 语言的类型转换语法(例如 (TypeName) expressionTypeName(expression))。然而,这种 C 风格的强制类型转换过于粗放,难以在代码中查找,并且无法区分不同类型的转换意图(例如,是去除 const 属性,还是在相关类型间转换,或是完全重新解释比特位)。

为了提供更安全、更明确的类型转换方式,C++ 引入了四个**类型转换运算符 (Type Cast Operators)**:

  • static_cast<>()
  • const_cast<>()
  • reinterpret_cast<>()
  • dynamic_cast<>() (已在 15.4 RTTI 中详细介绍)

这些运算符具有统一的语法格式:cast_name<TargetType>(expression)。它们使转换意图更加清晰,并且允许编译器进行更严格的检查。

1. static_cast<TargetType>(expression)

static_cast 用于比较“自然”和“合理”的类型转换,主要是在相关类型之间进行转换,或者执行编译器能够理解的标准转换。它在编译时进行类型检查。

主要用途:

  • 相关类型转换:
    • 在类层次结构中进行向上转换(派生类指针/引用 -> 基类指针/引用)。这是安全的,虽然通常隐式转换即可。
    • 在类层次结构中进行向下转换(基类指针/引用 -> 派生类指针/引用)。不安全! static_cast 不进行运行时类型检查。如果基类指针实际指向的不是目标派生类对象,使用转换后的指针会导致未定义行为。只有当你确定指针确实指向目标类型时,才应使用 static_cast 进行向下转换(dynamic_cast 是更安全的选择)。
  • 基本数据类型转换: 在数字类型之间进行转换(如 intfloat, doubleint)。
  • 枚举与整型转换: 在枚举类型和整型或浮点类型之间转换。
  • void* 转换: 将任何类型的指针转换为 void*,或将 void* 转换回原始类型的指针(或兼容类型的指针)。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
#include <iostream>

class Base { public: virtual ~Base() {} }; // 多态基类
class Derived : public Base {};

int main() {
// 基本类型转换
double pi = 3.14159;
int integer_pi = static_cast<int>(pi); // double to int (截断)
std::cout << "integer_pi: " << integer_pi << std::endl; // 输出 3

// 向上转换 (安全)
Derived d;
Base* pb = &d; // 隐式转换即可
Base* pb_static = static_cast<Base*>(&d); // 显式向上转换

// 向下转换 (不安全,需程序员保证)
Base* pb_maybe_d = new Derived();
// 假设我们确定 pb_maybe_d 指向 Derived
Derived* pd_static = static_cast<Derived*>(pb_maybe_d);
std::cout << "Static downcast successful (programmer assumed).\n";
delete pb_maybe_d;

Base* pb_not_d = new Base();
// !! 危险 !! pb_not_d 实际指向 Base,但我们强制转为 Derived*
// Derived* pd_wrong = static_cast<Derived*>(pb_not_d);
// pd_wrong->someDerivedMethod(); // *** 未定义行为 ***
delete pb_not_d;

// void* 转换
int i = 10;
void* vp = static_cast<void*>(&i); // int* to void*
int* ip = static_cast<int*>(vp); // void* back to int*
std::cout << "Value via void*: " << *ip << std::endl; // 输出 10

return 0;
}

2. const_cast<TargetType>(expression)

const_cast 是唯一能够移除 (cast away)添加 constvolatile 限定符的 C++ 转换运算符。

主要用途:

  • 移除 const: 当你有一个指向 const 数据的指针或引用,但你需要调用一个不接受 const 参数(但实际上不会修改数据)的函数时,可以使用 const_cast 临时移除 const 属性。
  • 添加 const: 虽然不常用,但也可以用来添加 const 属性。

重要警告:

  • const_cast 只能改变指针或引用的 const/volatile 属性,不能改变表达式的类型。例如,不能用 const_castconst char* 转换为 int*
  • 通过 const_cast 移除 const 属性后,如果原始对象本身就是 const 的,那么试图通过转换后的指针或引用去修改该对象的值,将导致**未定义行为 (Undefined Behavior)**!const_cast 主要用于处理接口不匹配的情况,而不是用来破坏常量性。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
#include <iostream>

// 假设有一个旧的 C 函数,它接受 char* 但保证不修改内容
void legacy_print(char* str) {
std::cout << "Legacy print: " << str << std::endl;
}

int main() {
const char* const_message = "Hello";

// 调用 legacy_print 需要 char*,但我们只有 const char*
// 我们知道 legacy_print 不会修改,所以使用 const_cast
char* non_const_message = const_cast<char*>(const_message);
legacy_print(non_const_message); // OK

const int constant_value = 100;
const int* p_const = &constant_value;

// 尝试移除 const 并修改
int* p_non_const = const_cast<int*>(p_const);
// *p_non_const = 200; // *** 未定义行为!*** 因为 constant_value 本身是 const

int variable_value = 50;
const int* p_const_var = &variable_value;
int* p_non_const_var = const_cast<int*>(p_const_var);
*p_non_const_var = 60; // OK,因为 variable_value 本身不是 const
std::cout << "variable_value: " << variable_value << std::endl; // 输出 60

return 0;
}

3. reinterpret_cast<TargetType>(expression)

reinterpret_cast 用于执行低级别的、可能不安全的类型转换。它本质上只是要求编译器重新解释表达式的**比特模式 (bit pattern)**,将其视为 TargetType 类型。它很少进行实际的转换,更多的是一种编译时的指令。

主要用途:

  • 指针与整型转换: 在指针类型和足够大的整型(如 uintptr_t)之间进行转换。
  • 不相关指针类型转换: 在不相关的指针类型之间进行转换(例如,int*char*)。这是非常危险的操作,通常只在需要对原始内存进行底层操作时使用。
  • 函数指针转换: 在不同的函数指针类型之间转换(同样非常危险)。

重要警告:

  • reinterpret_cast 的行为是高度依赖于具体实现和平台的。
  • 使用 reinterpret_cast 进行的转换几乎总是不可移植的。
  • 滥用 reinterpret_cast 极易导致未定义行为和难以调试的错误。
  • 它应该只在绝对必要且完全理解其后果的情况下使用,通常用于与底层硬件或旧的 C 代码交互。

示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
#include <iostream>
#include <cstdint> // for uintptr_t

struct Data {
int a;
double b;
};

int main() {
int i = 65; // ASCII 'A'

// 指针类型转换 (危险)
int* pi = &i;
// 将 int* 重新解释为 char*
char* pc = reinterpret_cast<char*>(pi);
// 结果取决于系统字节序 (endianness)
std::cout << "Reinterpreted char: " << *pc << std::endl; // 可能输出 'A' 或其他

// 指针与整数转换
uintptr_t addr_val = reinterpret_cast<uintptr_t>(pi); // 指针转整数
std::cout << "Address as integer: " << std::hex << addr_val << std::endl;
int* pi_back = reinterpret_cast<int*>(addr_val); // 整数转回指针
std::cout << "Value via int->ptr->int: " << std::dec << *pi_back << std::endl; // 输出 65

// 不相关指针类型转换 (非常危险)
Data d = {1, 3.14};
Data* pd = &d;
// 将 Data* 重新解释为 int*
int* p_int_from_data = reinterpret_cast<int*>(pd);
// 访问 *p_int_from_data 通常会得到 d.a 的值 (取决于内存布局)
std::cout << "Reinterpreted int from Data*: " << *p_int_from_data << std::endl; // 可能输出 1

return 0;
}

4. dynamic_cast<TargetType>(expression)

dynamic_cast 用于在具有虚函数的类层次结构中进行安全的向下转换。它在运行时检查转换的有效性。

  • 指针转换: 如果转换无效,返回 nullptr
  • 引用转换: 如果转换无效,抛出 std::bad_cast 异常。

(详细内容请参考 15.4 RTTI)

总结比较

运算符 主要用途 安全性 运行时检查 const/volatile 备注
static_cast 相关类型转换、数值转换、void* 转换 相对安全 不能移除 向下转换不安全
const_cast 移除或添加 const/volatile 低 (易未定义行为) 唯一能操作 不能改变基本类型,修改 const 对象是未定义行为
reinterpret_cast 低级别位模式重新解释、指针整数互转、不相关指针互转 非常低 不能移除 依赖实现、不可移植、极易出错
dynamic_cast 安全的向下转换(多态类型) 不能移除 需要虚函数,失败时返回 nullptr 或抛异常

使用原则:

  • 优先使用隐式转换和虚函数。
  • 当需要显式转换时,选择意图最明确、限制最严格的转换运算符。
  • 尽量使用 static_cast 进行“合理”的转换。
  • 只在需要改变 const/volatile 属性时使用 const_cast,并确保不修改原始 const 对象。
  • 避免使用 reinterpret_cast,除非绝对必要且完全理解后果。
  • 在多态类型向下转换时,优先使用 dynamic_cast 以确保安全。

15.6 总结

本章探讨了 C++ 中一些高级特性和处理特殊情况的技术,包括友元、嵌套类、异常处理和运行时类型识别(RTTI)以及类型转换运算符。这些工具提供了更精细的控制和更强大的错误处理能力。

主要内容回顾:

  1. 友元 (Friends):

    • 允许特定的外部函数(友元函数)、类(友元类)或成员函数(友元成员函数)访问一个类的 privateprotected 成员。
    • 通过在宿主类中使用 friend 关键字声明。
    • 友元关系不是传递的,也不是对称的。
    • 友元破坏了封装性,应谨慎使用,通常用于重载运算符(如 <<)或实现紧密协作的类。
  2. 嵌套类 (Nested Classes):

    • 在一个类(外围类)内部定义的类。
    • 作用域局限于外围类,外部访问需使用作用域解析符 (Enclosing::Nested)。
    • 嵌套类可以访问外围类的所有成员(通过对象、指针或引用)。
    • 外围类访问嵌套类成员受嵌套类自身访问控制限制。
    • 常用于实现与外围类相关的辅助类或隐藏实现细节。
  3. 异常处理 (Exceptions):

    • 一种结构化的错误处理机制,用于处理运行时发生的异常情况。
    • 使用 try 块包围可能抛出异常的代码,throw 语句抛出异常,catch 块捕获并处理异常。
    • 相比错误码或 abort(),异常能更好地分离错误检测和处理,自动沿调用栈传播。
    • 推荐使用类对象(最好继承自 std::exception)作为异常类型,可以携带更多信息并利用继承进行分类处理。
    • 栈解退 (Stack Unwinding): 异常发生时,局部对象按构造相反顺序销毁,其析构函数被调用,是 RAII 实现资源安全的关键。
    • noexcept (C++11): 用于声明函数保证不抛出异常,有助于优化和异常安全保证。析构函数应为 noexcept
    • throw; 用于在 catch 块中重新抛出当前异常。
    • catch(...) 用于捕获任何类型的异常。
    • 异常处理有性能开销,适用于处理异常而非常规流程。
  4. RTTI (运行时类型识别):

    • 允许程序在运行时查询对象的实际类型信息。
    • 主要用于具有虚函数的多态类层次结构。
    • dynamic_cast<>: 用于安全的向下转换。对指针转换失败返回 nullptr;对引用转换失败抛出 std::bad_cast
    • typeid(): 返回对象的类型信息 (std::type_info 对象),可用于比较精确类型。对空指针操作抛出 std::bad_typeid
    • std::type_info: 提供 name() 方法获取类型名称(格式依赖实现)和比较运算符。
    • 应优先使用虚函数实现多态,RTTI 作为补充或特定场景下的解决方案。
  5. 类型转换运算符:

    • C++ 提供了四个显式类型转换运算符,比 C 风格转换更安全、意图更明确。
    • static_cast<>: 用于相关的、编译时可检查的转换(数值、向上转换、不安全的向下转换、void*)。
    • const_cast<>: 唯一能移除或添加 const/volatile 的转换符。修改原始 const 对象是未定义行为。
    • reinterpret_cast<>: 低级别位模式重新解释,用于不相关指针转换、指针整数互转等。非常危险,不可移植。
    • dynamic_cast<>: 用于多态类型安全的运行时向下转换。

本章介绍的特性为处理类间关系、错误恢复和类型查询提供了更多工具,但也带来了复杂性。理解它们的原理、适用场景和潜在风险对于编写健壮、高效的 C++ 程序非常重要。

评论