2.3. 类型双关

C++开发者另一个容易出问题的领域是类型双关(type punning)。所谓类型双关,是指绕开语言类型系统的技术。虽然类型转换的标准工具是强制转换(cast) —— 在代码中显式可见且(除了C风格转换外)能明确表达转换意图 —— 这个话题会单独成章(第3章将详细讨论)。

本节将探讨实现类型双关的其他方式,包括推荐的方法和应该避免的做法。

2.3.1 通过联合体成员进行类型双关

联合体(union)是一种所有成员共享同一地址的类型。联合体的大小等于其最大成员的大小,对齐要求等于成员中最严格的对齐值。

请看以下示例:

struct X {
  char c[5]; short s;
} x;

// 在x.c和x.s之间填充一个字节
static_assert(sizeof x.s == 2 && sizeof x == 8);
static_assert(alignof(x) == alignof(short));

union U {
  int n; X x;
} u;

static_assert(sizeof u == sizeof u.x);
static_assert(alignof(u) == alignof(u.n));

int main() {}

可以通过联合体将四字节浮点数隐式转换为四字节整数 —— 在C语言中确实可行(但C++不同)。

尽管普遍认为这种做法在C++中合法,但事实并非如此(除一个特殊例外,会在稍后讨论)。在C++中,联合体最后写入的成员称为活跃成员(active member),只允许读取该成员。因为读取联合体的非活跃成员属于未定义行为(UB),而constexpr函数中不允许出现UB,所以以下代码非法:

union U {
  float f;
  int n;
};

constexpr int f() {
  U u{ 1.5f };
  return u.n; // UB (u.f是活跃成员)
}

int main() {
  // constexpr auto r0 = f(); // 不能编译
  auto r1 = f(); // 可以编译,不作为constexpr上下文,但会UB
}

像前例中f()这样的constexpr函数,如果在常量表达式上下文中调用,就不能包含未定义行为(UB)的代码。这使得这类函数成为验证某些观点的有效工具。

关于联合体成员间的转换,存在一个例外情况 —— 共同初始序列(common initial sequence)。

共同初始序列

根据wg21.link/class.mem.general#23的规范,两个结构体的共同初始序列(common initial sequence)是它们起始的、具有布局兼容类型的对应成员。以下结构体A和B的共同初始序列包含前两个成员(int与const int布局兼容,float与volatile float布局兼容):

struct A { int n; float f; char c; };
struct B{ const int b0; volatile float x; };

对于联合体,若读取的值同时属于非活跃成员与活跃成员的共同初始序列,则该操作合法:

struct A { int n0; char c0; };
struct B { int n1; char c1; float x; };

union U {
  A a;
  B b;
};

int f() {
  U u{ { 1, '2' } }; // 初始化 u.a
  return u.b.n1; // not UB
}

int main() {
  return f(); // Ok
}

应尽量减少此类型双关的使用,它会增加代码理解难度;但在实现多态底层表示时极具价值(如optional/string类),基于此可构建有效的优化策略。

2.3.2 intptr_t 与 uintptr_t

C++中无法以明确定义的方式,直接比较指向任意内存位置的指针,但可以通过指针关联的整数值进行明确定义的比较:

#include <iostream>
#include <cstdint>

int main() {
  using namespace std;
  int m, n;

  // 不允许简单地将&m与&n进行比较
  if(reinterpret_cast<intptr_t>(&m) < reinterpret_cast<intptr_t>(&n))
    cout << "m precedes n in address order\n";
  else
    cout << "n precedes m in address order\n";
}

std::intptr_t和std::uintptr_t足够容纳地址值的整数类型别名:使用有符号类型intptr_t处理可能产生负值的操作(如指针减法);使用无符号类型uintptr_t处理纯地址运算。

2.3.3 std::memcpy() 函数

由于历史原因(以及与C语言的兼容性),std::memcpy()具有特殊性质 —— 使用得当可以启动对象生命周期译者注:由于C语言没有对象的概念,C++ 为了保持与 C 的兼容性,在某些特定情况下,允许使用 std::memcpy 把一块未初始化内存(比如 std::malloc 返回的内存)复制为某个类型的对象内容,从而隐式地启动该对象的生命周期。

错误的类型双关用法:

// 假设这个例子成立
static_assert(sizeof(int) == sizeof(float));

#include <cassert>
#include <cstdlib>
#include <cstring>

int main() {
  float f = 1.5f;

  void *p = malloc(sizeof f);
  assert(p);

  int *q = std::memcpy(p, &f, sizeof f);
  int value = *q; // UB
  // ...
}

std::memcpy()会将float对象复制到p指向的存储区,启动了一个float对象的生命周期。而q是int*类型,对其进行解引用属于未定义行为(UB)。

正确的类型双关用法示例:

// 假设这个例子成立
static_assert(sizeof(int) == sizeof(float));

#include <cassert>
#include <cstring>

int main() {
  float f = 1.5f;
  int value;
  std::memcpy(&value, &f, sizeof f); // 合理
  // ...
}

第二个例子中,使用std::memcpy()将f的比特位复制到value,由此启动了value对象的生命周期。此后,该对象可以像普通int一样正常使用。

2.3.4 char*, unsigned char* 与 std::byte*的特殊性

char*、unsigned char*(不包括signed char*)和std::byte*类型在C++中具有特殊地位 —— 可以合法地指向内存位置,并作为其他类型的别名(参见wg21.link/basic.lval#11)。

当需要访问对象值表示的底层字节时,这些类型不可或缺。本书后续章节将偶尔使用这些类型进行底层字节操作,但需注意:此类操作本质上脆弱且不可移植,并且字节序等实现细节随平台而异,使用时应格外谨慎。

2.3.5 std::start_lifetime_as<T>()函数

最后要介绍的是std::start_lifetime_as<T>()和std::start_lifetime_as_array<T>()函数组。这些功能经过多年讨论,最终在C++23中正式确立。其作用是接收原始内存缓冲区等参数,返回指向该缓冲区的T*指针(同时启动该对象的生命周期),使得指针目标可立即正常使用:

static_assert(sizeof(short) == 2);

#include <memory>

int main() {
  char buf[]{ 0x00, 0x01, 0x02, 0x03 };
  short* p = std::start_lifetime_as<short>(buf);
  // 可将*p作为一个short类型的变量使用
}

这同样是需要谨慎使用的底层特性,目的是让C++代码能够安全实现底层文件I/O操作,以及网络编程(如将UDP数据包的值表示视为现有对象)。从而避免陷入未定义行为(UB)的陷阱。我们将在第15章详细探讨这些函数。