Спецификатор constexpr в C++: зачем он нужен и как работает

Новички могут путать спецификатор constexpr, появившийся в C++ с версии 11, с похожим на него квалификатором const.

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

constexpr можно использовать с переменными и функциями, в том числе конструкторами и операторами if. Разберемся в этом подробнее.

Переменные

Квалификатор const указывает компиляторам и другим программистам, что переменная доступна только для чтения. Любая попытка изменить ее приведет к ошибке компиляции.

В данном случае constexpr похож на const тем, что подразумевает const. Его тоже нельзя изменить. Разница в том, что const может быть вычислен как на стадии компиляции, так при выполнении программы, в зависимости от выбранного варианта инициализации.

int main() {
const int val = 1 + 2;
return 0;
}

В этом примере val вычисляется во время компиляции. При выполнении кода val всегда равно 3. Однако в следующем примере val вычисляется во время выполнения кода, поскольку включает вызов функции.

int Sum(const int a, const int b) {
return a + b;
}

int main(int argc, char **argv) {
const int val = Sum(1, 2);
return 0;
}

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

int main() {
constexpr int val = 1 + 2;
return 0;
}

Но это не применимо ко второму примеру. Мы получим ошибку компиляции из-за невозможности вычисления на этом этапе.

error: call to non-‘constexpr’ function ‘int Sum(int, int)’

Чтобы устранить эту проблему в данном примере, можно преобразовать функцию в constexpr.

constexpr int Sum(const int a, const int b) {
return a + b;
}

constexpr int main(int argc, char **argv) {
const int val = Sum(1, 2);
return 0;
}

Код выглядит так же, но теперь выполняется во время компиляции. При выполнении компилятор модифицирует код так:

int main(int argc, char **argv) {
int val = 3;
return 0;
}

Здесь не указаны const и constexpr, потому что после запуска программы они больше не используются. И const, и constexpr применяются только в процессе компиляции.

Функции и конструкторы

constexpr можно также использовать с функциями и конструкторами. Как и в предыдущем примере, можно определить функцию или конструктор как функцию constexpr.

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

constexpr int Sum(const int a, const int b) {
return a + b;
}

int main(int argc, char **argv) {
constexpr int val = Sum(1, 2);
int var = 3;
int val2 = Sum(val, var);

return 0;
}

В этом примере есть функция constexpr под названием Sum, которая вызывается в строках 6 и 8. Строка 6 вычисляется при компиляции (как и в примере предыдущего раздела), а строка 8  —  при выполнении, поскольку включает неконстантную переменную var.

Очевидно, что функция constexpr весьма полезна. Мы можем предоставить компилятору выбор: вычислять ее при компиляции или при выполнении. Для вычисления во время компиляции должен выполняться целый ряд условий (подробнее об этом по ссылке).

Операторы if

Начиная с C++17, можно определять if-операторы constexpr. Если вы не часто сталкиваетесь с общим кодом в повседневной работе, то, возможно, пользуетесь этой возможностью. Чтобы выяснить, зачем она необходима, нужно понимать концепцию SFINAE (Substitution Failure is not an Error, ошибка замены не является ошибкой) в метапрограммировании шаблона C++.

SFINAE и std::enable_if

При вызове функции компилятор должен выполнить проверку, чтобы узнать, какую из функций вызывать. Учитывайте перегрузку функций (function overloading), позволяющую давать нескольким функциям одинаковые имена. Говоря упрощенно, есть несколько шагов:

  • Name lookup (поиск по имени);
  • Template Argument Deduction (дедукция аргумента шаблона);
  • Template Argument Substitution (замена аргумента шаблона);
  • Overload Resolution (разрешение перегрузки).

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

Алгоритм SFINAE проявляется на шаге 3 (Template Argument Substitution), когда функция-кандидат проваливает тест Substitution (замены). Ошибка компиляции не возникает, а функция просто удаляется из списка кандидатов, как в следующем примере.

Нам нужно написать общую функцию Square(), которая может быть как арифметического, так и определяемого пользователем типа. Определяемый пользователем тип  —  это шаблон класса, показанный ниже.

template <typename T>
struct Number {
Number(const T& _val) :
value(_val) {}

T value;
};

