Компиляция на этапе выполнения в C++: constexpr, consteval и constinit

4c00a444458461d276764ddcd033ea8b.png

Привет, Хабр!

Сегодня мы поговорим о том, как constexpr, consteval, и constinit позволяют реализовывать компиляцию на этапе выполнения. Компиляция на этапе выполнения позволяет ускорить выполнение кода за счет выполнения расчетов на этапе компиляции, а не в рантайме.

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

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

constinit используется для инициализации статических и глобальных переменных.

А теперь подробней.

constexpr

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

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

Кроме того, функции должны быть достаточно простыми, чтобы компилятор мог вычислить их результат на этапе компиляции. Также они не могут содержать некоторые операторы, такие как goto, и не могут вызывать функции, которые не являются constexpr.

Примеры

constexpr в функциях:

constexpr int factorial(int n) {
    return n <= 1 ? 1 : n * factorial(n - 1);
}

int main() {
    // значение будет вычислено во время компиляции.
    constexpr int fact_of_5 = factorial(5);
}

С constexpr компилятор вычисляетfactorial(5) во время компиляции и использует это значение как константу времени компиляции. Т.е мы получаем ноль затрат времени на выполнение для вычисления факториала 5 при запуске программы.

constexpr с классами:

class Point {
public:
    constexpr Point(double x, double y) : x_(x), y_(y) {}

    constexpr double getX() const { return x_; }
    constexpr double getY() const { return y_; }

private:
    double x_;
    double y_;
};

int main() {
    constexpr Point p(10.5, 20.5);
    static_assert(p.getX() == 10.5, "X coordinate should be 10.5");
    static_assert(p.getY() == 20.5, "Y coordinate should be 20.5");
}

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

Ограничения constexpr:

#include 
#include 

constexpr std::vector makeVector(int size) { // ошибка компиляции!
    std::vector v(size, 0);
    return v;
}

int main() {
    auto v = makeVector(5);
}

Использованиеconstexpr с std::vector приведет к ошибке компиляции, поскольку std::vector требует динамического выделения памяти, которое невозможно в constexpr функциях.

consteval

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

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

Пример

#include 

// consteval гарантирует, что функция fibonacci будет вычислена на этапе компиляции
consteval int fibonacci(int n) {
    return (n <= 1) ? n : fibonacci(n-1) + fibonacci(n-2);
}

// использование consteval для инициализации константы на этапе компиляции
constexpr int fib10 = fibonacci(10);

int main() {
    // поскольку fib10 вычисляется на этапе компиляции, здесь нет никаких рантайм вычислений.
    std::cout << "Fibonacci(10) = " << fib10 << std::endl;

    // это также работает:
    // constexpr int fib20 = fibonacci(20);
    // std::cout << "Fibonacci(20) = " << fib20 << std::endl;

    // однако, следующий код не скомпилируется, поскольку значение не может быть вычислено на этапе компиляции
    // int n;
    // std::cin >> n;
    // std::cout << "Fibonacci(n) = " << fibonacci(n) << std::endl;

    return 0;
}

fibonacci с consteval вынуждает компилятор вычислять её результаты на этапе компиляции. Результаты для fibonacci(10) будут встраиваться прямо в исполняемый код как константа, без необходимости пересчитывать их каждый раз при выполнении программы.

constinit

В отличие от constexpr, который является своего рода всегда вычисляемым выражением, и consteval, которое требует вычисления на этапе компиляции без исключений, constinit подходит к делу более гибко.

constinit указывает, что переменная должна быть инициализирована во время старта программы, до входа в main(). constinit обеспечивает инициализацию статического или потокового хранилища без динамической инициализации. Говоря простым языком, constinit гарантирует, что переменная будет инициализирована на этапе загрузки программы, ещё до того, как программа начнет своё выполнение. Отсюда вытекает то, в отличие от constexpr, constinit не требует, чтобы переменная оставалась неизменной после инициализации.

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

#include 
#include 

struct LoggerConfig {
    int logLevel;
    std::string logPath;
};

// constinit указывает, что инициализация должна произойти на этапе старта программы.
constinit LoggerConfig globalLoggerConfig{3, "/var/log/myapp.log"};

int main() {
    // при запуске программы globalLoggerConfig уже инициализирован.
    std::cout << "Log Level: " << globalLoggerConfig.logLevel << std::endl;
    std::cout << "Log Path: " << globalLoggerConfig.logPath << std::endl;

    // так как это constinit, мы можем изменять значения после инициализации.
    globalLoggerConfig.logLevel = 4; // Допустимо

    std::cout << "Updated Log Level: " << globalLoggerConfig.logLevel << std::endl;

    // однако, следующий код не скомпилируется, если globalLoggerConfig был объявлен как constexpr
    // constexpr LoggerConfig testConfig{1, "/test.log"};
    // testConfig.logLevel = 2; // ошибка компиляции, т.к. constexpr не допускает изменений после инициализации.

    return 0;
}

Важно грамотно использовать эти инструменты, сочетая их с пониманием их особенностей и ограничений, чтобы максимально раскрыть потенциал C++ и достичь высокой производительности.

Узнать больше об этих инструментах и не только можно на специализации «C++ Developer».

© Habrahabr.ru