Упрощаем код с помощью if constexpr и концептов C++17/C++20

от автора

До C++17 у нас было несколько довольно неэлегантных способов написать static if (if, который работает во время компиляции). Например, мы можем использовать статическую диспетчеризацию или SFINAE. К счастью, ситуация изменилась к лучшему, ведь теперь мы можем воспользоваться для этого if constexpr  и концептами C++20!

Ну что ж, давайте разберемся, как мы можем использовать это в качестве замены std::enable_if кода!

  • Обновление от апреля 2021 г.: изменения, связанные с C++20 — концепты.

  • Обновление от августа 2022 г.: дополнительные примеры if constexpr (четвертый).

Введение  

If во время компиляции в форме if constexpr — это замечательная фича, которая была добавлена в C++17. Этот функционал может помочь нам значительно улучшить читаемость кода с большими нагромождениями шаблонов.

Кроме того, C++20 принес нам концепты (сoncepts)! Это еще один шаг на пути к достижению почти “органичного” кода времени компиляции.

На эту статью меня вдохновил пост на @Meeting C++ с очень похожим названием. Я нашел еще четыре примера, которые наглядно продемонстрировать в действии этот новый функционал:

  • Сравнение чисел

  • (Новинка!) Вычисление среднего значения в контейнере

  • Фабрики с переменным числом аргументов

  • Примеры реального кода из продакшена 

Но для начала, чтобы у нас было больше контекста, я все-таки хотел бы пройтись по некоторым базовым сведениям о enable_if.

Для чего может понадобиться if во время компиляции?    

Начнем с примера, в котором мы пытаемся преобразовать некоторый ввод в строку:

#include <string> #include <iostream>  template <typename T> std::string str(T t) {     return std::to_string(t); }  std::string str(const std::string& s) {     return s; }  std::string str(const char* s) {     return s; }  std::string str(bool b) {     return b ? "true" : "false"; }  int main() {     std::cout << str("hello") << '\n';     std::cout << str(std::string{"hi!"}) << '\n';     std::cout << str(42) << '\n';     std::cout << str(42.2) << '\n';     std::cout << str(true) << '\n'; }

Вы можете посмотреть этот код в Compiler Explorer.

В коде, приведенном выше, мы видим три перегрузки функций для конкретных типов и один шаблон функции для всех остальных типов, которые должны поддерживать to_string(). Похоже, что этот код работает. Давайте попробуем преобразовать все это в одну функцию.

Сработает ли здесь “заурядный” if?

Вот тестовый код:

template <typename T> std::string str(T t) {     if (std::is_convertible_v<T, std::string>)         return t;     else if (std::is_same_v<T, bool>)         return t ? "true" : "false";     else         return std::to_string(t); }

Звучит достаточно просто… но давайте попробуем скомпилировать этот код:

// код, вызывающий нашу функцию auto t = str("10"s);

В результате мы должны получить что-то вроде этого:

In instantiation of 'std::__cxx11::string str(T) [with T =  std::__cxx11::basic_string<char>; std::__cxx11::string =  std::__cxx11::basic_string<char>]': required from here error: no matching function for call to  'to_string(std::__cxx11::basic_string<char>&)'     return std::to_string(t);

is_convertible  возвращает true  для используемого нами типа (std::string), так что мы можем просто вернуть t  без какого-либо преобразования… так что же не так?

Вот в чем дело:

Компилятор скомпилировал все ветки и нашел ошибку в else. Он не может отбросить “неправильный” код для этого частного случая конкретизации шаблона.

Вот зачем нам нужен статический if, который “отбросит” ненужный код и скомпилирует только блок, в который ведет ветвление. Иными словами, мы по прежнему будем делать проверку синтаксиса для всего кода, но некоторые части функции не будут созданы.

std::enable_if   

Один из способов, которым можно реализовать статический if в C++11/14 — это использовать enable_if.

enable_ifenable_if_v C++14). У него довольно странный синтаксис:

template< bool B, class T = void >   struct enable_if;

enable_if выводит тип T, если входное условие B истинно. В противном случае это SFINAE, и конкретная перегрузка функции удаляется из набора перегрузок. Это означает, что в случае false компилятор “отбрасывает” код — это как раз то, что нам нужно.

Мы можем переписать наш пример следующим образом:

template <typename T> enable_if_t<is_convertible_v<T, string>, string> strOld(T t) {     return t; }  template <typename T> enable_if_t<!is_convertible_v<T, string>, string> strOld(T t) {     return to_string(t); } // префикс std:: был опущен

