Відкриття сили Modern C++: революційні основні можливості

У попередньому пості цієї серії, коли ми обговорювали особливості C++11, які відрізняють його від попередніх версій, ми згадали три основні особливості: type inference (виведення типів), uniform initialization (однорідна ініціалізація) та smart pointers (розумні вказівники). У цьому пості ми детальніше розглянемо ці три аспекти, а також move semantics (семантика переміщення), lambda expressions (лямбда-вирази) та concurrency (паралельне виконання), зосередившись на інших важливих аспектах, які дають можливість розробникам писати ефективний, виразний та масштабований код.

pic

Посилання та джерела

У C++ значення (values) класифікуються залежно від їхніх характеристик та тривалості життя. Це розмежування є критично важливим для розуміння виразів (expressions), поняття власності (ownership) та управління ресурсами (resource management). Тепер давайте поступово розглянемо цю тему:

1. Категорії значень у C++

У C++ значення розрізняються в залежності від їхнього місця в пам'яті та можливості повторного використання. Це розрізнення є важливим не тільки для мови, а й є внутрішньою особливістю того, як компілятор C++ створює машинний код. Ось кілька основних моментів:

1.1 lvalue (Locator Value)

Це значення, яке представляє об'єкт, що має сталу позицію в пам'яті (persistent location).

Наприклад:

Ці об'єкти мають ім'я або можуть бути використані для звернення до себе (наприклад, x).

Вони можуть бути лівою або правою частиною операції присвоєння.

1.2 rvalue (Right-Hand Value)

Це значення або об'єкт, який не має сталого місця в пам'яті.

Приклад:

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

1.3 xvalue, glvalue і prvalue (C++11 і пізніше)

У сучасному C++ були введені більш детальні категорії для посилань:

prvalue (Pure rvalue):

Це тимчасові об'єкти або літерали (константні значення).

xvalue (Expiring value):

Це значення, яке позначає об'єкт, ресурси якого можна переосмислити (наприклад, результат операції std::move).

glvalue (Generalized lvalue):

Це категорія, яка об'єднує lvalue та xvalue, і представляє об'єкти, що мають позицію в пам'яті. Це всі lvalue та rvalue посилання.

1.4 Таблиця посилань

Підсумовуючи, категорії значень у C++ можна класифікувати наступним чином:

pic

pic

2. Поняття власності та тривалості життя

Для тих, хто знайомий з Rust, поняття мутаторів і власності також існують у C++. Ці поняття важливі для розуміння тривалості життя об'єктів і управління ресурсами. У сучасному C++ власність і тривалість життя (lifetime) є основою управління ресурсами. Власність визначає, хто контролює ресурс, тоді як тривалість життя вказує, як довго ресурс перебуває в пам'яті. Ці два поняття тісно взаємопов'язані, оскільки те, скільки часу ресурс перебуває в пам'яті, визначається тим, хто ним володіє. Сучасний C++ надає потужні інструменти для вирішення проблем з тривалістю життя та запобігання помилок.

2.1 Тривалість життя

