3.4. C++标准类型转换操作

通常,C++支持四种显式类型转换(称之为强制转换) —— static_cast、dynamic_cast、const_cast和reinterpret_cast。C++11新增了第五种duration_cast,虽然与本书主题关联不大,但有时会在示例中出现(特别是在测量函数执行时间时)。最后,C++20引入了第6种bit_cast,这对本书讨论的内容具有重要意义。

以下各节将简要介绍每种C++强制转换,并举例说明使用场景和实际价值。

3.4.1 好朋友(大多数时候) —— static_cast

类型转换工具集中最好用、最高效的就是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时重提这点,后者的行为截然不同(且更加危险)。

3.4.2 问题出现的征兆 —— dynamic_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),这会导致生成的文件体积增大。由于这些开销,某些应用领域往往会避免使用这种转换,我们也将遵循这一原则。

3.4.3 与安全性耍个把戏 —— const_cast

无论是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 是一种用于调整类型系统安全性的工具,只应在特定受控场景下使用,而非用于执行诸如修改数学常量(如圆周率π)这类不合理操作。若执意尝试此类行为,将导致未定义行为。

3.4.4 “相信我,编译器” —— reinterpret_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

3.4.5 确定位模式正确 —— bit_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 同样可行且效率相当)。

3.4.6 虽不相关但仍需提及 —— duration_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将是确保结果格式符合需求的首选工具。

3.4.7 备受诟病的一个 —— C风格的转换

当需要进行类型转换时,可能会倾向于使用C风格强制转换,这种语法在许多语言中都存在且书写简单 —— (T)expr将表达式expr视为类型T。但这种简洁性实际上是个缺点而非优点,在C++代码中应尽量减少C风格转换的使用,原因如下:

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,需要彻底杜绝了这种可能性。