我想编写自己的“小向量”类型,而第一个障碍是弄清楚如何实现堆栈存储。
我偶然发现了std::aligned_storage
,这似乎是专门为实现任意堆栈存储而设计的,但我不清楚什么是安全的,什么是不安全的。cppreference.com有一个using的示例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();
}
}
};
除了这两条评论说:
注意:
std::launder
从 C++17 开始需要
“as of”条款本身就相当混乱。这是否意味着
此代码不正确或不可移植,应使用可移植版本
std::launder
(在 C++17 中引入),或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
; 如果传递了 ay
和z
该别名,则此函数将不会具有“所需的”行为:
.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
)都会生成观察到x
through修改的代码z
:
.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 是否对“严格别名”的定义较弱(并且有记录)。