此问题已在此处有答案:
What is the modern, correct way to do type punning in C++?(2个答案)
23天前关闭
假设我正在开发一个名为libModern的库。这个库使用一个遗留的C库libLegacy作为实现策略。libLegacy的接口看起来像这样:
typedef uint32_t LegacyFlags;
struct LegacyFoo {
uint32_t x;
uint32_t y;
LegacyFlags flags;
// more data
};
struct LegacyBar {
LegacyFoo foo;
float a;
// more data
};
void legacy_input(LegacyBar const* s); // Does something with s
void legacy_output(LegacyBar* s); // Stores data in s
libModern不应该将libLegacy的类型暴露给它的用户,原因有很多,其中包括:
- libLegacy是一个不应该泄露的实现细节。libModern的未来版本可能会选择使用另一个库而不是libLegacy。
- libLegacy使用难以使用、容易误用的类型,这些类型不应该成为任何面向用户的API的一部分。
处理这种情况的教科书方法是pimpl习语:libModern将提供一个 Package 器类型,该类型内部有一个指向遗留数据的指针。然而,这在这里是不可能的,因为libModern不能分配动态内存。一般来说,它的目标是不增加很多开销。
因此,libModern定义了自己的类型,这些类型与遗留类型的布局兼容,但具有更好的接口。在这个例子中,它使用了一个强enum
而不是普通的uint32_t
作为标志:
enum class ModernFlags : std::uint32_t
{
first_flag = 0,
second_flag = 1,
};
struct ModernFoo {
std::uint32_t x;
std::uint32_t y;
ModernFlags flags;
// More data
};
struct ModernBar {
ModernFoo foo;
float a;
// more data
};
现在的问题是:libModern如何在没有太多开销的情况下在传统类型和现代类型之间进行转换?我知道三个选择:
reinterpret_cast
。这是未定义的行为,但实际上会产生完美的组装。我想避免这种情况,因为我不能依赖这个明天仍然工作或另一个编译器。std::memcpy
。在简单的情况下,这会生成相同的最佳程序集,但在任何非平凡的情况下,这都会增加显著的开销。- C++20的
std::bit_cast
。在我的测试中,它最多只能产生与memcpy
完全相同的代码。在某些情况下,情况更糟。
这是与libLegacy接口的3种方式的比较:
1.与legacy_input()
接口
1.使用reinterpret_cast
:
void input_ub(ModernBar const& s) noexcept {
legacy_input(reinterpret_cast<LegacyBar const*>(&s));
}
组件:
input_ub(ModernBar const&):
jmp legacy_input
这是完美的codegen,但它调用了UB。
1.使用memcpy
:
void input_memcpy(ModernBar const& s) noexcept {
LegacyBar ls;
std::memcpy(&ls, &s, sizeof(ls));
legacy_input(&ls);
}
组件:
input_memcpy(ModernBar const&):
sub rsp, 24
movdqu xmm0, XMMWORD PTR [rdi]
mov rdi, rsp
movaps XMMWORD PTR [rsp], xmm0
call legacy_input
add rsp, 24
ret
严重恶化。
1.使用bit_cast
:
void input_bit_cast(ModernBar const& s) noexcept {
LegacyBar ls = std::bit_cast<LegacyBar>(s);
legacy_input(&ls);
}
装配:
input_bit_cast(ModernBar const&):
sub rsp, 40
movdqu xmm0, XMMWORD PTR [rdi]
mov rdi, rsp
movaps XMMWORD PTR [rsp+16], xmm0
mov rax, QWORD PTR [rsp+16]
mov QWORD PTR [rsp], rax
mov rax, QWORD PTR [rsp+24]
mov QWORD PTR [rsp+8], rax
call legacy_input
add rsp, 40
ret
我不知道这里发生了什么。
1.与legacy_output()接口
1.使用reinterpret_cast
:
auto output_ub() noexcept -> ModernBar {
ModernBar s;
legacy_output(reinterpret_cast<LegacyBar*>(&s));
return s;
}
组件:
output_ub():
sub rsp, 56
lea rdi, [rsp+16]
call legacy_output
mov rax, QWORD PTR [rsp+16]
mov rdx, QWORD PTR [rsp+24]
add rsp, 56
ret
1.使用memcpy
:
auto output_memcpy() noexcept -> ModernBar {
LegacyBar ls;
legacy_output(&ls);
ModernBar s;
std::memcpy(&s, &ls, sizeof(ls));
return s;
}
组装:
output_memcpy():
sub rsp, 56
lea rdi, [rsp+16]
call legacy_output
mov rax, QWORD PTR [rsp+16]
mov rdx, QWORD PTR [rsp+24]
add rsp, 56
ret
1.使用bit_cast
:
auto output_bit_cast() noexcept -> ModernBar {
LegacyBar ls;
legacy_output(&ls);
return std::bit_cast<ModernBar>(ls);
}
装配:
output_bit_cast():
sub rsp, 72
lea rdi, [rsp+16]
call legacy_output
movdqa xmm0, XMMWORD PTR [rsp+16]
movaps XMMWORD PTR [rsp+48], xmm0
mov rax, QWORD PTR [rsp+48]
mov QWORD PTR [rsp+32], rax
mov rax, QWORD PTR [rsp+56]
mov QWORD PTR [rsp+40], rax
mov rax, QWORD PTR [rsp+32]
mov rdx, QWORD PTR [rsp+40]
add rsp, 72
ret
Here您可以在浏览器资源管理器上找到完整的示例。
我还注意到,根据结构体的确切定义(即,成员的顺序、数量和类型)。但是UB版本的代码始终更好,或者至少和其他两个版本一样好。
现在我的问题是:
1.为什么代码生成器变化如此之大?这让我怀疑我是否错过了什么重要的东西。
1.有没有什么我可以做的,以指导编译器生成更好的代码,而不调用UB?
1.是否有其他符合标准的方法可以生成更好的代码?
4条答案
按热度按时间q5lcpyga1#
在编译器资源管理器链接中,Clang为所有输出情况生成相同的代码。我不知道GCC在这种情况下对
std::bit_cast
有什么问题。对于输入情况,这三个函数不能产生相同的代码,因为它们具有不同的语义。
对于
input_ub
,对legacy_input
的调用可能会修改调用者的对象。在其他两个版本中,情况并非如此。因此,编译器无法优化掉副本,不知道legacy_input
的行为。如果你传递by-value给输入函数,那么所有三个版本都会产生相同的代码,至少在编译器资源管理器链接中使用Clang。
要重现原始
input_ub
生成的代码,需要不断向legacy_input
传递调用方对象的地址。如果
legacy_input
是一个extern C
函数,那么我不认为标准规定了这两种语言的对象模型在这个调用中应该如何交互。因此,为了使用language-lawyer
标记,我假设legacy_input
是一个普通的C++函数。直接传递
&s
地址的问题是,在同一地址上通常没有LegacyBar
对象可以与ModernBar
对象进行指针转换。因此,如果legacy_input
试图通过指针访问LegacyBar
成员,则将是UB。从理论上讲,您可以在所需的地址创建LegacyBar对象,重用
ModernBar
对象的对象表示。但是,由于调用者可能希望在调用之后仍然存在ModernBar
对象,因此您需要通过相同的过程在存储中重新创建ModernBar
对象。不幸的是,您并不总是可以以这种方式重用存储。例如,如果传递的引用引用了一个
const
完全对象,那就是UB,还有其他要求。问题还在于调用者对旧对象的引用是否会引用新对象,这意味着这两个ModernBar
对象是否是透明可替换的。情况也并非总是如此。所以一般来说,我认为如果你不对传递给函数的引用施加额外的约束,你就不能在没有未定义行为的情况下实现同样的代码生成。
wn9m85ua2#
大多数非MSVC编译器都支持一个名为
__may_alias__
的属性,您可以使用该属性当然,当允许使用别名时,某些优化是无法实现的,因此只有在性能可以接受的情况下才使用别名
Godbolt链接
deyfvvtc3#
有任何理由以多个类型访问存储的程序应该在任何编译器上使用
-fno-strict-aliasing
或等效程序进行处理,这些编译器不会限制在一个类型的指针或左值转换为另一个类型的地方进行基于类型的别名假设,* 即使程序只使用标准规定的角情况行为 *。使用这样一个编译器标志将保证不会有基于类型的别名问题,而跳到只使用标准强制的极端情况则不会。clang和gcc有时都容易出现:1.在优化的一个阶段,将行为由标准强制的代码更改为行为不由标准强制的代码,在没有进一步优化的情况下,
1.在优化的后期阶段,以一种方式进一步转换代码,该方式对于由#1 * 产生的代码版本是允许的,但对于最初编写的代码是不允许的。
如果在直接编写的源代码上使用
-fno-strict-aliasing
生成性能可接受的机器代码,那么这是一种比试图跳过障碍以满足标准允许编译器施加的约束更安全的方法,因为这样做会使它们更有用[或者-对于质量差的编译器-在这样做会使它们不那么有用的情况下]。v09wglhw4#
您可以创建一个具有私有成员的联合来限制对旧表示的访问:
输入/输出函数被编译为与reinterpret_cast-versions(using gcc/clang)相同的代码:
和
请注意,这使用了匿名结构,并要求您包含遗留实现,而您提到您不希望这样做。此外,我错过了经验,完全相信没有隐藏的UB,所以如果其他人对此发表评论,那就太好了:)