Не так легко… верно? Кроме того, эта версия выглядит намного сложнее, чем отдельные функции и обычная перегрузка функций, которые были у нас в самом начале.

Вот почему нам нужен if constexpr из C++17, который может помочь в таких случаях.

Почитав эту статью, вы сами сможете быстро переписать нашу функцию str (или найти решение в конце).

Чтобы лучше разобраться с новыми фичами, давайте рассмотрим несколько базовых примеров:

Пример 1 — сравнение чисел    

Давайте начнем с самого простого примера: функция close_enough, которая работает с двумя числами. Если числа не являются числами с плавающей запятой (например, когда мы получаем два int), мы можем сравнить их напрямую. Для чисел с плавающей запятой лучше будет использовать некоторую достаточно маленькую величину, с которой мы будем сравнивать их разницу abs < epsilon.

Я нашел этот код в Practical Modern C++ Teaser — фантастическом пошаговом руководстве по современным фичам C++, написанном Патрисом Роем (Patrice Roy). Он был очень любезен и позволил мне включить этот пример в свою статью.

Версия С++11/14:

template <class T> constexpr T absolute(T arg) {    return arg < 0 ? -arg : arg; }  template <class T>  constexpr enable_if_t<is_floating_point<T>::value, bool>  close_enough(T a, T b) {    return absolute(a - b) < static_cast<T>(0.000001); } template <class T> constexpr enable_if_t<!is_floating_point<T>::value, bool>  close_enough(T a, T b) {    return a == b; }

Как видите, здесь используется enable_if. Эта функция очень похожа на нашу функцию str. Код проверяет тип входного числа — is_floating_point. Затем компилятор может удалить одну из перегрузок функции.

А теперь давайте посмотрим на версию C++17:

template <class T> constexpr T absolute(T arg) {    return arg < 0 ? -arg : arg; }  template <class T> constexpr auto precision_threshold = T(0.000001);  template <class T> constexpr bool close_enough(T a, T b) {    if constexpr (is_floating_point_v<T>) // << !!       return absolute(a - b) < precision_threshold<T>;    else       return a == b; }

Вау… все заключено в одной функции, которая к тому же выглядит почти как обычная функция.

С почти “заурядным” if 🙂

В конструкции if constexpr constexpr выражение вычисляется во время компиляции, после чего код в одной из ветвей отбрасывается.

Но важно отметить, что отброшенный код все-таки должен иметь правильный синтаксис. Компилятор выполнит базовую проверку синтаксиса, но затем пропустит эту часть функции на этапе конкретизации шаблона.

Именно поэтому следующий код генерирует ошибку компилятора:

template <class T> constexpr bool close_enough(T a, T b) {    if constexpr (is_floating_point_v<T>)        return absolute(a - b) < precision_threshold<T>;    else       return aaaa == bxxxx; // ошибка компилятора - синтаксис! }  close_enough(10.04f, 20.f);

Кстати, заметили ли вы какие-нибудь другие фичи C++17, которые здесь использовались?

Вы можете посмотреть этот код в @Compiler Explorer

Добавление концептов C++20   

Но подождите… на дворе уже 2021 год, так почему бы нам не воспользоваться концептами? 🙂

До C++20 мы могли рассматривать шаблонные параметры как что-то вроде void* в обычной функции. Если вы хотели ограничить такой параметр, вам приходилось использовать различные методы, описанные в этой статье. Но вместе с концептами мы получили естественный способ ограничить эти параметры.

Взгляните на следующий фрагмент кода: 

template <typename T> requires std::is_floating_point_v<T> constexpr bool close_enough20(T a, T b) {    return absolute(a - b) < precision_threshold<T>; } constexpr bool close_enough20(auto a, auto b) {    return a == b; }

Как видите, версия C++20 вернулась обратно к двум функциям. Но теперь код намного читабельнее, чем с enable_if. С помощью концептов мы можем легко выразить наши требования к шаблонным параметрам:

requires std::is_floating_point_v<T>

is_floating_point_v является свойством типа (из библиотеки <type_traits>), а оператор requires, как вы можете видеть, вычисляет булевы константные выражения.

Вторая функция использует новый обобщенный синтаксис функции, в котором мы можем опустить template<> и написать:

constexpr bool close_enough20(auto a, auto b) { }

Такой синтаксис мы получили с обобщенными (generic) лямбда-выражениями. Это не прямая трансляция нашего C++11/14 кода, поскольку он соответствует следующей сигнатуре:

template <typename T, typename U> constexpr bool close_enough20(T a, U b) { }

Вдобавок, C++20 предлагает нам краткий синтаксис для концептов, который основан на auto с ограничениями (constrained auto):

constexpr bool close_enough20(std::floating_point auto a,                               std::floating_point auto b) {    return absolute(a - b) < precision_threshold<std::common_type_t<decltype(a), decltype(b)>>; } constexpr bool close_enough20(std::integral auto a, std::integral auto b) {    return a == b; }

В качестве альтернативы, мы также можем использовать имя концепта вместо typename без оператора requires:

template <std::is_floating_point T> constexpr bool close_enough20(T a, T b) {    return absolute(a - b) < precision_threshold<T)>; }

В этом случае мы также переключились с is_floating_point_v на концепт floating_point определенный под заголовком <concepts>.

Вы можете посмотреть этот код в @Compiler Explorer

Хорошо, а как насчет других вариантов использования?

Пример 2 — вычисление среднего значения 

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

Вот, что мы хотели бы видеть:

std::vector ints { 1, 2, 3, 4, 5}; std::cout << Average(ints) << '\n';

Наша функция должна:

  • Принимать числа с плавающей запятой или целочисленные типы.

  • Возвращать double.

В C++20 для этого мы можем использовать диапазоны (ranges), но в целях этой статьи давайте рассмотрим другие способы реализовать такую функцию.

Вот возможная версия с концептами:

template <typename T>  requires std::is_integral_v<T> || std::is_floating_point_v<T> constexpr double Average(const std::vector<T>& vec) {     const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);             return sum / static_cast<double>(vec.size()); }

