通常,C++支持四种显式类型转换(称之为强制转换) —— static_cast、dynamic_cast、const_cast和reinterpret_cast。C++11新增了第五种duration_cast,虽然与本书主题关联不大,但有时会在示例中出现(特别是在测量函数执行时间时)。最后,C++20引入了第6种bit_cast,这对本书讨论的内容具有重要意义。
以下各节将简要介绍每种C++强制转换,并举例说明使用场景和实际价值。
类型转换工具集中最好用、最高效的就是static_cast。它基本安全,大多数情况下没有什么开销,而且能在constexpr上下文中使用,这使得它非常适合编译时的操作。
可以在涉及潜在风险的场景中使用static_cast,比如将int转换为float或者反过来。后一种情况下,能明确表明小数部分的丢失。也可以用static_cast将指针或引用从派生类转换到其直接或间接基类(只要没有歧义),这种转换完全安全且可以隐式完成;同样也能从基类转换到派生类。使用static_cast从基类向派生类转换效率极高。因为不会执行运行时检查,当转换错误时,风险极大,。
以下是几个示例:
struct B { virtual ~B() = default; /* ... */ };
struct D0 : B { /* ... */ };
struct D1 : B { /* ... */ };
class X {
public:
X(int, double);
};
void f(D0&);
void f(D1*);
int main() {
const float x = 3.14159f;
int n = static_cast<int>(x); // 没问题,也没警告
X x0{ 3, 3.5 }; // 没问题
// 可编译,可能警告(窄化转换)
X x1(3.5,0);
// 不可编译,不允许用大括号时进行窄化转换
// X x2{ 3.5, 0 };
X x3{ static_cast<int>(x), 3 }; // 没问题
D0 d0;
// 非法,与D0和D1没有基衍生关系
// D1* d1 = static_cast<D1*>(&d0);
// 好的,static_cast可以省略
B *b = static_cast<B*>(&d0);
// f(*b); // 非法
f(*static_cast<D0*>(b)); // 没问题
f(static_cast<D1*>(b)); // 可编译,但非常危险!
}
请特别注意最后一个static_cast的用法 —— 从基类向其派生类的转换确实应该使用static_cast,但必须确保转换结果确实是目标类型的对象。因为这种转换不会进行运行时验证,static_cast仅执行编译期检查。若不确定向下转型(downcast)是否安全,最好不要使用static_cast。
static_cast不仅能改变编译器对表达式类型的解读,还会根据转换涉及的类型调整实际访问的内存地址。当D类继承自两个非空基类B0和B1时,这两个基类在D对象中的地址必然不同(否则会产生重叠),因此从D*到某个基类的static_cast可能产生与原始指针不同的地址值。我们将在讨论reinterpret_cast时重提这点,后者的行为截然不同(且更加危险)。
某些情况下,可能持有一个类类型的指针或引用,而该类型与所需类型不同(但相关)。这种情况经常发生 —— 游戏引擎中,大多数类都派生自某个Component基类,而函数通常接受Component*参数,但需要访问其预期的派生类对象的成员。
这里的主要问题通常在于函数接口设计不当 —— 接受的参数类型不够精确。尽管如此,我们都需要交付软件,有时即使做出了一些需要重新审视的选择,也必须让代码正常工作。
进行这类转换的安全方式是使用dynamic_cast。这种转换允许以可测试转换是否成功的方式,将指针或引用从一种类型转换为另一种相关类型:对于指针,错误的转换会产生nullptr;而对于引用,错误的转换会抛出std::bad_cast。dynamic_cast的类型相关性不仅限于基类-派生类关系,还包括多重继承设计中从一个基类到另一个基类的转换。但在大多数情况下,dynamic_cast要求转换的表达式具有多态类型,即必须包含至少一个虚成员函数。
以下是几个示例:
struct B0 {
virtual int f() const = 0;
virtual ~B0() = default;
};
struct B1 {
virtual int g() const = 0;
virtual ~B1() = default;
};
class D0 : public B0 {
public: int f() const override { return 3; }
};
class D1 : public B1 {
public: int g() const override { return 4; }
};
class D : public D0, public D1 {};
int f(D *p) {
return p? p->f() + p->g() : -1; // Ok
}
// g 的接口不正确:它接受一个 D0& 类型的参数,但却试图将其当作 D1& 来使用。如果
// 引用的对象同时公开地继承自 D0 和 D1(例如,类 D 派生自 D0 和 D1),这样做才有意义。
int g(D0 &d0) {
D1 &d1 = dynamic_cast<D1&>(d0); // 如果错误则抛出异常
return d1.g();
}
#include <iostream>
int main() {
D d;
f(&d); // 没问题
g(d); // 没问题, D为D0
D0 d0;
// 调用 f(nullptr),因为 &d0 并不指向一个 D 类型的对象。
std::cout << f(dynamic_cast<D*>(&d0)) << '\n'; // -1
try {
g(d0); // 编译通过,但会抛出 bad_cast 异常。
} catch(std::bad_cast&) {
std::cerr << "Nice try\n";
}
}
虽然这个示例在抛出std::bad_cast时会显示消息,但这绝不能称为异常处理;“问题”依旧没有解决,代码仍可能在已损坏的状态下继续执行,这在更严肃的代码中可能造成更严重的后果。在这样简单的示例中,直接让代码失败并停止执行也是合理的选择。
实际上,dynamic_cast的使用应该很罕见,往往表明函数接口设计上还有改进空间。请注意dynamic_cast要求二进制文件包含运行时类型信息(RTTI),这会导致生成的文件体积增大。由于这些开销,某些应用领域往往会避免使用这种转换,我们也将遵循这一原则。
无论是static_cast、dynamic_cast还是reinterpret_cast,都无法改变表达式的cv限定符(const/volatile)。要完成这类操作,需要使用const_cast。通过const_cast,可以为表达式添加或移除const/volatile限定符 —— 这仅适用于指针或引用类型。
为什么要移除表达式的const限定呢?这种操作在许多场景下都非常实用。最常见的情况是:在const限定使用不当的遗留代码中(那些最初没有采用const正确性的旧代码),需要让const正确的类型与之兼容:
#include <vector>
struct ResourceHandle { /* ... */ };
// 这个函数只是观察一个资源而不修改它,
// 但类型系统并不知道这一点(参数不是 const 的)
void observe_resource(ResourceHandle*);
class ResourceManager {
std::vector<ResourceHandle *> resources;
// ...
public:
// 注意:const成员函数
void observe_resources() const {
// 我们想要观察每个资源,例如:收集数据
// to collect data
for(const ResourceHandle * h : resources) {
// 无法编译,h 是 const 的
// observe_resource(h);
// 暂时忽略 const 限定符
observe_resource(const_cast<ResourceHandle*>(h));
}
}
// ...
}
const_cast 是一种用于调整类型系统安全性的工具,只应在特定受控场景下使用,而非用于执行诸如修改数学常量(如圆周率π)这类不合理操作。若执意尝试此类行为,将导致未定义行为。
有时候,必须让编译器相信开发者的判断。当确定当前平台上sizeof(int)==4时,需要将int当作char[4]来处理,以便与期望该类型的现有API交互。但务必通过static_assert验证这一特性成立,而非假定所有平台都满足此条件(事实并非如此)。
这正是reinterpret_cast的用途 —— 其允许将某种类型的指针转换为完全不相关的指针类型。这种转换可用于利用指针互转特性(如第2章所述),但同样也能以多种危险且不可移植的方式“欺骗”类型系统。
以前述将整型转换为4字节数组为例 —— 若目的是操作单个字节,必须注意整数的字节序取决于平台特性,且除非采取谨慎措施,否则编写的代码很可能不具备可移植性。
reinterpret_cast仅改变表达式关联的类型,不会像static_cast那样在多重继承场景中进行地址微调(当从派生类指针转换为基类指针时)。
以下示例展示了这两种转换的区别:
struct B0 { int n = 3; };
struct B1 { float f = 3.5f; };
// B0是D的第一个基本子对象
class D : public B0, public B1 { };
int main() {
D d;
// b0 和 &d 指向相同的地址
// b1 和 &d 指向不相同的地址
B0 *b0 = static_cast<B0*>(&d);
B1 *b1 = static_cast<B1*>(&d);
int n0 = b0->n; // 没问题
float f0 = b1->f; // 没问题
// r0 和 &d 指向相同的地址
// r1 和 &d 也指向相同的地址……糟糕!
B0 *r0 = reinterpret_cast<B0*>(&d); // 不可靠
B1 *r1 = reinterpret_cast<B1*>(&d); // 糟糕的方式
int nr0 = r0->n; // 能用,但不可靠
float fr0 = r1->f; // 未定义行为
}
请谨慎使用 reinterpret_cast。相对安全的用途包括:
在给定足够宽度的整数类型时,将指针转换为整数表示(反之亦然)
在不同类型的空指针之间转换
在函数指针类型之间转换(通过转换后的指针调用函数的结果未定义)
如需了解此强制转换支持的所有转换类型,请参阅 wg21.link/expr.reinterpret.cast。
C++20 引入了 bit_cast,这是一种新的类型转换方式,可用于将比特位从一个对象复制到另一个等宽的对象,同时初始化目标对象(及其包含的任何子对象)的生命周期 —— 只要源类型和目标类型都可简单复制。这个有些“神奇”的标准库函数定义在 <bit> 头文件中,并且可以在 constexpr 上下文中使用。
示例代码如下:
#include <bit>
struct A { int a; double b; };
struct B { unsigned int c; double d; };
int main() {
constexpr A a{ 3, 3.5 }; // ok
constexpr B b = std::bit_cast<B>(a); // Ok
static_assert(a.a == b.c && a.b == b.d); // Ok
static_assert((void*)&a != (void*)&b); // Ok
}
A 和 B 都在编译期构造完成,且具有完全相同的比特位表示,但它们的地址不同,是两个完全独立的对象。它们的数据成员类型部分不同,但大小相同、排列顺序一致,且都是可简单复制的类型。
注意示例最后一行使用的 C 风格强制转换。这是少数几种合理使用 C 风格转换的场景(此处使用 static_cast 同样可行且效率相当)。
我们不会过多讨论duration_cast,它与我们关注的主题仅有间接关联。但由于它将作为本书中进行微基准测试的工具,至少需要提及。
这个定义在<chrono>头文件、属于std::chrono命名空间的duration_cast库函数具有constexpr特性,可用于在不同时间计量单位之间进行转换。想要测量某函数f()的执行耗时时:
#include <chrono>
#include <iostream>
int f() { /* ... */ }
int main() {
using std::cout;
using namespace std::chrono;
auto pre = system_clock::now();
int res = f();
auto post = system_clock::now();
cout << "Computed " << res << " in "
<< duration_cast<microseconds>(post - pre);
}
虽然尚不确定原始duration采用的时间单位,但通过duration_cast可以确保结果以所需单位(如微秒)呈现。本书后续将系统化基准测试实践,展示测量函数或代码块执行时间的规范化方法,而duration_cast将是确保结果格式符合需求的首选工具。
当需要进行类型转换时,可能会倾向于使用C风格强制转换,这种语法在许多语言中都存在且书写简单 —— (T)expr将表达式expr视为类型T。但这种简洁性实际上是个缺点而非优点,在C++代码中应尽量减少C风格转换的使用,原因如下:
可检索性差:C风格转换在源码文本搜索中难以识别,看起来像函数调用中的参数。由于强制转换是“欺骗”类型系统的手段,定期审查其使用合理性很有价值,因此能够快速定位它们很重要。相比之下,C++风格的转换是关键字,更易于查找。
意图不明确:C风格转换不传达转换的原因。当编写(T)expr时,没有说明是要修改cv限定符、遍历类层次结构,还是简单地改变指针类型等。特别是在不同指针类型间转换时,C风格转换通常会表现得像reinterpret_cast,某些情况下这可能导致灾难性后果。
C++中偶尔会看到C风格强制转换,主要出现在意图绝对明确的场景中,在bit_cast章节末尾已经见过一个示例。另一个典型场景是抑制编译器警告 —— 例如当调用标记为[[nodiscard]]的函数时,出于某些原因确实需要忽略返回值的情况。
来看一个通用函数的例子:
template <class ItA, class ItB>
bool all_equal(ItA bA, ItA eA, ItB bB, ItB eB) {
for(; bA != eA && bB != eB; ++bA, (void) ++bB)
if (*bA != bB)
return false;
return true;
}
该函数遍历两个由[bA,eA)和[bB,eB)界定的序列(较短序列结束时停止),比较两个序列“相同位置”的元素,仅当所有元素比较都相等时返回true。
代码中对void的C风格转换:在递增bA和bB时,将++bB的结果强制转换为void。这很奇怪,但这段代码可能被任何人(包括恶意用户或心不在焉的开发者)在各种场景中使用。假设有人重载了operator++(ItA)和operator++(ItB)之间的逗号操作符(这确实可行),就可能劫持函数执行意外代码。通过将参数转为void,需要彻底杜绝了这种可能性。