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 ставят там, где вы реально можете гарантировать отсутствие «вылетающих наружу» исключений.
Давайте соберём типовые кандидаты в одну таблицу. Это не священный текст, но хорошая стартовая карта.
| Категория | Пример | Почему уместно |
|---|---|---|
| Геттеры простых полей | |
Возвращаем значение, не выделяем память, не валидируем |
| Проверки/предикаты | |
Обычно просто сравнение/длина |
| 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 часто является технической опорой для 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, делайте это осознанно: либо гарантируйте корректное состояние, либо завершайте операцию предсказуемо (например, через понятный код возврата или установку флага), а не «делаем вид, что ничего не было».
ПЕРЕЙДИТЕ В ПОЛНУЮ ВЕРСИЮ