安全(且无成本)地重新解释大小数据

Safe (and costless) reinterpretation of sized data

本文关键字:解释 小数 数据 新解释 安全      更新时间:2023-10-16

我想编写自己的"小向量"类型,第一个障碍是弄清楚如何实现堆栈存储。

我偶然发现了std::aligned_storage,它似乎是专门为实现任意堆栈存储而设计的,但我非常不清楚什么是安全的,什么是不安全的。 cppreference.com 方便地有一个使用std::aligned_storage的示例,我将在这里重复:

template<class T, std::size_t N>
class static_vector
{
// properly aligned uninitialized storage for N T's
typename std::aligned_storage<sizeof(T), alignof(T)>::type data[N];
std::size_t m_size = 0;
public:
// Create an object in aligned storage
template<typename ...Args> void emplace_back(Args&&... args) 
{
if( m_size >= N ) // possible error handling
throw std::bad_alloc{};
// construct value in memory of aligned storage
// using inplace operator new
new(&data[m_size]) T(std::forward<Args>(args)...);
++m_size;
}
// Access an object in aligned storage
const T& operator[](std::size_t pos) const 
{
// note: needs std::launder as of C++17
return *reinterpret_cast<const T*>(&data[pos]);
}
// Delete objects from aligned storage
~static_vector() 
{
for(std::size_t pos = 0; pos < m_size; ++pos) {
// note: needs std::launder as of C++17
reinterpret_cast<T*>(&data[pos])->~T();
}
}
};

这里的几乎所有内容对我来说都是有意义的,除了那两条评论说:

注意:自 C++17 日起需要std::launder

"as of"条款本身相当令人困惑;这是否意味着

  1. 此代码不正确或不可移植,便携式版本应使用std::launder(在 C++17 中引入),或

  2. C++17 对内存混叠/重新解释规则进行了重大更改?

除此之外,从性能的角度来看,std::launder的使用让我感到担忧。我的理解是,在大多数情况下,编译器可以对内存别名做出非常强烈的假设(特别是指向不同类型的指针不引用相同的内存),以避免冗余内存负载。

我想在编译器方面保持这种级别的混叠确定性(即从我的小向量访问T与对普通T[]T *的访问相同),尽管从我读到的std::launder,这听起来像是一个完整的混叠屏障,即编译器必须假设它对洗涤指针的来源一无所知。我担心在每次operator[]都使用它会干扰通常的负载存储消除。

也许编译器比这更聪明,或者我首先误解了std::launder的工作方式。无论如何,我真的不知道我用这种级别的C++记忆黑客在做什么。很高兴知道我必须为这个特定的用例做些什么,但如果有人能启发我更一般的规则,那将不胜感激。

更新(进一步探索)

进一步阅读这个问题,我目前的理解是,除非使用std::launder,否则我粘贴在此处的示例在标准下具有未定义的行为。也就是说,较小的实验证明了我认为是未定义的行为,并没有表明Clang或GCC像标准所允许的那样严格。

让我们从在混叠指针的情况下明显不安全的东西开始:

float definitelyNotSafe(float *y, int *z) {
*y = 5.0;
*z = 7;
return *y;
}

正如人们所期望的那样,Clang和GCC(启用了优化和严格别名)都生成始终返回5.0的代码;如果传递yz该别名,则该函数将不会具有"期望"的行为:

.LCPI1_0:
.long   1084227584              # float 5
definitelyNotSafe(float*, int*):              # @definitelyNotSafe(float*, int*)
mov     dword ptr [rdi], 1084227584
mov     dword ptr [rsi], 7
movss   xmm0, dword ptr [rip + .LCPI1_0] # xmm0 = mem[0],zero,zero,zero
ret

但是,当编译器可以看到别名指针的创建时,事情会变得有点奇怪:

float somehowSafe(float x) {
// Make some aliasing pointers
auto y = &x;
auto z = reinterpret_cast<int *>(y);
*y = 5.0;
*z = 7;
return x;
}

在这种情况下,Clang和GCC(带有-O3-fstrict-aliasing)都生成代码,通过z观察x的修改:

.LCPI0_0:
.long   7                       # float 9.80908925E-45
somehowSafe(float):                       # @somehowSafe(float)
movss   xmm0, dword ptr [rip + .LCPI0_0] # xmm0 = mem[0],zero,zero,zero
ret

也就是说,编译器并不能保证"利用"未定义的行为;毕竟它是未定义的。在这种情况下,假设*z = 7没有效果是没有利润的。那么,如果我们"激励"编译器利用严格的别名呢?

int stillSomehowSafe(int x) {
// Make some aliasing pointers
auto y = &x;
auto z = reinterpret_cast<float *>(y);
auto product = float(x) * x * x * x * x * x;
*y = 5;
*z = product;
return *y;
}

假设*z = product*y的值没有影响显然对编译器有利;这样做将允许编译器将这个函数简化为一个总是返回5的函数。尽管如此,生成的代码没有做出这样的假设:

stillSomehowSafe(int):                  # @stillSomehowSafe(int)
cvtsi2ss        xmm0, edi
movaps  xmm1, xmm0
mulss   xmm1, xmm0
mulss   xmm1, xmm0
mulss   xmm1, xmm0
mulss   xmm1, xmm0
mulss   xmm1, xmm0
movd    eax, xmm1
ret

我对这种行为感到相当困惑。我知道我们对编译器在存在未定义行为时会做什么的保证为零,但我也感到惊讶的是,Clang和GCC都没有对这些优化更积极。这让我怀疑我是否误解了标准,或者 Clang 和 GCC 对"严格混叠"的定义是否较弱(和记录在案)。

std::launder主要是为了处理像std::optional或你的small_vector这样的场景,其中相同的存储可能会随着时间的推移被重用于多个对象,并且这些对象可能是const的,或者可能有const或引用成员

它对优化器说"这里有一个T,但它可能与您之前T不同,因此const成员可能已更改,或者引用成员可能引用其他内容"。

在没有const或引用成员的情况下,std::launder什么都不做,也是不必要的。见 http://eel.is/c++draft/ptr.launder#5