Comments 42
И обязательно выражайте свои мысли в коде правильно!
Честно говоря из всей статьи я так и не уловил какую-же мысль неправильно выражать через виртуальные функции, но правильно через вот это нагромождение в последнем примере.
У меня такое ощущение, что вам очень сильно "жмет" жесткое типизирование С++, и вы изобретаете варианты как-бы его расслабить и писать в стиле языков с динамической типизацией...
какую-же мысль неправильно выражать через виртуальные функции
Почти всё что написано сейчас с использованием виртуальных функций не отражает реальных намерений писавшего
И нет, жесткая типизация не жмёт, просто есть ситуации где нужен динамический полиморфизм, это вы отрицать я надеюсь не будете. И в этих ситуациях виртуальные функции это плохое решение(но при этом практически единственное в стандартном С++)
Почти всё что написано сейчас с использованием виртуальных функций не отражает реальных намерений писавшегоЭто почему? У меня, например, нет никаких проблем, перечисленных выше (копирование, наследование, вирт. функции), потому что применяю это только в некритичных нишах. И там лишние 100 копирований или аллокаций в секунду ничего не значат.
А на критичных участках кода не надо включать ООП головного мозга и делать ячейку матрицы объектом с (виртуальными) методами add, mul, clone, если можно сменить парадигму: писать не ООП-код, а процедурный, когда алгоритм обрабатывает данные, простые POD-ы, тип которых можно передать шаблонным параметром, чтобы не копипастить.
Применяя это к вашему примеру с Pet и Cat, не надо делать pet.Say(), а надо делать
say(Cat c);
say(Pet p);
и дать языку самому разрулить перегрузки.
Писать так, как принято в C++, следовать примеру стандартной библиотеки, а не натягивать сову на глобус.
??? Первый аргумент про то что вам плевать на производительность - не аргумент. Читаемость кода тоже улучшается.
Второе также звучит как очень плохой стиль, если что то можно сделать на компиляции - оно должно быть сделано на компиляции. Очевидно в статье речь про вещи которые происходят НЕ на компиляции и их НЕВОЗМОЖНО перенести на компиляцию.
Например, если написать так:
struct real_executor {
void execute(auto f) const {
f();
}
std::string t { "real executor string" };
};
// ...
any_executor exe = real_executor{};
То на последней строке, в any_executor будет скопирована строка t? Откуда этот тип заранее знает, что под неё нужно выделить место?
Возможно в следующей статье я буду объяснять как это устроено. Тут будет вызван мув конструктор(тот что по дефолту) real_executor в место в памяти, где any_executor хранит объект.
Есть также возможность написать так
any_executor exe{std::in_place_type<real_executor>);
Тогда не будет даже мув конструктора, а только конструирование объекта типа real_executor в памяти с дефолтным его конструктором
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) достаточно большим, чтобы там было достаточно места для любого такого объекта?
Для таких вещей есть variant, в С++ состояния на компиляции(если не считать багов стандарта) нет и соответственно сделать это невозможно. Будет аллокация для вмещения нового объекта, об этой аллокации будет известно на компиляции в точке exe = big_executor{};
Но можно в basic_any заменить аллокатор и размер буфера, кастомизация в общем имеется
Да, там оптимизация в виде буфера(регулируемого размера), а как ещё вы представляете это? Других решений просто нет и тут дело не в С++, а просто так устроено мироздание.
То же самое с вызовом, конечно же если могут быть вызваны разные функции в рантайме нужно выбрать какую из них вызвать - в рантайме. И в статье даже есть ассемблерный код этого вызова в сравнении с виртуальными функциями
Да, там будут другие проблемы, но и их так же можно будет решать через другие компромисы.
в контейнерах тоже нужно выделять память и вызывать эффективнее методы тоже не выйдет
К слову, в этом и есть самая большая проблема вашей библиотеки. В мире 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. Но и стандартный аллокатор достаточно хорош, не думаю что в реальном проекте будет разница по сравнению с пулом.
Разумеется и у меня в библиотеке не случайным образом выделяется память. Повторю - есть возможность добавить аллокатор. Так что аргумент про reserve(n) уходит в аллокаторы.
Что насчет "понимания когда происходит аллокация" - это известно на компиляции. Есть даже шаблонная переменная позволяющая узнать будет ли аллокация если положить тип T в any, если вам интересно можете посмотреть на неё и какие условия там проверяются
ВЫ НЕ МОЖЕТЕ СДЕЛАТЬ 2 ВЕКТОРА НА КОМПИЛЯЦИИ И ВЫЗЫВАТЬ ШАБЛОННЫЙ МЕТОД.
Потому что ВЫ НЕ ЗНАЕТЕ КАКОЙ ТИП НА РАНТАЙМЕ ЛЕЖИТ ТАМ, как вы собрались тут использовать шаблонный метод? Вы не понимаете проблему и уже пишите "решение"
А вот если я заранее не знаю, сколько мне надо векторов, то уже не могу. Но поскольку все типы известны в compile-тайм, то опять же могу, но не средствами языка, а кодогенераторами, например.
Тут уже либо потери на диспетчеризацию в рантайме, либо бескомпромисная копипаста нужного числа векторов в компил-тайме, зато с возможностью инлайна всех методов.
Жаль, что вы не захотели прокомментировать, чем же вам не нравятся virtual методы и создание объектов просто через new/make_unique (и тогда не надо заморачиваться с копируемостью). Вы упомянули, что ваше решение можно дотянуть до уровня «не хуже» по стоимости вызова (но и не лучше), видимо это рассматривалось как одно из важных аспектов.
Потому что new / make _unique это кривой ужасный код не отражающий реальных намерений программиста, ухудшающий понимание и производительность кода. Это упомянуто в посте.
Как вы будете копировать полиморфный объект корректно под unique_ptr - задумайтесь
Вот вам пример
struct machine {
any_engine m_engine;
};
Всё, тут есть дефолт конструкторы делающие всё что нужно.
Попробуйте повторить это с виртуальными функциями и make_unique
задумайтесьЗадумался. Возможно, будет интересно иметь полиморфный объект с поведением обычного value. Но пока не понимаю, где это можно применить в реальных проектах, а не в качестве демки. Ещё я не понимаю, как any_executor может хранить тип real_executor, не зная о нём. Например, как он вызовет его деструктор по окончанию жизненного цикла.
Пытался скомпилировать пример и посмотреть, но с MSVC 2019 (16.11.10) что-то не получается (с ключом /std:c++20 или /std:c++latest) — не нравится ему слово requires в строчке
if constexpr ((requires { typename Method::allocator_type; }))
Переключите в настройках проекта -. Общее -> Platform toolset на Visual Studio 2022 (v143), потому что в v142 баг парсера.
Возможно у вас не последняя версия, у msvc был баг парсера в этом месте. Можно переключить в VS на кланг или вынести эту строку в отдельную constexpr переменную на строку выше
Ну и вообще это делается через
cmake . -B build
cmake --build build
(в репозитории в ридми написано), там за вас расставлены все флаги
Проясните, пожалуйста, вопрос с вызовом деструктора, который я задал выше.
Механизм примерно такой же как с виртуальным деструктором
struct A
{
void execute(auto f) const { f(); }
std::string s;
~A() = default;
};
Как any_executor получит адрес деструктора, после такого присваивания:
any_executor a = A();
Посмотрите в реализацию, там лучше всего это объяснено.
struct destroy посмотрите например
basic_any< real_executor >
который сам умеет дестроиться. Понятно, почему комментаторы ниже говорили о CRTP, в ваших примерах это всё опущено.
Чего? Вы где это вообще взяли?
destroy::do_invoke(const T* self)
имеет шаблонный параметр типа, который надо разрушить.
Как я понял, T — это финальный тип basic_any с учётом подстановок всех шаблонов, и чтобы он стал типом real_executor, real_executor должен быть тем самым basic_any<real_executor>.
метод не имеет шаблонного параметра, шаблонным является struct destroy, на будущее посоветую вам не кричать о разоблачении автора не понимая как это работает
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{};
Последнее не компилируется потому что нужно добавить методы, (в readme всё написано)
aa::copy / aa::move, то есть по умолчанию только деструктор, хочется копировать - добавляешь копирование, хочется мув - добавляешь мув.
using any_executor = aa::any_with<execute, aa::copy, aa::move>;
Насчёт первого, нет, это не баг
any_executor exe = real_executor{};
в этой строке очевидно что real_executor создаётся дефолтным конструктором и в конце полного выражения разрушается деструктором.
Внутрь exe он перемещается не дефолтным конструктором, а мув, так что ничего не пишется.
В конце скоупа вызывается деструктор в exe, так что вывод в cout абсолютно верный
Очень напомнило Python с его Protocol.
В качестве теоретического упражнения на использование std::any - прикольно.
С практической точки зрения, я бы лично не стал бы включать ради такого заголовочный файл на 800 строк, полный шаблонной магии. Честное слово, лучше уж смириться с виртуальными функциями, чем тащить что-то такое монструозное и Boost-подобное. Выше уже дали грамотные советы как лучше организовывать код.
Как только модули будут поддерживаться cmake это будет С++20 модуль, так что можете не переживать насчет времени компиляции
хедер не нужно будет множество раз парсить вот и всё. Будет примерно то же самое по скорости что и просто создавать виртуальные таблички и инстанцировать наследников
P.S. надеюсь про советы выше как организовывать код вы писали про собственно статью, где эти советы и содержатся
Интересно. Похоже на Boost.TypeErasure.
Как перестать некорректно выражаться в коде