Хранилище стека для малых объектов, правило строгого сглаживания и Undefined Поведение
Я пишу обертку с стиранием типа, похожую на std::function
. (Да, я видел подобные реализации и даже предложение p0288r0, но мой прецедент довольно узкий и несколько специализированный.). Сильно упрощенный код ниже иллюстрирует мою текущую реализацию:
class Func{
alignas(sizeof(void*)) char c[64]; //align to word boundary
struct base{
virtual void operator()() = 0;
virtual ~base(){}
};
template<typename T> struct derived : public base{
derived(T&& t) : callable(std::move(t)) {}
void operator()() override{ callable(); }
T callable;
};
public:
Func() = delete;
Func(const Func&) = delete;
template<typename F> //SFINAE constraints skipped for brevity
Func(F&& f){
static_assert(sizeof(derived<F>) <= sizeof(c), "");
new(c) derived<F>(std::forward<F>(f));
}
void operator () (){
return reinterpret_cast<base*>(c)->operator()(); //Warning
}
~Func(){
reinterpret_cast<base*>(c)->~base(); //Warning
}
};
Скомпилировано, GCC 6.1 предупреждает о strict-aliasing:
warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
return reinterpret_cast<T*>(c)->operator()();
Я также знаю правило строгого сглаживания. С другой стороны, я в настоящее время не знаю, как лучше использовать оптимизацию стека небольших объектов. Несмотря на предупреждения, все мои тесты проходят на GCC и Clang (и дополнительный уровень косвенности предотвращает предупреждение GCC). Мои вопросы:
- В конечном итоге я сожгусь, игнорируя предупреждение для этого случая?
- Есть ли лучший способ создания объектов на месте?
Смотрите полный пример: Live on Coliru
Ответы
Ответ 1
Сначала используйте std::aligned_storage_t
. Это то, для чего оно предназначено.
Во-вторых, точный размер и компоновка типов virtual
и их детерминанты определяются компилятором. Выделение производного класса в блоке памяти, а затем преобразование адреса этого блока в базовый тип может работать, но в стандарте он не будет гарантировать.
В частности, если struct A {}; struct B:A{};
нет гарантии, если вы не являетесь стандартным макетом, то указатель-to-B
может быть reintepret
ed в качестве указателя на A
(особенно через a void*
). А классы с virtual
в них не являются стандартными.
Таким образом, переинтерпретация - это поведение undefined.
Мы можем обойти это.
struct func_vtable {
void(*invoke)(void*) = nullptr;
void(*destroy)(void*) = nullptr;
};
template<class T>
func_vtable make_func_vtable() {
return {
[](void* ptr){ (*static_cast<T*>(ptr))();}, // invoke
[](void* ptr){ static_cast<T*>(ptr)->~T();} // destroy
};
}
template<class T>
func_vtable const* get_func_vtable() {
static const auto vtable = make_func_vtable<T>();
return &vtable;
}
class Func{
func_vtable const* vtable = nullptr;
std::aligned_storage_t< 64 - sizeof(func_vtable const*), sizeof(void*) > data;
public:
Func() = delete;
Func(const Func&) = delete;
template<class F, class dF=std::decay_t<F>>
Func(F&& f){
static_assert(sizeof(dF) <= sizeof(data), "");
new(static_cast<void*>(&data)) dF(std::forward<F>(f));
vtable = get_func_vtable<dF>();
}
void operator () (){
return vtable->invoke(&data);
}
~Func(){
if(vtable) vtable->destroy(&data);
}
};
Это больше не зависит от гарантий преобразования указателей. Для этого просто требуется void_ptr == new( void_ptr ) T(blah)
.
Если вы действительно обеспокоены строгим псевдонимом, сохраните возвращаемое значение выражения new
как void*
и передайте это значение в invoke
и destroy
вместо &data
. Это будет не упрекнуть: указатель, возвращенный из new
, указатель на вновь созданный объект. Доступ к data
, срок службы которого закончился, вероятно, недопустим, но он также был недопустим.
Когда объекты начинают существовать и когда они заканчиваются, в стандарте относительно нечеткое. Последняя попытка, которую я видел для решения этой проблемы, - P0137-R1, где она вводит T* std::launder(T*)
, чтобы проблемы с псевдонимом исчезли предельно ясный способ.
Хранение указателя, возвращаемого new
, является единственным способом, который я знаю об этом четко и недвусмысленно, не сталкивается с проблемами псевдонимов объектов до P0137.
В стандарте указано:
Если объект типа T расположен по адресу A, указатель типа cv T *, значение которого является адресом A, называется указывать на этот объект, независимо от того, как было получено значение
возникает вопрос: "действительно ли новое выражение фактически гарантирует, что объект создается в соответствующем месте". Я не мог убедить себя, что это так однозначно. Однако в своих реализациях стирания моего типа я не сохраняю этот указатель.
Практически, вышесказанное будет делать то же самое, что и многие реализации С++, с таблицами виртуальных функций в простых случаях, подобных этому, за исключением того, что RTTI не создается.
Ответ 2
Лучшим вариантом является использование стандартного средства для выравнивания хранилища для создания объекта, которое называется aligned_storage
:
std::aligned_storage_t<64, sizeof(void*)> c;
// ...
new(&c) F(std::forward<F>(f));
reinterpret_cast<T*>(&c)->operator()();
reinterpret_cast<T*>(&c)->~T();
Пример.
Если доступно, вы должны использовать std::launder
, чтобы обернуть reinterpret_cast
s: Какова цель std:: write?; если std::launder
недоступен, вы можете предположить, что ваш компилятор пред-P0137 и reinterpret_cast
достаточны для правила "указывает на" ( [basic.compound]/3). Вы можете проверить std::launder
с помощью #ifdef __cpp_lib_launder
; пример.
Поскольку это стандартное средство, вам гарантируется, что если вы используете его в соответствии с описанием библиотеки (то есть, как указано выше), то нет никакой опасности сожжения.
В качестве бонуса это также обеспечит подавление любых предупреждений компилятора.
Одна из опасностей, не затронутых первоначальным вопросом, заключается в том, что вы отправляете адрес хранилища в полиморфный базовый тип вашего производного типа. Это только ОК, если вы убедитесь, что полиморфная база имеет один и тот же адрес ( [ptr.launder]/1: "Объект X
, который находится в пределах его времени жизни [...], находится в адрес A
" ) как полный объект во время построения, поскольку это не гарантируется Стандартом (поскольку полиморфный тип не является стандартным макетом). Вы можете проверить это с помощью assert
:
auto* p = new(&c) derived<F>(std::forward<F>(f));
assert(static_cast<base*>(p) == std::launder(reinterpret_cast<base*>(&c)));
Было бы проще использовать неполиморфное наследование с помощью ручной таблицы vtable, как предлагает Yakk, так как тогда наследование будет стандартным макетом, а субобъект базового класса будет иметь тот же адрес, что и полный объект.
Если мы рассмотрим реализацию aligned_storage
, это будет эквивалентно вашему alignas(sizeof(void*)) char c[64]
, просто завернутому в struct
, и даже gcc можно заткнуть, обернув ваш char c[64]
в struct
; хотя строго говоря, после P0137 вы должны использовать unsigned char
, а не просто char
. Тем не менее, это быстро развивающаяся область Стандарта, и это может измениться в будущем. Если вы используете предоставленный объект, у вас есть лучшая гарантия, что он будет продолжать работать.
Ответ 3
Другой ответ - это, в основном, восстановление того, что делают большинство компиляторов под капотом. Когда вы храните указатель, возвращенный новым местом размещения, тогда нет необходимости вручную создавать vtables
class Func{
struct base{
virtual void operator()() = 0;
virtual ~base(){}
};
template<typename T> struct derived : public base{
derived(T&& t) : callable(std::move(t)) {}
void operator()() override{ callable(); }
T callable;
};
std::aligned_storage_t<64 - sizeof(base *), sizeof(void *)> data;
base * ptr;
public:
Func() = delete;
Func(const Func&) = delete;
template<typename F> //SFINAE constraints skipped for brevity
Func(F&& f){
static_assert(sizeof(derived<F>) <= sizeof(data), "");
ptr = new(static_cast<void *>(&data)) derived<F>(std::forward<F>(f));
}
void operator () (){
return ptr->operator()();
}
~Func(){
ptr->~base();
}
};
Переход от derived<T> *
в base *
вполне допустим (N4431 §4.10/3):
Значение типа "указатель на cv D", где D - тип класса, может быть преобразовано в prvalue типа "pointer" к cv B ", где B - базовый класс (раздел 10) D. [..]
И поскольку соответствующие функции-члены виртуальны, вызов их через базовый указатель фактически вызывает соответствующие функции в производном классе.