Для реализации нам нужно ограничить шаблонный параметр до целых чисел или чисел с плавающей запятой.

У нас нет предопределенного концепта, объединяющего типы с плавающей запятой и целочисленные, поэтому мы можем попробовать написать свой собственный:

template <typename T>  concept numeric = std::is_integral_v<T> || std::is_floating_point_v<T>;

И использовать его следующим образом:

template <typename T>  requires numeric<T> constexpr double Average2(std::vector<T> const &vec) {     const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);             return sum / static_cast<double>(vec.size()); }

Мы также можем сделать этот код достаточно лаконичным:

constexpr double Average3(std::vector<numeric auto> const &vec) {     const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);             return sum / static_cast<double>(vec.size()); }

Так этот код будет выглядеть с enable_if C++14:

template <typename T>  std::enable_if_t<std::is_integral_v<T> || std::is_floating_point_v<T>, double> Average4(std::vector<T> const &vec) {     const double sum = std::accumulate(vec.begin(), vec.end(), 0.0);             return sum / static_cast<double>(vec.size()); }

Вы можете посмотреть этот код в @Compiler Explorer

Пример 3 — фабрика с переменным количеством аргументов

В параграфе 18 книги “Эффективное использование C++” Скотт Майерс описал функцию makeInvestment:

template<typename... Ts>  std::unique_ptr<Investment>  makeInvestment(Ts&&... params);

Это фабричный метод, который создает классы, наследуемые от Investment, и его главное преимущество заключается в том, что он поддерживает переменное количество аргументов!

Вот несколько предлагаемых типов для примера:

class Investment { public:     virtual ~Investment() { }      virtual void calcRisk() = 0; };  class Stock : public Investment { public:     explicit Stock(const std::string&) { }      void calcRisk() override { } };  class Bond : public Investment { public:     explicit Bond(const std::string&, const std::string&, int) { }      void calcRisk() override { } };  class RealEstate : public Investment { public:     explicit RealEstate(const std::string&, double, int) { }      void calcRisk() override { } };

Код из книги был слишком идеалистичным и практически нерабочим. Он работал только в тех случаях, когда все ваши классы имели одинаковое количество и типы входных параметров:

Скотт Майерс: История изменений и список исправлений для книги “Эффективное использование C++”:

Интерфейс makeInvestment нереалистичен, потому что он подразумевает, что все производные типы объектов могут быть созданы из одних и тех же типов аргументов. Это особенно бросается в глаза в коде примера реализации, где наши аргументы передаются всем конструкторам производных классов с помощью прямой передачи (perfect-forwarding).

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

// псевдокод: Bond(int, int, int) { } Stock(double, double) { } make(args...) {   if (bond)      new Bond(args...);   else if (stock)      new Stock(args...) }

Если вы напишете make(bond, 1, 2, 3), то блок else не будет скомпилирован — так как нет доступного Stock(1, 2, 3)! Чтобы этот код работал, нам нужно что-то вроде статического if, который будет отбрасывать во время компиляции части кода, которые не соответствуют условию.

