¿Cómo emular EBO cuando se usa almacenamiento sin procesar?

79

Tengo un componente que uso al implementar tipos genéricos de bajo nivel que almacenan un objeto de tipo arbitrario (puede o no ser un tipo de clase) que puede estar vacío para aprovechar la optimización de base vacía :

template <typename T, unsigned Tag = 0, typename = void>
class ebo_storage {
  T item;
public:
  constexpr ebo_storage() = default;

  template <
    typename U,
    typename = std::enable_if_t<
      !std::is_same<ebo_storage, std::decay_t<U>>::value
    >
  > constexpr ebo_storage(U&& u)
    noexcept(std::is_nothrow_constructible<T,U>::value) :
    item(std::forward<U>(u)) {}

  T& get() & noexcept { return item; }
  constexpr const T& get() const& noexcept { return item; }
  T&& get() && noexcept { return std::move(item); }
};

template <typename T, unsigned Tag>
class ebo_storage<
  T, Tag, std::enable_if_t<std::is_class<T>::value>
> : private T {
public:
  using T::T;

  constexpr ebo_storage() = default;
  constexpr ebo_storage(const T& t) : T(t) {}
  constexpr ebo_storage(T&& t) : T(std::move(t)) {}

  T& get() & noexcept { return *this; }
  constexpr const T& get() const& noexcept { return *this; }
  T&& get() && noexcept { return std::move(*this); }
};

template <typename T, typename U>
class compressed_pair : ebo_storage<T, 0>,
                        ebo_storage<U, 1> {
  using first_t = ebo_storage<T, 0>;
  using second_t = ebo_storage<U, 1>;
public:
  T& first() { return first_t::get(); }
  U& second() { return second_t::get(); }
  // ...
};

template <typename, typename...> class tuple_;
template <std::size_t...Is, typename...Ts>
class tuple_<std::index_sequence<Is...>, Ts...> :
  ebo_storage<Ts, Is>... {
  // ...
};

template <typename...Ts>
using tuple = tuple_<std::index_sequence_for<Ts...>, Ts...>;

Últimamente he estado jugando con estructuras de datos sin bloqueo y necesito nodos que, opcionalmente, contengan un dato en vivo. Una vez asignados, los nodos viven durante la vida útil de la estructura de datos, pero el dato contenido solo está vivo mientras el nodo está activo y no mientras el nodo se encuentra en una lista libre. Implementé los nodos usando almacenamiento y ubicación sin procesar new:

template <typename T>
class raw_container {
  alignas(T) unsigned char space_[sizeof(T)];
public:
  T& data() noexcept {
    return reinterpret_cast<T&>(space_);
  }
  template <typename...Args>
  void construct(Args&&...args) {
    ::new(space_) T(std::forward<Args>(args)...);
  }
  void destruct() {
    data().~T();
  }
};

template <typename T>
struct list_node : public raw_container<T> {
  std::atomic<list_node*> next_;
};

que está muy bien, pero desperdicia una porción de memoria del tamaño de un puntero por nodo cuando Testá vacío: un byte para raw_storage<T>::space_y sizeof(std::atomic<list_node*>) - 1bytes de relleno para la alineación. Sería bueno aprovechar EBO y asignar la representación de raw_container<T>un solo byte no utilizada de encima list_node::next_.

Mi mejor intento de crear un raw_ebo_storageEBO "manual" realiza:

template <typename T, typename = void>
struct alignas(T) raw_ebo_storage_base {
  unsigned char space_[sizeof(T)];
};

template <typename T>
struct alignas(T) raw_ebo_storage_base<
  T, std::enable_if_t<std::is_empty<T>::value>
> {};

template <typename T>
class raw_ebo_storage : private raw_ebo_storage_base<T> {
public:
  static_assert(std::is_standard_layout<raw_ebo_storage_base<T>>::value, "");
  static_assert(alignof(raw_ebo_storage_base<T>) % alignof(T) == 0, "");

  T& data() noexcept {
    return *static_cast<T*>(static_cast<void*>(
      static_cast<raw_ebo_storage_base<T>*>(this)
    ));
  }
};

que tiene los efectos deseados:

template <typename T>
struct alignas(T) empty {};
static_assert(std::is_empty<raw_ebo_storage<empty<char>>>::value, "Good!");
static_assert(std::is_empty<raw_ebo_storage<empty<double>>>::value, "Good!");
template <typename T>
struct foo : raw_ebo_storage<empty<T>> { T c; };
static_assert(sizeof(foo<char>) == 1, "Good!");
static_assert(sizeof(foo<double>) == sizeof(double), "Good!");

pero también algunos efectos indeseables, supongo que se deben a la violación del aliasing estricto (3.10 / 10) aunque el significado de "acceder al valor almacenado de un objeto" es discutible para un tipo vacío:

struct bar : raw_ebo_storage<empty<char>> { empty<char> e; };
static_assert(sizeof(bar) == 2, "NOT good: bar::e and bar::raw_ebo_storage::data() "
                                "are distinct objects of the same type with the "
                                "same address.");

Esta solución también tiene potencial para un comportamiento indefinido durante la construcción. En algún momento, el programa debe construir el objeto contenedor dentro del almacenamiento sin procesar con la ubicación new:

