
До 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_if (и enable_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. Если нет, то вернем nullptr (и make_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/
Добавить комментарий