无堆疙瘩。错误还是迷信?
Heap-free pimpl. Incorrect or superstition?
编辑:这个问题可以追溯到 C++17 之前。这些天 std::launder 或等效物应该添加到线路噪音中。我现在没有时间更新代码以匹配。
我渴望将接口与实现分开。这主要是为了保护使用库的代码免受所述库实现的更改,尽管减少编译时间当然是受欢迎的。
对此的标准解决方案是指向实现习语的指针,最有可能通过使用 unique_ptr 并仔细定义不符合实现的 class 析构函数来实现。
这不可避免地引起了对堆分配的担忧。我熟悉 "make it work, then make it fast"、"profile then optimise" 等智慧。网上也有文章,例如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 是否会在存在继承的情况下工作,但由于我不太喜欢接口中的继承,所以我不太介意。
如果我们大胆地假设我已经正确地编写了实现(我将把它附加到此 post,但此时它是一个未经测试的概念证明,所以这不是给定的),并且两者大小和对齐方式大于或等于实现的大小和对齐方式,那么代码是否表现出实现定义或未定义的行为?
#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 编写足够的测试以清除演示中的几个错误。感谢所有走到这一步的人!
是的,这是非常安全且可移植的代码。
但是,没有必要在您的赋值运算符中使用 placement new 和显式销毁。除了异常安全和更高效之外,我认为仅使用 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 数组,但我想那是个人喜好问题。
编辑:这个问题可以追溯到 C++17 之前。这些天 std::launder 或等效物应该添加到线路噪音中。我现在没有时间更新代码以匹配。
我渴望将接口与实现分开。这主要是为了保护使用库的代码免受所述库实现的更改,尽管减少编译时间当然是受欢迎的。
对此的标准解决方案是指向实现习语的指针,最有可能通过使用 unique_ptr 并仔细定义不符合实现的 class 析构函数来实现。
这不可避免地引起了对堆分配的担忧。我熟悉 "make it work, then make it fast"、"profile then optimise" 等智慧。网上也有文章,例如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 是否会在存在继承的情况下工作,但由于我不太喜欢接口中的继承,所以我不太介意。
如果我们大胆地假设我已经正确地编写了实现(我将把它附加到此 post,但此时它是一个未经测试的概念证明,所以这不是给定的),并且两者大小和对齐方式大于或等于实现的大小和对齐方式,那么代码是否表现出实现定义或未定义的行为?
#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 编写足够的测试以清除演示中的几个错误。感谢所有走到这一步的人!
是的,这是非常安全且可移植的代码。
但是,没有必要在您的赋值运算符中使用 placement new 和显式销毁。除了异常安全和更高效之外,我认为仅使用 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 数组,但我想那是个人喜好问题。