Мудрецы мира С++ часто напоминают нам о том как важно максимально точно выражать свои мысли в коде, делать код понятным и простым, не теряя при этом (а зачастую и выигрывая) в эффективности. Но задумайтесь как выглядит в С++ код связанный с динамическим полиморфизмом.

Сложные иерархии наследования, ручное управление памятью, провисшие ссылки, аллокация на каждый объект, тот самый виртуальный деструктор, о котором обязательно спрашивают джунов, большие накладные расходы на вызов, так как компилятор очень плох в девиртуализации, непереиспользуемые классы - класс, который написан для использования в полиморфном контексте неэффективно использовать в других контекстах, создание часто ненужных rtti нод...

Типичный код с виртуальными функциями выглядит примерно так:

class Ifoo {
public:
    virtual int foo() const = 0;
    virtual ~Ifoo() = default;
};

class Bar : public Ifoo {
public:
    int foo() const override {
        return i + 10;
    }
    int i;
};

К сожалению, многие просто не замечают здесь ничего особенного, нормальный же код?

Нет.

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

Подумайте как правильно скопировать такой полиморфный объект. Придётся делать новый метод типа Clone(), изощряться, ох, не этим мы хотели заниматься, неправда ли? Неужели эту проблему не замечал никто? Замечали конечно, но долгое время в стандарте С++ не было других способов работы с динамическим полиморфизмом.

В С++17 появились такие инструменты как std::any и std::variant, вкратце опишем их устройство, плюсы и минусы:

std::any - объект этого класса способен хранить любой другой копируемый объект. Собственно всё. Позволяет избежать некоторых аллокаций, но использовать это практически невозможно, нужно либо угадать что там лежит, либо просто использовать его как деструктор для любого объекта.

