Несколько новых возможностей 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?
Antervis
Да не особо. Ошибся в порядке/кол-ве аргументов в constructArgs(...) и втихую получил nullptr, чтобы потом что? В рантайме на него проверять? Уж лучше static_assert втыкать
sergio_nsk Автор
Да кто же ограничивает и мешает вставить
static_assert
вelse
.Antervis
так это же тавтология:
kesn
Здорово, целая статья про compile-time if! Ёжики, хватит жрать кактус (c++), переходите на тёмную сторону: в nim для этого есть просто ключевое слово when. Без всяких статей :)
VioletGiraffe
Лучше переходите на brainfuck, у него одни плюсы (ну, и угловые скобочки с запятыми).
UberSchlag
Спасибо за ультимативный аргумент! Теперь уж точно перейдем и все легаси перетащим! (нет)
kesn
Воу воу воу, полегче! Надеюсь, вы ещё не начали всё переписывать :) Смысл комментария был в другом: имхо невозможно усидеть на двух стульях сразу, что c++ и пытается делать — тут и совместимость сохранить надо, и фичи новые вводить. Получается монстр. Возможно, в один прекрасный момент вы захотите для вашего нового проекта попробовать что-то более продуктивное, с пакетным менеджером, с макросами, которые оперируют над AST (а не тупо подстановка), ну и с возможностью compile-time evaluation из коробки.
ToshiruWang
Тёмная — это потому что из-за тормозов приходится до поздна сидеть?
neurocore
Я правильно понял, что if constexpr гарантирует что условие развернётся на этапе компиляции? С другой стороны как контролируется «разворачиваемость условия» во время компиляции.
sergegers
Так и развернётся. Ложная ветка не будет компилироваться, примерно как #ifdef… #else… #endif.
knstqq
Если не развернётся, будет выброшена ошибка компиляции, что нельзя на этапе компиляции это вычислить.
Если нельзя гарантировать — просто не скомпилируется. А про constexpr выражения в стандарте есть описание того, что им может быть — это и есть гарантия
ktod
Читать чужой плюсовый код станет еще более увлекательно.
pavlushk0
Вопрос от дремучего непрофессионала. А зачем вообще нужно "шаблонное метопрограммтрование", и как часто всплывают кейсы, где без него никак? Вот именно никак, т.е. не решить задачу по другому. И пример, если можно) для общего образования.
Chaos_Optima
Ну конечно можно решить, всё можно решить и тупо на асме или брэйнфаке, вообще на любом тьюринг полном языке. Вопрос в удобстве такие фичи как compile time if даёт богатый простор для оптимизации при этом упрощая синтаксис.
pavlushk0
Я не умоляю устремлений людей придумывающих такие фичи, мне правда интересен кейс) Я смотрел лекции техносферы по С++, там упоминались такие вещи. Личной мой инструментарий С++ заканчивается на, скажем так, совсем базовой инкапсуляции.
мне правда интересен кейс
Chaos_Optima
Самое простое это патерн матчинг для типов, различные конвертации и серилизации, работа с биндингами по типу буст питона, упрощение работы с контейнерами и ещё куча кейсов но более специфичных тип внедрения зависимостей, автоматов и тд.
pavlushk0
Спасибо. Теперь есть от чего оттолкнуться для подробного гугления.
OrionGames
Очень много абстракций на таком уровне программирования, к сожалению нужно вникать, конечно все понятно, но на это уходит время и это печально)
Miron11
Не знаю, но мое мнение человека с гуманитарным образованием, что если для скрипки варианта 17 нужно для одной ноты смычок обычной формы ( обычный if ) а для другой ноты смычок с бетонным подвесом ( if constexpr ), то возникает вопрос, а что на ней собственно такое можно сыграть, чтобы идти на такие жуткие мучения. Я конечно понимаю, что если бы это сделало путешествие на Луну из невозможного простым и обыденным. Тут как говорится будем грызть метал и constexpr. Но вот так, чтобы элементарно проверять тип данных из того же Reflection только под иным синтаксисом…
Я не того уровня специалист, чтобы указывать дорогу мастерам творящим С++. Так что извините, если мои сомнения выглядят как упражнения доморощенного Фомы не верующего, но мне кажется что что — то тут не совсем так.
mayorovp
Это не две разные ноты. Если в рамках вашей аналогии if — это смычок для ноты, то if constexpr — это ручка для рисования нот.
Miron11
Вы хотите сравнить наше умение играть на скрипке или писать музыку?
Если я говорю, что это нота, то это нота. Пишите о том, что Вы знаете.
Вообще, три ответа на мой в общем — то очень скромный вопрос дилетанта наводят на мылси о некоем групповом предприятии. Больно много эмоций и полное отсутствие технических деталей. Я бы даже сказал необычно много эмоций и они, эмоции, какие — то все острые. Не пойму… что так уж защищают, вроде очень уважительно написал все.
terrier
А вот если бы вы следовали тому, что проповедуете, то вы бы в треде про constexpr не отписывались.
Miron11
Я прошу прощения, но фальшивые ноты в музыке программисту не грех заметить.
А повышенные тона программистов по поводу constexpr выдают то, на сколько это ошибочное нововведение. Нормальное «if» с таким огнём защищать не потребовалось бы.
То есть на лицо не только оголтелая лажа, но и не способность людей принадлежащих к роду деятельности адекватно воспринимать доброжелательную критику.
mayorovp
Проблема всех аналогий — в том, что местами они подобны котенку с дверцей.
Замеченное вами «повышение тона программистов» вызвано исключительно тем, что вы пытаетесь использовать неуместную аналогию. Оператор if constexpr не является смычком с бетонным подвесом, только и всего. И нет ничего удивительного в том, что на эмоциональный аргумент вы получаете эмоциональные ответы.
Antervis
if constexpr и if в общем случае не являются взаимозаменяемыми. Это разные инструменты для разных задач. Не надо пожалуйста говорить что пианино — громоздкая гитара
Antervis
эта и подобные фичи появляются в стандарте потому, что они нужны. И нужны они не вам. Возможно даже, что и не авторам библиотек, которыми вы пользуетесь. Скорее авторам какого-нибудь boost::python. Подумайте об этом в следующий раз когда будете писать что-нибудь типа import pyopencl.
Chaos_Optima
Тут не только тип данных, метапрограмирование это довольно обширный класс задач, с помощью if constexp вы можете примерно в одном и том же контексте работать с совершенно другим типом при этом имея 0 оверхед (что никак не получится при runtime reflection) т.к все проверки происходят ещё на этапе компиляции.
Miron11
0 оверхед очень дорогая вещь. Это имеет смысл для изменений уровня «полетим / не полетим на Луну». Подобные внедрения в код есть, например, в ядре обработки криптографических примитивов. Обычно их выполняют пользуясь расширением ассемблера asm {}.
Что касается templates и constraints, и далее метаданных и их проверки reflection, то Вы оцениваете оверхед через призму приземленного «а мне так кажется» пользователя, с целью оправдания сырого нововведения. Если это для «можно поспорить» точки зрения, то это конечно приемлемо. Но с точки зрения детального понимания runtime, то reflection так же неизбежен как смерть и налоги, без него просто нельзя. И в этом случае тривиальный запрос по верификации типа, это одна инструкция сравнения метаданных, которая в любом случае должны быть сделана. То есть для достаточно продвинутого компилятора это всего лишь вывод в стэк индикатора, считывающего результат целостности данных. То есть это 0 оверхэд в любом случае. Понимаете. Выигрыша нет. Зато язык стал сложнее. Вот в чем дело.
Может в этом нововведении есть какое — то зерно, которое мне не понятно. Вот об этом и был мой вопрос. Что — то о «полете на Луну». Именно это я и спросил. Если это есть, то пожалуйста, покажите.