Как стать автором
Обновить

Комментарии 56

Жёсткая шутка)

Но вообще на C++ штатное решение, если не хочется ходить через виртуальные функции или pimpl – темплейты (конкретнее – CRTP).

Жёсткая шутка)

Да, такой код в продакшн не годится)

конкретнее – CRTP

Спасибо. Не знал. Вполне себе рабочий вариант. Но код с шаблонами на c++, по мне, выглядит не очень красиво.

Статья, конечно, шуточная. В своем проекте я всё же запилил виртуальные функции. А реализацию классов в отдельные динамические библиотеки, чтобы можно было заменить файл библиотеки, скажем, с DirectX на Vulkan и без перекомпиляции основного бинарника всё заработало.

Да понятно, что шуточная. Вызов виртуальной функции – не так дорого, чтобы на нём экономить (плюс есть шанс, что современный компилятор его соптимизирует).

Тут скорее забавно, что CRTP на первый взгляд выглядит почти таким же безумием, как в статье (а то и бОльшим) – но активно используется.

Я, кстати, ради интереса попробовал собрать с оптимизацией вызовы колбеков на разных компиляторах. И у меня gcc догадался подставить фиксированный адрес, а вот msvс нет (смотрел ассемблер). Хотя у меня msvc не последний. Я к тому что надежда на компиляторы есть)

В самом примитивном случае - возможно, и то таблицу он никуда не денет, как и указатель на неё из типа. А если он в другой единице трансляции видит указатель/ссылку на виртуальную базу, то вероятность оптимизации 0

А если он в другой единице трансляции видит указатель/ссылку на виртуальную базу, то вероятность оптимизации 0

А если включена LTO?

Не понимаю как тут вам поможет CRTP, ничего лучше дефайна в таком случае не придумано. Нужно описать типы с одинаковым интерфейсом для разных платформ и сделать через #define простой алиас на платформозависимый тип

Оверхед виртуальной функции в бесконечное множество раз больше чем если бы её не было.

К тому же ухудшает восприятие кода и форсит выделять память, хуже решения не придумать(не считая того что в статье)

CRTP поможет поскольку скроет все платформозависимые типы в классе, который унаследован от шаблонного класса и который находится в платформозависимом файле *.cpp. Тут единственная проблема без оптимизации метод шаблонного класса будет вызывать аналогичный метод целевого класса, как прокси. Но при оптимизации оверхеда не будет. Но шаблоны это такое.

А вот то, что виртуальные методы ухудшают восприятие кода, согласен. Особенно когда взял какой-то проект и разбираешься в его архитектуре. Там местами настолько запутанно, что проще запустить и отрассировать его.

Ну вы же понимаете, что это ничем буквально не отличается от просто написания N типов под разные платформы? Только вместо этого вы влепите непойми к чему CRTP и напишете такие же типы?

Да не в этом идея) Идея изначальная была в том, чтобы в *.h файле для всех платформ не было различий и не было препроцессора. CRTP позволяет это сделать статически.

using render = std::conditional_t<IS_WINDOWS, window_render, linux_render>;

CRTP позволяет это сделать статически.

Не совсем. Он позволяет вынести платформозависимый код в отдельные классы, но для выбора реализации по прежнему понадобится препроцессор.

Согласен, я про препроцессор имел ввиду не надо будет писать #define

Не обязательно, вон выше же Kelbon написал пример.

Строго говоря, подстановка IS_WINDOWS всё равно делается препроцессором, но да, таким образом можно свести его использование к минимуму и сделать код достаточно аккуратным и понятным.

Когда реализации целиком разные – разумеется, нет никакого смысла в CRTP.

Он нужен только для вынесения общего кода в базовый класс (причём только если этот общий код может вызывать методы из конкретной реализации). Ну то есть решает плюс-минус ту же задачу, что виртуальные функции, но в compile-time и с некоторыми ограничениями.

