Обчислення на етапі компіляції: дослідження consteval та constinit в C++20

Підписуйтеся на Telegram-канал «DOU #tech», щоб не пропустити нові технічні статті

Привіт! Мене звати Олександра Шершень і я C++ Developer у компанії CHI software.

Я люблю ділитися своїми знаннями та досвідом з колегами та іншими людьми, з цієї причини вирішила писати технічні статті. Зокрема ця стаття, маю надію, буде цікавою для розробників на C++, які бажають поглибити свої знання в нових можливостях мови C++20.

Далі у тексті я детально розглядаю та пояснюю поняття consteval та constinit, двох нових специфікаторів, доступних в C++20. Також описую, як вони використовуються для обробки даних на етапі компіляції, а не під час виконання програми. Крім того, у статті я показую на практичних прикладах, як використання цих специфікаторів може покращити продуктивність вашого коду та спростити розробку.

Стаття буде корисною як для початківців розробників C++, так і для досвідчених програмістів, які бажають розширити свій набір знань та дізнатися про нові можливості мови C++20.

Вступ

У C++20 з’явився новий набір можливостей, які дозволяють виконувати обчислення під час компіляції. Ці можливості, які називаються consteval та constinit, пропонують розробникам багато переваг, включаючи покращену продуктивність, зменшення складності коду та підвищення гнучкості.

У цій статті ми розглянемо функції consteval та constinit у C++20 і те, як їх можна використовувати для підвищення ефективності та якості C++ програм. Ми розглянемо синтаксис і семантику цих функцій, а також практичні приклади того, як їх можна застосувати в реальних сценаріях.

Специфікатор consteval

Ключове слово consteval — визначає, що функція є функцією безпосереднього виклику, тобто кожен виклик функції повинен створювати константу часу компіляції.

consteval — специфікатор C++20, який дозволяє обчислювати функцію під час компіляції. Він вказує, що функція повинна обчислюватися під час компіляції і може містити лише набір константних виразів. Цей специфікатор дозволяє пришвидшити виконання коду, а також може бути використаний для різних обчислень під час компіляції, таких як синтаксичний аналіз рядків, математичні обчислення або генерація структур даних.

Правило:

Використовуйте consteval, якщо у вас є функція, яка з якихось причин (наприклад, з міркувань продуктивності) повинна виконуватися під час компіляції.

Ось приклад використання consteval у C++ для розбору рядка на етапі компіляції у числове значення:

#include <iostream>
#include <limits>

constexpr bool isDigit(char ch) {
return ch >= '0' && ch <= '9';
}

consteval int parseInt(const char* str, std::size_t size) {
int result = 0;
bool negative = false;

if (*str == '-') {
negative = true;
++str;
—size;
}

for (std::size_t i = 0; i < size; ++i) {
if (!isDigit(str[i])) {
return std::numeric_limits::quiet_NaN();
}
result = result * 10 + (str[i] — '0');
}

return negative ? -result : result;
}

int main() {
constexpr char str[] = "-12345";
constexpr auto value = parseInt(str, sizeof(str) — 1);
static_assert(value == −12345, "value does not match");
return 0;
}

Тут ми визначимо дві функції: isDigit() та parseInt(). isDigit() - допоміжна функція, яка повертає true, якщо символ є цифрою від ’0′ до ’9′. parseInt() - константна функція, яка отримує на вхід рядок та його довжину і повертає його ціле значення.

В основному, ми використовуємо parseInt() для розбору рядка «-12345» в його цілочисельне значення. Оскільки parseInt() обчислюється під час компіляції, його результат відомий під час компіляції і не потребує обчислення під час виконання. Потім результат перевіряється за допомогою статичного твердження, щоб переконатися, що він відповідає очікуваному значенню.

