JavaRush /Курси /C++ SELF /Як мислити про помилки: інваріанти до й після

Як мислити про помилки: інваріанти до й після

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

1. Помилка як сценарій і контракт

Коли ви лише починаєте програмувати й чуєте слово «помилка», легко уявити червоний текст у консолі та відчуття: «зараз мене сваритимуть». Насправді помилка — це сценарій, який ви зобовʼязані передбачити. Ідеться не лише про ситуацію «користувач увів нісенітницю», а й про сценарій «щось пішло не так посеред операції», коли частину дій уже виконано, а частину — ні.

У стандарті C++ багато уваги приділено тому, коли програма зобовʼязана завершитися через terminate і як формулювати такі ситуації — навколо цього навіть точаться окремі дискусії й правлять формулювання. Це добрий сигнал: межі помилок і те, «що гарантується після збою», — не дрібниця, а один із фундаментів мови.

Найкорисніша звичка на цьому етапі курсу звучить трохи занудно, зате працює як бронежилет: у кожного публічного методу має бути історія «до → під час → після». А ще краще, коли цю історію перевіряє не лише ваша віра в краще, а й кілька невеликих тестів.

Контракт методу: до, після й у разі винятку

Коли ви пишете метод, корисно тримати в голові три шари:

Передумова (до виклику): що має бути істинним, щоб виклик методу мав сенс.
Післяумова (після успіху): що гарантовано істинне, якщо метод завершився успішно.
Гарантія в разі винятку: що лишається істинним, якщо метод не завершився й кинув виняток.

І саме тут починається доросле життя. Бо «виняток кинуто» — це не просто «ну, не пощастило». Це окрема гілка контракту.

Щоб не перетворювати лекцію на збірник термінів, зведімо все в невелику таблицю.

Шар контракту Питання Приклад для add_task
Передумова «Чи можна це робити?» title не порожній
Післяумова «Що вийшло?» у списку зʼявилося завдання з новим id, next_id_ збільшився
За винятку «Що залишилося?» список або не змінився (strong), або міг змінитися частково (basic)

Сильна гарантія зазвичай звучить так: «або все завершилося успішно, або все лишилося як було». І сприймати таку поведінку дуже зручно: коду, який викликає метод, із нею значно легше працювати.

2. Інваріанти й коректність обʼєкта

Інваріант — це правило, яке має залишатися істинним для обʼєкта в усі моменти його життя (крім дуже коротких «внутрішніх» моментів усередині методу, якщо ви точно контролюєте межі). Для новачка це може здаватися «зайвою філософією», але на практиці інваріанти — це найшвидший спосіб зрозуміти, що саме зламалося, коли все вже горить.

Закріпімо цю ідею на невеликому класі з нашого навчального консольного застосунку. Нехай це буде простий менеджер завдань: ми додаємо завдання, позначаємо їх виконаними, видаляємо. Нічого космічного — зате матеріал ідеальний для тренування мислення про помилки.

Модель даних: Task і TaskList

Зробімо просту модель:

#include <string>

struct Task {
    int id = 0;
    std::string title;
    bool done = false;
};

Тепер контейнер:

#include <vector>

class TaskList {
public:
    void add_task(std::string title);
    void mark_done(int id);
    void remove_task(int id);

private:
    std::vector<Task> tasks_;
    int next_id_ = 1; // наступний id, який видаємо
};

Формулюємо інваріанти словами

Зараз ми прямо проговоримо інваріанти TaskList звичайною мовою. Це звучить як те, що цілком можна було б написати в документації:

  1. next_id_ завжди додатний.
  2. У кожного завдання id > 0.
  3. id у завдань унікальні.
  4. title у кожного завдання не порожній (якщо це правило нашого застосунку).

Зверніть увагу: ці правила не про винятки. Вони описують коректність стану. Винятки лише ускладнюють картину, бо можуть обірвати метод у неочікуваній точці — і якщо ви змінюєте обʼєкт «частинами», інваріанти легко порушити.

3. Практика: пишемо методи, які складно зламати

Зараз ми реалізуємо методи TaskList так, щоб:

  • вхід перевірявся до зміни стану;
  • зміни виконувалися через підготовку і commit;
  • після кожного публічного методу обʼєкт залишався коректним.

Маленький «детектор брехні»: validate_invariant()

Ми зробимо приватну функцію, яка перевіряє інваріанти. У реальному проєкті ви не завжди тримали б її ввімкненою, але як навчальний інструмент це золото.

#include <cassert>
#include <string>
#include <vector>

