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

noexcept — практический смысл

C++ SELF
54 уровень , 3 лекция
Открыта

1. noexcept как контракт и цена нарушения

Когда вы впервые видите noexcept, очень хочется прочитать его как «эта функция точно не ошибается». Увы, нет: ошибаться она может сколько угодно, просто не имеет права сообщать об этом наружу через исключение. Это похоже на табличку «не курить»: она не гарантирует, что дыма не будет, она гарантирует, что если дым появится — будет разбор полётов (а у C++ разбор полётов обычно короткий и громкий).

Запись вида:

int f() noexcept;

означает: если внутри f() что-то бросит исключение, оно не должно пересечь границу функции. И вот здесь ключевой момент: граница функции — важнее всего.

Внутри вы можете вызвать код, который потенциально бросает, но тогда вы обязаны перехватить исключение внутри f() и завершить функцию «по правилам» (например, вернуть запасное значение, записать ошибку в лог, выставить флаг и т.п.). Если исключение вылетит наружу — стандартная реакция языка: аварийное завершение программы.

Удобно держать это в голове как схему:

flowchart TD
    A[Внутри функции] --> B{Произошло исключение?}
    B -- нет --> C[Обычный return]
    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 часто является технической опорой для exception safety (например, в шаблоне 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, делайте это осознанно: либо гарантируйте корректное состояние, либо завершайте операцию предсказуемо (например, через понятный код возврата или установку флага), а не «делаем вид, что ничего не было».

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