Подтвердить что ты не робот

Хранилище стека для малых объектов, правило строгого сглаживания и 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

4b9b3361

Ответ 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. [..]

И поскольку соответствующие функции-члены виртуальны, вызов их через базовый указатель фактически вызывает соответствующие функции в производном классе.