Если бы не поддержка этого шаблона класса, мы могли бы легко реализовать шаблон функции следующим образом.

template<typename T>
T Square(const T& t) {
return t * t;
}

Однако этот шаблон функции не работает, когда ему передается объект типа Number<int>.

int integer_num = 5;
float floating_num = 5.0;
bool boolean = true;
Number<int> number_int(5);

auto res = Square(integer_num); // вызов int Square(int);
auto res2 = Square(floating_num); // вызов float Square(float);
auto res3 = Square(boolean); // вызов bool Square(bool);
auto res4 = Square(number_int); // вызов Number<int> Square(Number<int>);
// ошибка компиляции, не найден operator*

Строка 9 не скомпилируется, потому что Number<int> не реализует operator*.

Для решения этой проблемы нужно знать тип, переданный шаблону функции Square(), и добавить в него оператор проверки if-else, который будет вычисляться во время компиляции:

template<typename T>
T Square(const T& t) {
if (std::is_arithmetic<T>::value) {
return t * t;
} else {
return t.value * t.value;
}
}

Но такое решение не работает: при вызове, например с int, эта функция пытается найти int.value, которого не существует. Чтобы увидеть это более четко, смотрим результат создания экземпляра шаблона.

int Square(const int& t) {
if (true) {
return t * t;
} else {
return t.value * t.value;
}
}

Теперь понятно, почему он не работает. Часть else не удалена из функции, и мы получаем ошибку компиляции.

error: request for member ‘value’ in ‘t’, which is of non-class type ‘const int’

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

template<typename T>
typename std::enable_if<std::is_arithmetic<T>::value, T>::type Square(const T& t) {
return t * t;
}

template<typename T>
typename std::enable_if<! std::is_arithmetic<T>::value, T>::type Square(const T& t) {
return t.value * t.value;
}

Здесь указаны два шаблона функций: для арифметических и неарифметических типов. У std::enable_if с typedef :: type, если ему передается значение true, будет спецификатор доступа public. В противном случае члены с typedef с спецификатором доступа public будут отсутствовать.

При передаче Number<int> в Square() первый шаблон функции завершает замену шаблона с ошибкой, а второй  —  успешно. Ошибки для первой функции не возникает, она просто удаляется из списка функций-кандидатов. Затем компилятор выбирает вторую функцию.

С помощью двух и более шаблонов функций с std::enable_if мы в некотором роде имитируем if-else во время компиляции.

Как оператор constexpr if улучшает SFINAE

Взаимодействие SFINAE и std::enable_if работает и часто используется, но не очень интуитивно понятно. Такой излишне подробный код и незнакомый синтаксис порой трудно читать.

Сделать его более читаемым, начиная с C++17, позволяют if-операторы constexpr. Можно использовать настоящий if-else во время компиляции в одной функции, а не имитировать его, используя несколько функций с std::enable_if. Ниже показана реализация с помощью if-оператора constexpr.

template<typename T>
T Square(const T& t) {
if constexpr (std::is_arithmetic<T>::value) {
return t * t;
} else {
return t.value * t.value;
}
}

В этом примере используется только один шаблон функции, который к тому же намного ближе к знакомому нам оператору if-else. Этот способ работает, потому что компилятор берет только ветку с истинным условием (true) и отбрасывает другие.

Выводы

  • При использовании для переменных constexpr подразумевает const. Главная особенность заключается в том, что переменные constexpr вычисляются на этапе компиляции.
  • Спецификатор constexpr может использоваться с функциями и конструкторами. В отличие от функций, возвращающих const, использование constexpr в этом случае позволяет вычислять функции и конструкторы на этапе компиляции (если это возможно).
  • Чтобы улучшить читаемость кода, constexpr можно использовать для исполняемого во время компиляции if-else вместо обычной имитации с помощью SFINAE и std::enable_if.

Читайте также:

Читайте нас в TelegramVK и Яндекс.Дзен


Перевод статьи Debby Nirwan: C++ constexpr: What It Really Is?

Предыдущая статьяПолное руководство по React Context
Следующая статьяКак создать простую функцию AWS Lambda с помощью TypeScript