Без кучи прыщ. Неправильно или суеверие?
Я стремлюсь отделить интерфейс от реализации. Это в первую очередь для защиты кода, использующего библиотеку, от изменений в реализации указанной библиотеки, хотя сокращение времени компиляции, безусловно, приветствуется.
Стандартным решением для этого является указатель на идиому реализации, который, скорее всего, будет реализован с использованием 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
Это выглядит не так уж плохо для меня. Выравнивание и размер могут быть статически утверждены в реализации. Я могу выбирать между переоценкой обоих (неэффективно) или перекомпиляцией всего, если они меняются (утомительно) - но ни один из вариантов не является ужасным.
Я не уверен, что такого рода хакерство сработает при наличии наследования, но, поскольку я не очень люблю наследование в интерфейсах, я не слишком возражаю.
Если мы смело предположим, что я написал реализацию правильно (я добавлю ее в этот пост, но это непроверенное доказательство концепции на данный момент, так что это не дано), а размер и выравнивание больше или равны что из реализации, то код демонстрирует реализацию, определенную или неопределенную, поведение?
#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
- Все пять стандартных операций реализованы с точки зрения
- Размещение новых используется правильно
- Явные вызовы деструкторов, используемые правильно
Если это так, я напишу достаточно тестов для Valgrind, чтобы устранить несколько ошибок в демо. Спасибо всем, кто зашел так далеко!
редактировать: можно подтолкнуть много шаблонного в базовый класс. На моем github есть репозиторий под названием "pimpl", который изучает это. Я не думаю, что есть хороший способ неявного создания экземпляров произвольных перенаправленных конструкторов, так что все еще задействовано больше типов, чем мне бы хотелось.
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
вместо выровненного вручную массива символов, но я думаю, это дело вкуса.