Тут може бути корисно зрозуміти, що таке безпосередня функція. На сторінці довідки Cpp детально описано, коли функція вважається безпосередньою функцією. Короткий опис цих вимог наведено нижче:

  • це не повинна бути corountine (це функція, яка може призупиняти своє виконання для поновлення пізніше);
  • вона не повинна містити інструкцію throw у своєму тілі;
  • вона не повинна містити оператор goto (який зараз рідко використовується) або оператор мітки, за винятком case та default;
  • її параметри, а також тип повернення повинні бути літеральними типами або, коротко кажучи, типами, які можна обчислити під час компіляції (наприклад, всі типи, які можна використовувати в контексті constexpr).
#include <iostream>

consteval int sqr(int n) {
return n*n;
}
constexpr int r = sqr(100); // OK

int x = 100;
int r2 = sqr(x); // Error: Call does not produce a constant

consteval int sqrsqr(int n) {
return sqr(sqr(n)); // Not a constant expression at this point, but OK
}

constexpr int dblsqr(int n) {
return 2 * sqr(n); // Error: Enclosing function is not consteval
                   // and sqr(n) is not a constant
}

Вираз-ідентифікатор, що позначає безпосередню функцію, може з’являтися тільки у підвиразі безпосереднього виклику або у контексті безпосередньої функції (тобто у контексті, згаданому вище, в якому виклик безпосередньої функції не обов’язково повинен бути константним виразом). Вказівник або посилання на безпосередню функцію може бути прийняте, але не може уникнути обчислення константного виразу:

consteval int f() { return 42; }
consteval auto g() { return &f; }
consteval int h(int (*p)() = g()) { return p(); }
constexpr int r = h(); // OK
constexpr auto e = g(); // ill-formed: a pointer to an immediate function is
                       // not a permitted result of a constant expression

Специфікатор constinit

Ключове слово constinit стверджує, що змінна має статичну ініціалізацію, тобто нульову ініціалізацію та константну ініціалізацію, інакше програма буде неправильно сформована.

constinit — специфікатор C++20, який дозволяє ініціалізувати змінну під час компіляції або динамічної ініціалізації, але не під час виконання програми. Він гарантує, що змінна ініціалізується рівно один раз, і її значення не може бути змінено після ініціалізації.

Специфікатор constinit використовується для оголошення статичних і локальних для потоку змінних, які гарантовано ініціалізуються константним виразом. На відміну від змінних constexpr, які мають бути обчислені під час компіляції, змінні constinit можуть бути ініціалізовані під час динамічної ініціалізації. Однак, їх ініціалізація має бути виконана до того, як відбудеться будь-яка неконстантна ініціалізація.

Константа може бути застосована до змінних, які мають статичну або локальну тривалість зберігання.

Змінні зі статичною тривалістю зберігання ініціалізуються один раз при запуску програми і зберігають свої значення протягом усього часу роботи програми.

Ось приклад використання специфікатора constinit зі змінними зі статичною тривалістю зберігання в C++20:

#include <iostream>

template 
class MyClass {
public:
static constinit int staticValue;
};

template 
constinit int MyClass::staticValue = 42;

int main() {
MyClass myObj1;
MyClass myObj2;
std::cout << "myObj1.staticValue = " << myObj1.staticValue << std::endl;
std::cout << "myObj2.staticValue = " << myObj2.staticValue << std::endl;
return 0;
}

//Output
//myObj1.staticValue = 42
//myObj2.staticValue = 42

MyClass — це шаблон класу зі статичним членом даних staticValue типу int. staticValue ініціалізується значенням 42 з допомогою специфікатора constinit.

Під час виконання програми функція main() створює два об’єкти myObj1 та myObj2 типу MyClass<int> та MyClass<std::string> відповідно. Потім він виводить значення статичних членів даних staticValue для кожного об’єкту. Оскільки staticValue ініціалізується однаковим константним виразом для обох об’єктів, myObj1.staticValue та myObj2.staticValue мають значення 42.

Локальні для потоку змінні мають окремий екземпляр для кожного потоку, і їхні значення зберігаються протягом усього життя потоку.

Ось приклад використання constinit з локальною змінною потоку:

#include <iostream>
#include <thread>
#include <array>