CRTP позволяет иметь единый интерфейс к этим всем N типам и не различать, не знать (но компилятор фактически будет видеть определение, т.к. полиморфизм времени компиляции) их на вызывающей стороне.

CRTP не будет работать, потому что нужно целиком показывать классы "потомки" и тот platform specific код, который в них подставляется

Но зачем?

Это можно решить через систему сборки, которая в зависимости от платформы добавит в проект нужный cpp файл, а описание класса будет одинаковым и жить в h файле...

Да это оптимальный вариант, прост и работает без оверхеда.

Так не выйдет. Описание класса в .h начнёт сразу изобиловать платформо-специфичными деталями (в конце концов наполнение класса разное же, и sizeof знать надо, значит все типы знать надо, что в классе лежат). Почему лучше иметь какой-то абстрактный класс-интерфейс, pimpl или что-то вроде.

Выйдет, если всё платформозависимое столкать в кужные файлы, а в главном хедере оставить общий интерфейс с базовыми типами данных.

"Сам так делал" (с)

а platform specific поля из хедера ты куда денешь?

Вы получили new на ровном месте. Для использования класса дергать malloc, явный оверхед.

Подсистема рендеринга это большой класс и архитектурно он в любом случае создавался бы через new. К тому же это делается один раз при инициализации. Алокация во время основного цикла работы программы, вот это плохо. Плюс, внутри этого класса тот же DirectX или Vulkan при инициализации своих сущностей вызывает огромное количество алокаций памяти.

Не обязательно делать new. Это пример из моей либы. New только в конструкторе аллокатора. Все остальные классы создаются на стеке.

Пример.

У меня реализация класса лежит в динамической библиотеке и экземпляр класса создается через фабрику, там на стеке не будет алокации. К тому же проблема стека в том, что его надо контролировать, чтобы он не переполнился. Если на то пошло и надо оптимизировать по вызову динамической алокации, лучше выделить один раз себе здоровенный кусок памяти и со смещением выдавать своим сущностям.

Ну или совсем прекрасно, если можно использовать сущности как глобальные переменные, которые попадут в секцию data или bss. Там все на этапе старта программы алоцируется

Теоретически(и практически) можно убирать аллокации используя Small Object Optimization, не знаю где вам нужно контролировать стек, единственная ситуация когда он может на практике переполниться это бесконечная рекурсия

Если это по-сути синглтон, то его можно статически выделить. См. синглтон Майерса.

Принимая во внимание контекст задачи, где мы пытаемся абстрагироваться от реализации системы рендеринга, то вирутальные функций -- это как раз тот самый инструмент, который нужно здесь использовать. Этот инструмент был придуман ровно для этой задачи и подобным им. И яркий тому пример этого -- контекст в любой версии 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 секции кода, и оптимизиции компиляторов по девиртуализации.

Не нужно пытаться сэкономить на спичках. Данный класс будет тормозить совсем в других местах -- растеризации прямоугольников, рендеринге текста и выводе на экран.

Он будет тормозить если это виртуальный метод draw_pixel. А если выведи массив 100500 треугольников, то оверхеда не будет. Лет 20 назад, тоже виртуальные методы ничего не стоили. Если работа метода намного превышает время обращения к памяти, то это гуд.

Он не будет тормозить если это draw_pixel в цикле, компилятор достаточно умный чтобы разрешить указатель единожды и потом по нему ходить в цикле.

С другой стороны draw_pixel будет тормозить всегда, т.к. пакетная обработка всяко быстрее.

компилятор достаточно умный чтобы разрешить указатель единожды и потом по нему ходить в цикле.

Сколько бы умным он ни был, заинлайнить вызов он не сможет, это будет медленно

Вызов виртуальных функций на современных десктопных и мобильных CPU ничего уже не стоит, потому что branch-prediction, instruction cache секции кода, и оптимизиции компиляторов по

Нет, он всё ещё стоит и невероятно дорого. Запрет инлайнинга, размер бинаря, непонимание потом что происходит в коде и какого хрена кто то захреначил сюда виртуальные функции

