Almacenamiento de pila de objetos pequeños, reglas de alias estrictos y Comportamiento Indefinido


Estoy escribiendo un wrapper de función borrado de tipo similar a std::function. (Sí, he visto implementaciones similares e incluso la propuesta p0288r0, pero mi caso de uso es bastante estrecho y algo especializado.). El código muy simplificado a continuación ilustra mi implementación actual:

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
    }
};

Compiled , GCC 6.1 advierte sobre strict-aliasing :

warning: dereferencing type-punned pointer will break strict-aliasing rules [-Wstrict-aliasing]
         return reinterpret_cast<T*>(c)->operator()();

También sé acerca de la regla de alias estrictos. Por otro lado, actualmente no conozco un mejor manera de hacer uso de la optimización de la pila de objetos pequeños. A pesar de las advertencias, todas mis pruebas pasan en GCC y Clang, (y un nivel adicional de indirección evita la advertencia de GCC). Mis preguntas son:

  • ¿Eventualmente me quemaré ignorando la advertencia para este caso?
  • ¿Hay una mejor manera de crear objetos en el lugar?

Ver el ejemplo completo: Vivir en Coliru

Author: Community, 2016-09-13

3 answers

Primero, use std::aligned_storage_t. Para eso está destinado.

En segundo lugar, el tamaño exacto y el diseño de los tipos virtual y sus decentes está determinado por el compilador. Asignar una clase derivada en un bloque de memoria y luego convertir la dirección de ese bloque a un tipo base puede funcionar, pero no hay garantía en el estándar de que funcionará.

En particular, si tenemos struct A {}; struct B:A{}; no hay garantía a menos que sea un diseño estándar de que un puntero aB pueda ser reintepreted como un pointer-to - A (especialmente a través de un void*). Y las clases con virtual s en ellas no son layout estándar.

Así que la reinterpretación es un comportamiento indefinido.

Podemos evitar esto.

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);
  }
};

Esto ya no depende de las garantías de conversión de puntero. Simplemente requiere que void_ptr == new( void_ptr ) T(blah).

Si realmente le preocupa el alias estricto, almacene el valor devuelto de la expresión new como void*, y páselo a invoke y destroy en lugar de &data. Que va a ser irreprochable: el puntero devuelto de new es el puntero al objeto recién construido. El acceso del data cuya vida ha terminado es probablemente inválido, pero también lo era antes.

Cuando los objetos comienzan a existir y cuando terminan es relativamente borroso en el estándar. El último intento que he visto para resolver este problema es P0137-R1 , donde introduce T* std::launder(T*) para hacer que los problemas de aliasing desaparezcan de una manera extremadamente clara manera.

El almacenamiento del puntero devuelto por new es la única forma que conozco de que clara e inequívocamente no se encuentre con ningún problema de aliasing de objetos antes de P0137.

El estándar declaró:

Si un objeto de tipo T se encuentra en una dirección A, se dice que un puntero de tipo cv T* cuyo valor es la dirección A apunta a ese objeto, independientemente de cómo se obtuvo el valor

La pregunta es " ¿la nueva expresión realmente garantizar que el objeto se crea en el lugar en cuestión". No he podido convencerme de que lo diga tan inequívocamente. Sin embargo, en mis propias implementaciones de borrado de tipo, no almaceno ese puntero.

Prácticamente, lo anterior va a hacer lo mismo que muchas implementaciones de C++ hacen con tablas de funciones virtuales en casos simples como este, excepto que no hay RTTI creado.

 13
Author: Yakk - Adam Nevraumont,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-09-28 14:09:15

La mejor opción es utilizar la instalación estándar para el almacenamiento alineado para la creación de objetos, que se llama 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();

Ejemplo.

Si está disponible, debe usar std::launder para envolver sus reinterpret_casts: ¿Cuál es el propósito de std::launder?; si std::launder no está disponible, puede asumir que su compilador es pre-P0137 y las reinterpret_cast s son suficientes según la regla "puntos a" ( [basic.compuesto]/3). Puede probar std::launder usando #ifdef __cpp_lib_launder; ejemplo.

Dado que esta es una instalación Estándar, se le garantiza que si la usa de acuerdo con la descripción de la biblioteca (es decir, como se mencionó anteriormente), entonces no hay peligro de quemarse.

Como bonus, esto también asegurará que cualquier advertencia del compilador sea suprimida.

Un peligro no cubierto por la pregunta original es que estás fundiendo la dirección de almacenamiento a un tipo base polimórfico de tu tipo derivado. Esto solo está bien si se asegura de que el la base polimórfica tiene la misma dirección ( [ptr.lavandería]/1: "Un objeto X que está dentro de su vida [...] se encuentra en la dirección A") como el objeto completo en el momento de la construcción, ya que esto no está garantizado por el Estándar (ya que un tipo polimórfico no es estándar-diseño). Puedes comprobar esto con un assert:

    auto* p = new(&c) derived<F>(std::forward<F>(f));
    assert(static_cast<base*>(p) == std::launder(reinterpret_cast<base*>(&c)));

Sería más limpio usar herencia no polimórfica con una tabla v manual, como propone Yakk, ya que entonces la herencia será standard-layout y el subobjeto de la clase base tienen garantizada la misma dirección que el objeto completo.


Si observamos la implementación de aligned_storage, es equivalente a su alignas(sizeof(void*)) char c[64], solo envuelto en un struct, y de hecho gcc puede cerrarse envolviendo su char c[64] en un struct; aunque estrictamente hablando después de P0137 debe usar unsigned char en lugar de simplemente char. Sin embargo, este es un área de la Norma en rápida evolución, y esto podría cambiar en el futuro. Si utiliza el siempre tiene una mejor garantía de que va a seguir trabajando.

 6
Author: ecatmur,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2017-05-23 12:19:30

La otra respuesta es básicamente reconstruir lo que la mayoría de los compiladores hacen bajo el capó. Cuando se almacena el puntero devuelto por la colocación nueva, entonces no hay necesidad de construir manualmente 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();
    }
};

Va desde derived<T> * a base * es perfectamente válido (N4431 §4.10/3):

Un prvalue de tipo "puntero a cv D", donde D es un tipo de clase, se puede convertir en un prvalue de tipo " puntero a cv B", donde B es una clase base (Cláusula 10) de D. [..]

Y desde el las funciones miembro respectivas son virtuales, llamándolas a través del puntero base en realidad llama a las funciones respectivas en la clase derivada.

 2
Author: Daniel Jour,
Warning: date(): Invalid date.timezone value 'Europe/Kyiv', we selected the timezone 'UTC' for now. in /var/www/agent_stack/data/www/ajaxhispano.com/template/agent.layouts/content.php on line 61
2016-09-14 07:45:05