没有堆的pimpl.不正确还是迷信?

Jon*_*eld 13 c++ pimpl-idiom c++11

我渴望将接口与实现分开.这主要是为了保护使用库的代码来改变所述库的实现,尽管减少编译时间肯定是受欢迎的.

对此的标准解决方案是指向实现习惯用法的指针,最有可能通过使用unique_ptr并使用实现仔细定义类析构函数来实现.

这不可避免地引起了对堆分配的担忧.我熟悉"让它工作,然后快速完成","简介再优化"等智慧.还有在线文章,例如gotw,它宣称明显的解决方法是脆弱和不可移植.我有一个目前不包含任何堆分配的库 - 我想保持这种方式 - 所以让我们有一些代码.

#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
Run Code Online (Sandbox Code Playgroud)

这对我来说并不是太糟糕.在实现中可以静态地声明对齐和大小.我可以选择高估(低效)或重新编译所有内容(如果它们发生变化(繁琐)) - 但两种选择都不是很糟糕.

我不确定这种hackery是否会在继承存在的情况下工作,但由于我不太喜欢接口中的继承,所以我不介意太多.

如果我们大胆地假设我已经正确地编写了实现(我将它附加到这篇文章,但是这是一个未经测试的概念证明,因此这不是给定的),并且大小和对齐都大于或等于那个实现,那么代码是否展示了实现定义或未定义的行为?

#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;
}
Run Code Online (Sandbox Code Playgroud)

我知道这太可怕了.我不介意使用代码生成器,所以这不是我必须重复输出的东西.

要明确说明这个过长问题的关键,以下条件是否足以避免UB | IDB?

  • 状态大小与impl实例的大小匹配
  • 状态的对齐匹配impl实例的对齐
  • 所有五个标准操作都是根据impl实现的
  • 放置新的正确使用
  • 显式析构函数调用使用正确

如果是的话,我会为Valgrind编写足够的测试来清除演示中的几个错误.感谢任何走到这一步的人!

编辑:可以将很多样板文件推送到基类中.我的github上有一个名为"pimpl"的回购,正在探索这个.我不认为有一种很好的方法可以隐式实例化任意转发的构造函数,因此所涉及的输入仍然比我想要的更多.

Mik*_*eMB 5

是的,这是非常安全和可移植的代码.

但是,不需要在赋值运算符中使用放置新的和显式的销毁.除了它是异常安全和更高效之外,我认为使用以下的赋值运算符也更清晰example_impl:

//wrapping the casts
const example_impl& castToImpl(const unsigned char* mem) { return *reinterpret_cast<const example_impl*>(mem);  }
      example_impl& castToImpl(      unsigned char* mem) { return *reinterpret_cast<      example_impl*>(mem);  }


example& example::operator=(const example& other)
{
    castToImpl(this->state) = castToImpl(other.state);
    return *this;
}

example& example::operator=(example&& other)
{
    castToImpl(this->state) = std::move(castToImpl(other.state));
    return *this;
}
Run Code Online (Sandbox Code Playgroud)

就个人而言,我也会使用std::aligned_storage而不是手动对齐的char数组,但我想这是一个品味问题.

  • @ user1034749:为什么这是一个问题?问题的另一个选择是通过alignas运算符使用正确对齐的unsigned char数组 - 两种解决方案都是正确的. (2认同)