class MyThreadLocalClass {
public:
MyThreadLocalClass() = default;

void printThreadId() {
std::cout << "Thread ID: " << std::this_thread::get_id() << std::endl;
}
};

thread_local constinit std::array<MyThreadLocalClass, 1> myThreadLocalObjects{};

int main() {
std::thread t1([]{
std::cout << "Thread 1 started" << std::endl;
myThreadLocalObjects[0].printThreadId();
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Thread 1 ended" << std::endl;
});

std::thread t2([]{
std::cout << "Thread 2 started" << std::endl;
myThreadLocalObjects[0].printThreadId();
std::this_thread::sleep_for(std::chrono::seconds(1));
std::cout << "Thread 2 ended" << std::endl;
});

t1.join();
t2.join();

return 0;
}

//Output
//Thread 1 started
//Thread ID: 140633650480320
//Thread 2 started
//Thread ID: 140633650476224
//Thread 2 ended
//Thread 1 ended

MyThreadLocalClass має конструктор за замовчуванням, який використовується для ініціалізації об’єктів у масиві std::array. Функція printThreadId() викликається для кожного локального об’єкту для виведення ідентифікатора потоку. Коли потоки запущено та об’єднано, виведення показує, що кожен потік має власну копію об’єкту MyThreadLocalClass.

consteval VS constinit

consteval використовується, щоб вказати, що функцію слід обчислювати під час компіляції, що означає, що результат функції буде доступний під час компіляції. Це може бути корисно для оптимізації коду, оскільки дозволяє уникнути обчислень під час виконання, а також може використовуватися для перевірки вхідних параметрів під час компіляції, щоб переконатися, що вони правильні.

З іншого боку, constinit використовується, щоб вказати, що змінну слід ініціалізувати під час компіляції. Змінна має бути оголошена як constexpr, і її можна ініціалізувати лише константними виразами, що робить її константою часу компіляції. Це дозволяє ефективніше використовувати пам’ять і може сприяти підвищенню продуктивності програми.

Отже, якщо consteval використовується для обчислення функцій під час компіляції, то constinit — для ініціалізації змінних під час компіляції. Обидва способи можуть допомогти підвищити ефективність та продуктивність програми, дозволяючи виконувати обчислення під час компіляції, а не під час виконання.

#include <iostream>

consteval int factorial(int n) {
return n == 0 ? 1 : n * factorial(n — 1);
}

constinit int arr1[] = {1, 2, 3, factorial(4)};
constexpr int arr2[] = {1, 2, 3, factorial(4)};

int main() {
std::cout << "arr1: ";
for (int i = 0; i < sizeof(arr1) / sizeof(*arr1); ++i) {
std::cout << arr1[i] << " ";
}
std::cout << std::endl;

std::cout << "arr2: ";
for (int i = 0; i < sizeof(arr2) / sizeof(*arr2); ++i) {
std::cout << arr2[i] << " ";
}
std::cout << std::endl;

return 0;
}

//Output
//arr1: 1 2 3 24
//arr2: 1 2 3 24

У цьому коді визначено два масиви arr1 і arr2, які містять значення 1, 2, 3 і факторіал від 4. Функція factorial() визначена з використанням ключового слова consteval, яке вказує компілятору обчислити функцію під час компіляції. Це означає, що результат роботи factorial(4) відомий під час компіляції і може бути використаний для ініціалізації масивів.

Масив arr1 ініціалізується за допомогою ключового слова constinit, яке вказує, що він має бути ініціалізований під час компіляції і може бути ініціалізований лише один раз. Масив arr2 ініціалізується за допомогою ключового слова constexpr, яке також вказує, що він має бути обчислений під час компіляції і бути константним виразом.

У функції main() значення arr1 та arr2 виводяться на консоль з допомогою циклу. Виведення показує, що обидва масиви містять однакові значення, включаючи факторіал від 4, який дорівнює 24.

Висновок


