Как стать автором
Обновить

Комментарии 31

Не могу понять — тут std::function переизобретается?
В отличие от std::function здесь не используется динамическое выделение памяти. Вместо этого функторы хранятся как статические объекты, что, очевидно, имеет свои минусы, но лучше в плане производительности.
Если ооочень грубо — то похоже. Разница с ходу в том, что результирующий тип — пользовательский класс.
+ для перфекционистов код несколько проще.
Было на хабре уже как минимум два раза:

https://habrahabr.ru/post/159389/
https://habrahabr.ru/post/166589/

В принципе, я мог бы и про свою реализацию написать: https://github.com/SBKarr/stappler/blob/v2/common/apr/SPAprFunction.h, фича в использовании пулов памяти по образцу Apache Portable Runtime вместо new/delete, плюс отдельный скоростной тип для заворачивания лямбд (и только их) в колбеки, ибо архитектурно это разные подходы. (пока смотрел код, заметил пропущеyный std::forward...). Но, имхо, здесь маловато новаторства, потому статьи оно недостойно.
В указанных статьях требовался несколько другой результат. Собственно таки есть std::function и boost, которые дают общее решение. Если же его (общее) начинать приспосабливать под конкретный случай — иногда получается спагетти. Про новаторство — имхо, если прочитать Страуструпа и полную документацию по С++ (включая STL), то новаторства нет вообще. Но за сами ссылки спасибо — полезно знать любое мнение.

Просто хочу заметить, что "если не сильно менять и причёсывать" код из статьи, то, как раз таки, std::forward() там нигде не нужно, просто потому что форвардинг ссылки нигде и не используются (а должны! К сожалению, и std::move() упущен… ладно).


По поводу вашей реализации, извините, я не сильно всматривался, вам не хватило аллокатора который есть в интерфейсе std::function?
Плюс, по поводу std::forward(), в этом месте — кхм, навскидку не понял, почему там std::forward(), а не std::move()? Судя по этому месту, я дико извиняюсь, — вы неправльно используете std::forward()?

Ну, если вспоминать свой код… опасность в том, что для сей структуры может быть не вызван деструктор, и std::function не гарантирует работу без утечек памяти в таких условиях. Конкретные случаи объяснить без доступа к коду, который это дело использует сходу не смогу (разве что сказать, что в первом случае, который на самом деле второй, действительно нужен std::move, но в силу стандартной реализации оно работает как предполагается), ибо времени прошло прилично. Могу лишь сказать, что на данный момент оно работает в небольшом, но репрезентативном объёме.
Про второй случай с std::forward, тут я, возможно, не понимаю сути раскрытия parameter pack, но компилятор не разрешает в такой реализации принимать его по универсальной ссылке, а если не прокидывать аргументы, которые в самой упаковке идут как rvalue (сигнатура вида <void(string &&)>), то они совершенно справедливо станут lvalue, чем перестанут соответствовать сигнатуре вызываемой впоследствии функции. Как это точно описывает стандарт, я понять не могу, поэтому решение найдено эмпирически и проверено функционально.

Допустим, у нас есть:


#include <cstdio>
#include <utility>

struct UserType
{
};

// (1)
void Concrete(UserType&&)
{
    std::puts("Concrete(UserType&&)");
}

// (2)
void Concrete(UserType&)
{
    std::puts("Concrete(UserType&)");
}

template<typename T>
void DoForward(T&& parameter)
{
    Concrete(std::forward<T>(parameter));
}

int main()
{
    UserType value;
    // 1: аргумент (@value) у нас T& (UserType&, lvalue),
    // а параметр (@parameter) - T&& - происходит наложение
    // & + && -> получаем UserType&.
    // Вызывается (2)я версия Concrete()
    DoForward(value);

    // 2: Передаём временный обьект - 
    // аргумент (@value) у нас T&& (UserType&&, грубо говоря, rvalue),
    // а параметр (@parameter), всё тот же, T&& - происходит наложение
    // && + && -> получаем UserType&&.
    // Вызывается (1)я версия Concrete()
    DoForward(UserType{});
}

parameter в DoForward(), поскольку это шаблонная функция, попадает в (не знаю как перевести) "deduced context" — и всё что в main-е я написал, это, как раз таки, "вывод типа" параметра, который учитывает "шаблонный" тип параметра и тип аргумента функции. Из-за того, что это шаблон, как видно, мы можем получить либо rvalue либо lvalue. Всё просто: "deduced context" — юзаем forward().


Как работает forward(): для первого вызова в main-е, как я уже написал, тип parameter вывелся в UserType&, т.е., T — это UserType&. forward() принимает аргументом (всегда!) — именованную переменную — т.е., это всегда lvalue. Получаем, что forward() имеет на вход шаблонный аргумент типа UserType& и тип параметра, так же, UserType& — всё это означает, что переданный на вход аргумент — это lvalue! forward() ничего не делает.
Аналогично, для второго вызова в main-е, тип parameter вывелся в UserType&&, т.е., T — это UserType&&. Получаем, что forward() имеет на вход шаблонный аргумент типа UserType&& и параметр UserType& — переданный на вход аргумент — это rvalue! forward() делает move().