std::variant куда интереснее, объект этого класса всегда* хранит объект одного из типов, указанных на компиляции. Большой плюс - возможность заинлайнить деструкторы и другие нужные методы(проявляется особенно хорошо для тривиальных типов), никаких аллокаций, НО у этой штуки громадные проблемы с раздуванием кода и временем компиляции, можно неожиданно вовсе сделать страшные вещи (https://godbolt.org/z/3hxxP8PvW) к тому же ещё большие проблемы с исключениями - внезапно variant не всегда хранит значение, он может быть .valueless_by_exception! И полагаю никто всерьёз не собирается обрабатывать эту ситуацию, ну и пользоваться variant'ом неочень то удобно (через std::visit)

Резюмируя - std::any не подходит ни для чего в силу сложности взаимодействия с тем что лежит внутри, std::variant отлично подходит для тривиальных типов и когда sizeof у них близкий, а вот если объекты сложные, могут бросить исключение или у альтернатив сильно разный sizeof, то это будет крайне неэффективно, не считая конечно того что придётся менять код каждый раз, когда добавляется новая альтернатива.

И С++ до сих пор не имеет стандартной возможности работы с этим!

Но не отчаивайтесь, решение существует. Представьте сначала каким было бы идеальное решение, как бы оно выглядело в коде.

А пока вы представляете я опишу реальное. И оно очень красивое.

Что если мы пойдем дальше и разовьём идею std::any, позволив захватывать произвольные методы у объектов? Что то типа runtime концепта? Программист говорит "хочу хранить в этом объекте любые другие объекты, обладающие методом foo", или, строго говоря, удовлетворяющие списку требований.

Пример - программист хочет создать тип такой, который хранит любой executor, а executor'ом он считает всё, у чего есть метод execute принимающий std::function<void()>

В идеальном мире в коде я представляю себе это так:

template<typename T>
concept executor = requires (T value, std::function<void()> foo) {
  value.execute(foo)
};
using any_executor = any_<executor>;

А теперь реальный код, который реально работает

template<typename T>
struct Execute {
  static void do_invoke(const T& self, std::function<void()> foo) {
    self.execute(std::move(foo));
  }
};
using any_executor = aa::any_with<Execute>;

Всё! Тип создан, его можно использовать

struct real_executor {
  void execute(auto f) const {
    f();
  }
};
int main() {
  any_executor exe = real_executor{};
  aa::invoke<Execute>(exe, [] { std::cout << "Hello world"; });
}

Причём понятное дело можно объявить внутри any_executor метод, который вызывает Execute и пользоваться классом как и всеми другими, максимально удобно. Мы получили ясный и понятный код, value семантику (копирование, мув и прочие плюшки), убрали наследование, управление памятью (и аллокации собственно тоже почти все убрали), real_executor можно использовать не только в полиморфном контексте, то есть увеличилось переиспользование кода, плюсы со всех сторон. Немаловажно также упомянтуь, что мы получили strong exeption guarantee, которой у variant не было.

Ну и добавить конечно же можно любое количество требований к типу (методов), можно добавить только копирование или только мув (что например избавляет от проблемы в std::function, для решения которой добавляют std::move_only_function...)

Чуть не забыл - реализация лежит тут.

Пробуйте, предлагайте, взгляните на С++ по новому! И обязательно выражайте свои мысли в коде правильно!

P.S.

Вызов функции идентичен в ассемблерном коде вызову через vtable,

Виртуальные функции

Anyany

А остальное использование согласно бенчмаркам примерно на уровне variant по производительности (у виртуальных функций просто нет возможностей по копированию и муву объекта, так что сравнение было с variant и std::proxy, предлагаемым в С++23).

Также в реализации нет ни одного слова virtual и не создаются rtti ноды (но можно подключить метол aa::RTTI и можно будет спросить std::type_info у any).

https://github.com/kelbon/AnyAny

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


  1. Sklott
    11.04.2022 14:24
    +12

    И обязательно выражайте свои мысли в коде правильно!

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

    У меня такое ощущение, что вам очень сильно "жмет" жесткое типизирование С++, и вы изобретаете варианты как-бы его расслабить и писать в стиле языков с динамической типизацией...


    1. Kelbon Автор
      11.04.2022 14:41

      какую-же мысль неправильно выражать через виртуальные функции

      Почти всё что написано сейчас с использованием виртуальных функций не отражает реальных намерений писавшего

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


      1. qw1
        11.04.2022 15:29

        Почти всё что написано сейчас с использованием виртуальных функций не отражает реальных намерений писавшего
        Это почему? У меня, например, нет никаких проблем, перечисленных выше (копирование, наследование, вирт. функции), потому что применяю это только в некритичных нишах. И там лишние 100 копирований или аллокаций в секунду ничего не значат.

        А на критичных участках кода не надо включать ООП головного мозга и делать ячейку матрицы объектом с (виртуальными) методами add, mul, clone, если можно сменить парадигму: писать не ООП-код, а процедурный, когда алгоритм обрабатывает данные, простые POD-ы, тип которых можно передать шаблонным параметром, чтобы не копипастить.

        Применяя это к вашему примеру с Pet и Cat, не надо делать pet.Say(), а надо делать
        say(Cat c);
        say(Pet p);
        и дать языку самому разрулить перегрузки.

        Писать так, как принято в C++, следовать примеру стандартной библиотеки, а не натягивать сову на глобус.


        1. Kelbon Автор
          11.04.2022 15:42
          -3

          ??? Первый аргумент про то что вам плевать на производительность - не аргумент. Читаемость кода тоже улучшается.

          Второе также звучит как очень плохой стиль, если что то можно сделать на компиляции - оно должно быть сделано на компиляции. Очевидно в статье речь про вещи которые происходят НЕ на компиляции и их НЕВОЗМОЖНО перенести на компиляцию.


  1. qw1
    11.04.2022 15:40

    Хотелось бы ещё понять, что под капотом.
    Например, если написать так:

    struct real_executor {
      void execute(auto f) const {
        f();
      }
      std::string t { "real executor string" };
    };
    // ...
      any_executor exe = real_executor{};

    То на последней строке, в any_executor будет скопирована строка t? Откуда этот тип заранее знает, что под неё нужно выделить место?


    1. Kelbon Автор
      11.04.2022 15:55

      Возможно в следующей статье я буду объяснять как это устроено. Тут будет вызван мув конструктор(тот что по дефолту) real_executor в место в памяти, где any_executor хранит объект.

      Есть также возможность написать так
      any_executor exe{std::in_place_type<real_executor>);
      Тогда не будет даже мув конструктора, а только конструирование объекта типа real_executor в памяти с дефолтным его конструктором


      1. qw1
        11.04.2022 16:08

        А если так сделать:

        struct real_executor {
          void execute(auto f) const {
            f();
          }
          std::string t { "real executor string" };
        };
        
        struct big_executor {
          void execute(auto f) const {
            f();
          }
          char buffer[0x100000];
        };
        // ...
          any_executor exe = real_executor{};
        // ...
          exe = big_executor{};

        В области памяти, заранее выделенной под any_executor, достаточно места для big_executor? Компилятор как-то может увидеть все места, в которых происходят такие присваивания, и сделать sizeof(any_executor) достаточно большим, чтобы там было достаточно места для любого такого объекта?


        1. Kelbon Автор
          11.04.2022 16:30

          Для таких вещей есть variant, в С++ состояния на компиляции(если не считать багов стандарта) нет и соответственно сделать это невозможно. Будет аллокация для вмещения нового объекта, об этой аллокации будет известно на компиляции в точке exe = big_executor{};
          Но можно в basic_any заменить аллокатор и размер буфера, кастомизация в общем имеется


          1. qw1
            11.04.2022 16:33

            То есть, any_executor размещает объекты не прямо на своём месте, а динамической памяти, которую выделяет при необходимости и проксирует на них вызовы. Либо там возможна оптимизация, как в std::string — для маленьких строк хранить их прямо в себе, а строки побольше — в куче. Но тогда нужна runtime диспетчеризация, для обработки обоих этих случаев, а это тоже накладные расходы.


            1. qw1
              11.04.2022 16:37

              Просто статья выглядит как «ура! я решил все проблемы! вы должны по-новому взглянуть на c++», но механизм решения не объясняется, а когда начинаешь узнавать детали, вылезают всякие компромисы.


              1. Kelbon Автор
                11.04.2022 16:39

                Да, там оптимизация в виде буфера(регулируемого размера), а как ещё вы представляете это? Других решений просто нет и тут дело не в С++, а просто так устроено мироздание.

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


                1. qw1
                  11.04.2022 16:51

                  Другого решения нет в парадигме ООП. А в других парадигмах — пожалуйста. Например, можно объекты разного типа класть в разные контейнеры ))
                  Да, там будут другие проблемы, но и их так же можно будет решать через другие компромисы.


                  1. Kelbon Автор
                    11.04.2022 17:11

                    в контейнерах тоже нужно выделять память и вызывать эффективнее методы тоже не выйдет


                    1. qw1
                      11.04.2022 17:30

                      Насчёт памяти — зато я точно знаю, что vector.reserve(n) сделает ровно 1 аллокацию на нужное число объектов, а не то, что где-то под капотом any_pet operator = может быть аллокация, а может и не быть.

                      К слову, в этом и есть самая большая проблема вашей библиотеки. В мире C++ принято иметь полное понимание и гарантии сложности и потребления памяти. В стандарте точно документируется, когда вектор выделяет память, когда нет, когда может перемещать объекты, когда не может. Использовать ваш AnyAny без такой документации сомнительная затея.

                      Насчёт эффективности методов — вот решение. Если у меня строго 2 вида петов Dog и Cat, я делаю

                      std::vector<Cat> cats;
                      std::vector<Dog> dogs;

                      и дальше уже думаю, как избежать кучи бойлерплейта. например, вызвав 2 раза шаблонный метод
                      void Process(std::vector<T>& container);
                      // ...
                      Process(cats);
                      Process(dogs);
                      

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

                      Чем, кстати, вам не угодил IFoo, с которого вы начали статью? Как выяснилось, цена виртуального вызова там и у вас примерно одинакова (за исключением того, что про виртуальные методы знает компилятор и может их при возможности девиртуализировать или как-то ещё оптимизировать, чего с вашей библиотекой он сделать не может).
                      Не нравятся голые указатели? Берите unique_ptr. Не нравится, что в vector лежат не объекты, а сами указатели? И чем не нравится? Много мелких аллокаций? Возьмите какой-нибудь стандартный пул объектов и аллоцируйте сразу по 100500 и берите указатели из пула, создавайте объекты через микро-обёртку и placement new. Но и стандартный аллокатор достаточно хорош, не думаю что в реальном проекте будет разница по сравнению с пулом.


                      1. Kelbon Автор
                        11.04.2022 17:38

                        Разумеется и у меня в библиотеке не случайным образом выделяется память. Повторю - есть возможность добавить аллокатор. Так что аргумент про reserve(n) уходит в аллокаторы.

                        Что насчет "понимания когда происходит аллокация" - это известно на компиляции. Есть даже шаблонная переменная позволяющая узнать будет ли аллокация если положить тип T в any, если вам интересно можете посмотреть на неё и какие условия там проверяются

                        ВЫ НЕ МОЖЕТЕ СДЕЛАТЬ 2 ВЕКТОРА НА КОМПИЛЯЦИИ И ВЫЗЫВАТЬ ШАБЛОННЫЙ МЕТОД.

                        Потому что ВЫ НЕ ЗНАЕТЕ КАКОЙ ТИП НА РАНТАЙМЕ ЛЕЖИТ ТАМ, как вы собрались тут использовать шаблонный метод? Вы не понимаете проблему и уже пишите "решение"


                      1. qw1
                        11.04.2022 19:42

                        Как раз 2 вектора я могу сделать, что и продемонстрировал выше.
                        А вот если я заранее не знаю, сколько мне надо векторов, то уже не могу. Но поскольку все типы известны в compile-тайм, то опять же могу, но не средствами языка, а кодогенераторами, например.

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

                        Жаль, что вы не захотели прокомментировать, чем же вам не нравятся virtual методы и создание объектов просто через new/make_unique (и тогда не надо заморачиваться с копируемостью). Вы упомянули, что ваше решение можно дотянуть до уровня «не хуже» по стоимости вызова (но и не лучше), видимо это рассматривалось как одно из важных аспектов.


                      1. Kelbon Автор
                        11.04.2022 19:48

                        Потому что new / make _unique это кривой ужасный код не отражающий реальных намерений программиста, ухудшающий понимание и производительность кода. Это упомянуто в посте.

                        Как вы будете копировать полиморфный объект корректно под unique_ptr - задумайтесь

                        Вот вам пример
                        struct machine {
                        any_engine m_engine;
                        };

                        Всё, тут есть дефолт конструкторы делающие всё что нужно.

                        Попробуйте повторить это с виртуальными функциями и make_unique


                      1. qw1
                        11.04.2022 23:32

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

                        Пытался скомпилировать пример и посмотреть, но с MSVC 2019 (16.11.10) что-то не получается (с ключом /std:c++20 или /std:c++latest) — не нравится ему слово requires в строчке
                        if constexpr ((requires { typename Method::allocator_type; }))


                      1. Kelbon Автор
                        12.04.2022 08:14

                        Переключите в настройках проекта -. Общее -> Platform toolset на Visual Studio 2022 (v143), потому что в v142 баг парсера.

                        Возможно у вас не последняя версия, у msvc был баг парсера в этом месте. Можно переключить в VS на кланг или вынести эту строку в отдельную constexpr переменную на строку выше

                        Ну и вообще это делается через

                        cmake . -B build

                        cmake --build build

                        (в репозитории в ридми написано), там за вас расставлены все флаги


                      1. qw1
                        12.04.2022 08:55

                        В 2019-й студии у меня нет тулсета v143. Когда-нибудь обновлю, посмотрю ещё разок.

                        Проясните, пожалуйста, вопрос с вызовом деструктора, который я задал выше.


                      1. Kelbon Автор
                        12.04.2022 09:16

                        Механизм примерно такой же как с виртуальным деструктором


                      1. qw1
                        12.04.2022 12:03

                        Но здесь нет виртуальных методов. Адрес деструктора известен только в compile-time. Вот у меня есть класс

                        struct A
                        {
                            void execute(auto f) const { f(); }
                            std::string s;
                            ~A() = default;
                        };

                        Как any_executor получит адрес деструктора, после такого присваивания:
                        any_executor a = A();


                      1. Kelbon Автор
                        12.04.2022 12:08

                        Посмотрите в реализацию, там лучше всего это объяснено.

                        struct destroy посмотрите например


                      1. qw1
                        12.04.2022 12:20

                        Понял. Примеры, приведённые вами в статье, некорректны. real_executor нельзя присваивать в any_executor без наследования real_executor от специального

                        basic_any< real_executor > 
                        который сам умеет дестроиться.

                        Понятно, почему комментаторы ниже говорили о CRTP, в ваших примерах это всё опущено.


                      1. Kelbon Автор
                        12.04.2022 12:21
                        +1

                        Чего? Вы где это вообще взяли?


                      1. qw1
                        12.04.2022 12:32

                        метод, который вызывает деструктор

                        destroy::do_invoke(const T* self)

                        имеет шаблонный параметр типа, который надо разрушить.
                        Как я понял, T — это финальный тип basic_any с учётом подстановок всех шаблонов, и чтобы он стал типом real_executor, real_executor должен быть тем самым basic_any<real_executor>.


                      1. Kelbon Автор
                        12.04.2022 13:45
                        +1

                        метод не имеет шаблонного параметра, шаблонным является struct destroy, на будущее посоветую вам не кричать о разоблачении автора не понимая как это работает


                      1. qw1
                        12.04.2022 18:50

                        Обновил MSVS, пока детально не разбирался с механизмом, но кажется наткнулся на баги.

                        struct real_executor {
                          void execute(auto f) const {
                            f();
                          }
                          real_executor() { std::cout << " .ctor "; }
                          ~real_executor() { std::cout << " .dtor "; }
                        };
                        int main()
                        {
                                any_executor exe = real_executor{};
                                aa::invoke<Execute>(exe, [] { std::cout << "Hello"; });
                                return 0;
                        }

                        выводит
                        .ctor .dtor Hello .dtor
                        1 вызов конструктора и 2 вызова деструктора, непорядок.

                        Может быть, я что-то упускаю и вызывается какой-то другой конструктор, которого я не вижу? Сделал так:
                        struct inner
                        {
                          inner() { std::cout << " .ctor-inner "; }
                          ~inner() { std::cout << " .dtor-inner "; }
                        };
                        struct real_executor {
                          void execute(auto f) const {
                            f();
                          }
                          real_executor() { std::cout << " .ctor "; }
                          ~real_executor() { std::cout << " .dtor "; }
                          inner i;
                        };

                        выводит
                        .ctor-inner .ctor .dtor .dtor-inner Hello .dtor .dtor-inner
                        У класса inner я никак не могу пройти в другой конструктор, так что тут точно проблема.

                        И ещё, такое не компилируется:
                                any_executor exe = real_executor{};
                                exe = real_executor{};


                      1. Kelbon Автор
                        12.04.2022 18:57

                        Последнее не компилируется потому что нужно добавить методы, (в readme всё написано)

                        aa::copy / aa::move, то есть по умолчанию только деструктор, хочется копировать - добавляешь копирование, хочется мув - добавляешь мув.

                        using any_executor = aa::any_with<execute, aa::copy, aa::move>;


                      1. Kelbon Автор
                        12.04.2022 19:01
                        +1

                        Насчёт первого, нет, это не баг

                        any_executor exe = real_executor{};

                        в этой строке очевидно что real_executor создаётся дефолтным конструктором и в конце полного выражения разрушается деструктором.

                        Внутрь exe он перемещается не дефолтным конструктором, а мув, так что ничего не пишется.

                        В конце скоупа вызывается деструктор в exe, так что вывод в cout абсолютно верный


                      1. qw1
                        12.04.2022 19:35

                        Всё работает. Магия какая-то ))


  1. dendron
    11.04.2022 22:34
    +1

    Очень напомнило Python с его Protocol.

    В качестве теоретического упражнения на использование std::any - прикольно.

    С практической точки зрения, я бы лично не стал бы включать ради такого заголовочный файл на 800 строк, полный шаблонной магии. Честное слово, лучше уж смириться с виртуальными функциями, чем тащить что-то такое монструозное и Boost-подобное. Выше уже дали грамотные советы как лучше организовывать код.


    1. Kelbon Автор
      12.04.2022 09:18

      Как только модули будут поддерживаться cmake это будет С++20 модуль, так что можете не переживать насчет времени компиляции


      1. qw1
        12.04.2022 12:09

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


        1. Kelbon Автор
          12.04.2022 12:13

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


          1. qw1
            12.04.2022 12:23

            Так парсинг это относительно быстро. Тяжко выполнять подстановки в шаблоны, рекурсивно и с разными вариантами, пока в каком-то из вариантов не получится допустимая конструкция (см. SFINAE). И в модуле закешировать подстановки нельзя, потому что корень перебора — в пользовательском коде.


            1. Kelbon Автор
              12.04.2022 13:47
              +1

              В библиотеке практически нет никаких выборов перегрузок, которые заставляют компилятор изобретать типы, есть несколько простых специализаций, которые внутри разрешаются как перегрузки, но это отнюдь не самое затратное в компиляции


    1. Kelbon Автор
      12.04.2022 09:36

      P.S. надеюсь про советы выше как организовывать код вы писали про собственно статью, где эти советы и содержатся


  1. tarekd
    12.04.2022 04:47

    Поздравляю с изобретением велосипеда CRTP!

    Проблема в том, что он обеспечивает только Bounded Polymorphism.


    1. tarekd
      12.04.2022 05:34

      Можно прикрутить к CRTP еще и Type Erasure и получить такого Франкенштейна

      CRTP + Type Erasure
      #include <iostream>
      #include <memory>
      
      template <typename T> concept Executable = requires(T a) { a.execute(); };
      
      void execute(Executable auto &&e) { e.execute(); }
      
      struct Base {
        virtual void execute() const = 0;
        virtual ~Base(){};
      };
      
      template <typename Derived> struct Executor : Base {
        void execute() const { return static_cast<const Derived *>(this)->execute(); }
      };
      
      struct Foo : Executor<Foo> {
        void execute() const { std::cout << "Foo!" << std::endl; }
      };
      
      struct Moo : Executor<Moo> {
        void execute() const { std::cout << "Moo!" << std::endl; }
      };
      
      int main() {
        std::array<std::unique_ptr<Base>, 2> objs{std::make_unique<Foo>(),
                                                  std::make_unique<Moo>()};
      
        execute(Foo());         // no virtual dispatch
        execute(*objs.front()); // virtual dispatch
        execute(Moo());         // no virtual dispatch
        execute(*objs.back());  // virtual dispatch
      }


      1. Kelbon Автор
        12.04.2022 08:17

        Это не CRTP потому что CRTP про компайл тайм полиморфизм. А тут именно техника type erasure


  1. izvolov
    13.04.2022 00:08

    Интересно. Похоже на Boost.TypeErasure.