consteval і constinit є потужними специфікаторами, які можна використовувати для оптимізації та виявлення помилок у вашому коді під час компіляції. Переконавшись, що функції та змінні обчислюються та ініціалізуються під час компіляції, ви можете підвищити ефективність коду та зменшити ризик виникнення помилок під час виконання. При правильному використанні consteval та constinit можуть допомогти вам писати кращий, ефективніший та безпечніший код на C++.

👍ПодобаєтьсяСподобалось6
До обраногоВ обраному4
LinkedIn
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Дозволені теги: blockquote, a, pre, code, ul, ol, li, b, i, del.
Ctrl + Enter
Коли потоки запущено та об’єднано, виведення показує, що кожен потік має власну копію об’єкту MyThreadLocalClass

Я не бачу там по власній копії об’єкта MyThreadLocalClass в потоках. Скоріш там кожен потік виводить свій ID через виклик метода printThreadId на єдиному екземплярі об’єкта MyThreadLocalClass

Ти навіть могла б на нулл рефі типа MyThreadLocalClass викликати printThreadId і він вивів би 2 різні ID потоків

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

Коментар порушує правила спільноти і видалений модераторами.

constinit — специфікатор C++20, який дозволяє ініціалізувати змінну під час компіляції або динамічної ініціалізації, але не під час виконання програми. Він гарантує, що змінна ініціалізується рівно один раз, і її значення не може бути змінено після ініціалізації.

Хм, не зовсім так, constinit does not imply const: значення можна змінювати.
Фактично constinit лише гарантує, що ініціалізація не буде відбуватись в рантаймі (тобто змінна потрапить в .data замість .bss).

>в C++20
>вирішилА
в мене є червона ауді, кабріолет і костюм з окулярами від сонця. я важу 67, зріст 172. виходь за мене!

Js, ts, react
Маю сіру ауді ку5 і ліки від склерозу. Я важу 121, зріст 187. Вихожу, куди йти?

Можна айті-тіндер замутити, де замість фоток дівчат будуть хард скіли

На robota.ua для роботодавця є можливість поставити правильні фільтри: тільки дівчатка, тільки з фотографією, галузь айті, <21, бажана зарплата. Одразу бачиш досвід; деякі дівчата в резюме вказують ще і сімейний стан. Було б круто, якби на доу щось подібне було.

consteval та constinit

C++ настолько гибок и удобен, что даже у одного простого const есть уже целая куча возможностей:

std::shared_ptr<Object>
const std::shared_ptr<Object>
std::shared_ptr<const Object>
const std::shared_ptr<const Object>
consteval і constinit є потужними специфікаторами, які можна використовувати для оптимізації та виявлення помилок у вашому коді під час компіляції

Надеюсь, вы это используете для написания драйверов и систем реального времени.

C++ настолько гибок и удобен, что даже у одного простого const есть уже целая куча возможностей:

Реальный мир он сложный, да.

Надеюсь, вы это используете для написания драйверов и систем реального времени.

Спасибо за добрые слова.

Разметка сожрала темплейты

З іншого боку, constinit використовується, щоб вказати, що змінну слід ініціалізувати під час компіляції.

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

Ссылки на godbolt.org помогли бы

Олександре, у Вас талант, чекаю з нетерпінням наступної статті☺️

Олександре

Олександро, але, якщо колись можна було використати перше, то так цікавіше.

Олександре, в мене до Вас питання: чому в першому прикладі функція bool isDigit(char) має специфікатор constexpr, a не consteval? Ця функція викликається функцією з специфікатором consteval, отже її результат відомий під час компіляції.

Функція isDigit(char) має специфікатор constexpr, а не consteval, тому що її не можна викликати в контексті обчислення під час компіляції (як функцію, яку можна обчислити відразу під час компіляції). Вона використовується всередині функції parseInt, яка є функцією, яку можна обчислити відразу під час компіляції. Однак, функцію isDigit також можна використовувати в контексті часу виконання програми, і для цього її можна викликати як звичайну функцію. Тому краще позначити її як constexpr, щоб можна було використовувати її в обох контекстах (під час компіляції та виконання).

Дякую за відповідь. лише одне невеличке зауваження: Ви пишете