Вы буквально предлагаете сделать оверхед ради ничего

Невероятно дорого - это сколько? Как пара умножений и присваивание? Не считая поточечного рисования для всех остальных методов это будет едва различимая погрешность. Инлайнинг и размер бинаря это противоположности. Никакого непонимания при такой реализации как раз нет, в отличие от примера из статьи.

Вызов виртуальных функций действительно не многого стоит. А вот само их наличие препятствует оптимизации -- компилятор попросту не знает, что там вызовется по-указателю, через таблицу виртуальных функций. И должен генерировать обобщённый, подходящий на все случаи жизни код. Не может ничего заинлайнить, не может оптимизировать машинный код. Это ровно та причина, почему CRTP заметно лучше. Если в интерфейсе не дай бог есть какие-то легковесные/быстрые функции, они сразу становятся намного тяжелее. Не за счет вызова по указателю, а именно за счёт отсутствия возможности оптимизации кода в компиляторе.

Да, современные компиляторы умеют делать "девиртуализацию", но для этого они должны быть железобетонно уверены, что в этом вот указателе/ссылке на базовый класс лежит конкретно вот этот класс. Что работает преимущественно в вариантах, когда его в одной функции создали и тут же используют. Когда указатель -- статическая переменная (базового класса), то информация о типе уже теряется. Если тип указателя сделать не базовым, а конкретным (зависимым от платформы), то девиртуализация везде сработает -- возможно именно это и нужно автору.

Девиртуализация работает и в контекстах когда компилятор точно не уверен. И механизм прост как дверь: https://youtu.be/w0sz5WbS5AM?t=3088

Я подчёркиваю главную мысль в первом и последнем абзацах своего изначального комментария: автор пытается приложить столько усилий оптимизировать то что тормозить будет в последнюю очередь. Небольшой "потенциальный" оверхед виртуальных функций погоды не сделает, зато код станет портируемым, без UB и понятным другим разработчикам для сопровождения.

Идея и решение - извращение, но спасибо, что поделились, это интересно.

Если хотите запретить создание на стеке, то лучше делать приватным деструктор, а не конструкторы. Это даёт возможность не делать create и при появлении новых публичных конструкторов, например в потомках, всё ещё будет работать.

Пятничный юмор одобрен.
Мне не совсем понятны условия и требования, чтобы судить о решении.
Спрятать приватные члены — это pImpl без всяких виртуальных методов. Платформозависимость-это или виртуальщина если в рантайме, или шаблоны и условная компиляция через препроцессор или систему сборки. pImpl И виртуальщина одновременно обычно не используются. Виртуальщина предполагает абстрактные интерфейсы, где приватных полей нет.


pImpl недостатки в статье не состоятельны. Компилятор прекрасно оптимизирует не виртуальные вызовы методов динамического объекта. Даже при использовании умных указателей. А выделение памяти для pImpl предлагаемым подходом не устраняется, а усугубляется: теперь надо не pImpl втихую в конструкторе выделять(или инжектить через конструктор, если мы хотим еще и тестировать), а весь объект.


Для изучающих С++: Пожалуйста, не тащите С в С++. Это разные языки.
То, что одно КАК БЫ поддерживается в другом, это не преимущество, а недостаток С++. Разыменование виртуальных методов в реальной жизни может оказаться дешевле, чем динамическая выделение каждого экземпляра. В данной ситуации, когда рантайм подмена реализаций не требуется, компилятор может определить, что виртуальщина не использутся и заменить все на прямые вызовы.
Невозможность(или чрезмерная сложность) использовать стандартные контейнеры с таким классом — огромный недостаток подхода.
Перегрузка new/delete — очень мутное дело: https://habr.com/ru/post/490640/
Без бенчмарков нельзя оптимизировать. А бенчмаркать надо продакшен код, а не синтетику.

Аминь:)

