Несколько новых возможностей C++17 позволяют написать более компактный и ясный код. Это особенно важно при шаблонном мета-программировании, результат которого часто выглядит жутко…
Например если вы хотите выразить if
, который вычисляется во время компиляции, вы будете вынуждены написать код используя приём SFINAE (например enable_if
) или статическую диспетчеризацию (tag dispatching). Такие выражения тяжело понять, и они выглядят как магия для разработчиков, незнакомых с продвинутыми шаблонами мета-программирования.
К счастью, с появлением C++17 мы получаем if constexpr
. Теперь большинство приёмов SFINAE и статической диспетчеризации отпадает, и код уменьшается, становится похожим на "обычный" if
.
Эта статься демонстрирует несколько приёмов использования if constexpr
.
Введение
Статический if
в форме if constexpr
полезная возможность, появившаяся в C++17. Недавно на сайте Meeting C++ была опубликована статься о том, как автор статьи Jens упростил код, используя if constexpr
: Как if constexpr упрощает ваш код в C++17.
Я нашёл пару дополнительных примеров, которые могут продемонстрировать, как работает новая возможность.
- Сравнение чисел
- Фабрики с переменным числом аргументов
Я надеюсь, что эти примеры помогут вам понять статический if
из C++17.
Но для начала я бы хотел освежить основы enable_if
.
Для чего нужен if во время компиляции?
Услышав об этом в первый раз, возможно вы спросите, зачем нужен статический if
и эти сложные шаблонные выражения… Разве нормальный if
не будет работать?
Рассмотрим пример:
template <typename T>
std::string str(T t) {
if (std::is_same_v<T, std::string>) // строка или преобразуемый в строку
return t;
else
return std::to_string(t);
}
Эта функция может служить простым инструментом для вывода текстового представления объектов. Так как to_string
не принимает параметр типа std::string
, мы можем проверить это и просто вернуть t
если t
— string. Звучит просто… Но давайте попробуем скомпилировать этот код:
// код, который вызывает нашу функцию
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_same
даёт true
для используемого типа (string), и мы можем просто вернуть t
без преобразований… но что пошло не так?
Главная причина в этом: компилятор попытался разобрать обе условные ветви и нашёл ошибку в случае else
. Он не может отбросить "неправильный" кода в нашем частном случае конкретизации шаблона.
Вот для этого нам нужен статический if
, который будет "исключать" код и компилировать только тот блок, который подходит условию.
std::enable_if
Один из способов написать статический if
в C++11/14 — использовать enable_if
(и enable_if_v
начиная с C++14). Он имеет достаточно странный синтаксис::
template< bool B, class T = void >
struct enable_if;
enable_if
выводит тип T, если условие B истинно. Иначе, согласно SFINAE, частичная перегрузка функции удаляется из доступных перегрузок фунции.
Мы можем переписать наш простой пример так:
template <typename T>
std::enable_if_t<std::is_same_v<T, std::string>, std::string> str(T t) {
return t;
}
template <typename T>
std::enable_if_t<!std::is_same_v<T, std::string>, std::string> str(T t) {
return std::to_string(t);
}
Это не просто, не так ли?
Я использовал enable_if
, чтобы отделить случай, когда тип — строка… Но точно такой же эффект можно достичь простой перегрузкой функции, избежав использование enable_if
.
Далее мы упростим подобный код с помощью if constexpr
из C++17. После этого мы сможем быстро переписать нашу функцию str
.
Использование первое — сравнение чисел
Начнём с простого примера: функция close_enough
, работающая с двумя числами. Если числа не с плавающей точкой (например, когда мы имеем два целочисленных int
), мы можем просто сравнить их. Для чисел с плавающей точкой лучше использовать некоторую малую величину epsilon.
Я нашёл этот пример в Практическая головоломка современного C++ (Practical Modern C++ Teaser) — фантастическое введение в возможности современного C++ от Patrice Roy. Он любезно разрешил мне включить его пример.
Версия для C++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
вычисляется во время компиляции и затем пропускается код одной из ветвей выражения.
Здесь используются чуть больше возможностей C++17. Вы видите, какие?
Использование второе — фабрика с переменным количеством параметров
В главе 18 книги "Эффективное использование С++" Скотта Майрса описывается метод, названный 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 { }
};
Пример из книги слишком идеализированный и не рабочий — он работает, пока конструкторы ваших классов принимают одинаковое число и одинаковые типы входных аргументов.
Скотт Майрес комментирует в исправлениях и дополнениях к его книге "Эффективное использование С++" так:
Интерфейс
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)
! Чтобы это заработало, нам нужно что-то похожее на static if
— компилировать это только тогда, когда это удовлетворяет условию, иначе отбросить.
Вот код, который мог бы работать:
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
в противном случае.
До 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
позволяет быстро проверить, будет ли данный тип конструироваться из заданного списка аргументов. // @cppreference.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;
}
Мы можем даже расширить функциональность логироваием действий, используя свёртку выражения:
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
ушёл прочь; нам даже не нужна перегрузка функции. Мы можем написать выразительный код всего лишь в одной функции.
В зависимости от результата вычисления условия выражения if constexpr
только один блок кода будет компилироваться. В нашем случае, если объект может быть сконструирован из заданного набора атрибутов, тогда мы компилируем вызов make_unique
. Если нет, то возвращаем nullptr
(и make_unique
даже не компилируется).
Заключение
Условные выражения времени компиляции — замечательная возможность, которая сильно упрощает использование шаблонов. Кроме того, код становится яснее, чем при использовании существовавших ранее решений: статической диспетчеризации (tag dispatching) или enable_if
(SFINAE). Сейчас вы можете выразить свои намерения "похоже" на код в рантайме.
В этой статье рассматривались только простые выражения, и я призываю вас исследовать более широко применимость новых возможностей.
Возвращаясь назад к нашему примеру функции str
: можете ли вы сейчас переписать её используя if constexpr?
:)
Автор: sergio_nsk