Всем привет! Решил на выходных продолжить писать свой домашний проект и наступила пора реализовать платформозависимый код. Самым простым вариантом было бы описать классы в *.h файле, а в зависимости от платформы, закрытые поля засунуть под #define. При этом, саму реализацию по конкретным платформам разнести по *.cpp файлам и включать их в компиляцию в зависимости от текущей платформы. Но... мне не нравится как выглядит описание класса с #define, поэтому я решил убрать препроцессор и оставить в описании класса только интерфейс. И да, я не пользовался абстрактными классами и pimpl, всё еще хуже :-)
Дисклеймер
Основной язык автора С.
Данный материал не несет в себе цель оскорбить или еще как-то нанести вред приверженцам чистого кода. Также автор не претендует на новизну, поскольку идея, по сути, тривиальна. Информация предоставлена исключительно потому как автор посчитал ее забавной.
Продолжим. Какие варианты здесь приходят сразу на ум. У меня два:
Использовать абстрактный класс с виртуальными методами, затем наследовать его и реализовать эти методы. Добавить статичный метод для создания конкретного экземпляра.
Воспользоваться техникой pimpl (Pointer to Implementation), когда в исходном классе только указатель на другой класс/структуру, который(ая) реализует требуемый функционал или содержит поля с данными.
Первый вариант мне не нравится поскольку на ровном месте создается ненужная дополнительная нагрузка (тот самый overhead) в виде таблицы виртуальный методов. По сути это указатели на функции. Возможно при некой оптимизации какой-нибудь компилятор додумается оптимизировать вызовы, возможно.
Второй вариант тоже не нравится, ибо для доступа к полям скрытого класса нужно каждый раз считать еще одно смещение. К тому же здесь есть дополнительная нагрузка в виде вызова new/malloc для скрытого класса/структуры.
Как бы было проще если можно было бы так
// A.h
class A
{
private:
static const size_t _sizeofdata;
unsigned char _data[_sizeofdata];
};
// A.cpp
struct LinuxSpecifiecData
{
int a, b, c;
};
const size_t A::_sizeofdata = sizeof(LinuxSpecifiecData);
Но ясен пень так нельзя
И тут я подумал, а можно ли реализовать класс, который будет без этих дополнительных нагрузок и при этом не надо будет в описании класса декларировать закрытые поля. Тут я задумался как бы я сделал это на чистом С и у меня возникла идея. Экстремально простая. Естественно, не без минусов, но об этом позже.
Необходимо при вызове new для класса выделять память для другого (скрытого) класса, ну или структуры. К примеру имеется у нас описание класса со следующими методами (файл Render.h):
class Render
{
public:
void clear();
void present();
void clipRegion(int x, int y, int width, int height);
void drawLine(int x1, int y1, int x2, int y2, unsigned int color);
void drawRectangle(
int x, int y, int width, int height,
unsigned int cornerRadius,
unsigned int solidColor, unsigned int borderColor);
void drawPicture(int x, int y, int width, int height, int pictureIndex);
void drawText(
int x, int y, int width, int height,
int fontIndex,
const char *text, unsigned int textLength,
unsigned int foregroundColor, unsigned int backgroundColor);
};
У каждой платформы своя реализация и свои закрытые поля у этого класса, но объявлять мы их здесь не будем. Лучше объявим их в новой структуре в исходном файле, к примеру LinuxRender.cpp:
...
struct LinuxRenderData
{
int a, b, c;
};
...
Теперь переопределим для класса Render операторы new и delete.
// Render.h
class Render
{
public:
...
void *operator new(std::size_t size);
void operator delete(void *ptr);
...
};
// LinuxRender.cpp
...
void *Render::operator new(std::size_t size) {
return malloc(sizeof(LinuxRenderData));
}
void Render::operator delete(void *ptr) {
free(ptr);
}
...
Теперь у нас при создании экземпляра объекта выделяется память для скрытой структуры. И мы можем использовать данные этой структуры в методах класса Render. К примеру добавим метод initialize.
// Render.h
class Render
{
public:
...
int initialize();
...
};
// LinuxRender.cpp
...
int Render::initialize() {
LinuxRenderData *pdata = (LinuxRenderData *)this;
pdata->a = pdata->b = pdata->c = 0;
return 0;
}
...
Вот собственно и вся идея. Но, воскликнет читатель, что будет если класс Render будет создан не через new, а, к примеру, в стеке или глобально. И тут начинаются минусы. Тут я скажу что всё сломается) Ибо sizeof(Render) будет 1, поскольку полей класс Render не имеет. Лепим костыли. Поэтому надо запретить создавать класс не через new. Для этого делаем конструкторы закрытыми. А также создадим статичный метод create для создания экземпляров класса.
// Render.h
class Render
{
public:
static Render *create();
...
private:
Render();
Render(const Render &);
...
};
// LinuxRender.cpp
...
Render *Render::create() {
return new Render();
}
...
Здесь можно добавить ещё один костыль в виде статичного метода sizeOf, ибо sizeof не будет выдавать правильное значение.
// Render.h
class Render
{
public:
static size_t sizeOf();
...
};
// LinuxRender.cpp
...
size_t Render::sizeOf() {
return sizeof(LinuxRenderData);
}
...
Итоги
Я, конечно, получил что хотел. Но здесь есть минусы:
Автоматически вычисленные фишки компилятора, такие как sizeof или еще какие-либо операции над типом работать не будут. И тут надо четко понимать что вы делаете, ибо можно выстрелить себе во все места на ровном месте.
Необходимость переопределять для каждого класса оператор new
Нельзя выделять память под экземпляр объекта без new (по умолчанию, стек, глобальная память работать не будут)
Необходимость создания метода для каждого подобного класса, который будет создавать экземпляр этого класса
Еще не очень удобно в каждом методе класса приводить тип к скрытому классу/структуре (LinuxRenderData *pdata = (LinuxRenderData *)this)
Поэтому не рекомендую пользоваться никому. Рассматривайте статью как запоздалый пятничный юмор :-)
Комментарии (51)
Racheengel
09.07.2022 19:52+1Но зачем?
Это можно решить через систему сборки, которая в зависимости от платформы добавит в проект нужный cpp файл, а описание класса будет одинаковым и жить в h файле...
JordanCpp
09.07.2022 19:54+3Да это оптимальный вариант, прост и работает без оверхеда.
fk0
11.07.2022 01:03Так не выйдет. Описание класса в .h начнёт сразу изобиловать платформо-специфичными деталями (в конце концов наполнение класса разное же, и sizeof знать надо, значит все типы знать надо, что в классе лежат). Почему лучше иметь какой-то абстрактный класс-интерфейс, pimpl или что-то вроде.
Racheengel
11.07.2022 21:08Выйдет, если всё платформозависимое столкать в кужные файлы, а в главном хедере оставить общий интерфейс с базовыми типами данных.
"Сам так делал" (с)
JordanCpp
09.07.2022 19:53+2Вы получили new на ровном месте. Для использования класса дергать malloc, явный оверхед.
Fen1xL Автор
09.07.2022 21:07Подсистема рендеринга это большой класс и архитектурно он в любом случае создавался бы через new. К тому же это делается один раз при инициализации. Алокация во время основного цикла работы программы, вот это плохо. Плюс, внутри этого класса тот же DirectX или Vulkan при инициализации своих сущностей вызывает огромное количество алокаций памяти.
JordanCpp
09.07.2022 21:09Не обязательно делать new. Это пример из моей либы. New только в конструкторе аллокатора. Все остальные классы создаются на стеке.
Fen1xL Автор
09.07.2022 21:20У меня реализация класса лежит в динамической библиотеке и экземпляр класса создается через фабрику, там на стеке не будет алокации. К тому же проблема стека в том, что его надо контролировать, чтобы он не переполнился. Если на то пошло и надо оптимизировать по вызову динамической алокации, лучше выделить один раз себе здоровенный кусок памяти и со смещением выдавать своим сущностям.
Fen1xL Автор
09.07.2022 21:31+1Ну или совсем прекрасно, если можно использовать сущности как глобальные переменные, которые попадут в секцию data или bss. Там все на этапе старта программы алоцируется
Kelbon
09.07.2022 21:35Теоретически(и практически) можно убирать аллокации используя Small Object Optimization, не знаю где вам нужно контролировать стек, единственная ситуация когда он может на практике переполниться это бесконечная рекурсия
fk0
11.07.2022 01:05Если это по-сути синглтон, то его можно статически выделить. См. синглтон Майерса.
tony-space
09.07.2022 19:56+5Принимая во внимание контекст задачи, где мы пытаемся абстрагироваться от реализации системы рендеринга, то вирутальные функций -- это как раз тот самый инструмент, который нужно здесь использовать. Этот инструмент был придуман ровно для этой задачи и подобным им. И яркий тому пример этого -- контекст в любой версии DirectX
https://docs.microsoft.com/en-us/windows/win32/api/d3d11/nn-d3d11-id3d11devicecontext
Имея абстракный интерфейс на руках:
1. Можно полностью избавиться от define, переложив создание объектов на абстракную фабрику.
2. Подключение реализации фабрики и реализации интерфейса можно разрулить через CMake: если собираем под Windows, подключаем одни CPP файлы, если под Linux, то другие.
3. Нет необходимости городить кастомные аллокаторы / деалокаторы. Если очень уж нужно, то всё это можно переложить на фабрику, и так называемый deleter умных указателей.
Вызов виртуальных функций на современных десктопных и мобильных CPU ничего уже не стоит, потому что branch-prediction, instruction cache секции кода, и оптимизиции компиляторов по девиртуализации.
Не нужно пытаться сэкономить на спичках. Данный класс будет тормозить совсем в других местах -- растеризации прямоугольников, рендеринге текста и выводе на экран.JordanCpp
09.07.2022 20:03+1Он будет тормозить если это виртуальный метод draw_pixel. А если выведи массив 100500 треугольников, то оверхеда не будет. Лет 20 назад, тоже виртуальные методы ничего не стоили. Если работа метода намного превышает время обращения к памяти, то это гуд.
tzlom
09.07.2022 23:15Он не будет тормозить если это draw_pixel в цикле, компилятор достаточно умный чтобы разрешить указатель единожды и потом по нему ходить в цикле.
С другой стороны draw_pixel будет тормозить всегда, т.к. пакетная обработка всяко быстрее.
Kelbon
10.07.2022 06:54+1компилятор достаточно умный чтобы разрешить указатель единожды и потом по нему ходить в цикле.
Сколько бы умным он ни был, заинлайнить вызов он не сможет, это будет медленно
Kelbon
09.07.2022 21:37Вызов виртуальных функций на современных десктопных и мобильных CPU ничего уже не стоит, потому что branch-prediction, instruction cache секции кода, и оптимизиции компиляторов по
Нет, он всё ещё стоит и невероятно дорого. Запрет инлайнинга, размер бинаря, непонимание потом что происходит в коде и какого хрена кто то захреначил сюда виртуальные функции
Вы буквально предлагаете сделать оверхед ради ничего
playermet
10.07.2022 15:27Невероятно дорого - это сколько? Как пара умножений и присваивание? Не считая поточечного рисования для всех остальных методов это будет едва различимая погрешность. Инлайнинг и размер бинаря это противоположности. Никакого непонимания при такой реализации как раз нет, в отличие от примера из статьи.
fk0
11.07.2022 01:13Вызов виртуальных функций действительно не многого стоит. А вот само их наличие препятствует оптимизации -- компилятор попросту не знает, что там вызовется по-указателю, через таблицу виртуальных функций. И должен генерировать обобщённый, подходящий на все случаи жизни код. Не может ничего заинлайнить, не может оптимизировать машинный код. Это ровно та причина, почему CRTP заметно лучше. Если в интерфейсе не дай бог есть какие-то легковесные/быстрые функции, они сразу становятся намного тяжелее. Не за счет вызова по указателю, а именно за счёт отсутствия возможности оптимизации кода в компиляторе.
Да, современные компиляторы умеют делать "девиртуализацию", но для этого они должны быть железобетонно уверены, что в этом вот указателе/ссылке на базовый класс лежит конкретно вот этот класс. Что работает преимущественно в вариантах, когда его в одной функции создали и тут же используют. Когда указатель -- статическая переменная (базового класса), то информация о типе уже теряется. Если тип указателя сделать не базовым, а конкретным (зависимым от платформы), то девиртуализация везде сработает -- возможно именно это и нужно автору.
tony-space
11.07.2022 02:37+1Девиртуализация работает и в контекстах когда компилятор точно не уверен. И механизм прост как дверь: https://youtu.be/w0sz5WbS5AM?t=3088
Я подчёркиваю главную мысль в первом и последнем абзацах своего изначального комментария: автор пытается приложить столько усилий оптимизировать то что тормозить будет в последнюю очередь. Небольшой "потенциальный" оверхед виртуальных функций погоды не сделает, зато код станет портируемым, без UB и понятным другим разработчикам для сопровождения.
bfDeveloper
09.07.2022 21:13+1Идея и решение - извращение, но спасибо, что поделились, это интересно.
Если хотите запретить создание на стеке, то лучше делать приватным деструктор, а не конструкторы. Это даёт возможность не делать create и при появлении новых публичных конструкторов, например в потомках, всё ещё будет работать.
NightShad0w
09.07.2022 21:59+1Пятничный юмор одобрен.
Мне не совсем понятны условия и требования, чтобы судить о решении.
Спрятать приватные члены — это pImpl без всяких виртуальных методов. Платформозависимость-это или виртуальщина если в рантайме, или шаблоны и условная компиляция через препроцессор или систему сборки. pImpl И виртуальщина одновременно обычно не используются. Виртуальщина предполагает абстрактные интерфейсы, где приватных полей нет.pImpl недостатки в статье не состоятельны. Компилятор прекрасно оптимизирует не виртуальные вызовы методов динамического объекта. Даже при использовании умных указателей. А выделение памяти для pImpl предлагаемым подходом не устраняется, а усугубляется: теперь надо не pImpl втихую в конструкторе выделять(или инжектить через конструктор, если мы хотим еще и тестировать), а весь объект.
Для изучающих С++: Пожалуйста, не тащите С в С++. Это разные языки.
То, что одно КАК БЫ поддерживается в другом, это не преимущество, а недостаток С++. Разыменование виртуальных методов в реальной жизни может оказаться дешевле, чем динамическая выделение каждого экземпляра. В данной ситуации, когда рантайм подмена реализаций не требуется, компилятор может определить, что виртуальщина не использутся и заменить все на прямые вызовы.
Невозможность(или чрезмерная сложность) использовать стандартные контейнеры с таким классом — огромный недостаток подхода.
Перегрузка new/delete — очень мутное дело: https://habr.com/ru/post/490640/
Без бенчмарков нельзя оптимизировать. А бенчмаркать надо продакшен код, а не синтетику.Fen1xL Автор
09.07.2022 22:25-1Я в комментарий выше написал, что это больше шуточная статья. Подмена сущности с сохранением интерфейса показалось мне забавным.
По поводу что быстрее, понятно, что каждое решение имеет свои плюсы и минусы. И надо понимать где и когда это применять.
JordanCpp
09.07.2022 22:32+2Всем это понятно. Просто программисты на С++, заточены на производительность, и лишний вызов, обращение к памяти, виртуальный вызов, лишний new, как красная тряпка для быка. Сразу реакция, подождите минуточку:)
Fen1xL Автор
09.07.2022 22:53Ну я скажу так, я разработчик низкоуровневого софта (драйвера под ОС, либо bare metal под embedded) и мой основной язык это С. По большей части никаких динамических алокаций, абстракций и прочего, что сожрет и так сильно ограниченные ресурсы. И проблема С++ в том, что его многие абстракции не годятся для производительности, они больше про удобство, хотя если не использовать все эти фичи, то разницы нет. И поэтому С++ развращает)
JordanCpp
09.07.2022 23:04+1Я простой богобоязненный бэкенд разработчик С#. Обмазываюсь, тормозами каждый день. Тысячи new доверяю в руки сборщика мусора. Создаю классы зная, что все они ссылочные типы и живут в куче. Обмазываю все интерфейсами, зная, что это как минимум дополнительное обращение к памяти. И понимаю, что как бы я не старался, но у C# есть предел производительности, который невозможно преодолеть. Как то на грустной ноте закончил.:)
Kelbon
10.07.2022 06:56Абстракции С++ про производительность как раз. То что вы вместо span используете указатель и размер это не увеличит производительность, а уменьшит, плюс вы получите возможность ошибаться на ровном месте
sashagil
10.07.2022 01:34В плане обмена опытом рекомендую посмотреть начало (и конец, с ответами на вопросы по конкретной теме) вот этой презентации, то есть, её части, посвящённые FastPImpl: https://www.youtube.com/watch?v=mkPTreWiglk&t=1046s (перемотано 2 мин 20 сек от начала - сразу к делу).
Disclaimer: я с Антоном Полухиным не знаком и к Яндексу отношения не имею, просто с интересом просмотрел эту презентацию и использовал изложенные идеи реализации Fast Pimpl, когда мне это понадобилось (не найдя ничего лучше на тот момент). Сейчас посмотрел написанный тогда код - определение template-класса FastPImpl (помощника) занимает примерно 25 строк довольно прямолинейной логики. Они включают в себя определения делегирующего конструктора и деструктора (а не операторов new, delete), проверку соответствия размера и выравнивания во время компиляции (с помощью static_assert), удобные аксессоры для использующего класса. Move-конструктор и move-присваивание тоже делегируются, а копирующие я делитнул (и до сих пор ни один из классов, скрывающих таким способом реализацию, не потребовал семантики копирования). Использую как раз в ситуациях вроде описанной вами - для изоляции публичного объявляния класса от реализации (когда это имеет смысл) с нулевым / минимальным оверхеадом (зависит от того, включена ли опция link time code generation в билде). Использующие классы - публичные интерфейсы своих скрытых за FastPImpl реализаций - имеют некоторое количество тривиального бойлерплейта, но - терпимо.Fen1xL Автор
10.07.2022 10:46По поводу FastPimpl в видео, хардкодить размер в хедере это первое что приходит в голову, это, конечно, на этапе компиляции проверяется, да и работает в итоге. Но как-то выглядит не очень. Спасибо за ссылку
mapron
10.07.2022 12:08Я тоже сразу вспомнил Полухина, когда открыл эту статью)
Но широко использовать его подход для сотен классов не получится. Оптимизировать один два класса так, да, можно.
По сути C++20 Modules должны делать FastPimpl за нас.
mapron
10.07.2022 12:06Есть еще одно решение — модули. Не полностью заменяет Pimpl, но для части сценариев — да.
www.reddit.com/r/cpp_questions/comments/rz9yxi/do_c20_modules_make_the_pimpl_idiom_irrelevant
Получается примерно какая штука, во время сборки модуля у вас будетclass Render { private: SomeLinuxType m_one; AnotherLinuxType m_two; };
грубо и приближенно, при импорте этого модуля (если никто не экспортирует классы) юзер получит некое подобиеclass Render { private: byte m_one[sizeof(SomeLinuxType)]; byte m_two[sizeof(AnotherLinuxType )]; };
Т.е. пользователю класса не нужно подлючать в хедер определения типов полей (что зачастую основной повод для Pimpl).
Плюсы:
1. no indirection. разместил на стеке, никакой динамической аллокации. можно фигачить переменные с глобальным лайфтаймом, и прочие плюшки. Компилятор видит типы приватных полей ( в отличие от программиста!) и может делать всякие инлайновые доступы внутри SomeLinuxType того же.
Минусы:
1. Нужна поддержка C++20 Modules. Нужна поддержка и в компиляторе и в билд системе. Нужна ХОРОШАЯ поддержка, без багов (т.е. если компилятор таки будет делать экспорт определений из модуля, это опять же не вариант).
2. При изменении SomeLinuxType, необходима перекомпиляция интерфейса модуля Render, а значит и всего пользовательского кода который использует Render. Если вы хотите изменять код и не иметь пересборки зависимостей — не годится.
3. вытекает из предыдущего, нет ABI stability. Добавили поле в SomeLinuxType? получите изменение sizeof(Render). (у решения автора статьи его тоже нет, впрочем)
aamonster
Жёсткая шутка)
Но вообще на C++ штатное решение, если не хочется ходить через виртуальные функции или pimpl – темплейты (конкретнее – CRTP).
Fen1xL Автор
Да, такой код в продакшн не годится)
Спасибо. Не знал. Вполне себе рабочий вариант. Но код с шаблонами на c++, по мне, выглядит не очень красиво.
Статья, конечно, шуточная. В своем проекте я всё же запилил виртуальные функции. А реализацию классов в отдельные динамические библиотеки, чтобы можно было заменить файл библиотеки, скажем, с DirectX на Vulkan и без перекомпиляции основного бинарника всё заработало.
aamonster
Да понятно, что шуточная. Вызов виртуальной функции – не так дорого, чтобы на нём экономить (плюс есть шанс, что современный компилятор его соптимизирует).
Тут скорее забавно, что CRTP на первый взгляд выглядит почти таким же безумием, как в статье (а то и бОльшим) – но активно используется.
Fen1xL Автор
Я, кстати, ради интереса попробовал собрать с оптимизацией вызовы колбеков на разных компиляторах. И у меня gcc догадался подставить фиксированный адрес, а вот msvс нет (смотрел ассемблер). Хотя у меня msvc не последний. Я к тому что надежда на компиляторы есть)
Kelbon
В самом примитивном случае - возможно, и то таблицу он никуда не денет, как и указатель на неё из типа. А если он в другой единице трансляции видит указатель/ссылку на виртуальную базу, то вероятность оптимизации 0
Aldrog
А если включена LTO?
Kelbon
Не понимаю как тут вам поможет CRTP, ничего лучше дефайна в таком случае не придумано. Нужно описать типы с одинаковым интерфейсом для разных платформ и сделать через #define простой алиас на платформозависимый тип
Оверхед виртуальной функции в бесконечное множество раз больше чем если бы её не было.
К тому же ухудшает восприятие кода и форсит выделять память, хуже решения не придумать(не считая того что в статье)
Fen1xL Автор
CRTP поможет поскольку скроет все платформозависимые типы в классе, который унаследован от шаблонного класса и который находится в платформозависимом файле *.cpp. Тут единственная проблема без оптимизации метод шаблонного класса будет вызывать аналогичный метод целевого класса, как прокси. Но при оптимизации оверхеда не будет. Но шаблоны это такое.
А вот то, что виртуальные методы ухудшают восприятие кода, согласен. Особенно когда взял какой-то проект и разбираешься в его архитектуре. Там местами настолько запутанно, что проще запустить и отрассировать его.
Kelbon
Ну вы же понимаете, что это ничем буквально не отличается от просто написания N типов под разные платформы? Только вместо этого вы влепите непойми к чему CRTP и напишете такие же типы?
Fen1xL Автор
Да не в этом идея) Идея изначальная была в том, чтобы в *.h файле для всех платформ не было различий и не было препроцессора. CRTP позволяет это сделать статически.
Kelbon
using render = std::conditional_t<IS_WINDOWS, window_render, linux_render>;
Aldrog
Не совсем. Он позволяет вынести платформозависимый код в отдельные классы, но для выбора реализации по прежнему понадобится препроцессор.
Fen1xL Автор
Согласен, я про препроцессор имел ввиду не надо будет писать #define
fk0
Не обязательно, вон выше же Kelbon написал пример.
Aldrog
Строго говоря, подстановка IS_WINDOWS всё равно делается препроцессором, но да, таким образом можно свести его использование к минимуму и сделать код достаточно аккуратным и понятным.
aamonster
Когда реализации целиком разные – разумеется, нет никакого смысла в CRTP.
Он нужен только для вынесения общего кода в базовый класс (причём только если этот общий код может вызывать методы из конкретной реализации). Ну то есть решает плюс-минус ту же задачу, что виртуальные функции, но в compile-time и с некоторыми ограничениями.
fk0
CRTP позволяет иметь единый интерфейс к этим всем N типам и не различать, не знать (но компилятор фактически будет видеть определение, т.к. полиморфизм времени компиляции) их на вызывающей стороне.