Мудрецы мира С++ часто напоминают нам о том как важно максимально точно выражать свои мысли в коде, делать код понятным и простым, не теряя при этом (а зачастую и выигрывая) в эффективности. Но задумайтесь как выглядит в С++ код связанный с динамическим полиморфизмом.
Сложные иерархии наследования, ручное управление памятью, провисшие ссылки, аллокация на каждый объект, тот самый виртуальный деструктор, о котором обязательно спрашивают джунов, большие накладные расходы на вызов, так как компилятор очень плох в девиртуализации, непереиспользуемые классы - класс, который написан для использования в полиморфном контексте неэффективно использовать в других контекстах, создание часто ненужных 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,
А остальное использование согласно бенчмаркам примерно на уровне variant по производительности (у виртуальных функций просто нет возможностей по копированию и муву объекта, так что сравнение было с variant и std::proxy, предлагаемым в С++23).
Также в реализации нет ни одного слова virtual и не создаются rtti ноды (но можно подключить метол aa::RTTI и можно будет спросить std::type_info у any).
Комментарии (42)
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? Откуда этот тип заранее знает, что под неё нужно выделить место?Kelbon Автор
11.04.2022 15:55Возможно в следующей статье я буду объяснять как это устроено. Тут будет вызван мув конструктор(тот что по дефолту) real_executor в место в памяти, где any_executor хранит объект.
Есть также возможность написать так
any_executor exe{std::in_place_type<real_executor>);
Тогда не будет даже мув конструктора, а только конструирование объекта типа real_executor в памяти с дефолтным его конструктором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) достаточно большим, чтобы там было достаточно места для любого такого объекта?Kelbon Автор
11.04.2022 16:30Для таких вещей есть variant, в С++ состояния на компиляции(если не считать багов стандарта) нет и соответственно сделать это невозможно. Будет аллокация для вмещения нового объекта, об этой аллокации будет известно на компиляции в точке exe = big_executor{};
Но можно в basic_any заменить аллокатор и размер буфера, кастомизация в общем имеетсяqw1
11.04.2022 16:33То есть, any_executor размещает объекты не прямо на своём месте, а динамической памяти, которую выделяет при необходимости и проксирует на них вызовы. Либо там возможна оптимизация, как в std::string — для маленьких строк хранить их прямо в себе, а строки побольше — в куче. Но тогда нужна runtime диспетчеризация, для обработки обоих этих случаев, а это тоже накладные расходы.
qw1
11.04.2022 16:37Просто статья выглядит как «ура! я решил все проблемы! вы должны по-новому взглянуть на c++», но механизм решения не объясняется, а когда начинаешь узнавать детали, вылезают всякие компромисы.
Kelbon Автор
11.04.2022 16:39Да, там оптимизация в виде буфера(регулируемого размера), а как ещё вы представляете это? Других решений просто нет и тут дело не в С++, а просто так устроено мироздание.
То же самое с вызовом, конечно же если могут быть вызваны разные функции в рантайме нужно выбрать какую из них вызвать - в рантайме. И в статье даже есть ассемблерный код этого вызова в сравнении с виртуальными функциями
qw1
11.04.2022 16:51Другого решения нет в парадигме ООП. А в других парадигмах — пожалуйста. Например, можно объекты разного типа класть в разные контейнеры ))
Да, там будут другие проблемы, но и их так же можно будет решать через другие компромисы.Kelbon Автор
11.04.2022 17:11в контейнерах тоже нужно выделять память и вызывать эффективнее методы тоже не выйдет
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. Но и стандартный аллокатор достаточно хорош, не думаю что в реальном проекте будет разница по сравнению с пулом.Kelbon Автор
11.04.2022 17:38Разумеется и у меня в библиотеке не случайным образом выделяется память. Повторю - есть возможность добавить аллокатор. Так что аргумент про reserve(n) уходит в аллокаторы.
Что насчет "понимания когда происходит аллокация" - это известно на компиляции. Есть даже шаблонная переменная позволяющая узнать будет ли аллокация если положить тип T в any, если вам интересно можете посмотреть на неё и какие условия там проверяются
ВЫ НЕ МОЖЕТЕ СДЕЛАТЬ 2 ВЕКТОРА НА КОМПИЛЯЦИИ И ВЫЗЫВАТЬ ШАБЛОННЫЙ МЕТОД.
Потому что ВЫ НЕ ЗНАЕТЕ КАКОЙ ТИП НА РАНТАЙМЕ ЛЕЖИТ ТАМ, как вы собрались тут использовать шаблонный метод? Вы не понимаете проблему и уже пишите "решение"
qw1
11.04.2022 19:42Как раз 2 вектора я могу сделать, что и продемонстрировал выше.
А вот если я заранее не знаю, сколько мне надо векторов, то уже не могу. Но поскольку все типы известны в compile-тайм, то опять же могу, но не средствами языка, а кодогенераторами, например.
Тут уже либо потери на диспетчеризацию в рантайме, либо бескомпромисная копипаста нужного числа векторов в компил-тайме, зато с возможностью инлайна всех методов.
Жаль, что вы не захотели прокомментировать, чем же вам не нравятся virtual методы и создание объектов просто через new/make_unique (и тогда не надо заморачиваться с копируемостью). Вы упомянули, что ваше решение можно дотянуть до уровня «не хуже» по стоимости вызова (но и не лучше), видимо это рассматривалось как одно из важных аспектов.
Kelbon Автор
11.04.2022 19:48Потому что new / make _unique это кривой ужасный код не отражающий реальных намерений программиста, ухудшающий понимание и производительность кода. Это упомянуто в посте.
Как вы будете копировать полиморфный объект корректно под unique_ptr - задумайтесь
Вот вам пример
struct machine {
any_engine m_engine;
};Всё, тут есть дефолт конструкторы делающие всё что нужно.
Попробуйте повторить это с виртуальными функциями и make_unique
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; }))
Kelbon Автор
12.04.2022 08:14Переключите в настройках проекта -. Общее -> Platform toolset на Visual Studio 2022 (v143), потому что в v142 баг парсера.
Возможно у вас не последняя версия, у msvc был баг парсера в этом месте. Можно переключить в VS на кланг или вынести эту строку в отдельную constexpr переменную на строку выше
Ну и вообще это делается через
cmake . -B build
cmake --build build
(в репозитории в ридми написано), там за вас расставлены все флаги
qw1
12.04.2022 08:55В 2019-й студии у меня нет тулсета v143. Когда-нибудь обновлю, посмотрю ещё разок.
Проясните, пожалуйста, вопрос с вызовом деструктора, который я задал выше.
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();
Kelbon Автор
12.04.2022 12:08Посмотрите в реализацию, там лучше всего это объяснено.
struct destroy посмотрите например
qw1
12.04.2022 12:20Понял. Примеры, приведённые вами в статье, некорректны. real_executor нельзя присваивать в any_executor без наследования real_executor от специального
который сам умеет дестроиться.basic_any< real_executor >
Понятно, почему комментаторы ниже говорили о CRTP, в ваших примерах это всё опущено.
qw1
12.04.2022 12:32метод, который вызывает деструктор
destroy::do_invoke(const T* self)
имеет шаблонный параметр типа, который надо разрушить.
Как я понял, T — это финальный тип basic_any с учётом подстановок всех шаблонов, и чтобы он стал типом real_executor, real_executor должен быть тем самым basic_any<real_executor>.
Kelbon Автор
12.04.2022 13:45+1метод не имеет шаблонного параметра, шаблонным является struct destroy, на будущее посоветую вам не кричать о разоблачении автора не понимая как это работает
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{};
Kelbon Автор
12.04.2022 18:57Последнее не компилируется потому что нужно добавить методы, (в readme всё написано)
aa::copy / aa::move, то есть по умолчанию только деструктор, хочется копировать - добавляешь копирование, хочется мув - добавляешь мув.
using any_executor = aa::any_with<execute, aa::copy, aa::move>;
Kelbon Автор
12.04.2022 19:01+1Насчёт первого, нет, это не баг
any_executor exe = real_executor{};
в этой строке очевидно что real_executor создаётся дефолтным конструктором и в конце полного выражения разрушается деструктором.
Внутрь exe он перемещается не дефолтным конструктором, а мув, так что ничего не пишется.
В конце скоупа вызывается деструктор в exe, так что вывод в cout абсолютно верный
dendron
11.04.2022 22:34+1Очень напомнило Python с его Protocol.
В качестве теоретического упражнения на использование std::any - прикольно.
С практической точки зрения, я бы лично не стал бы включать ради такого заголовочный файл на 800 строк, полный шаблонной магии. Честное слово, лучше уж смириться с виртуальными функциями, чем тащить что-то такое монструозное и Boost-подобное. Выше уже дали грамотные советы как лучше организовывать код.
Kelbon Автор
12.04.2022 09:18Как только модули будут поддерживаться cmake это будет С++20 модуль, так что можете не переживать насчет времени компиляции
qw1
12.04.2022 12:09Модули не сильно сократят время компиляции таких конструкций, потому что большая часть времени это не построение AST, а подстановка выражений в шаблоны из пользовательского кода, и там ничего нельзя закешировать/рассчитать заранее.
Kelbon Автор
12.04.2022 12:13хедер не нужно будет множество раз парсить вот и всё. Будет примерно то же самое по скорости что и просто создавать виртуальные таблички и инстанцировать наследников
qw1
12.04.2022 12:23Так парсинг это относительно быстро. Тяжко выполнять подстановки в шаблоны, рекурсивно и с разными вариантами, пока в каком-то из вариантов не получится допустимая конструкция (см. SFINAE). И в модуле закешировать подстановки нельзя, потому что корень перебора — в пользовательском коде.
Kelbon Автор
12.04.2022 13:47+1В библиотеке практически нет никаких выборов перегрузок, которые заставляют компилятор изобретать типы, есть несколько простых специализаций, которые внутри разрешаются как перегрузки, но это отнюдь не самое затратное в компиляции
Kelbon Автор
12.04.2022 09:36P.S. надеюсь про советы выше как организовывать код вы писали про собственно статью, где эти советы и содержатся
tarekd
12.04.2022 04:47Поздравляю с изобретением
велосипедаCRTP!
Проблема в том, что он обеспечивает только Bounded Polymorphism.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 }
Kelbon Автор
12.04.2022 08:17Это не CRTP потому что CRTP про компайл тайм полиморфизм. А тут именно техника type erasure
Sklott
Честно говоря из всей статьи я так и не уловил какую-же мысль неправильно выражать через виртуальные функции, но правильно через вот это нагромождение в последнем примере.
У меня такое ощущение, что вам очень сильно "жмет" жесткое типизирование С++, и вы изобретаете варианты как-бы его расслабить и писать в стиле языков с динамической типизацией...
Kelbon Автор
Почти всё что написано сейчас с использованием виртуальных функций не отражает реальных намерений писавшего
И нет, жесткая типизация не жмёт, просто есть ситуации где нужен динамический полиморфизм, это вы отрицать я надеюсь не будете. И в этих ситуациях виртуальные функции это плохое решение(но при этом практически единственное в стандартном С++)
qw1
А на критичных участках кода не надо включать ООП головного мозга и делать ячейку матрицы объектом с (виртуальными) методами add, mul, clone, если можно сменить парадигму: писать не ООП-код, а процедурный, когда алгоритм обрабатывает данные, простые POD-ы, тип которых можно передать шаблонным параметром, чтобы не копипастить.
Применяя это к вашему примеру с Pet и Cat, не надо делать pet.Say(), а надо делать
say(Cat c);
say(Pet p);
и дать языку самому разрулить перегрузки.
Писать так, как принято в C++, следовать примеру стандартной библиотеки, а не натягивать сову на глобус.
Kelbon Автор
??? Первый аргумент про то что вам плевать на производительность - не аргумент. Читаемость кода тоже улучшается.
Второе также звучит как очень плохой стиль, если что то можно сделать на компиляции - оно должно быть сделано на компиляции. Очевидно в статье речь про вещи которые происходят НЕ на компиляции и их НЕВОЗМОЖНО перенести на компиляцию.