1. noexcept як контракт і ціна порушення
Коли ви вперше бачите noexcept, дуже хочеться прочитати це як «ця функція точно не помиляється». На жаль, ні: помилятися вона може скільки завгодно, просто не має права повідомляти про це назовні через виняток. Це схоже на табличку «Не курити»: вона не гарантує, що диму не буде, але гарантує, що, якщо він зʼявиться, наслідки будуть швидкими й гучними.
Запис на кшталт:
int f() noexcept;
означає: якщо всередині f() щось кине виняток, він не повинен перетнути межу функції. І тут ключовий момент: найважливіше — межа функції.
Усередині ви можете викликати код, який потенційно може кинути виняток, але тоді зобовʼязані перехопити його в межах f() і завершити функцію коректно — наприклад, повернути запасне значення, записати помилку в лог, виставити прапорець тощо. Якщо виняток вилетить назовні, стандартна реакція мови — аварійне завершення програми.
Зручно уявляти це так:
flowchart TD
A[Усередині функції] --> B{Виник виняток?}
B -- ні --> C[Звичайне повернення]
B -- так --> D{Функція noexcept?}
D -- ні --> E[Виняток підіймається вище стеком]
D -- так --> F["std::terminate (аварійне завершення)"]
Тобто noexcept — це не «суперброня від помилок», а жорсткий контракт: «через виняток назовні не повідомляю».
Що буде при порушенні: std::terminate
Якщо виняток покинув noexcept-функцію, стандартна поведінка — викликати std::terminate. Це означає, що програма завершує роботу негайно, і ніякого «я ще трішки попрацюю й збережу файлик» уже не буде.
Мінімальний приклад того, як не треба:
#include <stdexcept>
void bad() noexcept {
throw std::runtime_error("boom");
}
int main() {
bad(); // програма завершиться через std::terminate
}
Чому C++ такий суворий? Тому що noexcept — це контракт, на який може спиратися інший код. Наприклад, стандартна бібліотека й ваш власний код можуть будувати алгоритми «без запасного парашута», якщо ви пообіцяли, що виняток не вилетить. Порушення такої обіцянки ламає не лише вашу функцію, а й зовнішню логіку, тому мова обирає максимально передбачуваний вихід: завершення програми.
Чи можна зробити noexcept-функцію, усередині якої інші операції потенційно кидають винятки? Так. Але тоді ви маєте поставити «барʼєр» — перехопити все всередині й не випускати назовні. Наприклад, інколи так роблять у деструкторах, бо за своєю природою вони мають бути максимально безпечними для зовнішнього коду:
#include <iostream>
struct Cleaner {
~Cleaner() noexcept {
try {
std::cout << "cleanup\n";
} catch (...) {
// навіть якщо щось пішло не так, назовні не випускаємо
}
}
};
Це не означає «ловимо все й мовчимо». Це означає: «я зобовʼязаний виконати контракт noexcept, тому виняток назовні не випускаю». Що саме робити всередині catch — логувати, ставити прапорець, ігнорувати — залежить від політики проєкту, але сам факт такого барʼєра тут виправданий саме контрактом.
2. Де noexcept зазвичай доречний
Дуже хочеться поставити noexcept всюди, щоб було «швидше» і «крутіше». Так робити не варто: це як наклеїти на всі двері табличку «Вихід тут», а потім дивуватися, що люди врізаються в стіну. noexcept ставлять там, де ви справді можете гарантувати відсутність винятків, які «вилітають назовні».
Давайте зведемо типових кандидатів у таблицю. Це не священний текст, але хороша стартова мапа.
| Категорія | Приклад | Чому доречно |
|---|---|---|
| Гетери простих полів | |
Повертаємо значення, не виділяємо памʼять і нічого не перевіряємо |
| Перевірки/предикати | |
Зазвичай це просто порівняння або перевірка довжини |
| swap для класу | |
swap часто використовують як «commit-операцію», тому він має бути передбачуваним |
| Деструктори | (за своєю природою) |
Деструктор не повинен викидати винятки назовні, інакше під час розкручування стека почнеться хаос |
| Дрібні утиліти без виділення памʼяті | |
Чиста арифметика й порівняння |
Важливий нюанс: «гетери простих полів» — справді хороший кандидат. Наприклад:
#include <string>
struct Task {
int id{};
std::string title;
bool done{false};
bool is_done() const noexcept { return done; }
};
is_done() не робить нічого, що могло б кинути виняток, і саме такі функції доречно позначати noexcept: це підкреслює, що виклик безпечний і ви не очікуєте від нього сюрпризів.
Зі swap трохи цікавіше: swap часто є технічною опорою для гарантій безпеки щодо винятків, наприклад у шаблоні commit/rollback. Якщо swap може несподівано кинути виняток, вся ідея «короткого commit-кроку» стає менш надійною.
3. Де noexcept ставити небезпечно
Є цілий клас функцій, яким не можна чесно пообіцяти noexcept, бо вони за своєю природою можуть натрапити на помилку, а виняток — це нормальний спосіб повідомити про неї.
Якщо ви поставили noexcept там, де помилка справді можлива, то перетворюєте «помилка → виняток → обробка» на «помилка → terminate». Тобто ви не прибрали помилку, а просто прибрали можливість її обробити.
Типові небезпечні зони — це операції, повʼязані з виділенням памʼяті й зростанням контейнерів, перетворенням рядків на числа, побудовою великих рядків, а також «валідатори», які за контрактом кидають invalid_argument.
Порівняймо два варіанти на маленькому прикладі парсингу:
#include <string>
#include <stdexcept>
int parse_id(const std::string& s) { // НЕ noexcept
if (s.empty()) {
throw std::invalid_argument("empty id");
}
return std::stoi(s); // може кинути
}
Якби ми написали int parse_id(...) noexcept, то будь-яка помилка введення перетворилася б на аварійне завершення. Це поганий UX навіть для консольної утиліти, а для бібліотеки — майже злочин проти людства.
Ще один приклад: метод, який додає елемент у std::vector, майже завжди потенційно може кинути виняток — хоча б через виділення памʼяті під час зростання. Навіть якщо «в середньому» все буде добре, обіцяти noexcept ви не маєте права, якщо не контролюєте всю ситуацію:
#include <string>
#include <vector>
struct TaskBook {
std::vector<std::string> titles;
void add(std::string t) { // НЕ noexcept
titles.push_back(std::move(t)); // може виділяти памʼять
}
};
Це не означає, що такі функції «погані». Це означає, що їхній контракт не повинен забороняти винятки, якщо виняток — нормальна частина роботи.
4. noexcept(expr) і умовний noexcept
Одна з найпрактичніших частин теми — оператор noexcept(expr). Його оцінюють на етапі компіляції, і він відповідає на запитання: «чи може цей вираз кинути виняток?» Результат — true або false. Це зручно, коли ви хочете не «вірити на слово», а формально перевірити властивості коду.
Почнімо з простого прикладу:
static int add(int a, int b) noexcept { return a + b; }
static_assert(noexcept(add(1, 2)));
static_assert тут відіграє роль «охоронця на вході»: якщо хтось змінить add так, що вона перестане бути noexcept, проєкт просто не збереться. Це корисно у великих кодових базах, де зміни інколи котяться, як снігова куля.
Найцікавіше починається, коли ви використовуєте noexcept(expr) для умовного noexcept: «моя функція noexcept тоді й лише тоді, коли noexcept те, що я викликаю всередині». Це дає змогу писати чесніші обгортки.
Наприклад, для swap можна зробити так, щоб він був noexcept, якщо swap усіх полів теж noexcept:
#include <utility>
#include <vector>
struct Box {
std::vector<int> data;
void swap(Box& other) noexcept(noexcept(data.swap(other.data))) {
data.swap(other.data);
}
};
Так, запис виглядає трохи «шаблонно», але зміст у нього прямий: ми не вигадуємо обіцянку, а виводимо її з гарантій складових частин. Це значно чесніше, ніж ставити noexcept «на око».
5. Приклад: підсилюємо TaskBook
Щоб тема не залишилася лише теорією, вбудуймо її в навчальний проєкт. Нехай у нас є консольний застосунок TaskBook — невеликий менеджер завдань. Ми зберігаємо завдання у std::vector, присвоюємо їм id і вміємо додавати нові. Такі навчальні застосунки добре показують, де noexcept допомагає, а де стає небезпечним.
Почнімо з моделі завдання:
#include <string>
struct Task {
int id{};
std::string title;
bool done{false};
bool is_done() const noexcept { return done; }
};
Тут is_done() — чесний кандидат на noexcept: він просто читає значення bool.
Тепер зробімо сховище TaskBook. Ми хочемо, щоб swap був максимально передбачуваним, і noexcept тут доречний:
#include <utility>
#include <vector>
class TaskBook {
public:
void swap(TaskBook& other) noexcept {
tasks_.swap(other.tasks_);
std::swap(next_id_, other.next_id_);
}
private:
std::vector<Task> tasks_;
int next_id_{1};
};
Чому тут noexcept виглядає розумно? Ми не виділяємо памʼять вручну, не перевіряємо введення, не парсимо рядки й не викликаємо stoi. Ми просто міняємо місцями два вектори й два числа.
Тепер додамо оператор присвоювання в стилі copy-and-swap. Важливо: сам operator= не зобовʼязаний бути noexcept. Він може кинути виняток на етапі створення параметра other — копіювання може виділяти памʼять, і це нормально. Але якщо копіювання не вдалося, обʼєкт ліворуч не змінюється — це і є strong guarantee.
#include <utility>
class TaskBook {
public:
void swap(TaskBook& other) noexcept;
TaskBook& operator=(TaskBook other) { // НЕ noexcept
swap(other);
return *this;
}
};
Тут ролі розділено вдало: потенційно небезпечна частина — копіювання — відбувається ще до входу в тіло, а commit виконується через swap, який у нас noexcept. Маємо логіку, яку легко перевірити навіть просто очима.
А от метод add_task ми не позначаємо noexcept, бо він може збільшувати std::vector і виділяти памʼять:
#include <string>
#include <utility>
#include <vector>
class TaskBook {
public:
void add_task(std::string title) { // НЕ noexcept
tasks_.push_back(Task{next_id_, std::move(title), false});
++next_id_;
}
private:
std::vector<Task> tasks_;
int next_id_{1};
};
Якщо вам дуже хочеться спитати: «а що, як я все одно поставлю noexcept, адже в мене все маленьке?» — одного дня воно перестане бути маленьким. І тоді замість обробки помилки ви отримаєте std::terminate.
6. Типові помилки під час використання noexcept
Помилка № 1: ставити noexcept як «прискорювач», не розуміючи, що це контракт.
Новачок часто сприймає noexcept як constexpr: мовляв, додав — і стало крутіше та швидше. На практиці noexcept — це обіцянка, за порушення якої програма завершиться через std::terminate. Якщо ви не можете довести, що виняток не вийде назовні, краще цього не обіцяти.
Помилка № 2: позначати noexcept функції, які працюють із введенням, парсингом і валідацією.
Парсинг чисел, розбір рядків, перевірка користувацьких даних — це місця, де помилки є нормальними. Якщо ви заборонили винятки назовні, то будь-який «поганий рядок» перетворюється на аварійне завершення. Це не «надійність», а «самознищення при вигляді пробілу».
Помилка № 3: думати, що noexcept автоматично дає strong guarantee.
noexcept каже лише про те, чи вийде виняток назовні. Він нічого не обіцяє щодо незмінності стану. Ви можете написати noexcept-функцію, яка змінює половину полів, потім ловить виняток усередині й повертає «якось». З погляду контракту noexcept усе виконано, а з погляду логіки обʼєкт може залишитися в дивному стані. Тому noexcept і exception safety — це різні осі, і їх варто тримати в голові окремо.
Помилка № 4: робити noexcept-функцію і забувати, що всередині викликається код, який потенційно може кинути виняток.
Особливо часто це трапляється після рефакторингу: учора функція була простим гетером, ви поставили noexcept, а завтра туди додали операції зі std::string, push_back або stoi. Компілятор не завжди «забороняє» це напряму, бо проблема може проявитися лише під час виконання. У підсумку — несподіване std::terminate у найнезручнішому місці. Уникнути цього допомагає дисципліна: або прибираємо noexcept, або будуємо внутрішній барʼєр і продуману обробку, або використовуємо noexcept(expr) і static_assert, якщо це критично.
Помилка № 5: «ловити все» всередині noexcept і мовчки продовжувати, удаючи, що все добре.
Інколи люди дізнаються про std::terminate і починають ставити try/catch (...) { } всюди, аби тільки програма не падала. Формально контракт noexcept буде дотримано, але ви ризикуєте приховати справжню причину проблеми й залишити систему в непередбачуваному стані. Якщо ви вже ставите барʼєр усередині noexcept, робіть це усвідомлено: або гарантуйте коректний стан, або завершуйте операцію передбачувано — наприклад, через зрозумілий код повернення чи встановлення прапорця, — а не вдавайте, що нічого не сталося».
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