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

в 12:18, , рубрики: c++, верните мне мой 2007-й, Программирование

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

Автор: 0xd34df00d

Источник


* - обязательные к заполнению поля


https://ajax.googleapis.com/ajax/libs/jquery/3.4.1/jquery.min.js