LINUX.ORG.RU

PIMPL без указателя

 


1

8

Всем хорошо известен паттерн 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?

★★★★★

Последнее исправление: Dendy (всего исправлений: 4)
Ответ на: комментарий от anonymous

На стеке не создашь

unique_ptr<MyClass>

в вектор не сунешь

А с каких пор объекты в том же Qt можно засовывать в вектор? У них же запрещён оператор копирования.

Часто тебе приходится в Qt делать миллионы QObject?

Я выше показал, что даже в простом примере Qt при старте создаётся 5000+ объектов, в реальных задачах на порядок больше, что на слабом железе выливается в тормоза, особенно когда это не единственное приложение в системе.

std::make_shared тоже

В Qt объекты и так никогда не были разделяемыми, иначе в базовом классе был бы подсчёт ссылок. Но если очень хочется, то можно специализировать std::allocate_shared.

Dendy ★★★★★
() автор топика
Ответ на: комментарий от annulen

Кстати, в llvm PDF тоже никак не объясняется создание объекта на стеке. Ведь для этого на этапе компиляции нужно знать размер объекта, который предлагается вычислять в рантайме приплюсовав к размеру класса размер приватных данных. А поскольку у нас должна сохраняться бинарная совместимость, то размер приватных данных на этапе компиляции неизвестен и может меняться от версии к версии Qt.

Dendy ★★★★★
() автор топика
Ответ на: комментарий от Dendy

Справедливо, но может существовать в виде флага configure типа -fuck-off-abi-compatibility, я думаю пользователей найдется немало

annulen ★★★★★
()
Последнее исправление: annulen (всего исправлений: 1)
Ответ на: комментарий от Dendy

Я выше показал, что даже в простом примере Qt при старте создаётся 5000+ объектов, в реальных задачах на порядок больше, что на слабом железе выливается в тормоза, особенно когда это не единственное приложение в системе.

Должно хорошо оптимизироваться переопределением new для конкретных типов/размеров - по типу slab, но более заоптимизированное под конкретный случай.

Pavval ★★★★★
()
Вы не можете добавлять комментарии в эту тему. Тема перемещена в архив.