class TaskList {
public:
    void add_task(std::string title);

private:
    void validate_invariant() const {
        assert(next_id_ > 0);

        for (std::size_t i = 0; i < tasks_.size(); ++i) {
            assert(tasks_[i].id > 0);
            assert(!tasks_[i].title.empty());

            for (std::size_t j = i + 1; j < tasks_.size(); ++j) {
                assert(tasks_[i].id != tasks_[j].id);
            }
        }
    }

private:
    std::vector<Task> tasks_;
    int next_id_ = 1;
};

Так, тут подвійний цикл. І так, це не найшвидший спосіб. Але це перевірка інваріанта, а не робочий алгоритм. Зараз ми вчимося мислити правильно, а не змагатися за мікросекунди.

add_task: спочатку перевіряємо, потім готуємо, потім фіксуємо зміни

#include <stdexcept>
#include <utility>

void TaskList::add_task(std::string title) {
    if (title.empty()) {
        throw std::invalid_argument("Task title must not be empty");
    }

    TaskList tmp = *this; // підготовка нового стану (може кинути виняток)
    tmp.tasks_.push_back(Task{tmp.next_id_, std::move(title), false});
    ++tmp.next_id_;

    // фіксуємо зміни одним кроком
    tasks_.swap(tmp.tasks_);
    std::swap(next_id_, tmp.next_id_);

    validate_invariant();
}

Зверніть увагу: тут майже «автоматично» спрацьовує відкат. Якщо копіювання *this у tmp або push_back раптом кине виняток, то this ще не змінювали. Саме так і виглядає стиль, з яким найпростіше працювати: усі ризиковані операції відбуваються на тимчасовому обʼєкті.

До речі, те, що в стандарті точиться багато дискусій про те, які операції мають бути noexcept і де доречно жорстко обіцяти «не кидаю», — теж про контракт і передбачуваність. Наприклад, обговорюють вимоги noexcept навіть для низькорівневих речей на кшталт operator delete у нових стандартах.

4. Тестуємо крайові сценарії

На цьому етапі курсу ми ще не переходимо до повноцінних unit-тестів із бібліотеками — до цього дійдемо окремо. Але вже зараз можна робити дуже практичну річ: ручні мініперевірки крайових випадків.

Суть крайових випадків у тому, що більшість помилок живе не в «звичайному» сценарії, а в місцях на кшталт:

  • порожній рядок замість назви,
  • id не знайдено,
  • дуже довгий рядок,
  • повторний виклик операції,
  • виняток посеред зміни стану.

Важливо: крайові сценарії — це не «чекліст для галочки». Це спосіб перевірити, чи справді ваш контракт «до/після» витримує зіткнення з реальністю.

Міністенд для перевірки: пробуємо й друкуємо результат

Зробімо функцію, яка викликає add_task і виводить, що сталося.

#include <exception>
#include <iostream>
#include <string>

void try_add(TaskList& list, const std::string& title) {
    try {
        list.add_task(title);
        std::cout << "Додано завдання: '" << title << "'\n"; // Додано завдання: '...'
    } catch (const std::exception& e) {
        std::cout << "Не вдалося додати завдання '" << title << "': " << e.what() << "\n";
    }
}

І використаємо її в main:

#include <iostream>

int main() {
    TaskList list;

    try_add(list, "Buy milk");  // Додано завдання: 'Buy milk'
    try_add(list, "");          // Не вдалося додати завдання '': Task title must not be empty

    std::cout << "Програма працює.\n"; // Програма працює.
}

Це не «тести індустріального рівня». Але це вже тестування мислення: ви свідомо перевіряєте, що помилка не залишає обʼєкт у дивному стані й не ламає застосунок повністю.

Виняток посеред операції

Найнеприємніший клас помилок — коли операція переривається посередині, а обʼєкт лишається в напівзміненому стані. Упродовж усієї лекції ми боролися саме з цим через commit/rollback — і тепер час перевірити, чи справді перемогли, а не просто красиво про це поговорили.

Зімітуймо ситуацію: «усередині методу щось упало», наприклад, якась допоміжна функція раптово кинула виняток.

Функція, яка «інколи ламається»:

#include <stdexcept>

void maybe_fail(bool fail) {
    if (fail) {
        throw std::runtime_error("Імітований збій");
    }
}

Метод, який спочатку готує, потім (можливо) падає, і лише після цього комітить:

#include <utility>

void add_task_with_failure(TaskList& list, std::string title, bool fail) {
    TaskList tmp = list; // підготовка
    tmp.add_task(std::move(title)); // може кинути виняток і сам по собі
    maybe_fail(fail);               // імітуємо збій після основної логіки
    list = std::move(tmp);          // фіксуємо зміни через присвоєння
}

Так, тут є нюанс: ми викликаємо tmp.add_task, який уже робить commit усередині tmp. Але для list це однаково безпечно: доки ми не зробили list = ..., початковий обʼєкт не змінювався.

