15.1 友元
通常,类的 private 和 protected 成员只能被该类的成员函数访问。这是 C++ 封装性的体现,有助于保护数据和隐藏实现细节。然而,在某些特殊情况下,允许特定的外部函数或类访问一个类的私有或保护成员会非常方便。C++ 提供了友元 (friend) 机制来实现这种受控的访问。
友元可以是:
- 友元函数 (Friend Function): 一个非成员函数被声明为某个类的友元。
- 友元类 (Friend Class): 一个类被声明为另一个类的友元。
- 友元成员函数 (Friend Member Function): 某个类的成员函数被声明为另一个类的友元。
声明友元:
在需要授予访问权限的类(我们称之为宿主类)的定义内部,使用 friend 关键字来声明友元。
1 | class HostClass { |
重要特性:
- 访问权限: 友元函数或友元类(及其所有成员函数)可以访问宿主类的所有成员,包括
private和protected成员。 - 非传递性: 友元关系不是传递的。如果类 A 是类 B 的友元,类 B 是类 C 的友元,这并不意味着类 A 是类 C 的友元。
- 非对称性: 友元关系不是对称的。如果类 A 是类 B 的友元,这并不意味着类 B 是类 A 的友元。
- 声明位置:
friend声明可以放在类定义的public,protected, 或private部分,效果是相同的。通常习惯放在类定义的开始或结束处。
15.1.1 友元类
当一个类被声明为另一个类的友元时,这个友元类的所有成员函数都可以访问宿主类的私有和保护成员。
示例: 假设有一个 Tv 类(电视)和一个 Remote 类(遥控器)。遥控器需要能够直接调整电视的状态(如频道、音量),即使这些状态是私有的。
1 | // tv.h -- Tv and Remote classes |
在这个例子中,Remote 类被声明为 Tv 的友元,因此 Remote 的成员函数(如 set_chan)可以直接访问 Tv 对象的私有成员 channel。
15.1.2 友元成员函数
有时,我们不需要让整个类成为友元,只需要让某个类的特定成员函数成为另一个类的友元。
声明语法:
1 | class HostClass { |
编译顺序和前向声明:
声明友元成员函数时需要特别注意编译顺序和前向声明:
- 定义提供友元成员函数的类 (OtherClass): 编译器需要先知道
OtherClass的完整定义,才能处理其中的memberFunc。 - 定义宿主类 (HostClass): 在
HostClass中声明OtherClass::memberFunc为友元。 - 定义友元成员函数 (OtherClass::memberFunc): 这个函数的实现需要看到
HostClass的完整定义,因为它需要访问HostClass的私有/保护成员。
这通常需要使用**前向声明 (Forward Declaration)**。
示例: 让 Remote::set_chan 成为 Tv 的友元,而不是整个 Remote 类。
1 | // tvfm.h -- Tv and Remote classes using a friend member |
在这个修改后的版本中,只有 Remote::set_chan 函数可以访问 Tv 的私有成员,而 Remote 的其他成员函数则不能(除非它们只调用 Tv 的公有方法)。这提供了比友元类更精细的访问控制。
15.1.3 其他友元关系
相互友元 (Mutual Friends): 两个类可以互为友元。
1 | class ClassB; // 前向声明 |
将友元函数放在何处:
- 如果友元函数只访问类的公有接口,它可以是普通非成员函数。
- 如果友元函数需要访问类的私有/保护成员,它必须被声明为友元。
- 如果一个函数需要访问两个不同类的私有/保护成员,那么它需要被这两个类都声明为友元。
15.1.4 共同的友元
一个函数可以是多个类的友元。
1 | class ClassC; // 前向声明 |
sharedFriend 函数可以同时访问 ClassC 和 ClassD 的私有成员。
使用友元的时机:
友元破坏了类的封装性,因为它允许外部代码直接访问内部实现细节。因此,应该谨慎使用友元。
- 何时考虑使用:
- 重载运算符: 尤其是需要访问两个不同类对象内部数据(如
operator<<输出流操作符)或左操作数不是类对象的情况。 - 紧密协作的类: 当两个或多个类在概念上紧密耦合,需要高效地共享信息时(如
Tv和Remote)。 - 底层实现: 在某些底层库或框架中,为了性能或实现特定功能可能需要友元。
- 重载运算符: 尤其是需要访问两个不同类对象内部数据(如
- 替代方案: 在使用友元之前,考虑是否可以通过扩展类的公有接口(添加访问器或功能函数)来满足需求。
友元提供了一种绕过访问控制的机制,但应作为最后的手段,而不是常规设计工具。
15.2 嵌套类
C++ 允许在一个类中定义另一个类,这种在类内部定义的类称为嵌套类 (Nested Class) 或内部类 (Inner Class)**。包含嵌套类的类称为外围类 (Enclosing Class)** 或**外部类 (Outer Class)**。
目的:
嵌套类主要用于实现与外围类紧密相关的辅助类或数据结构,有助于将实现细节封装在外围类内部,提高代码的组织性和局部性。例如,链表或树结构的节点 (Node) 通常只为特定的容器类服务,将其嵌套在容器类内部就很自然。
语法:
1 | class EnclosingClass { |
15.2.1 嵌套类和访问权限
嵌套类的访问权限遵循以下规则:
作用域:
- 嵌套类的名称作用域仅限于其外围类。在外部引用嵌套类时,必须使用外围类的名称和作用域解析运算符 (
::),例如EnclosingClass::NestedClassPublic。 - 嵌套类的声明位置(
public,protected,private)决定了外部代码是否以及如何能够引用该嵌套类类型本身。-
public: 外部代码可以使用EnclosingClass::NestedClassPublic。 -
protected: 只有EnclosingClass及其派生类可以使用EnclosingClass::NestedClassProtected。 -
private: 只有EnclosingClass内部可以使用NestedClassPrivate。
-
- 嵌套类的名称作用域仅限于其外围类。在外部引用嵌套类时,必须使用外围类的名称和作用域解析运算符 (
嵌套类对外围类的访问:
- 嵌套类的成员函数可以访问外围类的所有成员(
public,protected,private),包括类型名、静态成员、枚举常量等。 - 重要: 嵌套类访问外围类的非静态成员时,必须通过外围类的对象、指针或引用来进行。嵌套类对象本身不包含一个指向其外围类对象的隐式指针(不像 Java 的内部类)。
- 嵌套类的成员函数可以访问外围类的所有成员(
外围类对嵌套类的访问:
- 外围类的成员函数可以创建嵌套类的对象。
- 外围类对其嵌套类的成员的访问权限,遵循嵌套类自身的访问控制规则(
public,protected,private)。仅仅因为一个类是嵌套的,并不意味着外围类可以无视其访问控制。外围类不能直接访问嵌套类的private成员(除非外围类是嵌套类的友元)。
示例:链式队列中的 Node 嵌套类
回顾第 12 章的队列模拟,Queue 类内部定义了一个 Node 结构。这就是一个典型的嵌套类(或结构)应用。
1 | // queue.h (部分) |
在这个例子中:
-
Node的作用域仅限于Queue类。外部代码不能直接使用Node类型。 -
Queue的成员函数(如构造函数、析构函数、enqueue)可以自由地创建Node对象,并访问其成员item和next(因为struct成员默认是public的,并且Node在Queue的作用域内)。
15.2.2 模板中的嵌套
嵌套类也可以在类模板中定义。
1 | template <typename T> |
- 当外围类是模板时,嵌套类的定义通常也依赖于外围类的模板参数(如
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 |
|
缺点:
- 程序突然终止,用户可能不知道原因。
- 没有机会进行清理工作(如保存数据、关闭文件、释放资源)。
15.3.2 返回错误码
一种更常见的做法是让函数在出错时返回一个特殊的错误码(例如 0, -1, false 或 nullptr),调用者负责检查这个返回值并采取相应措施。
1 |
|
缺点:
- 调用者必须检查: 调用者很容易忘记检查错误码,导致错误被忽略。
- 错误码混淆: 函数的正常返回值可能与错误码冲突。
- 错误信息有限: 单个错误码可能不足以描述错误的具体原因。
- 错误传递复杂: 在深层嵌套的函数调用中,每一层都需要检查并向上传递错误码,使代码冗长且容易出错。
15.3.3 异常机制
C++ 异常处理提供了一种更结构化、更强大的错误处理方式,它将错误检测(在发生错误的地方)与错误处理(在能够处理该错误的地方)分离开来。
它主要涉及三个关键字:
-
throw: 当函数检测到无法处理的错误时,使用throw关键字引发 (throw) 或 抛出 (raise) 一个异常。throw后面跟着一个表达式,该表达式的值(称为异常对象)的类型决定了异常的类型。 -
try: 将可能引发异常的代码块(包括函数调用)放在try块中。try关键字后面跟着一个花括号{}包围的代码块。 -
catch: 紧跟在try块之后,使用一个或多个catch块来捕获 (catch) 和处理 (handle) 异常。每个catch块指定它能处理的异常类型。
基本流程:
- 程序执行进入
try块。 - 如果在
try块中的代码(或其调用的任何函数)执行了throw语句,一个异常被引发。 - 程序立即跳出当前的
try块(以及从try块开始到throw点之间的所有函数调用栈),开始查找匹配的catch块。 - 程序按顺序检查紧跟在
try块后面的catch块。 - 如果找到一个
catch块,其声明的异常类型与抛出的异常对象类型匹配(或者是其基类,或者是catch(...)),则执行该catch块中的代码。 - 执行完匹配的
catch块后,程序继续执行该catch块之后的代码(除非catch块本身又抛出异常或终止程序)。 - 如果在当前
try...catch结构中没有找到匹配的catch块,异常会继续向外层传播,查找包含当前try块的更外层try块对应的catch块。 - 如果异常一直传播到
main函数之外(即没有在任何地方被捕获),程序通常会调用std::terminate()异常终止。
示例:
1 |
|
优点:
- 分离错误处理: 将错误处理代码与正常逻辑分开,使代码更清晰。
- 强制处理 (某种程度上): 未捕获的异常通常会导致程序终止,迫使开发者考虑错误处理。
- 自动传播: 异常会自动沿着调用栈向上传播,直到找到合适的处理程序,无需在每层函数手动传递错误码。
- 类型安全: 可以根据异常对象的类型来区分不同的错误,并进行相应的处理。
- 资源清理: 结合 RAII(资源获取即初始化),异常处理可以确保在异常发生时自动释放资源(通过栈解退时调用局部对象的析构函数)。
15.3.4 将对象用作异常类型
throw 语句可以抛出任何类型的表达式结果,包括基本类型(如 int, const char*)或类类型的对象。
强烈建议使用类类型的对象作为异常类型,原因如下:
- 携带更多信息: 对象可以包含多个数据成员,携带关于错误的更丰富信息(错误码、错误描述、发生位置等)。
- 类型层次: 可以利用类的继承关系来组织异常类型。
catch块可以捕获基类类型的异常,从而处理该基类及其所有派生类的异常。这允许我们编写更通用的错误处理代码。
示例:自定义异常类
1 |
|
捕获顺序: 当使用继承层次结构的异常类时,catch 块的顺序非常重要。应该将派生类的 catch 块放在基类 catch 块之前。否则,基类 catch 块会首先捕获到派生类异常,导致派生类的特定处理逻辑无法执行。
按引用捕获: 推荐使用 const 引用 (const ExceptionType& e) 来捕获异常。
- 避免对象**切片 (slicing)**:如果按值捕获基类异常,当抛出的是派生类对象时,派生类特有的部分会丢失。
- 避免复制开销。
- 使用
const引用表明处理程序不打算修改捕获到的异常对象。
15.3.5 异常规范和 C++11 (throw(), noexcept)
早期 C++ 允许使用异常规范 (Exception Specifications) 来声明函数可能抛出哪些类型的异常。
1 | void func1() throw(BadArgument, DomainError); // 可能抛出 BadArgument 或 DomainError |
问题: 异常规范在实践中被证明效果不佳且难以维护。
- 编译器通常只在运行时检查,而不是编译时。
- 如果函数抛出了未在规范中列出的异常,程序的行为是调用
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 |
|
注意: 如果在栈解退过程中,某个对象的析构函数自身抛出了异常,而此时已经有一个异常正在处理中,程序会调用 std::terminate()。因此,析构函数应该避免抛出异常(通常应声明为 noexcept)。
15.3.7 其他异常特性
重新抛出异常 (
throw;): 在catch块内部,可以使用不带任何操作数的throw;语句将当前捕获到的异常重新抛出,交由外层的catch块处理。这允许一个catch块执行部分处理(如记录日志),然后将异常传递给更高层进行进一步处理。1
2
3
4
5
6
7
8catch (const MyException& e) {
log_error(e.what()); // 记录错误
if (can_handle_partially(e)) {
// ... 部分处理 ...
} else {
throw; // 重新抛出原始异常,让外层处理
}
}捕获所有异常 (
catch(...)):catch(...)可以捕获任何类型的异常。它通常放在所有其他catch块的最后,用于进行最终的清理或记录未知错误。在catch(...)块内部无法知道捕获到的异常的具体类型和信息(除非重新抛出给外层)。1
2
3
4
5catch (...) {
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 |
|
标准异常类层次结构(部分):
1 | std::exception |
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 是实现异常安全的关键技术。函数通常追求以下几种异常安全保证级别(从弱到强):
- 基本保证 (Basic Guarantee): 操作失败时,对象保持在某个有效状态(不一定和操作前相同),没有资源泄漏。
- 强保证 (Strong Guarantee): 操作失败时,对象的状态回滚到操作开始之前的状态(事务性)。
- 不抛出保证 (Nothrow Guarantee): 操作保证不会抛出任何异常 (
noexcept)。
- 析构函数: 绝对不要让析构函数抛出异常。
- 构造函数: 谨慎处理构造函数中的异常,使用 RAII 管理资源。
- 何时使用: 异常最适合处理那些阻止函数完成其预期任务的、不常见的错误情况,特别是当错误发生在深层嵌套调用中,需要将错误信息传递给高层调用者时。
异常处理是 C++ 中一个强大的错误处理工具,但也需要谨慎使用,并结合 RAII 等技术来确保程序的健壮性和资源的正确管理。
15.4 RTTI(运行时类型识别)
RTTI 是 Runtime Type Identification 的缩写,即运行时类型识别。它是 C++ 的一项机制,允许程序在运行时发现和使用对象的实际类型信息。
通常,我们通过基类指针或引用来操作派生类对象(多态)。在这种情况下,代码只关心对象是否符合基类定义的接口。但有时,我们可能需要知道指针或引用实际指向的对象的确切派生类型,以便执行该派生类特有的操作。RTTI 就是为了解决这类问题而设计的。
RTTI 主要通过以下三个元素实现:
-
dynamic_cast<>运算符: 用于在类层次结构中进行**安全的向下转换 (Downcasting)**。它可以将基类指针或引用转换为派生类指针或引用,并在转换无效时提供明确的失败指示。 -
typeid()运算符: 返回一个指向std::type_info对象的引用,该对象包含了关于操作数类型的信息(如类型名称)。 -
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_pointer是nullptr,则转换失败,返回 **nullptr**。
- 如果
- 引用转换:
- 如果
source_reference确实引用一个TargetType类型的对象(或者是TargetType的派生类对象),则转换成功,返回对该对象的TargetType&引用。 - 如果
source_reference引用的对象不是TargetType类型(或其派生类),则转换失败,抛出std::bad_cast异常(定义在<typeinfo>中)。
- 如果
- 前提: 基类必须包含至少一个虚函数(即是多态基类),
示例:
1 |
|
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(): 用于确定一个类型在编译器的内部排序顺序中是否位于另一个类型之前(用途较少)。
注意:
- 要使用
typeid和type_info,需要包含<typeinfo>头文件。 - 如果
typeid的操作数是一个**空指针 (nullptr)**,它会抛出std::bad_typeid异常。 - 如果
typeid用于非多态类型(没有虚函数)的指针解引用,它返回的是指针的静态类型信息,而不是实际指向对象的类型信息。
示例:
1 |
|
dynamic_cast vs typeid:
-
dynamic_cast主要用于安全的类型转换,并检查对象是否属于某个类型或其派生类。 -
typeid主要用于获取对象的精确类型信息并进行比较。它不直接用于类型转换。
在需要根据类型执行不同操作时,如果这些操作可以通过虚函数实现,则优先使用虚函数。如果必须进行向下转换,dynamic_cast 通常是比 typeid 结合 static_cast 更安全的选择。
RTTI 和编译器选项:
某些编译器可能提供禁用 RTTI 的选项,以减少代码大小或运行时开销。如果禁用了 RTTI,dynamic_cast 和 typeid 的行为可能会改变或导致编译错误。
15.5 类型转换运算符
C++ 继承了 C 语言的类型转换语法(例如 (TypeName) expression 或 TypeName(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是更安全的选择)。
- 基本数据类型转换: 在数字类型之间进行转换(如
int到float,double到int)。 - 枚举与整型转换: 在枚举类型和整型或浮点类型之间转换。
-
void*转换: 将任何类型的指针转换为void*,或将void*转换回原始类型的指针(或兼容类型的指针)。
示例:
1 |
|
2. const_cast<TargetType>(expression)
const_cast 是唯一能够移除 (cast away) 或添加 const 或 volatile 限定符的 C++ 转换运算符。
主要用途:
- 移除
const: 当你有一个指向const数据的指针或引用,但你需要调用一个不接受const参数(但实际上不会修改数据)的函数时,可以使用const_cast临时移除const属性。 - 添加
const: 虽然不常用,但也可以用来添加const属性。
重要警告:
-
const_cast只能改变指针或引用的const/volatile属性,不能改变表达式的类型。例如,不能用const_cast将const char*转换为int*。 - 通过
const_cast移除const属性后,如果原始对象本身就是const的,那么试图通过转换后的指针或引用去修改该对象的值,将导致**未定义行为 (Undefined Behavior)**!const_cast主要用于处理接口不匹配的情况,而不是用来破坏常量性。
示例:
1 |
|
3. reinterpret_cast<TargetType>(expression)
reinterpret_cast 用于执行低级别的、可能不安全的类型转换。它本质上只是要求编译器重新解释表达式的**比特模式 (bit pattern)**,将其视为 TargetType 类型。它很少进行实际的转换,更多的是一种编译时的指令。
主要用途:
- 指针与整型转换: 在指针类型和足够大的整型(如
uintptr_t)之间进行转换。 - 不相关指针类型转换: 在不相关的指针类型之间进行转换(例如,
int*到char*)。这是非常危险的操作,通常只在需要对原始内存进行底层操作时使用。 - 函数指针转换: 在不同的函数指针类型之间转换(同样非常危险)。
重要警告:
-
reinterpret_cast的行为是高度依赖于具体实现和平台的。 - 使用
reinterpret_cast进行的转换几乎总是不可移植的。 - 滥用
reinterpret_cast极易导致未定义行为和难以调试的错误。 - 它应该只在绝对必要且完全理解其后果的情况下使用,通常用于与底层硬件或旧的 C 代码交互。
示例:
1 |
|
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)以及类型转换运算符。这些工具提供了更精细的控制和更强大的错误处理能力。
主要内容回顾:
友元 (Friends):
- 允许特定的外部函数(友元函数)、类(友元类)或成员函数(友元成员函数)访问一个类的
private和protected成员。 - 通过在宿主类中使用
friend关键字声明。 - 友元关系不是传递的,也不是对称的。
- 友元破坏了封装性,应谨慎使用,通常用于重载运算符(如
<<)或实现紧密协作的类。
- 允许特定的外部函数(友元函数)、类(友元类)或成员函数(友元成员函数)访问一个类的
嵌套类 (Nested Classes):
- 在一个类(外围类)内部定义的类。
- 作用域局限于外围类,外部访问需使用作用域解析符 (
Enclosing::Nested)。 - 嵌套类可以访问外围类的所有成员(通过对象、指针或引用)。
- 外围类访问嵌套类成员受嵌套类自身访问控制限制。
- 常用于实现与外围类相关的辅助类或隐藏实现细节。
异常处理 (Exceptions):
- 一种结构化的错误处理机制,用于处理运行时发生的异常情况。
- 使用
try块包围可能抛出异常的代码,throw语句抛出异常,catch块捕获并处理异常。 - 相比错误码或
abort(),异常能更好地分离错误检测和处理,自动沿调用栈传播。 - 推荐使用类对象(最好继承自
std::exception)作为异常类型,可以携带更多信息并利用继承进行分类处理。 - 栈解退 (Stack Unwinding): 异常发生时,局部对象按构造相反顺序销毁,其析构函数被调用,是 RAII 实现资源安全的关键。
-
noexcept(C++11): 用于声明函数保证不抛出异常,有助于优化和异常安全保证。析构函数应为noexcept。 -
throw;用于在catch块中重新抛出当前异常。 -
catch(...)用于捕获任何类型的异常。 - 异常处理有性能开销,适用于处理异常而非常规流程。
RTTI (运行时类型识别):
- 允许程序在运行时查询对象的实际类型信息。
- 主要用于具有虚函数的多态类层次结构。
-
dynamic_cast<>: 用于安全的向下转换。对指针转换失败返回nullptr;对引用转换失败抛出std::bad_cast。 -
typeid(): 返回对象的类型信息 (std::type_info对象),可用于比较精确类型。对空指针操作抛出std::bad_typeid。 -
std::type_info: 提供name()方法获取类型名称(格式依赖实现)和比较运算符。 - 应优先使用虚函数实现多态,RTTI 作为补充或特定场景下的解决方案。
类型转换运算符:
- C++ 提供了四个显式类型转换运算符,比 C 风格转换更安全、意图更明确。
-
static_cast<>: 用于相关的、编译时可检查的转换(数值、向上转换、不安全的向下转换、void*)。 -
const_cast<>: 唯一能移除或添加const/volatile的转换符。修改原始const对象是未定义行为。 -
reinterpret_cast<>: 低级别位模式重新解释,用于不相关指针转换、指针整数互转等。非常危险,不可移植。 -
dynamic_cast<>: 用于多态类型安全的运行时向下转换。
本章介绍的特性为处理类间关系、错误恢复和类型查询提供了更多工具,但也带来了复杂性。理解它们的原理、适用场景和潜在风险对于编写健壮、高效的 C++ 程序非常重要。