Pull to refresh

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 заменить аллокатор и размер буфера, кастомизация в общем имеется

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

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

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

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

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

Насчёт памяти — зато я точно знаю, что 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. Но и стандартный аллокатор достаточно хорош, не думаю что в реальном проекте будет разница по сравнению с пулом.

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

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

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

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

Как раз 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

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

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

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

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

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

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

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

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

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

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

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

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

метод, который вызывает деструктор
destroy::do_invoke(const T* self)

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

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

Обновил 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{};

Последнее не компилируется потому что нужно добавить методы, (в 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 модуль, так что можете не переживать насчет времени компиляции

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

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

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

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

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

UFO landed and left these words here
UFO landed and left these words here

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

Sign up to leave a comment.

Articles