Во всех остальных случаях — у нас нет "deduced context-а" и всё, каким мы его видим, таким и есть — т.е. T&& — это rvalue — нужно делать move(). T& — это ссылка — делаем что хотим, а T — хм, переменная, которая, больше не используется, поэтому можно сделать move().


Т.е., в случае:


template<typename T>
struct NonDeducedContext
{

    static void call(T parameter)
    {
        DoForward(std::move(parameter));
    }
}

для параметра функции call() нет вывода типа, потому что он уже выведен для класса в целом (мы его указываем при инстанциировании шаблона: NonDeducedContext<UserType>). Т.е., parameter — это value type — это копия аргумента функции call(). Ниже, по коду, он нигде не используется, поэтому я спокойно его муваю, тем самым говоря, что я его больше не использую и "делайте со мной что хотите".

А теперь представим, что у нас есть функция типа void moveNamedVector(const string &, vector &&). Нам нужно вызывать её из функции, находящейся в структуре, с шаблонным аргументов в виде parameter pack:

template <typename… Args>
struct F {
void f(Args… args) {

}
}

В таком случае, parameter pack должен состоять из <const string &, vector &&>. Когда мы раскрываем такой пакет, по виду moveNamedVector(args...), каждый элемент попадает в deduced context, и при этом имеет имя (пусть виртуальное и недоступное пользователю), а значит, каждый элемент без использования forward будет передан как lvalue. В итоге компилятор будет искать сигнатуру void moveNamedVector(const string &, const vector &), которая не соответствует желаемой. С использованием move(args)… компилятор попробует получить rvalue из всего переданного, и будет искать void (string &&, vector &&), опять промах. Остаётся использовать std::forward. Кстати, gcc и clang в их реализации function со мной согласны.

P.S. Оказывается GCC требует признать аллокаторы для std::function устаревшими и не поддерживает их совсем. А в реализации MSVC аллокаторы не проверяются на совместимость при move assignment, что, вапще говоря, серьёзное упущение. Хорошо, что я туда не полез из других соображений, а то долго бы ловил получившийся гейзенбаг.

Не, долго писать, но вкратце:


С использованием move(args)… компилятор попробует получить rvalue из всего переданного, и будет искать void (string &&, vector &&), опять промах. Остаётся использовать std::forward.

Если вы будете использовать std::forward() в этом контексте, то это аналогично std::move() для всех параметров. Т.е., с вашим примером, будет std::move() как для 1го аргумента, так и для второго, т.е., если была бы ещё перегрузка moveNamedVector(string&&, vector&&), то вызвалась бы именно она, независимо от того использовали ли бы вы std::move() (более идиоматично) либо std::forward() (нестандартное использование).


По поводу std::function, да, использование с аллокаторами задепрекейтили в C++17: Deprecating Allocator Support in std::function, потому что:


there are technical issues with storing an allocator in a type-erased context and then recovering that allocator later for any allocations needed during copy assignment.

Забавно

Авторы libc++ со мной солидарны, https://gcc.gnu.org/onlinedocs/gcc-6.2.0/libstdc++/api/a01295_source.html (строки 2129 -2137) в таком использовании std::forward, и по опыту наличия в одном классе перегрузок на все лады: (const string &, string &&), (string &&, const string &), (string &&, string &&), подлянка с перемещением константной lvalue в таком случае не вылазит. Как я уже говорил, не могу объяснить это словами стандарта, только практикой использования.

Спасибо.

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


Во-вторых, статическая переменная инициализируется один раз, поэтому чудо-объект Wrap одноразовый.


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

Wrap работает столько раз, сколько разній аргументов ему можно передать (по правилам параметров шаблонов). Только в том случае, если все параметры совпадают, а это по сути попытка второй раз встроить один и тот же объект, функция вернет повторяющийся адрес (что вроде-бы и логично). Единственная возможная проблема — вызов версии Wrap для функторов с параметром — функцией. Тут согласен — объект попортит. Но там и нет полного встраивания.
Код по ссылке не подвержен девиртуализации. Собственно ее для тестом можно исключить выбирая метод по пользовательскому вводу.
Про std::function опять-же для других целей все замечательно. Для конкретно решавшейся задачи — это сильное усложнение и кода и механизма.
что вроде бы и логично

Нет, не логично. Могут быть объекты одного типа, но с разным состоянием.


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

Пользовательский ввод сам по себе не гарантирует отключение девиртуализации. Нужно более серьёзное обоснование.


Про std::function опять-же для других целей все замечательно. Для конкретно решавшейся задачи — это сильное усложнение и кода и механизма.

