C++开发者另一个容易出问题的领域是类型双关(type punning)。所谓类型双关,是指绕开语言类型系统的技术。虽然类型转换的标准工具是强制转换(cast) —— 在代码中显式可见且(除了C风格转换外)能明确表达转换意图 —— 这个话题会单独成章(第3章将详细讨论)。
本节将探讨实现类型双关的其他方式,包括推荐的方法和应该避免的做法。
联合体(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类),基于此可构建有效的优化策略。
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处理纯地址运算。
由于历史原因(以及与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一样正常使用。
char*、unsigned char*(不包括signed char*)和std::byte*类型在C++中具有特殊地位 —— 可以合法地指向内存位置,并作为其他类型的别名(参见wg21.link/basic.lval#11)。
当需要访问对象值表示的底层字节时,这些类型不可或缺。本书后续章节将偶尔使用这些类型进行底层字节操作,但需注意:此类操作本质上脆弱且不可移植,并且字节序等实现细节随平台而异,使用时应格外谨慎。
最后要介绍的是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章详细探讨这些函数。