Обчислення на етапі компіляції: дослідження 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++.
33 коментарі
Додати коментар Підписатись на коментаріВідписатись від коментарів