О новых стандартах C++

    Сегодня у меня довольно короткий пост. Я бы его и не писал, наверное, но на Хабре в комментах довольно часто можно встретить мнение, что плюсы становятся хуже, комитет делает непонятно что непонятно зачем, и вообще верните мне мой 2007-й. А тут такой наглядный пример вдруг попался.


    Почти ровно пять лет назад я писал о том, как на C++ сделать каррирование. Ну, чтобы если можно написать foo(bar, baz, quux), то можно было бы писать и Curry(foo)(bar)(baz)(quux). Тогда C++14 только вышел и еле-еле поддерживался компиляторами, так что код использовал только C++11-фишки (плюс пара костылей для симуляции библиотечных функций из C++14).


    А тут я что-то на этот код снова наткнулся, и мне прямо резануло глаза, насколько он многословный. Плюс ещё и календарь не так давно переворачивал и заметил, что сейчас уже 2019-й год, и можно посмотреть, как C++17 может облегчить нашу жизнь.


    Посмотрим?


    Хорошо, посмотрим.


    Исходная реализация, от которой будем плясать, выглядит примерно так:


    template<typename F, typename... PrevArgs>
    class CurryImpl
    {
         const F m_f;
    
         const std::tuple<PrevArgs...> m_prevArgs;
    public:
         CurryImpl (F f, const std::tuple<PrevArgs...>& prev)
         : m_f { f }
         , m_prevArgs { prev }
         {
         }
    private:
         template<typename T>
         std::result_of_t<F (PrevArgs..., T)> invoke (const T& arg, int) const
         {
              return invokeIndexed (arg, std::index_sequence_for<PrevArgs...> {});
         }
    
         template<typename IF>
         struct Invoke
         {
              template<typename... IArgs>
              auto operator() (IF fr, IArgs... args) -> decltype (fr (args...))
              {
                   return fr (args...);
              }
         };
    
         template<typename R, typename C, typename... Args>
         struct Invoke<R (C::*) (Args...)>
         {
              R operator() (R (C::*ptr) (Args...), C c, Args... rest)
              {
                   return (c.*ptr) (rest...);
              }
    
              R operator() (R (C::*ptr) (Args...), C *c, Args... rest)
              {
                   return (c->*ptr) (rest...);
              }
         };
    
         template<typename T, std::size_t... Is>
         auto invokeIndexed (const T& arg, std::index_sequence<Is...>) const ->
                   decltype (Invoke<F> {} (m_f, std::get<Is> (m_prevArgs)..., arg))
         {
              return Invoke<F> {} (m_f, std::get<Is> (m_prevArgs)..., arg);
         }
    
         template<typename T>
         auto invoke (const T& arg, ...) const -> CurryImpl<F, PrevArgs..., T>
         {
              return { m_f, std::tuple_cat (m_prevArgs, std::tuple<T> { arg }) };
         }
    public:
         template<typename T>
         auto operator() (const T& arg) const -> decltype (invoke (arg, 0))
         {
              return invoke (arg, 0);
         }
    };
    
    template<typename F>
    CurryImpl<F> Curry (F f)
    {
         return { f, {} };
    }

    В m_f лежит сохранённый функтор, в m_prevArgs — сохранённые на предыдущих вызовах аргументы.


    Тут operator() должен определить, можно ли уже звать сохранённый функтор, или же надо продолжать накапливать аргументы, поэтому он делает довольно стандартный SFINAE при помощи хелпера invoke. Кроме того, для того, чтобы вызвать функтор (или проверить его вызываемость), мы покрываем всё это ещё одним слоем SFINAE, чтобы понять, как именно это делать (ибо вызывать указатель на член и, скажем, свободную функцию надо по-разному), и для этого мы используем вспомогательную структуру Invoke, которая наверняка неполна… Короче, много всего.


    Ну и эта штука совершенно отвратительно работает с move semantics, perfect forwarding и прочими милыми сердцу плюсовика нашего времени словами. Починить это будет чуть сложнее, чем необходимо, так как кроме непосредственно решаемой задачи есть ещё куча не совсем относящегося к ней кода.


    Ну и опять же, в C++11 нет вещей типа std::index_sequence и сопутствующих, или алиаса std::result_of_t, так что чистый C++11-код был бы ещё тяжелее.


    Итак, перейдём, наконец, к C++17.


    Во-первых, нам не нужно указывать возвращаемый тип operator(), можно написать просто:


    template<typename T>
    auto operator() (const T& arg) const
    {
        return invoke (arg, 0);
    }

    Технически это не совсем то же самое (по-разному выведется «ссылочность»), но в рамках нашей задачи это несущественно.


    Кроме того, нам не нужно руками делать SFINAE для проверки вызываемости m_f с сохранёнными аргументами. C++17 даёт нам две клёвые фичи: constexpr if и std::is_invocable. Выкинем всё, что у нас было раньше, и напишем скелет нового operator():


    template<typename T>
    auto operator() (const T& arg) const
    {
         if constexpr (std::is_invocable_v<F, PrevArgs..., T>)
              // вызвать функцию
         else
              // вернуть ещё одну обёртку с сохранённым arg
    }

    Вторая ветка тривиальная, можно скопировать тот код, который уже был:


    template<typename T>
    auto operator() (const T& arg) const
    {
         if constexpr (std::is_invocable_v<F, PrevArgs..., T>)
              // вызвать функцию
         else
              return CurryImpl<F, PrevArgs..., T> { m_f, std::tuple_cat (m_prevArgs, std::tuple<T> { arg }) };
    }

    Первая ветка будет поинтереснее. Нам нужно вызвать m_f, передавая все аргументы, сохранённые в m_prevArgs, плюс arg. К счастью, нам больше не нужны никакие integer_sequence: в C++17 есть стандартная библиотечная функция std::apply для вызова функции с аргументами, сохранёнными в tuple. Только нам нужно засунуть в конец тупла ещё один аргумент (arg), так что мы можем либо сделать std::tuple_cat, либо просто распаковать std::apply'ем имеющийся тупл в дженерик-лямбду (ещё одна фича, появившаяся после C++11, хоть и не в 17-м!). По моему опыту инстанциирование туплов медленное (в компилтайме, естественно), поэтому я выберу второй вариант. В самой лямбде мне понадобится вызвать m_f, и чтобы сделать это правильно, я могу использовать ещё однну появившуюся в C++17 библиотечную функцию, std::invoke, выкинув написанный руками хелпер Invoke:


    template<typename T>
    auto operator() (const T& arg) const
    {
         if constexpr (std::is_invocable_v<F, PrevArgs..., T>)
         {
              auto wrapper = [this, &arg] (auto&&... args)
              {
                   return std::invoke (m_f, std::forward<decltype (args)> (args)..., arg);
              };
              return std::apply (std::move (wrapper), m_prevArgs);
         }
         else
              return CurryImpl<F, PrevArgs..., T> { m_f, std::tuple_cat (m_prevArgs, std::tuple<T> { arg }) };
    }

    Полезно заметить, как auto-выводимый тип возвращаемого значения позволяет возвращать значения разных типов в разных ветках if constexpr.


    В любом случае, это по большому счёту всё. Или вместе с необходимой обвязкой:


    template<typename F, typename... PrevArgs>
    class CurryImpl
    {
         const F m_f;
    
         const std::tuple<PrevArgs...> m_prevArgs;
    public:
         CurryImpl (F f, const std::tuple<PrevArgs...>& prev)
         : m_f { f }
         , m_prevArgs { prev }
         {
         }
    
         template<typename T>
         auto operator() (const T& arg) const
         {
              if constexpr (std::is_invocable_v<F, PrevArgs..., T>)
              {
                   auto wrapper = [this, &arg] (auto&&... args)
                   {
                        return std::invoke (m_f, std::forward<decltype (args)> (args)..., arg);
                   };
                   return std::apply (std::move (wrapper), m_prevArgs);
              }
              else
                   return CurryImpl<F, PrevArgs..., T> { m_f, std::tuple_cat (m_prevArgs, std::tuple<T> { arg }) };
         }
    };
    
    template<typename F, typename... Args>
    CurryImpl<F, Args...> Curry (F f, Args&&... args)
    {
         return { f, std::forward_as_tuple (std::forward<Args> (args)...) };
    }

    Мне кажется, это значительное улучшение по сравнению с исходной версией. И читать проще. Даже как-то скучно, челленджа нет.


    Кроме того, мы могли бы также избавиться от фукнции Curry и напрямую использовать CurryImpl, положившись на deduction guides, но это лучше сделать, когда мы разберёмся с perfect forwarding'ом и прочим подобным. Что плавно подводит нас...


    Теперь совершенно очевидно, насколько это ужасная реализация с точки зрения копирования аргументов, этого несчастного perfect forwarding'а и тому подобного. Но что куда более важно, исправить это теперь куда легче. Но это мы, впрочем, сделаем как-нибудь в следующем посте.


    Вместо заключения


    Во-первых, в C++20 появится std::bind_front, который покроет львиную долю моих юзкейсов, в которых такая штука мне нужна. Можно вообще будет выкинуть. Грустно.


    Во-вторых, писать на плюсах становится всё легче, даже если писать какой-то шаблонный код с метапрограммированием. Больше не нужно думать, какой вариант SFINAE выбрать, как распаковать тупл, как вызвать функцию. Просто берёшь и пишешь, if constexpr, std::apply, std::invoke. С одной стороны, это хорошо, к C++14 или, тем более, 11 возвращаться не хочется. С другой — ощущение, будто львиный пласт навыков становится ненужным. Нет, всё равно полезно уметь что-то там этакое на шаблонах навернуть и понимать, как внутри себя вся эта библиотечная магия работает, но если раньше это было нужно постоянно, то теперь — ну, существенно реже. Это вызывает какие-то странные эмоции.

    Share post
    AdBlock has stolen the banner, but banners are not teeth — they will be back

    More
    Ads

    Comments 46

      0

      У вас этот код, что-то наподобие composed operations реализует?
      PS: если бы не boost::asio, мне бы пришлось что-то такое же пилить :)

        0

        Не, всё сильно проще. Изначально передо мной стояла задача зафиксировать первые N-1 или N-2 аргумента у функтора (чтобы передать получившееся в какой-нибудь std::transform или std::sort). std::bind скучно и семантически не совсем то (это произвольное частичное применение), так что родился такой вот велосипед.

        +1
        ощущение, будто львиный пласт навыков становится ненужным
        Ну так это и есть причина для:
        в комментах довольно часто можно встретить мнение, что плюсы становятся хуже, комитет делает непонятно что непонятно зачем, и вообще верните мне мой 2007-й

        Раньше человек мог с гордым видом наколдовать что-то типа
        for (std::map<int, std::string>::const_iterator it = Map.begin(); ...
        и чувствовать себя чуть ли не небожителем.
        А с этими их новшествами и упрощениями шокировать джуниоров и случайных прохожих становится всё сложнее и сложнее.
          +3

          Ну, люди говорят, что они не понимают, зачем это всё это новое блестящее нужно, а тут вроде как очевидно.


          Ну и ладно, в принципе, чужая душа — потёмки.

            0
            Это же ужасно. typedef-ы для кого были придуманы?
            +1

            В конце статьи взгрустнулость, особенно по второму тегу. Но я хочу внести немного позитива. Много программистов, особенно новоиспеченных джуниров, да и программистов с сединой, что я знаю, слово картирование в первый раз слышат и про SFINAE знать не знают… Та
            Но тенденция к упрощения конечно есть, с другой стороны, все что мы делаем, оно и направлено на упрощение жизни. За статью спасибо, все по полочкам!..

              +10

              Есть только одна беда :) Остались тонны кода написанного еще на С++98. Я уж не говорю сколько с тех пор наваяли на С++1x. В итоге теперь нужно знать и предыдущую магию, и новую магию :) И если мне доучить небольшой кусок не составит труда, то новичкам въехать вот это вот всё будет с каждым новым стандартом всё труднее и труднее.


              Пока в стандартах не начнут отрезать старое гавно, я буду придерживаться мнения что комитет делает не то и не так. Добавление кучи новых фич увеличивает порог вхождения, что конечно повышает нашу ценность, как кадров, но в итоге С++ превратится в COBOL, если будут продолжать в том же духе.

                0

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

                  0

                  Контракты так ждал, а взяли и обламали…

                  +4

                  Оттого, что старое говно отрежут от новых стандартов, старый C++98-код не превратится в свежий C++20-код. Вы просто совсем не сможете пользоваться новыми стандартами со старой кодовой базой. С одной стороны, это и язык убить может, с другой — да, не придётся учить новые стандарты, если вы работаете с кодовой базой. Но вы их и так не учить-то можете!


                  А что до порога вхождения… Ну, новые фичи облегчают страдания и, наоборот, уменьшают количество мыслей «а ну его нафиг совсем этот C++, следующая моя работа будет на чистом хаскеле».

                    0
                    Если писать новый проект, то лучше использовать новый стандарт С++17, а современем С++20. Если это легаси код на каком-то С++98, то печально.
                      0
                      Скажу про себя. С++ не есть язык, которым я пользуюсь постоянно.
                      Постоянно мне требуется слишком шировкий пласт от ассемблера до C, Verilog, и даже иногда bash, sh, JS, PHP, Python. C++, да я использую время от времени по необходимости. И если раньше я его понимал, где-то до С++11, то дальше мне все труднее и труднее.
                      Я уже не понимаю, только с грустью смотрю на все это. Казалось бы — бери учи, но набор задач слишком широк, чтобы значительно концентрироваться на одном направлении.
                        0
                        Я вот прямо совсем ничего не пишу и не читаю на С++ в промышленном масштабе. Но навороты приведенные в статье читать проще в новыми плюшками, чем со старыми, о чем собственно и статья.
                        А в общем читать стало сложнее мне кажется не из-за изменения самого языка и появления новых конструкций, а из-за способов его применения. Так в 2007(с) году шаблонные извращения в стиле Александреску применялись достаточно редко, по большей части все писали на диалекте похожем на Java. Но свою тщетную попытку разобраться с тем как внутри работает boost::spirit я хорошо запомнил… И что-то мне подсказывает, что если его переписать сейчас, то даже мне станет понятнее. Хотя то как работает у автора «простое» каррирование я не понимаю, а только интуитивно догадываюсь.
                          0

                          Собственно, можно взять spirit x2, написанный на C++03, и spirit x3, написанный на C++14. Последний собирается быстрее, писать на нём приятнее и короче (semantic actions на x2 — адище), сообщения об ошибках понятнее.


                          Но да, я на самом деле об этом не задумывался особо, а вы ведь правы. За последние лет 10-12 очень сильно изменился стиль написания кода. ИМХО, не в последнюю очередь из-за того, что компиляторы стали гораздо лучше все это поддерживать.

                            +1
                            Помимо лучшей поддержки компиляторами, а тот же С++03, который для студента в 2005-2007 я знал очень хорошо, поддерживался GCC и MSVC даже до принятия стандарта, выросла скорость обмена информацией за счет развития социальных сетей, блогов и прочего. Раньше крутые знания было принято оформлять в книгу, например та же шаблонная магия Александреску(кажется 98 года книжка). Идеями делились в основном на разрозненных форумах, хайповсти в общем было сильно меньше. Потом пошел какой-то взрывной рост вэба(web 2.0) и всякие трюки с метапрограммированием и не только стали доступны более широкому кругу лиц, функциональный подход стал менее маргинальным и его элементы появились во всех mainstream языках.
                            Естественно все ИМХО.
                      0
                      А что конкретно отрезать предлагаете? Так, что бы ничего не поламалось?
                        0

                        Имеется ввиду, наверное, без обратной совместимости.

                        +3
                        Новичкам надо просто сразу учиться писать в соответствии с актуальной редакцией C++ core guidelines, там не так уж много всего. А выкидывать ничего не надо, если надо будет переписывать тонну кода просто ради того, чтобы он собирался с -std=c++[N+1], то новыми версиями стандарта просто никто не будет пользоваться — если сейчас можно заниматься модернизацией старого кода постепенно, то после выкидывания будет по принципу «все или ничего», а на это никто не пойдет.
                          0
                          С другой стороны, если на заре эпохи программист хотя бы примерно понимал, какой машинный код будет сгенерирован из его С/С++ кода, то с современными версиями языка все становится сильно неочевидно. А непонимание, как код работает внутри может навредить конечному результату.
                            0
                            Именно поэтому близко к железу многие предпочитают C. Не потому, что лучше, а потому что понятнее. Читаемость…
                          0

                          Отрезают, только не так быстро.
                          Например, в GCC давно есть опция -Wold-style-cast. Потом и в ошибку синтаксиса превратится.

                          0
                          C++1x не для людей, а для роботов.
                            +3

                            То ли дело C++03, да?

                            +6
                            А можете написать реальный use case, как вы этот класс используете. Для меня всегда было проблемой связать теорию современного С++ с моей повседневной практикой.
                            Может посоветуете что-то почитать на тему «Вот у нас задача, на С мы бы ее рещали вот так, а современный С++ позволяет сделать вот так и это сильно удобней/быстрее/переносимей и т.д.».
                              +1

                              При мышлении в терминах С++ желание делать что-то подобное обычно не возникает. А вот если освоить языки, основанные на других подходах и идеях, то при возвращении к плюсам может захотеться.


                              Я не могу привести примера с каррингом, но лично мне при написании плюсового кода очень не хватает optional и variant, которые появились только с С++17.


                              Optional прямо просится для случаев, когда значения может не быть. Вместо него в плюсах используют указатели на null, магические значения типа 0, -1 или итераторы, указывающие на конец коллекции.


                              Пример для variant — если функция возвращает результат или причину ошибки, то на Си это решается довольно некрасиво — что-нибудь типа "передать указатель на структуру в функцию, она туда запишет результат, если захочет" и потом проверить, что функция вернула 0, а не код ошибки. В таких случаях хочется писать что-то типа func (int) -> Ok(value) | Fail(error_code).


                              P.S. к сожалению, и optional и variant в С++ довольно ограничены, но без них ещё хуже.

                                0
                                Если не ошибаюся в С++23 должны завести std::expected<T, E> для решения задачи как описано в вашем случае.
                                  0
                                  Мне в variant сильно не нравится get, который переносит ошибку типов в рантайм. Объехать это можно визитором, но с ним кот становится совсем уж громоздким. Не хватает паттерн-матчинга по-человечески.
                                  pastebin.com/DuZkyDBm
                                    +1
                                    Проповзал для добавления паттерн-матчинга уже есть. И паттерн-матчинг является приоритетным направлением для С++23. p1260r0
                                      0
                                      Спасибо за инфо.
                                      0

                                      В приведенном случае было бы довольно легко обойтись с std::visit через auto-лямбду


                                      struct my_struct {
                                        static constexpr const std::string_view type_name = "my_struct";
                                      };
                                      std::ostream &operator<<(std::ostream &o, const my_struct &data);
                                      // ...
                                      std::visit([](const auto &e) {
                                        using T = typename std::decay<decltype(e)>::type;
                                        std::cout << T::type_name << "(" << e << ")" << std::endl;
                                      }, my_var);
                                        0
                                        Да, согласен, но до тех пор, пока все конструкторы данных от одного аргумента.
                                          0

                                          ну, можете дописать


                                          if constexpr(std::is_same_v<T, struct Operator>) {
                                            // ...
                                          } else {
                                            // ...
                                          }

                                          Но пример кажется слишком искусственным и я не очень улавливаю, почему тут нельзя обойтись простой перегрузкой оператора << для каждого типа

                                      0
                                      В вашем случае для результата функции лучше пойдет std::expected. Пока это ещё в стандарте не реализовано, но есть уже готовые решения
                                      +1
                                      Каррирование довольно часто бывает нужно в асинхронном коде. Например, есть функция, которая вызывает коллбэк по завершении работы
                                      void request(std::string request, std::function<void(std::string response)> callback);

                                      И у вас есть метод, который принимает какую-нибудь дополнительную информацию, например номер запроса
                                      void parseResponse(size_t id, std::string response);
                                      Для того, чтобы эту функцию передать в request, можно воспользоваться каррированием
                                      request(requestStr, std::bind(parseResponse, id, _1));

                                      В общем, попробуйте пописать асинхронный код на C++. Довольно быстро поймете, зачем нужна функциональщина и шаблоны :)
                                        0

                                        В Qt для решения подобных задач раньше использовался QSignalMapper (он и сейчас есть, но уже deprecated, потому что новый синтаксис connect() в Qt 5, слава б-гу и C++11, позволяет использовать std::bind) :)

                                          +1

                                          Меня поражает, почему в стандарт не пропихнули синтаксический сахар, что сделает std::function из нестатического метода класса. Писать std::bind с обязательным соблюдением порядка и количества аргументов — это такое себе решение.

                                            0

                                            std::mem_fn же.

                                          +1
                                          А можете написать реальный use case, как вы этот класс используете. Для меня всегда было проблемой связать теорию современного С++ с моей повседневной практикой.

                                          Почти всегда как bind_front. Из совсем недавнего, что, собственно, заставило снова посмотреть на этот класс — например, если есть функция width(QFontMetrics, QString), возвращающая ширину строки, написанной данным шрифтом. Можно написать


                                          const auto& fm = fontMetrics ();
                                          header->setWidth (0, width (fm, "some sample string"));
                                          header->setWidth (1, width (fm, "other sample string"));
                                          header->setWidth (2, width (fm, "third sample string"));

                                          а можно


                                          const auto& wfm = Curry (&width) (fontMetrics ());
                                          header->setWidth (0, wfm ("some sample string"));
                                          header->setWidth (1, wfm ("other sample string"));
                                          header->setWidth (2, wfm ("third sample string"));

                                          Да, это смешно, но это всё равно некое снижение дубликации кода и уменьшение шума.


                                          Чуть менее смешно — например, у вас есть функция QString FormatName (ContentType, Name) и вы хотите написать функцию QStringList FormatNames (ContentType, QList<Name>). Вы можете просто сделать что-то вроде


                                          QStringList FormatNames (ContentType ct, const QList<Name>& names)
                                          {
                                              return Map (names, Curry (&FormatName) (ct));
                                          }

                                          где Map — это как std::transform, только как map из функциональщины. И тому подобное.


                                          По-моему. это делает код куда более выразительным и кратким.


                                          Может посоветуете что-то почитать на тему «Вот у нас задача, на С мы бы ее рещали вот так, а современный С++ позволяет сделать вот так и это сильно удобней/быстрее/переносимей и т.д.».

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


                                          Или у меня есть недоORM-фреймворк, который генерит SQL-таблицы по описанию соответствующих структур данных и запросы на базе вполне обычного и проверяемого тайпчекером C++-кода. Не надо писать SQL руками и бояться, что опечатаешься или напишешь ерунду.


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


                                          Как я понимаю, на С только кодогенерировать остаётся.

                                            0
                                            Проблема только в том, что в этих случаях просто повезло, что аргументы в функциях идут именно в таком порядке. Как я понимаю, сейчас идет подвижка в сторону уйти от std::bind к std::bind_front, что уже не будет давать такой гибкости
                                              0

                                              Именно поэтому функциональщики аккуратно выбирают порядок аргументов функций, ну и нередко можно видеть функции типа «foo, this is a version of bar with arguments swapped».

                                          0
                                          А мне бы вот хотелось поддержки fast pimpl прямо в языке, а не через ручное бойлерплетописательство или многократно мультиплицировнную TMP кашицу. Увы(
                                            +1
                                            pimpl'ом и так чаще злоупотребляют, чем используют по делу
                                            0
                                            Разве этого не было в boost еще лет 10 назад?
                                              0

                                              Ну был boost::bind, и не 10, а сильно больше. Но смысл не в том, чтобы сделать такое, а в том, чтобы показать, как новые стандарты помогают писать код.

                                              0
                                              Дико извиняюсь, сам я плюсы не видел с времен лаб в институте.
                                              А это вот так теперь весь промышленный код на нем выглядит?
                                              Очень больно после 10 лет с питончиком читать такой синтаксис прост.
                                                0

                                                Зависит. Работал в местах, где код как С с классами (ну или джава). Сейчас работаю в месте, где всё скорее так, и очень доволен.

                                              Only users with full accounts can post comments. Log in, please.