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

Сегодня мы поговорим о том, как 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 <iostream>
#include <vector>

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

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

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

consteval

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

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

Пример

#include <iostream>

// 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 <iostream>
#include <string>

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».

Комментарии (29)


  1. rukhi7
    26.03.2024 05:33
    +13

    Сегодня мы поговорим о том, как constexprconsteval, и constinit позволяют реализовывать компиляцию на этапе выполнения.

    constexpr делает возможным вычисление значений переменных во время компиляции.

    Так на этапе выполнения или во время компиляции, или это (неожиданно!) одно и то же?

    Сдается мне, заголовок

    Компиляция на этапе выполнения в C++ ...

    не соответствует содержанию.


    1. MaxLevs
      26.03.2024 05:33

      Хорошее замечание. Но здесь стоит отметить, что такой расклад вполне возможен, если рассматривать общую картину, так как в некоторых языках программирования возможна докомпиляция при старте приложения.
      Другой вопрос — насколько это относится непосредственно к C++.


      1. rukhi7
        26.03.2024 05:33

        так вот же написано:

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

        или

        constinit указывает, что переменная должна быть инициализирована во время старта программы, до входа в main()

        по поводу

        возможна докомпиляция при старте приложения

        Да! Похоже это глобальная тенденция: статью не читаем, обсуждаем то, что сами придумаем примерно по теме.


        1. MaxLevs
          26.03.2024 05:33
          +1

          Нет, я обсуждаю конкретно содержимое комментария. Специально отделяю общий случай и текущий.
          Что в общем случае, без уточнения языка, докомпиляция в рантайме вполне возможна.
          Однако в текущем случае, когда язык C++ и const(expr/eval/init), возникают вопросы. Может быть, у кого-то даже претензии, не важно.

          Уточнение было адресовано, скорее, другим читателям, которые пока не понимают особенностей. Разъяснение причины возникновения вопросов к статье.


          1. rukhi7
            26.03.2024 05:33

            ну вообще, да, написано так, что что можно обсуждать что угодно, потому что не понятно что имел ввиду автор. С одной стороны, с другой стороны,

            я исходил из того что автор сам себе противоречит, поэтому непонятно есть ли смысл анализировать то что он написал.

            докомпиляция в рантайме вполне возможна

            это что-то страшное - это надо компилятор с препроцессором и с линкером как библиотеки тащить, мне кажется это не реально. +вечная проблема с хидерами на С/С++ совсем не реально.


            1. unreal_undead2
              26.03.2024 05:33

              это что-то страшное - это надо компилятор с препроцессором и с линкером как библиотеки тащить, мне кажется это не реально. 

              -lclang* -lLLVMMCJIT и вперёд )


            1. mmatvey1123
              26.03.2024 05:33

              мне кажется это не реально

              Насколько я знаю, jvm в процессе своей работы проводит дополнительную оптимизацию байт-кода. То есть можно считать, что происходит "докомпиляция" программы.


      1. Paulus
        26.03.2024 05:33
        +2

        в некоторых языках программирования возможна докомпиляция при старте приложения.

        В некоторых языках возможна и кодогенерация во время исполнения, Expression в C# живой пример

        Другой вопрос — насколько это относится непосредственно к C++

        Простой ответ: нисколько. Ни один существующий стандарт С++ не предусматривает компиляции в runtime


    1. voldemar_d
      26.03.2024 05:33

      Имхо, здесь перепутано с "вычислением на этапе компиляции".

      Если не ошибаюсь, начиная с C++20, на этапе компиляции могут быть выполнены некоторые алгоритмы из STL. В каком-то смысле это можно назвать "выполнение на этапе компиляции", а здесь наоборот написано.


  1. maisvendoo
    26.03.2024 05:33
    +13

    Может быть "вычисление на этапе компиляции", не?


  1. Explorus
    26.03.2024 05:33
    +3

    Компиляция на этапе выполнения в С++??? Впервые вижу такой термин в плюсах ))) Есть стойкое ощущение, что статья требует серьезной доработки.


    1. SalazarMAX
      26.03.2024 05:33
      +3

      Компиляция на этапе выполнения? Нет ничего проще!

      system("make all");


      1. hello_my_name_is_dany
        26.03.2024 05:33

        Передовой JIT-компилятор

        system("g++ main.cpp -o main && ./main");


  1. a-tk
    26.03.2024 05:33
    +6

    Дык ОТУС же - гарантия максимально низкого качества материала.


  1. unreal_undead2
    26.03.2024 05:33
    +2

    constinit указывает, что переменная должна быть инициализирована во время старта программы, до входа в main()

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


  1. NeoCode
    26.03.2024 05:33

    Ну прежде всего конечно не "компиляция на этапе выполнения" а "выполнение на этапе компиляции":)

    А вообще на мой взгляд все эти новые возможности C++ выглядят как-то костыльно. Если constexpr это вроде как просьба к компилятору, а не требование (по типу inline?) , то какие могут быть ошибки? Не получилось сделать во время компмляции - будет во время выполнения, или как?

    Понятно что у кого-то возникла потребность использовать тот же код и для времени компиляции и для времени выполнения. Понятно что во время компиляции любой код не выполнить - есть ограничения. Но вот способ реализации этого в языке... странный. На мой взгляд было бы логичнее единственное ключевое слово с фигурными скобками, которое заставляло бы компилятор вычислять все содержимое блока во время компиляции - или выдавать ошибку. Зачем это привязали именно к объектам - непонятно.


    1. unreal_undead2
      26.03.2024 05:33

      которое заставляло бы компилятор вычислять все содержимое блока

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


      1. NeoCode
        26.03.2024 05:33

        Можно было сделать как в языке zig, ключевое слово comptime:

        fn multiply(a: i64, b: i64) i64 {
            return a * b;
        }
        
        pub fn main() void {
            const len = comptime multiply(4, 5);
            const my_static_array: [len]u8 = undefined;
        }

        Интуитивно просто, привязка именно к выражению (фрагменту кода), а не к объекту.

        А вообще и разрешить блокам возвращать значения не помешало бы, во многих языках блоки кода работают как выражения.


        1. ZirakZigil
          26.03.2024 05:33

          А вообще и разрешить блокам возвращать значения не помешало бы

          Вместо { ... } пишите [&] { ... return whatever; }().


      1. RranAmaru
        26.03.2024 05:33

        В gcc (в режиме C, не С++) есть интересное расширение позволяющее вставлять куски кода в выражение. Результатом такого блока является последнее вычисленное выражение.

        Пример:

        int x = 7;
        while(x>1) {  
          printf("%d\n", 
            10 * ({ if(x%2) x=3*x+1; else x/=2; x;})
          );
        }


        1. NeoCode
          26.03.2024 05:33

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


    1. HiTechSpoon
      26.03.2024 05:33

      Справедливости ради, inline - это тоже не требование, а просьба. Если нет дополнительных ключевых слов (always_inline, __forceinline, etc...), то компилятор сам решает инлайнить или нет.


      1. a-tk
        26.03.2024 05:33

        В современном C++ inline - это про объявление, а не про встраивания тела функции... В какой-то момент это ключевое слово поменяло семантику.


      1. NeoCode
        26.03.2024 05:33
        +1

        Справедливости ради, inline - это тоже не требование, а просьба

        Да, я это и имел в виду (хотя фраза получилась неоднозначной, да). ИМХО, с точки зрения дизайна языка просьбы к компилятору вообще лучше оформлять не ключевыми словами, а какими-то атрибутами/аннотациями.


    1. Paulus
      26.03.2024 05:33

      Если constexpr это вроде как просьба к компилятору, а не требование (по типу inline?)

      Так Inline тоже уже давно не требование, попробуйте напрмер экспортировать такую функцию из .so или .dll


  1. ZirakZigil
    26.03.2024 05:33

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

    Что тут подразумевается под "динамически изменяется"? Что ему можно сделать resize/shrink_to_fit? Это сделать можно.


  1. MaxLevs
    26.03.2024 05:33

    Не понимаю, чего к статье столько комментариев о "низком качестве".

    Если опустить оплошность в преамбуле и оставить сам материал, то получается приемлемый обзор фичи const(expr|eval|init).

    Доработать, и будет прекрасно.


  1. jvsg6
    26.03.2024 05:33

    Получается, что, если функция fibonacci на этапе исполнения требовала 15 секунд на выполнение, то сделав ее consteval время компиляции возрастет на те же самые 15 секунд? Но на этапе исполнения трат уже не будет?


  1. ghaardees
    26.03.2024 05:33

    constinit LoggerConfig globalLoggerConfig{3, "/var/log/myapp.log"};

    Не сработает, т.к. конструктор std::string (тип одного из элементов структуры LoggerConfig) динамически выделяет память.