Я хотел написать свой собственный тип «маленький вектор», и первым препятствием было выяснить, как реализовать хранилище в стеке.
Я наткнулся на 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();
}
}
};
Почти все здесь имеет смысл для меня, за исключением тех двух комментариев, говорящих:
примечание: необходимо std::launder
по состоянию на C ++ 17
Предложение «как» само по себе довольно запутанно; Означает ли это, что
Этот код является неправильным или непереносимым, и в переносной версии следует использовать 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
; эта функция не будет иметь «желаемого» поведения, если ей передано y
и 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
до 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 более слабые (и задокументированные) определения «строгого алиасинга».