1. Навіщо в C++ потрібен noexcept
Якщо ви звикли до звичайного return, то «вихід через виняток» звучить майже як альтернативна реальність. Певною мірою так і є: це шлях, за якого функція не повертає керування звичайним способом, а виконання ніби «проривається» назовні. Сьогодні ми не вивчаємо синтаксис try/catch і не вчимося «ловити» винятки. Нам достатньо такої моделі: інколи всередині функції може статися подія, через яку керування залишить її не через return.
noexcept — це спосіб сказати компілятору й усім, хто читає код: «ця функція ніколи не повинна завершуватися таким аварійним виходом». Це не коментар і не прохання. Це обіцянка на рівні контракту. Ба більше, у сучасних версіях C++ специфікація винятків є частиною типу функції, тобто впливає на сумісність сигнатур і перевантаження.
Ось тут і проявляється головний сенс: noexcept допомагає робити код передбачуваним. Коли ви проєктуєте тип із переміщенням, то хочете, щоб воно було «залізобетонним»: перенесення вказівника, занулення джерела — і все. Без сюрпризів.
Синтаксис noexcept
На перший погляд noexcept виглядає майже надто просто — і це добре. Контракт має читатися миттєво.
Мінімальна форма:
#include <iostream>
void print_hello() noexcept {
std::cout << "Hello!\n"; // Hello!
}
Тут noexcept стоїть в оголошенні функції й означає: ця функція не повинна завершуватися виходом через виняток. У межах нашої спрощеної моделі сьогодні можна думати так: якщо всередині print_hello() станеться щось, що спробує «вилетіти назовні як виняток», програма не продовжить роботу «як зазвичай».
Ще один важливий момент: noexcept — це не «прискорювач». Так, він може впливати на оптимізації, але насамперед це семантика. Контракт. Обіцянка.
Іноді ви побачите форму noexcept(true) або noexcept(false). Поки що достатньо зрозуміти саму ідею: noexcept буває умовним, але в наших прикладах ми використовуватимемо базову форму noexcept, бо вона найкраще підходить для move-операцій ресурсного типу.
2. Що буде в разі порушення noexcept
Уявіть, що noexcept — це табличка «НЕ ВХОДИТИ» на дверях. Поки ви не порушуєте це правило, усе нормально. Але якщо ви все-таки зайшли, то далі буде не «штраф», а радше «двері за вами зачиняться, і будівля перейде в режим евакуації».
Технічно це означає таке: якщо виняток намагається покинути noexcept-функцію, викликається std::terminate(), і програма аварійно завершує роботу. Це не щось, що «може статися», а базове правило мови.
Для інтуїції зручно намалювати блок-схему:
flowchart TD
A["Функція оголошена noexcept"] --> B["Всередині сталася подія, яка намагається вийти назовні як виняток"]
B --> C{"Виняток виходить за межі noexcept-функції?"}
C -- "Так" --> D["std::terminate()"]
D --> E["Програму аварійно завершено"]
C -- "Ні" --> F["Робота триває звичайним способом"]
Чому так жорстко? Тому що noexcept — це обіцянка, на яку можуть спиратися інші частини програми і стандартна бібліотека. Якби порушення noexcept «мʼяко» оброблялося, увесь сенс контракту розмився б.
Тому важливе правило для новачків звучить так: ніколи не ставте noexcept «про всяк випадок». Це не «хай буде», а «я впевнений, що назовні нічого не вилетить».
3. Чому noexcept важливий для move-операцій
Тут часто виникає запитання: «Гаразд, контракт строгий. Але чому навколо move стільки розмов про noexcept?» Причина дуже практична: переміщення часто використовується всередині стандартних контейнерів, особливо коли контейнеру треба «переселити» елементи в нове місце.
Уявіть std::vector<T>. Він зберігає елементи в одному неперервному блоці памʼяті. Іноді йому потрібно збільшити місткість, виділити новий блок і перенести туди елементи. І от тут постає питання: як переносити елементи — копіюванням чи переміщенням?
Якщо move-конструктор типу T позначено noexcept, то для vector переміщення виглядає як «безпечна операція переселення». Якщо ж move потенційно може «вилетіти назовні», контейнер може віддати перевагу копіюванню, якщо воно доступне. У стандартній бібліотеці навіть є ідея «переміщай, лише якщо це не кидає» — її часто повʼязують із підходом move_if_noexcept (назву можна запамʼятати як підказку, але наразі достатньо зрозуміти мотивацію).
Це не означає, що без noexcept усе зламається. Це означає, що тип стає менш дружнім до контейнерів і алгоритмів, які хочуть безпечно й ефективно переставляти елементи.
4. Приклад: ресурсний Buffer і move-операції з noexcept
Подивімося на noexcept на конкретному типі, який безпосередньо володіє ресурсом.
Нехай у нас є Buffer — власник динамічного масиву int. Це навчальний приклад, але він добре показує механіку володіння:
#include <cstddef>
struct Buffer {
std::size_t size{};
int* data{nullptr};
explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
~Buffer() { delete[] data; }
};
Проблема вже знайома: копіювання за замовчуванням зламає володіння, тобто просто скопіює адресу. Тому в попередніх лекціях ми додали глибоке копіювання (копіювальний конструктор і копіювальне присвоювання). А тепер додамо переміщення — і саме тут noexcept зазвичай доречний.
Move-конструктор і noexcept
Тут важливо чітко проговорити сенс: move-конструктор — це операція, яка зазвичай виконує дуже прості дії: присвоює вказівник і розмір, а потім занулює джерело. У такому коді ми зазвичай не виділяємо памʼять і не виконуємо «небезпечних» операцій. Тому його зручно й логічно оголосити noexcept: ми справді можемо чесно пообіцяти, що «вильоту назовні» не буде.
#include <cstddef>
struct Buffer {
std::size_t size{};
int* data{nullptr};
explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
~Buffer() { delete[] data; }
Buffer(Buffer&& other) noexcept : size(other.size), data(other.data) {
other.size = 0;
other.data = nullptr;
}
};
Зверніть увагу на дві «священні» дії: ми забрали ресурс і перевели джерело в порожній стан. І цей порожній стан коректно переживе деструктор (delete[] nullptr безпечний).
Move-присвоювання і noexcept
Логіка схожа, але зʼявляється додаткова відповідальність: ліворуч уже міг бути ресурс, і його треба коректно звільнити. Тут теж зазвичай немає підстав «вилітати назовні», якщо ви не робите нічого, крім delete[] і присвоювань.
#include <cstddef>
struct Buffer {
std::size_t size{};
int* data{nullptr};
explicit Buffer(std::size_t n) : size(n), data(new int[n]{}) {}
~Buffer() { delete[] data; }
Buffer(Buffer&& other) noexcept : size(other.size), data(other.data) {
other.size = 0;
other.data = nullptr;
}
Buffer& operator=(Buffer&& other) noexcept {
if (this == &other) return *this;
delete[] data;
size = other.size;
data = other.data;
other.size = 0;
other.data = nullptr;
return *this;
}
};
Невелика «перевірка на здоровий глузд»: якщо в move-операціях ви раптом пишете new, то майже напевно робите щось не так. Move — це «перенесення володіння», а не «побудова нового ресурсу».
5. Як зрозуміти, чи можна писати noexcept
Новачкам хочеться універсального рецепта: «Скажіть, коли можна, а коли не можна». Універсального рецепта на 100 % немає. Але є цілком робоча логіка перевірки, яку можна застосовувати навіть без упевненого знання try/catch.
Спершу поставте собі запитання: «Чи може ця функція зробити щось, що потенційно призведе до виходу назовні через виняток?» Типові підозрювані — виділення памʼяті, створення складних обʼєктів, операції введення/виведення, «розумні» контейнери, які всередині можуть виділяти памʼять. Якщо відповідь «так» або «не впевнені» — не ставте noexcept.
Якщо функція виконує лише прості й передбачувані дії, як-от присвоювання вказівників, обмін цілими числами, занулення полів, звільнення ресурсу, то noexcept зазвичай доречний.
Зручно тримати під рукою мінітаблицю:
| Операція | Часто доречний noexcept? | Чому |
|---|---|---|
| Move «перенесення вказівника + занулення» | Так | Зазвичай немає виділення памʼяті |
| Деструктор, який просто звільняє ресурс | Так | Звільнення ресурсу має бути надійним |
| Копіювання з new і циклом копіювання | Зазвичай ні | Виділення памʼяті може не вдатися |
| Функція, яка пише в std::cout | Зазвичай ні | Введення/виведення може бути налаштовано на винятки, та й давати тут «залізобетонну» обіцянку не варто |
noexcept — це частина інтерфейсу. Ви не просто кажете «всередині все добре», а повідомляєте користувачу типу: «на мене можна розраховувати». Тому краще недообіцяти, ніж переобіцяти.
Чому noexcept — це про довіру
У програмуванні «обіцянки» потрібні не заради моралі, а для того, щоб інші частини системи могли ухвалювати рішення. І noexcept — саме така обіцянка. Вона дає змогу будувати простіший і передбачуваніший код навколо вашої функції.
Наприклад, якщо у вашого типу move-операції noexcept, то переміщення можна використовувати як базову цеглинку для безпечних операцій перестановки. Навіть якщо ви поки не використовуєте це безпосередньо, стандартна бібліотека може цим скористатися.
Крім того, noexcept дисциплінує вас як автора. Якщо ви позначили move noexcept, у вас зʼявляється внутрішня установка: «у move я не додаю логіки, яка може призвести до проблем». Це часто веде до чистішого дизайну: move залишається «механічним перенесенням володіння», а всі складні речі переходять у звичайні функції або в копіювання, якщо воно взагалі потрібне.
6. Типові помилки під час роботи з noexcept
Помилка № 1: ставити noexcept, бо «так прийнято».
Найчастіша пастка — побачити в інтернеті Buffer(Buffer&&) noexcept і скопіювати це, не розуміючи ціни такої обіцянки. Якщо всередині функції є бодай один сценарій, за якого можливий «вихід назовні через виняток», то ви підписуєте контракт, порушення якого завершить програму через std::terminate().
Помилка № 2: позначити noexcept функцію, яка виділяє памʼять.
Новачок інколи думає: «ну в мене ж зазвичай виділяється, значить усе гаразд». Але «зазвичай» — не контракт. Виділення памʼяті — це як спроба знайти місце на парковці біля ТЦ 31 грудня: інколи виходить, інколи ні. Тому копіювання, яке робить new, майже ніколи не варто позначати noexcept.
Помилка № 3: плутати noexcept і const.
Ці слова часто стоять поруч в оголошеннях, і мозок намагається сприйняти їх як одне й те саме. const означає «метод не змінює стан обʼєкта». noexcept означає «функція не завершиться виходом через виняток». Це різні осі: одна — про зміни обʼєкта, друга — про аварійний шлях виходу.
Помилка № 4: ускладнити move-операції й забути переглянути noexcept.
Типова історія: спочатку move — це перенесення вказівника, усе чесно noexcept. Потім хтось додав у move логування, перерахунок хеша, створення тимчасового std::string, і move перестав бути таким уже простим. Але noexcept залишився. У результаті ви отримали міну сповільненої дії: усе виглядає красиво, але в несподіваний момент програма може аварійно завершитися.
Помилка № 5: думати, що noexcept — це «про оптимізацію, можна не перейматися».
Так, noexcept може поліпшувати поведінку й ефективність контейнерів. Але його основний сенс — коректність контракту. Це не тюнінг двигуна, а гальма. Оптимізація — приємний бонус, а не причина ставити noexcept.
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