К слову, в данном случае перегрузка new/delete вовсе не нужна.

Я в комментарий выше написал, что это больше шуточная статья. Подмена сущности с сохранением интерфейса показалось мне забавным.

По поводу что быстрее, понятно, что каждое решение имеет свои плюсы и минусы. И надо понимать где и когда это применять.

Всем это понятно. Просто программисты на С++, заточены на производительность, и лишний вызов, обращение к памяти, виртуальный вызов, лишний new, как красная тряпка для быка. Сразу реакция, подождите минуточку:)

Ну я скажу так, я разработчик низкоуровневого софта (драйвера под ОС, либо bare metal под embedded) и мой основной язык это С. По большей части никаких динамических алокаций, абстракций и прочего, что сожрет и так сильно ограниченные ресурсы. И проблема С++ в том, что его многие абстракции не годятся для производительности, они больше про удобство, хотя если не использовать все эти фичи, то разницы нет. И поэтому С++ развращает)

Я простой богобоязненный бэкенд разработчик С#. Обмазываюсь, тормозами каждый день. Тысячи new доверяю в руки сборщика мусора. Создаю классы зная, что все они ссылочные типы и живут в куче. Обмазываю все интерфейсами, зная, что это как минимум дополнительное обращение к памяти. И понимаю, что как бы я не старался, но у C# есть предел производительности, который невозможно преодолеть. Как то на грустной ноте закончил.:)

Ну к слову C# крутой. Я на нем тестовые программки пишу, удобней чем С++. А местами, из-за JIT компиляции бывало, что он быстрее работал нежели программа на C++.

Абстракции С++ про производительность как раз. То что вы вместо span используете указатель и размер это не увеличит производительность, а уменьшит, плюс вы получите возможность ошибаться на ровном месте

В плане обмена опытом рекомендую посмотреть начало (и конец, с ответами на вопросы по конкретной теме) вот этой презентации, то есть, её части, посвящённые 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 реализаций - имеют некоторое количество тривиального бойлерплейта, но - терпимо.

По поводу FastPimpl в видео, хардкодить размер в хедере это первое что приходит в голову, это, конечно, на этапе компиляции проверяется, да и работает в итоге. Но как-то выглядит не очень. Спасибо за ссылку

Да, магические числа (размер, выравнивание болванки std::aligned_storage - кстати, этот класс из стандарта убирают в C++23) в хедере; при несоответсвии правильным числам (размеру и выравниванию класса Impl, реализации) static_assert выдаёт правильные числа в диагностике (трюк, по-моему, в презентации Антона описан). То, что выглядит не очень - сходная цена при ограниченном применении. Я подумывал о более широком применении FastPImpl с генерацией скучного кода, но - поскольку применяю не часто, пока не сделал.

Я тоже сразу вспомнил Полухина, когда открыл эту статью)
Но широко использовать его подход для сотен классов не получится. Оптимизировать один два класса так, да, можно.
По сути C++20 Modules должны делать FastPimpl за нас.

Спасибо за ссылку про модули ниже - кое-что читал по ним, но пока не играл с ними (не могу использовать на работе, и, похоже, ещё довольно долго не смогу).

С FastPImpl у меня есть опция (которую, впрочем, я пока не применял) подменять реализацию класса Xyz::Impl моком в случае, если я собираю тест для кода, использующего Xyz. Просто, вместо либы, реализующей настоящий Xyz::Impl, при линковке подставить мок-либу (ну или просто добавить в код теста #include на хедер-онли реализацию мока Xyz::Impl). Вероятно, с модулями тоже можно проделывать хаки такого рода, по крайней мере, при определённой дисциплине использования модулей.

Есть еще одно решение — модули. Не полностью заменяет 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). (у решения автора статьи его тоже нет, впрочем)

и как это будет реализовано? путем добавление #ifdef мусора при написании класса Render?

по-моему, это то, чего автор как раз хотел избежать

Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Истории