【问题标题】:Heap-free pimpl. Incorrect or superstition?无堆粉刺。不正确还是迷信?
【发布时间】:2015-11-17 23:28:58
【问题描述】:

编辑:这个问题可以追溯到 C++17 之前。这些天 std::launder 或等效应添加到线路噪音中。我现在没有时间更新代码以匹配。

我渴望将接口与实现分开。这主要是为了保护使用库的代码免受所述库的实现发生变化的影响,尽管减少编译时间当然是受欢迎的。

对此的标准解决方案是指向实现习惯用法的指针,最有可能通过使用 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

这对我来说看起来还不错。对齐和大小可以在实现中静态断言。我可以选择高估两者(低效)或重新编译所有内容(乏味) - 但两种选择都不可怕。

我不确定这种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;
}

我知道这很可怕。不过我不介意使用代码生成器,所以我不必重复输入。

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

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

如果是这样,我将为 Valgrind 编写足够的测试来清除演示中的几个错误。感谢所有能走到这一步的人!

【问题讨论】:

  • 我一般同意你的看法:像这样的技术对于性能至关重要,尤其是在多线程应用程序中(堆通常由各种互斥保护,并且可能还有许多其他线程:保持正常运行使用这种技术的堆栈可能意味着多线程应用程序缩放之间的差异)。
  • 避免所有堆分配意味着避免所有标准库容器。包括std::string,以及避免使用标准异常。这是相当严格的。但如果它对此足够重要,那么我认为使用新位置肯定是个好主意。然而,有一个GOTW 正是关于这个的。非常值得一看。
  • 所有这些重新解释演员表的意义何在?您可以只转发声明 èxample_impl 并存储从放置 new 返回的指针。除此之外,我很确定你的构造是 leagle - 虽然我不能引用标准中的相关部分
  • @user1034749 这个想法是提供一个稳定的 ABI。根据预期用途,您甚至可以为 capacity 提供大于要求的值,以允许未来的增长。

标签: c++ c++11 pimpl-idiom


【解决方案1】:

是的,这是完全安全且可移植的代码。

但是,没有必要在 赋值运算符 中使用放置新和显式销毁。除了异常安全和更高效之外,我认为仅使用 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;
}

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

【讨论】:

  • aligned_storage 的使用是不是口味问题,使用reinterpret_cast 并且未正确对齐存储即使在x86/amd64 这样的对齐容差平台上也可能会崩溃,更不用说sparcarm等其他人了。在x86/amd64上,问题可能在于example_impl的方法中double的使用,以及编译器sse2指令的排放
  • @user1034749:那么为什么会出现这个问题呢?问题中的替代方法是通过 alignas 运算符使用正确对齐的无符号字符数组 - 两种解决方案都是正确的。
  • @user1034749 也许您在 OP 的代码中忽略了 alignas(detail::alignment)std::aligned_storage 是对齐无符号字符数组或类似的语法糖包装器,它不提供该语言尚未提供的任何功能。
  • 我想我宁愿让演员表暴露在外,也不愿通过 unsigned char * 路由它们。调用 impl 的赋值运算符无疑更好,但我会这样做。谢谢。
  • @JonChesterfield:最后,我假设在这两种情况下,函数调用/局部变量都会被优化掉,所以你可以随心所欲
猜你喜欢
  • 1970-01-01
  • 2014-07-05
  • 1970-01-01
  • 2019-02-26
  • 1970-01-01
  • 1970-01-01
  • 1970-01-01
  • 2014-04-06
  • 1970-01-01
相关资源
最近更新 更多