JavaRush /Курси /C++ SELF /noexcept — практичний сенс

noexcept — практичний сенс

C++ SELF
Рівень 54 , Лекція 3
Відкрита

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 ставлять там, де ви справді можете гарантувати відсутність винятків, які «вилітають назовні».

Давайте зведемо типових кандидатів у таблицю. Це не священний текст, але хороша стартова мапа.

Категорія Приклад Чому доречно
Гетери простих полів
int id() const noexcept
Повертаємо значення, не виділяємо памʼять і нічого не перевіряємо
Перевірки/предикати
bool empty() const noexcept
Зазвичай це просто порівняння або перевірка довжини
swap для класу
void swap(X&) noexcept
swap часто використовують як «commit-операцію», тому він має бути передбачуваним
Деструктори
~X() noexcept
(за своєю природою)
Деструктор не повинен викидати винятки назовні, інакше під час розкручування стека почнеться хаос
Дрібні утиліти без виділення памʼяті
int clamp(...) noexcept
Чиста арифметика й порівняння

Важливий нюанс: «гетери простих полів» — справді хороший кандидат. Наприклад:

#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, робіть це усвідомлено: або гарантуйте коректний стан, або завершуйте операцію передбачувано — наприклад, через зрозумілий код повернення чи встановлення прапорця, — а не вдавайте, що нічого не сталося».

Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