Pimpl livre de pilha. Incorreto ou superstição?

Eu aspiro separar a interface da implementação. Isso é principalmente para proteger o código usando uma biblioteca de alterações na implementação da referida biblioteca, embora os tempos de compilação reduzidos sejam certamente bem-vindos.

A solução padrão para isso é o ponteiro para o idioma de implementação, provavelmente implementado usando um unique_ptr e definindo cuidadosamente o destruidor de classe fora de linha, com a implementação.

Inevitavelmente, isso levanta preocupações sobre a alocação de heap. Estou familiarizado com "faça funcionar, depois faça rápido", "perfil e depois otimize" e essa sabedoria. Também existem artigos online, por exemplogotw, que declara a solução óbvia para ser quebradiça e não portátil. Eu tenho uma biblioteca que atualmente não contém alocações de heap - e eu gostaria de mantê-lo assim - então vamos ter algum código de qualquer maneira.

#ifndef PIMPL_HPP
#define PIMPL_HPP
#include <cstddef>

namespace detail
{
// Keeping these up to date is unfortunate
// More hassle when supporting various platforms
// with different ideas about these values.
const std::size_t capacity = 24;
const std::size_t alignment = 8;
}

class example final
{
 public:
  // Constructors
  example();
  example(int);

  // Some methods
  void first_method(int);
  int second_method();

  // Set of standard operations
  ~example();
  example(const example &);
  example &operator=(const example &);
  example(example &&);
  example &operator=(example &&);

  // No public state available (it's all in the implementation)
 private:
  // No private functions (they're also in the implementation)
  unsigned char state alignas(detail::alignment)[detail::capacity];
};

#endif

Isso não parece tão ruim para mim. O alinhamento e o tamanho podem ser declarados estaticamente na implementação. Posso escolher entre superestimar ambos (ineficientes) ou recompilar tudo se eles mudarem (tedioso) - mas nenhuma opção é terrível.

Não tenho certeza de que esse tipo de invasão funcione na presença de herança, mas como não gosto muito de herança em interfaces, não me importo muito.

Se assumirmos corajosamente que eu escrevi a implementação corretamente (anexarei a esta postagem, mas é uma prova de conceito não testada neste momento, portanto não é um dado), e o tamanho e o alinhamento são maiores ou iguais a o da implementação, o código exibe o comportamento definido ou indefinido da implementação?

#include "pimpl.hpp"
#include <cassert>
#include <vector>

// Usually a class that has behaviour we care about
// In this example, it's arbitrary
class example_impl
{
 public:
  example_impl(int x = 0) { insert(x); }

  void insert(int x) { local_state.push_back(3 * x); }

  int retrieve() { return local_state.back(); }

 private:
  // Potentially exotic local state
  // For example, maybe we don't want std::vector in the header
  std::vector<int> local_state;
};

static_assert(sizeof(example_impl) == detail::capacity,
              "example capacity has diverged");

static_assert(alignof(example_impl) == detail::alignment,
              "example alignment has diverged");

// Forwarding methods - free to vary the names relative to the api
void example::first_method(int x)
{
  example_impl& impl = *(reinterpret_cast<example_impl*>(&(this->state)));

  impl.insert(x);
}

int example::second_method()
{
  example_impl& impl = *(reinterpret_cast<example_impl*>(&(this->state)));

  return impl.retrieve();
}

// A whole lot of boilerplate forwarding the standard operations
// This is (believe it or not...) written for clarity, so none call each other

example::example() { new (&state) example_impl{}; }
example::example(int x) { new (&state) example_impl{x}; }

example::~example()
{
  (reinterpret_cast<example_impl*>(&state))->~example_impl();
}

example::example(const example& other)
{
  const example_impl& impl =
      *(reinterpret_cast<const example_impl*>(&(other.state)));
  new (&state) example_impl(impl);
}

example& example::operator=(const example& other)
{
  const example_impl& impl =
      *(reinterpret_cast<const example_impl*>(&(other.state)));
  if (&other != this)
    {
      (reinterpret_cast<example_impl*>(&(this->state)))->~example_impl();
      new (&state) example_impl(impl);
    }
  return *this;
}

example::example(example&& other)
{
  example_impl& impl = *(reinterpret_cast<example_impl*>(&(other.state)));
  new (&state) example_impl(std::move(impl));
}

example& example::operator=(example&& other)
{
  example_impl& impl = *(reinterpret_cast<example_impl*>(&(other.state)));
  assert(this != &other); // could be persuaded to use an if() here
  (reinterpret_cast<example_impl*>(&(this->state)))->~example_impl();
  new (&state) example_impl(std::move(impl));
  return *this;
}

#if 0 // Clearer assignment functions due to MikeMB
example &example::operator=(const example &other) 
{
  *(reinterpret_cast<example_impl *>(&(this->state))) =
      *(reinterpret_cast<const example_impl *>(&(other.state)));
  return *this;
}   
example &example::operator=(example &&other) 
{
  *(reinterpret_cast<example_impl *>(&(this->state))) =
          std::move(*(reinterpret_cast<example_impl *>(&(other.state))));
  return *this;
}
#endif

int main()
{
  example an_example;
  example another_example{3};

  example copied(an_example);
  example moved(std::move(another_example));

  return 0;
}

Eu sei que isso é horrível. Não me importo em usar geradores de código, portanto, não é algo que eu precise digitar repetidamente.

Para declarar explicitamente o cerne dessa questão prolongada, as seguintes condições são suficientes para evitar UB | BID?

Tamanho do estado corresponde ao tamanho da instância de implO alinhamento de estado corresponde ao alinhamento da instância de implTodas as cinco operações padrão implementadas em termos das implementaçõesCanal novo usado corretamenteChamadas explícitas de destruidor usadas corretamente

Se forem, escreverei testes suficientes para que o Valgrind elimine os vários erros da demonstração. Obrigado a todos que chegarem tão longe!

editar: É possível inserir grande parte do clichê na classe base. Há um repositório no meu github chamado "pimpl", que está explorando isso. Eu não acho que haja uma boa maneira de instanciar implicitamente construtores encaminhados arbitrariamente, para que haja ainda mais digitação envolvida do que eu gostaria.

questionAnswers(1)

yourAnswerToTheQuestion