Тривалість життя об'єкта визначається його створенням та використанням.
Ми можемо розглянути час існування об'єкта в 4 різних випадках:

  • Автоматичний час існування (Об'єкти стеку): Локальні змінні визначаються в блоках виразів і автоматично знищуються, коли виходять із блоку виразу.

  • Динамічний час існування (Об'єкти купи): Об'єкти, створені за допомогою операторів керування пам'яттю (new та delete), залишаються в пам'яті до того часу, поки програміст не звільнить пам'ять.

  • Статичний час існування (Глобальні та статичні об'єкти): Глобальні змінні та змінні, визначені за допомогою ключового слова static, залишаються в пам'яті від початку до кінця роботи програми.

  • Тимчасовий час існування (Тимчасові об'єкти): Тимчасові об'єкти створюються для тимчасового зберігання значень у виразах і зникають після завершення виразу.

2.2 Зв'язок між власністю та часом існування

Ми трохи розібралися з поняттями часу існування. Як я вже зазначав раніше, власність визначає, хто контролює ресурс протягом його існування. Якщо власність та час існування не керуються правильно, можуть виникнути такі проблеми:

  • Подвійне звільнення пам'яті: Коли один і той самий ресурс звільняється більше ніж один раз.
  • Утечка пам'яті: Коли пам'ять не звільняється.
  • Невірне використання вказівників: Доступ до об'єкта після закінчення його часу існування.

Наприклад:

Такі ситуації можуть призвести до несподіваної поведінки програми та помилок (в найкращому випадку може виникнути помилка сегментації).

2.3 Моделі власності в сучасному C++: RAII

Сучасний C++ вирішує проблеми власності та часу існування за допомогою принципу Resource Acquisition Is Initialization (RAII). RAII зв'язує власність ресурсу з об'єктом і синхронізує його час існування з життєвим циклом об'єкта.

Цей принцип працює таким чином:

  • Кожен ресурс інкапсулюється в класі, де:
    • Конструктор отримує ресурс і ініціалізує всі інваріанти класу або генерує виключення, якщо це не вдалося.
    • Деструктор звільняє ресурс і ніколи не генерує виключень.
    • Ресурс завжди використовується через екземпляр класу RAII, де:
    • Клас має автоматичний або тимчасовий час існування,
    • Або час існування класу обмежений часом існування автоматичного або тимчасового об'єкта.

3. Управління часом існування з розумними вказівниками

Розумні вказівники (smart pointers) застосовують принцип RAII для спрощення управління пам'яттю і допомагають уникнути витоків пам'яті. У сучасному C++ стандартні класи бібліотеки, такі як std::unique_ptr, std::shared_ptr та std::weak_ptr, автоматизують управління пам'яттю і забезпечують безпечне звільнення ресурсів.

3.1 std::unique_ptr

Може мати лише один вказівник на об'єкт.

std::unique_ptr означає, що на об'єкт може бути лише один вказівник, тобто інші вказівники не можуть вказувати на той самий об'єкт, а коли час існування цього об'єкта закінчується, його деструктор автоматично звільняє ресурси. Таким чином, проблема з дворазовим видаленням пам'яті виключена.

3.2 std::shared_ptr

Може мати кілька вказівників на об'єкт.

std::shared_ptr дозволяє кільком вказівникам володіти об'єктом, при цьому лічильник посилань збільшується при кожному використанні вказівника, а об'єкт звільняється, коли лічильник посилань досягає нуля.

Цей приклад не зовсім точний, оскільки std::shared_ptr зазвичай використовуються в ситуаціях, коли необхідно мати кілька вказівників на один об'єкт, наприклад, для безпечного розподілу даних між потоками. Я продемонструю це на прикладі з потоками.

3.3 std::weak_ptr

std::weak_ptr є слабким посиланням на std::shared_ptr і не володіє об'єктом, а лише дозволяє уникнути циклічних посилань. Створюється тільки з std::shared_ptr і допомагає запобігти утворенню циклічних залежностей.
Тепер я хочу пояснити це за допомогою прикладу, але також хочу подати приклад для потоків, а також для цього типу посилань, який я сам не зовсім розумію :).

4. Семантика переміщення та передача власності

4.1. Навіщо потрібна семантика переміщення?

Чи справді нам потрібна передача власності? Так, потрібно. У класичному C++ (до C++11) об'єкти за замовчуванням копіювалися. Подумайте про це так: у вас є ресурс, який ви хочете передати іншій функції, в цьому випадку і ресурс, який у вас є, і той, який ви передаєте в функцію, існуватимуть одночасно в пам'яті. І тому для кожного ресурсу буде виділено окрему область пам'яті, яку потрібно буде окремо звільнити. Це одна з основних причин витоків пам'яті. Крім того, клонування — це дорогий процес, особливо для об'єктів, що управляють ресурсами, таких як std::vector, std::string, оскільки глибока копія (deep copy) вимагає значних витрат. Семантика переміщення дозволяє оптимізувати продуктивність, замінюючи копіювання переміщенням ресурсів. Це особливо корисно для об'єктів, що управляють динамічними ресурсами, такими як пам'ять або дескриптори файлів.

4.2. Як працює семантика переміщення?

  • Конструктор переміщення: Переносить ресурси з одного об'єкта в інший, залишаючи початковий об'єкт в дійсному, але неоприділеному стані.
  • Оператор присвоєння переміщення: Для вже ініціалізованих об'єктів переносить ресурси подібно до конструктора переміщення.

Приклад: Використання конструктора переміщення

Основні моменти:

  • std::move перетворює lvalue на xvalue, дозволяючи використати конструктор переміщення.
  • Після переміщення початковий об'єкт (vec1) очищується, але залишається дійсним (valid).

Приклад: Використання оператора присвоєння переміщення

4.3. Зв'язок між значеннями та семантикою переміщення

Роль rvalue в семантиці переміщення Семантика переміщення використовує rvalue для оптимізації продуктивності:

  • rvalue є тимчасовими і їх "переміщення" є безпечним.
  • Функції та конструктори часто перевантажуються для lvalue-ссилок (T&) та rvalue-ссилок (T&&).

Приклад: Перевантаження для lvalue та rvalue посилань

Виведення:

Викликано конструктор копіювання  
Викликано конструктор переміщення

4.4. Семантика переміщення та управління пам'яттю

Щоб забезпечити RAII, важливі такі моменти:

  • Використовуйте std::move, коли вам більше не потрібен оригінальний об'єкт і коли передача власності є безпечною.
  • Уникайте зайвих використань, інакше ви можете несподівано зробити об'єкти недійсними (invalidate).
  • Для великих об'єктів, щоб уникнути дорогих глибоких копій, використовуйте переміщення.
  • Використовуйте семантику переміщення для класів, які керують динамічними ресурсами, такими як дескриптори файлів або буфери пам'яті.

Семантика переміщення та ефективне повернення функцій

Семантика переміщення має значну перевагу при поверненні великих об'єктів:

У сучасних компіляторах оптимізація значень при поверненні (RVO) або операції переміщення роблять управління ресурсами більш ефективним.

Примітка: nullptr і std::nullptr_t

Я не можу не згадати nullptr, адже концепція Null Safety забрала останні два роки моєї роботи (завдяки Dart і Kotlin).

nullptr — це спеціальне значення, введене в C++11, яке позначає, що вказівник (pointer) є недійсним. Порівняно з давнім NULL, nullptr є більш безпечним і послідовним при використанні з типами вказівників. Наприклад, NULL також сприймається як 0, що створює плутанину через його універсальну природу. nullptr же явно представляє null pointer і запобігає таким проблемам.

До введення nullptr, як я вже згадував, це значення і має тип std::nullptr_t. Воно може бути неявно перетворене на будь-який тип вказівника, але не може бути перетворене на типи цілих чисел.
Тепер я хочу навести приклад, але одночасно хочу подати приклад для потоків, а також для цього типу посилань, який я сам не зовсім розумію :).

4. Семантика переміщення та передача власності

4.1. Навіщо потрібна семантика переміщення?

Чи справді нам потрібна передача власності? Так, потрібно. У класичному C++ (до C++11) об'єкти за замовчуванням копіювались. Подумайте про це так: у вас є ресурс, який ви хочете передати іншій функції, в цьому випадку і ресурс, який у вас є, і той, який ви передаєте в функцію, існуватимуть одночасно в пам'яті. І тому для кожного ресурсу буде виділено окрему область пам'яті, яку потрібно буде окремо звільнити. Це одна з основних причин витоків пам'яті. Крім того, клонування — це дорогий процес, особливо для об'єктів, що управляють ресурсами, таких як std::vector, std::string, оскільки глибока копія (deep copy) вимагає значних витрат. Семантика переміщення дозволяє оптимізувати продуктивність, замінюючи копіювання переміщенням ресурсів. Це особливо корисно для об'єктів, що управляють динамічними ресурсами, такими як пам'ять або дескриптори файлів.

4.2. Як працює семантика переміщення?

  • Конструктор переміщення: Переносить ресурси з одного об'єкта в інший, залишаючи початковий об'єкт в дійсному, але неоприділеному стані.
  • Оператор присвоєння переміщення: Для вже ініціалізованих об'єктів переносить ресурси подібно до конструктора переміщення.

Приклад: Використання конструктора переміщення

Основні моменти:

  • std::move перетворює lvalue на xvalue, дозволяючи використати конструктор переміщення.
  • Після переміщення початковий об'єкт (vec1) очищується, але залишається дійсним (valid).

Приклад: Використання оператора присвоєння переміщення

4.3. Зв'язок між значеннями та семантикою переміщення

Роль rvalue в семантиці переміщення Семантика переміщення використовує rvalue для оптимізації продуктивності:

  • rvalue є тимчасовими і їх "переміщення" є безпечним.
  • Функції та конструктори часто перевантажуються для lvalue-ссилок (T&) та rvalue-ссилок (T&&).

Приклад: Перевантаження для lvalue та rvalue посилань

Виведення:

Викликано конструктор копіювання  
Викликано конструктор переміщення

4.4. Семантика переміщення та управління пам'яттю

Щоб забезпечити RAII, важливі такі моменти:

  • Використовуйте std::move, коли вам більше не потрібен оригінальний об'єкт і коли передача власності є безпечною.
  • Уникайте зайвих використань, інакше ви можете несподівано зробити об'єкти недійсними (invalidate).
  • Для великих об'єктів, щоб уникнути дорогих глибоких копій, використовуйте переміщення.
  • Використовуйте семантику переміщення для класів, які керують динамічними ресурсами, такими як дескриптори файлів або буфери пам'яті.

Семантика переміщення та ефективне повернення функцій

Семантика переміщення має значну перевагу при поверненні великих об'єктів:

У сучасних компіляторах оптимізація значень при поверненні (RVO) або операції переміщення роблять управління ресурсами більш ефективним.

Примітка: nullptr і std::nullptr_t

Я не можу не згадати nullptr, адже концепція Null Safety забрала останні два роки моєї роботи (завдяки Dart і Kotlin).

nullptr — це спеціальне значення, введене в C++11, яке позначає, що вказівник (pointer) є недійсним. Порівняно з давнім NULL, nullptr є більш безпечним і послідовним при використанні з типами вказівників. Наприклад, NULL також сприймається як 0, що створює плутанину через його універсальну природу. nullptr же явно представляє null pointer і запобігає таким проблемам.

До введення nullptr, як я вже згадував, це значення і має тип std::nullptr_t. Воно може бути неявно перетворене на будь-який тип вказівника, але не може бути перетворене на типи цілих чисел.
Однак не впадайте в оману, вважаючи, що це працює лише з неконтрольованими типами даних або базовими типами — воно може бути використано й для користувацьких типів (хоча наскільки це доцільно для типів, що включають mutex, не можу сказати).

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

На завершення…

Підсумовуючи, такі можливості, як Move semantics (семантика переміщення), lambda expressions (лямбда-вирази) та concurrency (паралельність) у C++ надають розробникам потужні інструменти для написання ефективного та сучасного коду. Ці особливості не тільки покращують продуктивність, але й спрощують складні завдання програмування.

Наступного разу ми заглибимося в інші, більш просунуті можливості Modern C++.

Перекладено з: Modern C++’ın Gücünü Keşfetmek: Devrimsel Temel Özellikler

Leave a Reply

Your email address will not be published. Required fields are marked *