Сенс цього прикладу не в «ідеальному дизайні», а в тренуванні рефлексу: будь-яку операцію, яка потенційно може завершитися винятком, треба виконати до commit-кроку.

До речі, у стандартних обговореннях навіть поведінку «що робити в разі винятку» для деяких алгоритмів спеціально уточнюють і переписують, бо це важлива частина поведінкового контракту програми.

5. Дизайн і класифікація помилок

Інваріанти як інструмент супроводу

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

Наприклад, візьмімо remove_task(id). Передумова: id має існувати, або ми заздалегідь вирішуємо, що інакше це помилка. Післяумова: завдання з таким id більше немає, а решта не змінили свої id. Гарантія в разі винятку: якщо щось пішло не так, стан або не змінився (strong), або залишився валідним (basic).

І тут зʼявляється важливий нюанс: видалення зі std::vector може пересувати елементи, викликати переміщення тощо. Тобто навіть «проста операція видалення» — це сценарій, у якому в разі винятку треба чітко розуміти, що саме ви обіцяєте.

Ми не будемо в цій лекції заглиблюватися в специфіку гарантій кожного контейнера й кожної дії — це окрема велика тема. Наша мета — навчитися ставити собі правильні запитання й будувати оновлення стану так, щоб у вашій голові не лишалося «магії».

Помилка користувача й системний збій

Дуже частий біль новачка: «Де кидати виняток, а де мовчки продовжувати?». Якщо чесно, це питання не синтаксису, а здорового глузду й контракту.

Зазвичай корисно подумки ділити проблеми на два типи.

Проблема першого типу — очікувана й «зовнішня»: користувач увів порожнє імʼя, передав відʼємний id, указав неіснуючу команду. Це нормально, це частина життя програми. Тут помилка має бути зрозумілою і, як правило, не повинна ламати обʼєкт.

Проблема другого типу — неочікувана й «внутрішня»: «не вистачило памʼяті», «зламалося введення», «внутрішній інваріант порушено». Це вже ближче до ситуації «ми не очікували такого стану», і тут ви або прокидаєте виняток вище, або (у рідкісних місцях) завершуєте роботу програми.

Суть у тому, що один і той самий механізм (винятки) може сигналізувати і про перше, і про друге. Але підхід до проєктування стану обʼєкта при цьому лишається однаковим: обʼєкт не має перетворюватися на «напівгарбуз».

6. Типові помилки

Помилка № 1: інваріанти «живуть у голові», а не в коді.
Новачок часто впевнений: «ну, зрозуміло ж, що next_id_ завжди додатний». Зрозуміло — до першої правки через місяць. Щойно зʼявляється другий метод, третій, четвертий і ви починаєте вносити зміни в кількох місцях, мозок перестає бути надійним сховищем правил. Допомагає проста дисципліна: формулювати інваріанти словами й мати бодай якусь перевірку (наприклад, validate_invariant() у режимі налагодження).

Помилка № 2: перевірка входу після зміни стану.
Це класичний сценарій: «спочатку присвоїли, потім подумали». У результаті навіть коректно кинутий виняток залишає обʼєкт зміненим, хоча операція не завершилася. Виправляється це майже завжди однаково: перевіряйте вхідні дані до змін або працюйте з тимчасовим обʼєктом і фіксуйте зміни наприкінці.

Помилка № 3: commit «частинами».
Коли ви спершу змінюєте одне поле, потім друге, а потім третє, то самі створюєте «вікно», у якому обʼєкт тимчасово некоректний. Якщо в цей момент прилетить виняток, отримаєте напівоновлений стан. Значно простіше й надійніше фіксувати зміни одним коротким кроком (часто через swap/operator= тимчасового обʼєкта), щоб точка «обʼєкт змінився» була одна й добре помітна.

Помилка № 4: тестують лише «щасливі» сценарії.
Код «додати завдання» майже завжди працює на рядку "Buy milk". Але справжні помилки проявляються на порожньому рядку, при повторному видаленні, на неіснуючому id, на дуже довгому тексті, на винятку посеред операції. Якщо хоча б інколи свідомо проганяти такі випадки, хай навіть ручними мініперевірками, ви дуже швидко дорослішаєте як розробник і заощаджуєте собі години налагодження в майбутньому.

1
Опитування
Безпека винятків, рівень 54, лекція 4
Недоступний
Безпека винятків
Безпека винятків
Коментарі
ЩОБ ПОДИВИТИСЯ ВСІ КОМЕНТАРІ АБО ЗАЛИШИТИ КОМЕНТАР,
ПЕРЕЙДІТЬ В ПОВНУ ВЕРСІЮ