Всем привет! Решил на выходных продолжить писать свой домашний проект и наступила пора реализовать платформозависимый код. Самым простым вариантом было бы описать классы в *.h файле, а в зависимости от платформы, закрытые поля засунуть под #define. При этом, саму реализацию по конкретным платформам разнести по *.cpp файлам и включать их в компиляцию в зависимости от текущей платформы. Но... мне не нравится как выглядит описание класса с #define, поэтому я решил убрать препроцессор и оставить в описании класса только интерфейс. И да, я не пользовался абстрактными классами и pimpl, всё еще хуже :-)

Дисклеймер

Основной язык автора С.

Данный материал не несет в себе цель оскорбить или еще как-то нанести вред приверженцам чистого кода. Также автор не претендует на новизну, поскольку идея, по сути, тривиальна. Информация предоставлена исключительно потому как автор посчитал ее забавной.

Продолжим. Какие варианты здесь приходят сразу на ум. У меня два:

  1. Использовать абстрактный класс с виртуальными методами, затем наследовать его и реализовать эти методы. Добавить статичный метод для создания конкретного экземпляра.

  2. Воспользоваться техникой 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);
}
...

Итоги

Я, конечно, получил что хотел. Но здесь есть минусы:

  1. Автоматически вычисленные фишки компилятора, такие как sizeof или еще какие-либо операции над типом работать не будут. И тут надо четко понимать что вы делаете, ибо можно выстрелить себе во все места на ровном месте.

  2. Необходимость переопределять для каждого класса оператор new

  3. Нельзя выделять память под экземпляр объекта без new (по умолчанию, стек, глобальная память работать не будут)

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

  5. Еще не очень удобно в каждом методе класса приводить тип к скрытому классу/структуре (LinuxRenderData *pdata = (LinuxRenderData *)this)

Поэтому не рекомендую пользоваться никому. Рассматривайте статью как запоздалый пятничный юмор :-)

Комментарии (51)


  1. aamonster
    09.07.2022 18:54
    +5

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

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


    1. Fen1xL Автор
      09.07.2022 20:56

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

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

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

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

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


      1. aamonster
        09.07.2022 21:04

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

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


        1. Fen1xL Автор
          09.07.2022 21:10

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


          1. Kelbon
            09.07.2022 21:47
            +1

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


            1. Aldrog
              09.07.2022 22:16

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

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


        1. Kelbon
          09.07.2022 21:31
          -1

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

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

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


          1. Fen1xL Автор
            09.07.2022 21:39

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

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


            1. Kelbon
              09.07.2022 21:45

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


              1. Fen1xL Автор
                09.07.2022 21:54

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


                1. Kelbon
                  09.07.2022 22:20

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


                1. Aldrog
                  09.07.2022 22:27

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

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


                  1. Fen1xL Автор
                    09.07.2022 22:34

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


                  1. fk0
                    11.07.2022 01:00

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


                    1. Aldrog
                      11.07.2022 14:06

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


              1. aamonster
                09.07.2022 21:56

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

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


              1. fk0
                11.07.2022 01:00

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


  1. Racheengel
    09.07.2022 19:52
    +1

    Но зачем?

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


    1. JordanCpp
      09.07.2022 19:54
      +3

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


      1. fk0
        11.07.2022 01:03

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


        1. Racheengel
          11.07.2022 21:08

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

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


  1. JordanCpp
    09.07.2022 19:53
    +2

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


    1. Fen1xL Автор
      09.07.2022 21:07

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


      1. JordanCpp
        09.07.2022 21:09

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

        Пример.


        1. Fen1xL Автор
          09.07.2022 21:20

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


          1. Fen1xL Автор
            09.07.2022 21:31
            +1

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


          1. Kelbon
            09.07.2022 21:35

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


      1. fk0
        11.07.2022 01:05

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


  1. 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 секции кода, и оптимизиции компиляторов по девиртуализации.

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


    1. JordanCpp
      09.07.2022 20:03
      +1

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


      1. tzlom
        09.07.2022 23:15

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

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


        1. Kelbon
          10.07.2022 06:54
          +1

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

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


    1. Kelbon
      09.07.2022 21:37

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

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

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


      1. playermet
        10.07.2022 15:27

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


    1. fk0
      11.07.2022 01:13

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

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


      1. tony-space
        11.07.2022 02:37
        +1

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

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


  1. bfDeveloper
    09.07.2022 21:13
    +1

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

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


  1. NightShad0w
    09.07.2022 21:59
    +1

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


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


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


    1. JordanCpp
      09.07.2022 22:03

      Аминь:)


      1. fk0
        11.07.2022 01:15

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


    1. Fen1xL Автор
      09.07.2022 22:25
      -1

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

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


      1. JordanCpp
        09.07.2022 22:32
        +2

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


        1. Fen1xL Автор
          09.07.2022 22:53

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


          1. JordanCpp
            09.07.2022 23:04
            +1

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


            1. Fen1xL Автор
              09.07.2022 23:13

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


              1. JordanCpp
                09.07.2022 23:15
                +1

                Ересь:) Ловите еретика:)


          1. Kelbon
            10.07.2022 06:56

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


  1. 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 реализаций - имеют некоторое количество тривиального бойлерплейта, но - терпимо.


    1. Fen1xL Автор
      10.07.2022 10:46

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


    1. mapron
      10.07.2022 12:08

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


  1. 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). (у решения автора статьи его тоже нет, впрочем)