C 只有一种强制转换 (T)x:数值截断是它,去掉 const 是它,把指针当成整数也是它,读代码的人看不出这一次是哪种。C++ 把它拆成四个关键字,每个只做一类事,写出来就看得到意图,用错了编译器能拦下一部分。下面挨个看它们做什么、编译出来是什么。
1. static_cast:编译期就能算清的转换
1.1 数值转换、void*、enum
最常用的是相关类型之间的转换:int 和 double 互转(static_cast<int>(3.9) 是 3,小数直接截掉)、enum class 和整数互转、把 void* 找回原来的指针类型。这些转换不用转换函数也能隐式发生的不少,写成 static_cast 是为了把“这里有意丢精度”或“这里有意换类型”写明白,编译器也会因此不再给缩窄转换报警告。
它拒绝的是不相关的指针:static_cast<float*>(int_ptr) 编译不过,两个类型之间没有任何关系,这种只能用 reinterpret_cast,也就是明着说“我知道这很危险”。
1.2 沿着继承关系转指针:多继承时要加减偏移
派生类指针转基类指针(向上转)总是安全的,隐式就能转;基类指针转派生类指针(向下转)要写 static_cast。单继承时基类子对象在派生类对象的开头,转换前后指针数值一样。多继承就不一样了:
struct A { int a; };
struct B { int b; };
struct C : A, B { int c; };
C 对象里先放 A 子对象,再放 B 子对象(布局见对象布局)。B* 必须指向 B 子对象,所以 C* 转 B* 时数值要加 4,B* 转回 C* 要减 4:
编译出来就是一条加减法,外加对空指针的特判(空指针转完必须还是空指针,不能变成 4):
B* up(C* p) { return p; } // 隐式向上转
C* down(B* p) { return static_cast<C*>(p); }
_Z2upP1C: # g++ -O2,x86-64
xorl %edx, %edx
leaq 4(%rdi), %rax # p + 4
testq %rdi, %rdi
cmove %rdx, %rax # p 是空的,结果也给空
ret
_Z4downP1B:
xorl %edx, %edx
leaq -4(%rdi), %rax # p - 4
testq %rdi, %rdi
cmove %rdx, %rax
ret
转引用时没有这个特判,引用不能为空,就只剩一条 leaq 4(%rdi), %rax。
1.3 向下转不检查
上面 down 里只有减 4,没有任何一条指令去看 p 指向的到底是不是 C 里的那个 B。如果它其实指向一个单独的 B 对象,减 4 得到的地址根本不是一个 C,再通过它访问成员就是未定义行为。static_cast 向下转的正确性完全靠你自己保证:你确实知道对象的真实类型时才能用。 比如按消息头里的类型字段分发、switch 里每个分支已经知道是哪种消息;或者 CRTP 里基类把 this 转成模板参数给的派生类。不知道时,要用下一节的 dynamic_cast。
2. dynamic_cast:运行时查对象的真实类型
2.1 返回 nullptr,或者抛 bad_cast
dynamic_cast 会在运行时确认对象真的是目标类型(或它的派生类),是就给出调整好的指针,不是就失败。失败的表现分两种,因为指针能表示“没有”,引用不能:
P* p = make(); // 可能是 Q,也可能是别的派生类
if (Q* q = dynamic_cast<Q*>(p)) { … } // 不是 Q:得到 nullptr
Q& r = dynamic_cast<Q&>(*p); // 不是 Q:抛 std::bad_cast
向下转时,源类型必须是多态类型,也就是至少有一个虚函数的类,否则编译不过。原因在下一节:它要找的类型信息挂在 vtable 上,没有虚函数就没有 vtable。
2.2 编译出来:顺着 vptr 找 typeinfo,再调一个库函数
struct P { virtual ~P(); };
struct Q : P { int q; };
Q* dyn(P* p) { return dynamic_cast<Q*>(p); }
_Z3dynP1P: # g++ -O2,x86-64
testq %rdi, %rdi
je .L10 # 空指针直接返回空
xorl %ecx, %ecx # 第 4 个参数:提示,0 表示 P 在 Q 里偏移 0
leaq _ZTI1Q(%rip), %rdx # 目标类型 Q 的 typeinfo
leaq _ZTI1P(%rip), %rsi # 源类型 P 的 typeinfo
jmp __dynamic_cast # 剩下的交给 C++ 运行库
.L10:
xorl %eax, %eax
ret
编译器只准备好参数,真正的检查在运行库的 __dynamic_cast 里。它要做的事,顺序如下:
- 通过 vptr 拿到 vtable,读
offset_to_top,找到完整对象的开头。p可能指向一个中间的基类子对象,先回到整个对象才能谈“它是什么类型”。 - 读 vtable 里的 typeinfo 指针,得到完整对象的真实类型。
- 在这个类型的基类图里找目标类型。单继承链上只要往上比一串 typeinfo;多继承、虚继承时还要确认目标基类是唯一的、
public的,找到后算出它在完整对象里的偏移。
vtable 里的 offset_to_top 和 typeinfo 这两格,对象布局那页有图。
2.3 代价:一次库函数调用
static_cast 是一条加减法,dynamic_cast 是一次读几次内存、比几次 typeinfo 的函数调用,还挡住了内联。本机(GCC 16.2,-O2)对一个单继承、命中的 dynamic_cast 循环计时,大约 3–4 ns 一次,同样的循环换成 static_cast 不到 0.3 ns。继承层次越深,或者 typeinfo 要按名字比较(跨动态库时可能如此),它越慢。
偶尔用一次,这点开销无所谓。频繁地 dynamic_cast 往往说明设计在绕路:调用方需要先问“你到底是哪个子类”,通常是因为该放进虚函数里的行为放在了外面。把这件事做成虚函数,由对象自己去做,类型判断就省了(见虚函数)。
3. const_cast:只去掉 const
3.1 合法用法:对象本身不是 const
const_cast 只能加上或去掉 const、volatile,别的什么都不改。它存在的理由是对接写得不严谨的接口:一个老的 C 函数参数写成 char*,实际只读不写,你手里只有 const char*,就用 const_cast 去掉 const 传过去。另一个常见用法是让 const 和非 const 两个版本的成员函数共用一份实现:非 const 版本调 const 版本,再把结果的 const 去掉,这时对象本来就不是 const,去掉没有问题。
3.2 对象本来就是 const 时,写它是未定义行为
能不能通过去掉 const 的指针去写,取决于对象本身,而不是指针:对象定义时不是 const,只是被一个 const 指针指着,写没问题;对象定义时就是 const,怎么转都不能写,写了就是未定义行为。 原因有两个,都能看到:
const int a = 10;
int* pa = const_cast<int*>(&a);
*pa = 20;
std::printf("a=%d *pa=%d\n", a, *pa); // 打印 a=10 *pa=20
同一个地址,读出来两个值。编译器知道 a 是 const、初值是 10,用到 a 的地方直接换成了常数 10,根本不去读内存。
如果 const 对象是全局的,编译器把它放进只读段(Linux 上是 .rodata),这一页映射成只读,写它的那条指令直接触发段错误。本机把上面的 a 换成全局的 const int limit = 100; 去写,程序就崩在这一行。
4. reinterpret_cast:不改任何位,换个类型来看
4.1 能做什么
reinterpret_cast 把一个指针原样当作另一个不相关类型的指针,或者在指针和整数(std::uintptr_t)之间转换。它不生成任何指令,只是告诉编译器“从现在起按这个类型看”。常见的合法用途:把指针转成整数打印、对齐检查、存进只收整数的接口里,再转回来;按字节看任何对象,转成 char*、unsigned char* 或 std::byte*;系统调用和网络接口约定好的转换,比如 sockaddr_in* 转 sockaddr*。
4.2 严格别名:换个类型去读写,编译器不认
麻烦在“转完以后去读写”。C++ 有一条严格别名(strict aliasing)规则:一个对象只能通过它自己的类型(或 char、unsigned char、std::byte)去访问。 编译器依赖这条规则做优化:它认为 int* 和 float* 不会指向同一块内存,所以通过 float* 写入不会改变 int* 读到的值。
int alias(int* i, float* f) { *i = 1; *f = 2.0f; return *i; }
_Z5aliasPiPf: # g++ -O2,x86-64
movl $1, (%rdi) # *i = 1
movl $1, %eax # 返回值直接定成 1,不再读 *i
movl $0x40000000, (%rsi) # *f = 2.0f
ret
把同一块内存的两个指针传进去,alias(&buf, reinterpret_cast<float*>(&buf)):-O2 下返回 1,可内存里实际是 0x40000000(2.0f 的位);-O0 下不做这个优化,返回 1073741824,也就是把这 4 个字节当 int 读。同一份代码,优化级别不同结果就不同,这就是未定义行为的样子。
4.3 要看一个值的字节:memcpy 或 std::bit_cast
想知道一个 float 的二进制位,正确的写法是把字节拷出来,而不是换个指针去读:
float f = 2.0f;
std::uint32_t u1;
std::memcpy(&u1, &f, sizeof u1); // 一直都对
auto u2 = std::bit_cast<std::uint32_t>(f); // C++20,两边大小必须一样,还能在编译期用
两种写法都不会真的去调 memcpy,编译出来就是一条寄存器之间的移动:
_Z4bitsf: # bit_cast<unsigned>(f),g++ -O2
movd %xmm0, %eax # 浮点寄存器里的 32 位原样搬到整数寄存器
ret
5. C 风格转换:替你挑一种
5.1 它按顺序试,试中哪个用哪个
(T)x 和函数式的 T(x) 不是第五种转换,它依次尝试:const_cast;static_cast;static_cast 再加 const_cast;reinterpret_cast;reinterpret_cast 再加 const_cast。第一种能编译通过的就是结果。所以同样写 (T)x,有时是安全的数值转换,有时去掉了 const,有时是 reinterpret_cast,代码上看不出区别,搜索代码时也很难把它们找出来。
5.2 类型没定义完整时,悄悄退成 reinterpret_cast
最隐蔽的情况和 1.2 节的指针偏移有关。在一个文件里,C 只有前向声明 struct C;,没有定义:
struct B { int b; };
struct C; // 只声明
C* to_c(B* p) { return (C*)p; } // C 风格转换
编译器不知道 C 继承了 B,static_cast 试不通,于是它选了 reinterpret_cast,不报错,也不减 4。在另一个文件里用完整定义的 C 调它,本机打出来是:
&obj=0000003135fffcf4 (C*)pb with C complete=0000003135fffcf4 (C*)pb in a file where C is incomplete=0000003135fffcf8
同一个转换,在知道 C 定义的文件里得到对象开头,在不知道的文件里差了 4 字节。如果这里写的是 static_cast<C*>(p),类型不完整时会直接编译报错,问题当场暴露。C++ 风格转换的价值就在这里:你说清楚要哪一种,做不到时编译器拒绝,而不是换一种去做。
上面的汇编和输出都来自本机 GCC 16.2(-std=c++20)编译、运行的结果;汇编是加 -mabi=sysv 生成的 Linux 调用约定版本,运行在 Windows 上,地址格式是 MinGW 的 %p。