її не можна викликати в контексті обчислення під час компіляції (як функцію, яку можна обчислити відразу під час компіляції)

насправді її можна викликати під час компіляції (що, власне, й робиться в функції parseInit), але її можна викликати й під час виконання програми. В Вашому прикладі немає виклику функції isDigit під час виконання, але можна її викликати. Дякую за добрий приклад того, коли потрібний специфікатор constexpr, а коли — consteval. Саме в цьому прикладі функцію isDigit можна позначити як consteval, й він буде скомпільований, тому краще було би ще проілюструвати як цю функцію можна викликати й під час виконання.

А було пояснення, навіщо це робити новими ключовими словами, хоча можна було б зробити атрибутами?

(А то іноді економлять на ключових словах, як у випадку лямбди, а потім створюють їх по десятку за версію стандарту...)

Мнеее... може, стаття і гарна, а картинка — не в тему.
Там де 78 кнопок, там майже повна клавіатура компа. Якщо вона потрібна, то в чому проблема? Пульт від телевізора теж нормальний.

Колись у мене був iPod... всі ці ігри з сенсорним коліщатком були гарні аж до моменту поки я не спробував використати його в темряві, просто засинаючи з апаратом біля подушки. Ось тут я раптово і захотів варіант з нормальними ну хоча б 6 кнопками але щоб я легко міг натискати їх за будь-яких умов.
Так що, якщо «комітет» зробив 6-10 кнопок на плеєрі, а Джобс — iPod, нафіг піде Джобс.

А ось приклад з ATM — да, класика і навіть не того, що в комітеті впихують різні варіанти щоб загодити всім — а що для того, щоб прийняти якесь спільне рішення, можуть прийняти повну херню, але вона буде всім приблизно однаково незручна.

Ось тут стаття порівнює:
* const
* constexpr
* consteval
* constinit
www.cppstories.com/...​2022/const-options-cpp20

І там в коментах пишуть, що ще є:
* if constexpr
* if consteval
en.cppreference.com/w/cpp/language/if

А взагалі ото усе — капєц, і Лінукс, на котрому усі працюють, писаний на С. І нема сенсу робити програму більш стабільною, ніж операційна система, що її запускає.

і Лінукс, на котрому усі працюють, писаний на С.

1) Не всі. Частково на нещастя, частково на щастя, бо тотальний Linux це теж не добре.
2) Так, про 12309 всі памʼятаємо;)) але я ще маю надію, що колись розум повернеться до розробників.

Вот.
Хотя более общая проблема (и ближе к обсуждаемому) тут.

робити програму більш стабільною

не програму а код програми єто другоє ))

і Лінукс, на котрому усі працюють, писаний на С

а компилятор, который собирает этот линукс, специально переписали с С на C++

Переписали пока что пару процентов кода. Спешить им некуда. Хотя сама тенденция меня радует: C++ даже в базовом варианте выгоднее C для больших проектов хотя бы за счёт пространств имён и инкапсуляции.

Воно ніби-то зрозуміло, що оскільки Сі на дуже багато відсотків є частиною С++, то можна знайти у С++ такі фічі, які не завадили б... Але... як на мене проблема С++ більше у тому, що ця мова програмування нагадує мені чимось героїн: фіч багато, деякі виглядають прикольно, але коли починаєш захоплюватися, то з часом не знаєш, як з цього злізти :-)

що ця мова програмування нагадує мені чимось героїн: фіч багато, деякі виглядають прикольно, але коли починаєш захоплюватися, то з часом не знаєш, як з цього злізти :-)

Просто виставляєш що дозволено, а що ні (наприклад `-std=c++11`) і воно само регулює:))

Ну... то як дозволити `consteval` але заборонити наслідування та віртуальні функції?

Не знаю навіщо таке, але це точно окремими статичними аналізаторами.
Мабуть, на базі Clang таке робиться за реальні ресурси.

Перекладати coroutine як «підпрограма» невірно. Підпрограма — це subroutine.

Підписатись на коментарі