Хотелось бы увидеть ассемблерный код ваших обёрток в сравнении с ассемблерным кодом std::function на более-менее реальной программе.
Пока что весь ассемблерный код был показан только на вырванных из контекста участках кода. На которых компилятор, естественно, всё хорошо соптимизировал.

А лучше всего не ограничиваться ассемблером, а ещё и замерить реальное время работы.

Когда оно попадет в проект — постараюсь добавить сюда результаты юнит-тестов. Но изначально это не совсем замена для std::function. Здесь основная цель перекинуть все на виртуальность.

Ситуацию с множественным вызовом Wrap я действительно не рассмотрел. Как только смогу — докину апдейт.

А вот про девиртуализацию можно поспорить. Зависимость от пользовательского ввода или других рантайм данных (скажем чтение из файла или банально результат random) не позволяет компилятору определить что именно он должен девиртуализировать. По крайней мере я не знаком и не находил подобных возможностей. Если можете — пожалуйста опишите конкретнее или дайте пример.
Закинул апдейт про решение ошибки для однотипных функторов и уточнение разницы с std::function. Тестов производительности, простите, пока-что нету.
А что правда, что глядя на ассемблерный код можно сказать, что это будет быстрее, а вот это помедленней?
Может я не прав, но мне кажется, что у современных процессоров Интел время исполнения очереди команд такое не очевидное…
Раньше да, как-то можно было прикинуть в уме, хотя бы примерно одна команда один такт, но это правило уже давно не работает.
Грубо прикинуть иногда можно. Так, можно ожидать, что быстрее будет работать более короткий код, содержащий меньше команд. Команда команде рознь, конечно, но если команды все простые (mov, add, sub и т.п.) — то можно ожидать, что они примерно равноценны.

Далее, если есть условные или косвенные переходы — то можно ожидать, что такой код будет работать медленнее по сравнению со случаем, когда условных и косвенных переходов нет.

Также могут сильно тормозить обращения к памяти. Когда их меньше — то скорее всего будет работать быстрее.

Ну и «тяжелые» команды — вычисления с плавающей запятой, всякие там синусы, деление — все это тоже долго исполняется по сравнению с mov/add.
У меня просто есть печальный опыт: мы пытались длинную си-шную функцию переписать на ассемблере, чтоб было быстрее. Ко всеобщему изумлению — не смогли. Вроде бы и инструкций меньше, и короче выглядит, а по измерениям получается не быстрее.
Обращения к памяти кажется не тормозят, видимо все в кеш попадало.
Переходы так же быстры и на них потерь не много — видимо хорошо работает предсказание переходов.
Ну, бывает и такое. Быть может, ваш алгоритм действительно не подлежал оптимизации. Во всяком случае, вы испробовали все способы и теперь более-менее точно знаете это.

Я думаю, что ваш результат — это не повод отказываться от ассемблера во всех случаях, так же как и мои положительные результаты — не повод всегда ломиться в ассемблер. Тут нужен опыт и интуиция для принятия правильных решений. И добывается такой опыт практикой.
1) Можно оценить какие типы инструкций присутствуют, может ли код быть дальше оптимизирован компилятором если применять в реальном проекте.
2) При желании можно открыть справочник по процессорам (собственно что и делалось), и оценить расходы на выполнение каждой инструкции (хотя-бы приблизительно для целевого железа).
Что-то меня нервирует момент с функцией Wrap в разделе «функторы»:
внутри неё определёна статическая переменная W — значит, для всех вызовов с одинаковым аргументом темплейта (Func) будет работать с одной и той же переменной W (проинициализирована она будет только при первом вызове для данного Func).

Т.е. вызов Wrap для нескольких функторов одного типа вернёт один и тот же результат.

Я где-то ошибся? (проверять сейчас некогда)
Мне действительно стоит проверить и описать этот аспект работы кода. Спасибо за замечание.
Подобный механизм имеет смысл только в случае необходимости нескольких виртуальных методов. std::function как обертка для лямбд на gcc 5.4 часто работает быстрее, чем виртуальные функции.
Да, всё верно. Суть именно в виртуализации. Изначально требовалось по указателям на одинаковый тип добираться до различных методов и данных. Это это-же по сути и делает механизм виртуальности. Остальное — обертка чтобы сгрузить всё на него.
Вы можете кратко пояснить особенности использования default в этом контексте, какие могут всплыть моменты при реализации родственных задач?
Извините, я не совсем понял вопрос. Можете конкретизировать что именно в родственных задачах вам интересно и какие задачи собственно имеются ввиду?
Про default (если снова таки я правильно понял вопрос) то всё достаточно просто -для корректного удаления виртуальных объектов нужен виртуальный деструктор, при этом его тело в данном случае тривиально (потому default). На самом деле в данном примере удаление происходит не через работу с указателями, потому деструктор можно не описывать вообще. Но в дань правильному коду и во избежании потенциальных ошибок я решил его описать.
Зарегистрируйтесь на Хабре, чтобы оставить комментарий

Публикации

Изменить настройки темы

Истории