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_;

    // commit одним шагом
    tasks_.swap(tmp.tasks_);
    std::swap(next_id_, tmp.next_id_);

    validate_invariant();
}

Обратите внимание, как здесь «сам собой» получается rollback. Если копирование *this в tmp или push_back вдруг бросит исключение, то this ещё не меняли. Это и есть стиль, в котором проще всего жить: рискованные операции происходят на временном объекте.

Кстати, то, что в стандарте много дискуссий о том, какие операции должны быть noexcept и где уместно жёстко обещать “не бросаю”, — это тоже про контракт и предсказуемость. Например, обсуждаются требования noexcept даже для низкоуровневых вещей вроде operator delete в новых стандартах.

4. Тестируем крайние сценарии

На этом этапе курса мы ещё не упираемся в полноценные unit-тесты с библиотеками (до этого дойдём отдельно). Но есть очень практичная вещь, которую можно делать уже сейчас: ручные мини-проверки крайних случаев.

Смысл “edge cases” в том, что большинство ошибок живёт не в «обычном» сценарии, а в местах вроде:

  • пустая строка вместо имени,
  • 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 << "Added task: '" << title << "'\n"; // Added task: '...'
    } catch (const std::exception& e) {
        std::cout << "Add failed for '" << title << "': " << e.what() << "\n";
    }
}

И используем в main:

#include <iostream>

int main() {
    TaskList list;

    try_add(list, "Buy milk");  // Added task: 'Buy milk'
    try_add(list, "");          // Add failed for '': Task title must not be empty

    std::cout << "Program is alive.\n"; // Program is alive.
}

Это не «тесты уровня индустрии». Но это уже тестирование мышления: вы сознательно проверяете, что ошибка не оставляет объект в странном состоянии и не ломает приложение целиком.

Исключение посреди операции

Самый неприятный класс ошибок — когда операция прерывается в середине, а объект остаётся в полу-состоянии. Мы весь день боролись с этим через commit/rollback — и сейчас самое время проверить, что мы действительно победили, а не просто красиво поговорили.

Сымитируем ситуацию: “внутри метода что-то упало”, например, какая-то подфункция внезапно бросила исключение.

Функция, которая «иногда ломается»:

#include <stdexcept>

void maybe_fail(bool fail) {
    if (fail) {
        throw std::runtime_error("Simulated failure");
    }
}

Метод, который сначала готовит, потом (возможно) падает, и только потом коммитит:

#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);          // commit присваиванием
}

Да, тут есть нюанс: мы вызываем tmp.add_task, который уже делает commit внутри tmp. Но для list это всё равно безопасно: пока мы не сделали list = ..., исходный объект не менялся.

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

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

5. Дизайн и классификация ошибок

Инварианты как инструмент сопровождения

Сейчас будет мысль, которая поначалу кажется слишком простой: если вы явно формулируете, что метод обязан сохранить, то вы автоматически начинаете писать код иначе.

Например, возьмём remove_task(id). Предусловие: id должен существовать (или мы решаем, что иначе это ошибка). Постусловие: задачи с таким id больше нет, а остальные не поменяли свои id. Гарантия при исключении: если что-то пошло не так, состояние либо не изменилось (strong), либо осталось валидным (basic).

И тут всплывает прикольная штука: удаление из std::vector может двигать элементы, вызывать перемещения и т.д. То есть даже «простая операция удаления» — это сценарий, где при исключениях нужно понимать, что вы обещаете.

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

Ошибка пользователя vs системный сбой

Очень частая боль новичка: «Где бросать исключение, а где молча продолжать?». Если честно, это вопрос не синтаксиса, а здравого смысла и контракта.

Обычно полезно мысленно делить проблемы на два типа.

Проблема первого типа — ожидаемая и “внешняя”: пользователь ввёл пустое имя, передал отрицательный id, указал несуществующую команду. Это нормально, это часть жизни программы. Здесь ошибка должна быть понятной и, как правило, не должна ломать объект.

Проблема второго типа — неожиданная и “внутренняя”: «не хватило памяти», «сломался ввод», «внутренний инвариант нарушен». Это уже ближе к “мы не ожидали такого состояния”, и здесь вы либо пробрасываете исключение выше, либо (в редких местах) завершаете работу программы.

Суть в том, что один и тот же механизм (исключения) может сигнализировать и то, и другое. Но подход к проектированию состояния объекта при этом остаётся одинаковым: объект не должен превращаться в «полу-тыкву».

6. Типичные ошибки

Ошибка №1: инварианты “живут в голове”, а не в коде.
Новичок часто уверен: “ну понятно же, что next_id_ всегда положительный”. Понятно — до первой правки через месяц. Как только появляется второй метод, третий, четвёртый, и вы начинаете коммитить изменения в нескольких местах, мозг перестаёт быть надёжным хранилищем правил. Помогает простая дисциплина: формулировать инварианты словами и иметь хоть какую-то проверку (например, validate_invariant() в debug-режиме).

Ошибка №2: проверка входа после изменения состояния.
Это классический сценарий «сначала присвоили, потом подумали». В результате даже корректно брошенное исключение оставляет объект изменённым, хотя операция не завершилась. Лечится почти всегда одинаково: валидируйте входные данные до изменений или работайте с временным объектом и коммитьте в конце.

Ошибка №3: commit “по кускам”.
Когда вы меняете сначала одно поле, потом другое, а потом третье, вы сами создаёте “окно”, где объект временно некорректен. Если в это окно прилетит исключение — получаете полу-обновлённое состояние. Намного проще и надёжнее делать commit одним коротким шагом (часто через swap/присваивание временного объекта), чтобы точка “объект изменился” была одна и хорошо видимая глазами.

Ошибка №4: тестируются только “счастливые” сценарии.
Код “добавить задачу” почти всегда работает на строке "Buy milk". Но настоящие баги вылезают на пустой строке, на повторном удалении, на несуществующем id, на очень длинном тексте, на исключении в середине операции. Если хотя бы иногда сознательно прогонять такие случаи (пусть даже ручными мини-проверками), вы резко ускоряете своё взросление как разработчика — и экономите себе часы дебага в будущем.

1
Задача
C++ SELF, 54 уровень, 4 лекция
Недоступна
Безопасные лимиты
Безопасные лимиты
1
Задача
C++ SELF, 54 уровень, 4 лекция
Недоступна
Реестр имён
Реестр имён
1
Задача
C++ SELF, 54 уровень, 4 лекция
Недоступна
Перевод с откатом
Перевод с откатом
1
Задача
C++ SELF, 54 уровень, 4 лекция
Недоступна
Командный список
Командный список
1
Опрос
Exception safety, 54 уровень, 4 лекция
Недоступен
Exception safety
Exception safety
Комментарии
ЧТОБЫ ПОСМОТРЕТЬ ВСЕ КОММЕНТАРИИ ИЛИ ОСТАВИТЬ КОММЕНТАРИЙ,
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