Несколько статей назад вместе с одним из моих читателей мы придумали работающее решение (подробнее вы можете прочитать в Nice C++ Factory Implementation 2).

Вот код, который будет работать:

template <typename... Ts>  unique_ptr<Investment>  makeInvestment(const string &name, Ts&&... params) {     unique_ptr<Investment> pInv;      if (name == "Stock")         pInv = constructArgs<Stock, Ts...>(forward<Ts>(params)...);     else if (name == "Bond")         pInv = constructArgs<Bond, Ts...>(forward<Ts>(params)...);     else if (name == "RealEstate")         pInv = constructArgs<RealEstate, Ts...>(forward<Ts>(params)...);      // далее вызываем дополнительные методы для инициализации pInv...      return pInv; }

Как видите, вся “магия” происходит внутри функции convertArgs.

Основная идея заключается в том, чтобы возвращать unique_ptr<Type>, когда у нас есть Type, сконструированный из заданного набора атрибутов, или nullptr в противном случае.

До С++17  

В моем предыдущем решении (до C++17) мы использовали std::enable_if, и это выглядело так:

// до C++17 template <typename Concrete, typename... Ts> enable_if_t<is_constructible<Concrete, Ts...>::value, unique_ptr<Concrete>> constructArgsOld(Ts&&... params) {     return std::make_unique<Concrete>(forward<Ts>(params)...); }  template <typename Concrete, typename... Ts> enable_if_t<!is_constructible<Concrete, Ts...>::value, unique_ptr<Concrete> > constructArgsOld(...) {     return nullptr; }

std::is_constructible (смотрите c++ reference.com) — позволяет нам быстро проверить, можно ли использовать список аргументов для создания данного типа.

В C++17 есть хелпер:

is_constructible_v = is_constructible<T, Args...>::value;

Так мы могли бы сделать код немного лаконичнее…

Тем не менее, использование enable_if выглядит неэлегантно и чересчур сложно. Как насчет C++17 версии?

С if constexpr   

Вот обновленная версия:

template <typename Concrete, typename... Ts> unique_ptr<Concrete> constructArgs(Ts&&... params) {     if constexpr (is_constructible_v<Concrete, Ts...>)       return make_unique<Concrete>(forward<Ts>(params)...);    else        return nullptr; }

Очень лаконично!

Мы можем даже добавить сюда логирование, используя свертку (fold expression):

template <typename Concrete, typename... Ts> std::unique_ptr<Concrete> constructArgs(Ts&&... params) {      cout << __func__ << ": ";     // свертка:     ((cout << params << ", "), ...);     cout << "\n";      if constexpr (std::is_constructible_v<Concrete, Ts...>)         return make_unique<Concrete>(forward<Ts>(params)...);     else        return nullptr; }

Неплохо… не так ли? 🙂

Вся сложность синтаксиса enable_if канула в лету; нам даже не нужна перегрузка функции для else. Теперь мы можем заключить весь этот выразительный код в одной функции.

if constexpr вычисляет условие, в результате чего будет скомпилирован только один блок. В нашем случае, если тип может быть сконструирован из заданного набора атрибутов, мы скомпилируем make_unique. Если нет, то вернем nullptrmake_unique даже не будет создана).

C++20   

Мы можем легко заменить enable_if концептами:

// C++20: template <typename Concrete, typename... Ts> requires std::is_constructible_v<Concrete, Ts...> std::unique_ptr<Concrete> constructArgs20(Ts&&... params) {     return std::make_unique<Concrete>(std::forward<Ts>(params)...); }  template <typename Concrete, typename... Ts> std::unique_ptr<Concrete> constructArgs20(...) {     return nullptr; }

Но я не уверен, что этот вариант лучше. Я думаю, что в этом случае, if constexpr выглядит намного лаконичнее и проще для понимания.

Вы можете посмотреть этот код в @Compiler Explorer

Пример 4 — реальные проекты     

if constexpr годится не только для экспериментальных демок — он уже нашел применение в продакшене.

Если мы посмотрим на опенсорсную реализацию STL от команды MSVC, мы можем найти несколько случаев, где if constexpr пришелся очень кстати.

Журнал изменений можно посмотреть здесь: https://github.com/microsoft/STL/wiki/Changelog

Вот некоторые из улучшений:

  • Теперь вместо статической диспетчеризации используется if constexpr в: get<I>() и get<T>() для pair (#2756)

  • Вместо статической диспетчеризации, перегрузок или специализаций используется if constexpr в таких алгоритмах, как is_permutation(), sample(), rethrow_if_nested() и default_searcher (#2219), общих механизмах <map> и <set> (#2287) и паре других мест.

  • Используется if constexpr вместо статической диспетчеризации в оптимизации в find() (#2380), basic_string(first, last) (#2480)

  • Улучшена реализация вектора, также для упрощения кода был задействован if constexpr (#1771)

Давайте посмотрим на улучшения для std::pair:

Untag dispatch get for pair by frederick-vs-ja · Pull Request #2756 · microsoft/STL

До C++17 код выглядел следующим образом:

template <class _Ret, class _Pair> constexpr _Ret _Pair_get(_Pair& _Pr, integral_constant<size_t, 0>) noexcept {     // получаем ссылку на элемент 0 в паре _Pr     return _Pr.first; }  template <class _Ret, class _Pair> constexpr _Ret _Pair_get(_Pair& _Pr, integral_constant<size_t, 1>) noexcept {     // получаем ссылку на элемент 1 в паре _Pr     return _Pr.second; }  template <size_t _Idx, class _Ty1, class _Ty2> _NODISCARD constexpr tuple_element_t<_Idx, pair<_Ty1, _Ty2>>&      get(pair<_Ty1, _Ty2>& _Pr) noexcept {     // получаем ссылку на элемент по адресу _Idx в паре _Pr     using _Rtype = tuple_element_t<_Idx, pair<_Ty1, _Ty2>>&;     return _Pair_get<_Rtype>(_Pr, integral_constant<size_t, _Idx>{}); }

И после изменения:

template <size_t _Idx, class _Ty1, class _Ty2> _NODISCARD constexpr tuple_element_t<_Idx, pair<_Ty1, _Ty2>>& get(pair<_Ty1, _Ty2>& _Pr) noexcept {     // получить ссылку на элемент по адресу _Idx в паре _Pr     if constexpr (_Idx == 0) {         return _Pr.first;     } else {         return _Pr.second;     } }

Теперь это одна функция, и ее намного легче читать! Больше нет никакой необходимости использовать статическую диспетчеризацию и хелпер integer_constant.

В другой библиотеке, на этот раз связанной с SIMD-типами и вычислениями (популярная реализация от Agner Fog), вы можете найти множество примеров использования if constexpr:

https://github.com/vectorclass/version2/blob/master/instrset.h

Одним из ярких примеров является функция маски:

// zero_mask: возвращает компактную битовую маску для обнуления с использованием маски AVX512. // Параметр a является ссылкой на массив constexpr int индексов перестановок template <int N> constexpr auto zero_mask(int const (&a)[N]) {     uint64_t mask = 0;     int i = 0;      for (i = 0; i < N; i++) {         if (a[i] >= 0) mask |= uint64_t(1) << i;     }     if constexpr      (N <= 8 ) return uint8_t(mask);     else if constexpr (N <= 16) return uint16_t(mask);     else if constexpr (N <= 32) return uint32_t(mask);     else return mask; }

Без if constexpr код был бы намного длиннее и потенциально дублировался бы.

Заключение

if во время компиляции — замечательная фича, значительно упрощающая шаблонный код. Более того, она намного выразительнее и элегантнее, чем предыдущие решения: статическая диспетчеризация и enable_if (SFINAE). Теперь мы можем легко выразить свои намерения наподобии с рантайм-кодом.

Мы также переработали код примеров с нововведениями C++20! Как видите, благодаря концептам код стал еще более читабельным, ведь теперь мы можем выразить требования к своим типам “естественным” образом. Мы также получили несколько сокращений синтаксиса и несколько способов сообщить о наших ограничениях.

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

Возвращаясь назад…

И возвращаясь к нашему примеру со str:

Можете ли вы теперь переписать str (из начала этой статьи), используя if constexpr? 🙂 Попробуйте, а затем взгляните на мое простое решение в @CE.

Еще больше

Вы можете найти больше примеров и вариантов использования if constexpr в моей книге о C++17: C++17 в деталях на @Leanpub или печатную версию на @Amazon.


Статья подготовлена в преддверии старта курса «C++ Developer. Professional«. Всех желающих приглашаем посмотреть запись открытого урока «Умные указатели», на котором разобрали, что такое умные указатели и зачем они нужны; а также провели обзор умных указателей, входящих в stl: unique_ptr, shared_ptr, weak_ptr. Посмотреть можно по ссылке.


ссылка на оригинал статьи https://habr.com/ru/company/otus/blog/688688/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *