Всем хорошо известен паттерн PIMPL через скрытие реализации за указателем. С известными же проблемами неоптимального исполнения, когда нужно дополнительно аллоцировать память под сам указатель. Сходу нашёл в интернете варианты вместо указателя использовать заранее большой массив, на котором вызывается placement new, но такой подход вызывает ещё больше вопросов.
В самом C++ для скрытия реализации есть абстрактные интерфейсы без данных, реализация которых создаётся через фабрику или статический метод вместо конструктора. Однако с абстрактными интерфейсами одна беда — если наследовать один интерфейс от другого, то их реализации будут иметь ромбовидное наследование — один раз от наследуемого интерфейса, второй раз от реализации базового интерфейса. Ложку дёгтя добавляет необходимость замены конструкторов фабриками и руками делать правильные вызовы родительских конструкторов. Добавим сюда необходимость писать для каждого класса фактически два: один обстрактный, второй с реализацией. Получим достаточно неудобный подход, недостатки которого устраняет PIMPL.
Возьмём к примеру библиотеку Qt, объектная модель которой полностью построена на PIMPL и наследовании от базового QObject. При этом сами объекты на стеке или по значению создаются крайне редко, в повальном случае экземпляры QObject выделяются в куче. Получаем двойную аллокацию: первую для самого указателя, вторую для данных по указателю. Также при использовании подобных экземпляров будет двойное разименование, что также негативно скажется на производительности. При этом подсознательно понимаешь, что подобный подход бессмысленнен, ведь PIMPL-указатель никогда не подменяет реализацию на этапе выполнения, он создаётся исключительно в конструкторе и удаляется исключительно в деструкторе объекта.
А что если вообще без указателя и данных в интерфейсе? Вместо дополнительного указателя на данные использовать указатель на интерфейс. Аллокацию же переопределять операторами new и delete.
Вот простой пример, нарисованный на коленке:
#pragma once
#include <cstddef>
class A
{
public:
A(int value = 42);
int value() const;
void setValue(int value);
DECLARE_PRIVATE(A)
};
class B : public A
{
public:
B(int data);
int data() const;
DECLARE_PRIVATE(B)
};
#include "lib.h"
class A::Private
{
public:
int value;
};
DEFINE_PRIVATE(A)
A::A(const int value)
{
to_private()->value = value;
}
int A::value() const
{
return to_private()->value;
}
void A::setValue(const int value)
{
to_private()->value = value;
}
class B::Private : public A::Private
{
public:
int data;
};
DEFINE_PRIVATE(B)
B::B(const int data)
{
to_private()->data = data;
}
int B::data() const
{
return to_private()->data;
}
DECLARE_PRIVATE и DEFINE_PRIVATE переопределяют операторы new и delete и определяют методы приведения реализации к интерфейсу:
#define DECLARE_PRIVATE(T) \
public: \
void * operator new(std::size_t size); \
void operator delete(void * ptr); \
class Private; \
private: \
const Private * to_private() const { return reinterpret_cast<const Private*>(this); } \
Private * to_private() { return reinterpret_cast<Private*>(this); }
#define DEFINE_PRIVATE(T) \
void * T::operator new(const std::size_t size) \
{ \
return new Private; \
} \
\
void T::operator delete(void * const ptr) \
{ \
delete static_cast<Private*>(ptr); \
}
Ограничения данного подхода очевидны: объекты нельзя создавать на стеке или в виде переменной-члена класса. Аллоцировать их можно только через new, к примеру:
std::unique_ptr<B> b(new B(95));
b->setValue(33);
Сравним с традиционным подходом в Qt:
- Объявление класса: Qt — Q_DECLARE_PRIVATE(classname), в нашем случае то же самое — DECLARE_PRIVATE(classname).
- Определение класса: Qt — наследование реализации, то же самое у нас + DEFINE_PRIVATE(classname).
- Аллокация класса: Qt — явный вызов базового класса с передачей указателя на реализацию
MyObject::MyObject() : MyParentObject(*new MyObjectPrivate)
у нас — неявный вызов базового класса без лишних телодвижений.
- Обращение к реализации: Qt — d_func(), у нас — to_private().
- Аллокация объекта в куче: Qt — две аллокации, в нашем случае — одна.
- Аллокация объекта на стеке: Qt — одна аллокация, в нашем случае тоже одна, через явный operator new.
Осталось только понять как запретить создавать объекты на стеке на этапе компиляции, но это уже детали.
Я догадываюсь, что всё выше есть велосипед, но почему-то я нигде не встречал подобной реализации (кроме исходников Андроида). Собственно, какие у есть минусы у данного подхода? Какие известные проекты практикуют подобный подход вместо традиционного PIMPL?