До 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;
}
Как видите, вся “магия” происходит внутри функции constructArgs
.
Основная идея заключается в том, чтобы возвращать 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
. Посмотреть можно по ссылке.