struct A : raw_ebo_storage<empty<char>> { int i; };
static_assert(sizeof(A) == sizeof(int), "");
A a;
a.value = 42;
::new(&a.get()) empty<char>{};
static_assert(sizeof(empty<char>) > 0, "");

Recuerde que a pesar de estar vacío, un objeto completo necesariamente tiene un tamaño distinto de cero. En otras palabras, un objeto completo vacío tiene una representación de valor que consta de uno o más bytes de relleno. newconstruye objetos completos, por lo que una implementación conforme podría establecer esos bytes de relleno en valores arbitrarios en la construcción en lugar de dejar la memoria intacta como sería el caso para construir un subobjeto base vacío. Por supuesto, esto sería catastrófico si esos bytes de relleno se superpusieran a otros objetos activos.

Entonces, la pregunta es, ¿es posible crear una clase de contenedor que cumpla con los estándares que use almacenamiento sin procesar / inicialización retrasada para el objeto contenido y aproveche EBO para evitar desperdiciar espacio de memoria para la representación del objeto contenido?

Casey
fuente
@Columbo Si el tipo de contenedor se deriva del tipo contenido, la construcción / destrucción de un objeto contenedor necesariamente construye / destruye el subobjeto contenido. Para la construcción, eso significa que pierde la capacidad de preasignar objetos de contenedor o debe retrasar su construcción hasta que esté listo para construir un contenedor. No es gran cosa, solo agrega otra cosa para rastrear: objetos de contenedor asignados pero aún no construidos. Sin embargo, destruir un objeto contenedor con un subobjeto contenedor muerto es un problema más difícil: ¿cómo se evita el destructor de la clase base?
Casey
Ah, discúlpeme. Olvidé que la construcción / destrucción demorada no es posible de esta manera y la llamada implícita al destructor.
Columbo
`template <typename T> struct alignas (T) raw_ebo_storage_base <T, std :: enable_if_t <std :: is_empty <T> :: value>>: T {}; ? With maybe more tests on T` para asegurarse de que esté construido de manera vacía ... o de alguna manera para asegurarse de que pueda construir Tsin construir T, asumiendo que T::T()tiene efectos secundarios. ¿Tal vez una clase de rasgos para construidos / destruidos sin vacío Tque diga cómo construir un vacío T?
Yakk - Adam Nevraumont
Otro pensamiento: ¿hacer que la clase de almacenamiento ebo tome una lista de tipos que no se le permite tratar como vacíos, porque la dirección de la clase de almacenamiento ebo se superpondrá con ella si lo hace?
Yakk - Adam Nevraumont
1
Al aparecer, extraerá atómicamente un elemento de una lista libre, lo construirá y lo colocará atómicamente en una lista de seguimiento. En el desmontaje, se eliminará atómicamente de una lista de seguimiento, llamará a un destructor y luego se insertará atómicamente en la lista libre. Entonces, en las llamadas al constructor y al destructor, el puntero atómico no está en uso y podría modificarse libremente, ¿correcto? Si es así, la pregunta será: ¿puede poner el puntero atómico en la space_matriz y usarlo de manera segura mientras no está construido en la lista libre? Entonces space_no contendrá T sino algo de envoltura alrededor de T y el puntero atómico.
Speed8ump

Respuestas:

2

Creo que usted mismo dio la respuesta en sus diversas observaciones:

  1. Quieres memoria en bruto y una nueva ubicación. Esto requiere tener al menos un byte disponible, incluso si desea construir un objeto vacío mediante la ubicación new.
  2. Desea cero bytes de sobrecarga para almacenar cualquier objeto vacío.

Estos requisitos son contradictorios. Por tanto, la respuesta es No , eso no es posible.

Sin embargo, podría cambiar un poco más sus requisitos al requerir la sobrecarga de cero bytes solo para tipos vacíos y triviales.

Podrías definir un nuevo rasgo de clase, p. Ej.

template <typename T>
struct constructor_and_destructor_are_empty : std::false_type
{
};

Entonces te especializas

template <typename T, typename = void>
class raw_container;

template <typename T>
class raw_container<
    T,
    std::enable_if_t<
        std::is_empty<T>::value and
        std::is_trivial<T>::value>>
{
public:
  T& data() noexcept
  {
    return reinterpret_cast<T&>(*this);
  }
  void construct()
  {
    // do nothing
  }
  void destruct()
  {
    // do nothing
  }
};

template <typename T>
struct list_node : public raw_container<T>
{
  std::atomic<list_node*> next_;
};

Entonces úsalo así:

using node = list_node<empty<char>>;
static_assert(sizeof(node) == sizeof(std::atomic<node*>), "Good");

Por supuesto, todavía tienes

struct bar : raw_container<empty<char>> { empty<char> e; };
static_assert(sizeof(bar) == 1, "Yes, two objects sharing an address");

Pero eso es normal para EBO:

struct ebo1 : empty<char>, empty<usigned char> {};
static_assert(sizeof(ebo1) == 1, "Two object in one place");
struct ebo2 : empty<char> { char c; };
static_assert(sizeof(ebo2) == 1, "Two object in one place");

Pero siempre y cuando se utilice siempre constructy destructni la colocación de nuevo en &data(), que es de oro.

Rumburak
fuente
Gracias a @Deduplicator por hacerme consciente del poder de std::is_trivial:-)
Rumburak