Исключения в C++: типы, синтаксис и обработка
Разберёмся, для чего нужны исключения в языке C++ и какими они бывают. Изучим синтаксис выбрасывания, обработки и рассмотрим особые случаи.
19К открытий24К показов
Поговорим об исключениях в C++, начиная определением и заканчивая грамотной обработкой.
- Инструмент программирования для исключительных ситуаций
- Исключения: панацея или нет
- Синтаксис исключений в C++
- Базовые исключения стандартной библиотеки
- Заключение
Георгий Осипов
Один из авторов курса «Разработчик C++» в Яндекс Практикуме, разработчик в Лаборатории компьютерной графики и мультимедиа ВМК МГУ
Исключения — важный инструмент в современном программировании. В большинстве источников тема исключений раскрывается не полностью: не описана механика их работы, производительность или особенности языка C++.
В статье я постарался раскрыть тему исключений достаточно подробно. Она будет полезна новичкам, чтобы узнать об исключениях, и программистам с опытом, чтобы углубиться в явление и достичь его полного понимания.
Статья поделена на две части. Первая перед вами и содержит базовые, но важные сведения. Вторая выйдет чуть позже. В ней — информация для более продвинутых разработчиков.
В первой части разберёмся:
- для чего нужны исключения;
- особенности C++;
- синтаксис выбрасывания и обработки исключений;
- особые случаи, связанные с исключениями.
Также рассмотрим основные стандартные типы исключений, где и для чего они применяются.
Мы опираемся на современные компиляторы и Стандарт C++20. Немного затронем C++23 и даже C++03.
Если вы только осваиваете C++, возможно, вам будет интересен курс «Разработчик C++» в Яндекс Практикуме. У курса есть бесплатная вводная часть. Именно она может стать вашим первым шагом в мир C++. Для тех, кто знаком с программированием, есть внушительная ознакомительная часть, тоже бесплатная.
Инструмент программирования для исключительных ситуаций
В жизни любой программы бывают моменты, когда всё идёт не совсем так, как задумывал разработчик. Например:
- в системе закончилась оперативная память;
- соединение с сервером внезапно прервалось;
- пользователь выдернул флешку во время чтения или записи файла;
- понадобилось получить первый элемент списка, который оказался пустым;
- формат файла не такой, как ожидалось.
Примеры объединяет одно: возникшая ситуация достаточно редка, и при нормальной работе программы, всех устройств, сети и адекватном поведении пользователя она не возникает.
Хороший программист старается предусмотреть подобные ситуации. Однако это бывает сложно: перечисленные проблемы обладают неприятным свойством — они могут возникнуть практически в любой момент.
На помощь программисту приходят исключения (exception). Так называют объекты, которые хранят данные о возникшей проблеме. Механизмы исключений в разных языках программирования очень похожи. В зависимости от терминологии языка исключения либо выбрасывают (throw), либо генерируют (raise). Это происходит в тот момент, когда программа не может продолжать выполнять запрошенную операцию.
После выбрасывания в дело вступает системный код, который ищет подходящий обработчик. Особенность в том, что тот, кто выбрасывает исключение, не знает, кто будет его обрабатывать. Может быть, что и вовсе никто — такое исключение останется сиротой и приведёт к падению программы.
Если обработчик всё же найден, то он ловит (catch) исключение и программа продолжает работать как обычно. В некоторых языках вместо catch используется глагол except (исключить).
Обработчик ловит не все исключения, а только некоторые — те, что возникли в конкретной части определённой функции. Эту часть нужно явно обозначить, для чего используют конструкцию try (попробовать). Также обработчик не поймает исключение, которое ранее попало в другой обработчик. После обработки исключения программа продолжает выполнение как ни в чём не бывало.
Исключения: панацея или нет
Перед тем как совершить операцию, нужно убедиться, что она корректна. Если да — совершить эту операцию, а если нет — выбросить исключение. Так делается в некоторых языках, но не в C++. Проверка корректности — это время, а время, как известно, деньги. В C++ считается, что программист знает, что делает, и не нуждается в дополнительных проверках. Это одна из причин, почему программы на C++ такие быстрые.
Но за всё нужно платить. Если вы не уследили и сделали недопустимую операцию, то в менее производительных языках вы получите исключение, а в C++ — неопределённое поведение. Исключение можно обработать и продолжить выполнение программы. Неопределённое поведение гарантированно обработать нельзя.
Но некоторые виды неопределённого поведения вполне понятны и даже могут быть обработаны. Это зависит от операционной системы:
- сигналы POSIX — низкоуровневые уведомления, которые отправляются программе при совершении некорректных операций и в некоторых других случаях;
- структурированные исключения Windows (SEH) — специальные исключения, которые нельзя обработать средствами языка.
Особенность C++ в том, что не любая ошибка влечёт исключение, и не любую ошибку можно обработать. Но если для операции производительность не так критична, почему бы не сделать проверку?
У ряда операций в C++ есть две реализации. Одна супербыстрая, но вы будете отвечать за корректность, а вторая делает проверку и выбрасывает исключение в случае ошибки. Например, к элементу класса std::vector
можно обратиться двумя способами:
vec[15]
— ничего не проверяет. Если в векторе нет элемента с индексом 15, вы получаете неопределённое поведение. Это может быть сигнал SIGSEGV, некорректное значение или взрыв компьютера.vec.at(15)
— то же самое, но в случае ошибки выбрасывается исключение, которое можно обработать.
В C++ вам даётся выбор: делать быстро или делать безопасно. Часто безопасность важнее, но в определённых местах программы любое промедление критично.
Синтаксис исключений в C++
Ловим исключения
Начнём с примера:
В примере есть один try
-блок и один catch
-блок. Если в блоке try
возникает исключение типа ExceptionType
, то выполнение блока заканчивается. При этом корректно удаляются созданные объекты — в данном случае переменная var
. Затем управление переходит в конструкцию catch
. Сам объект исключения передаётся в переменную e
. Выводя e.what()
, мы предполагаем, что у типа ExceptionType
есть метод what
.
Если в блоке try
возникло исключение другого типа, то управление также прервётся, но поиск обработчика будет выполняться за пределами функции SomeFunction
— выше по стеку вызовов. Это также касается любых исключений, возникших вне try
-блока.
Во всех случаях объект var
будет корректно удалён.
Исключение не обязано возникнуть непосредственно внутри DoSomething*()
. Будут обработаны исключения, возникшие в функциях, вызванных из DoSomething*
, или в функциях, вызванных из тех функций, да и вообще на любом уровне вложенности. Главное, чтобы исключение не было обработано ранее.
Ловим исключения нескольких типов
Можно указать несколько блоков catch
, чтобы обработать исключения разных типов:
Ловим все исключения
Если перед catch(...)
есть другие блоки, то он означает «поймать все остальные исключения». Ставить другие catch
-блоки после catch(...)
не имеет смысла.
Перебрасываем исключение
Внутри catch(...)
нельзя напрямую обратиться к объекту-исключению. Но можно перебросить тот же объект, чтобы его поймал другой обработчик:
Можно использовать throw
в catch
-блоках с указанным типом исключения. Но если поместить throw
вне блока catch
, то программа тут же аварийно завершит работу через вызов std::terminate()
.
Перебросить исключение можно другим способом:
Этот способ обладает дополнительным преимуществом: можно сохранить исключение и перебросить его в другом месте. Однако результат std::current_exception()
— это не объект исключения, поэтому его можно использовать только со специализированными функциями.
Принимаем исключение по ссылке
Чтобы избежать лишних копирований, можно ловить исключение по ссылке или константной ссылке:
Это особенно полезно, когда мы ловим исключение по базовому типу.
Выбрасываем исключения
Чтобы поймать исключение, нужно его вначале выбросить. Для этого применяется throw.
Если throw
используется с параметром, то он не перебрасывает исключение, а выбрасывает новое. Параметр может быть любого типа, даже примитивного. Использовать такую конструкцию разрешается в любом месте программы:
Вывод: «Поймано исключение типа int, содержащее число –15».
Создаём типы для исключений
Выбрасывать int
или другой примитивный тип можно, но это считается дурным тоном. Куда лучше создать специальный тип, который будет использоваться только для исключений. Причём удобно для каждого вида ошибок сделать отдельный класс. Он даже не обязан содержать какие-то данные или методы: отличать исключения друг от друга можно по названию типа.
В итоге будет напечатана только фраза: «Найдено отрицательное число», поскольку –15 проверено раньше нуля.
Ловим исключение по базовому типу
Чтобы поймать исключение, тип обработчика должен в точности совпадать с типом исключения. Например, нельзя поймать исключение типа int
обработчиком типа unsigned int
.
Но есть ситуации, в которых типы могут не совпадать. Про одну уже сказано выше: можно ловить исключение по ссылке. Есть ещё одна возможность — ловить исключение по базовому типу.
Например, чтобы не писать много catch
-блоков, можно сделать все используемые типы исключений наследниками одного. В этом случае рекомендуется принимать исключение по ссылке.
Выбрасываем исключение в тернарной операции ?:
Напомню, что тернарная операция ?:
позволяет выбрать из двух альтернатив в зависимости от условия:
Оператор throw
можно использовать внутри тернарной операции в качестве одного из альтернативных значений. Например, так можно реализовать безопасное деление:
Это эквивалентно такой записи:
Согласитесь, первый вариант лаконичнее. Так можно выбрасывать несколько исключений в одном выражении:
Вся функция — try-блок
Блок try
может быть всем телом функции:
Тут мы просто опустили фигурные скобки функции. По-другому можно записать так:
Исключения в конструкторе
Есть как минимум два случая возникновения исключений в конструкторе объекта:
- Внутри тела конструктора.
- При конструировании данных объекта.
В первом случае исключение ещё можно поймать внутри тела конструктора и сделать вид, как будто ничего не было.
Во втором случае исключение тоже можно поймать, если использовать try-блок в качестве тела конструктора. Однако тут есть особенность: сделать вид, что ничего не было, не получится. Объект всё равно будет считаться недоконструированным:
Тут мы увидим оба сообщения: «Знаменатель дроби не может быть нулём» и «Дробь не построена».
Если объект недоконструирован, то его деструктор не вызывается. Это логичная, но неочевидная особенность языка. Однако все полностью построенные члены – данные объекта будут корректно удалены:
Запустим код и увидим такой вывод:
Объект типа A создался и удалился, а объект типа B создался не до конца и поэтому не удалился.
Не все исключения в конструкторах можно обработать. Например, нельзя поймать исключения, выброшенные при конструировании глобальных и thread_local
объектов, — в этом случае будет вызван std::terminate
.
Исключения в деструкторе
В этом разделе примера не будет, потому что исключения в деструкторе — нежелательная практика. Бывает, что язык удаляет объекты вынужденно, например, при поиске обработчика выброшенного исключения. Если во время этого возникнет другое исключение в деструкторе какого-то объекта, то это приведёт к вызову std::terminate
.
Более того, по умолчанию исключения в деструкторе запрещены и всегда приводят к вызову std::terminate
. Выможете разрешить их для конкретного конструктора — об этом я расскажу в следующей части — но нужно много раз подумать, прежде чем сделать это.
Обрабатываем непойманные исключения
Поговорка «не пойман — не вор» для исключений не работает. Непойманные исключения приводят к завершению программы через std::terminate
. Это нештатная ситуация, но можно предотвратить немедленное завершение, добавив обработчик для std::terminate
:
Однако не стоит надеяться, что программа после обработки такой неприятной ситуации продолжит работу как ни в чём не бывало. std::terminate
— часть завершающего процесса программы. Внутри него доступен только ограниченный набор операций, зависящий от операционной системы.
Остаётся только сохранить всё, что можно, и извиниться перед пользователем за неполадку. А затем выйти из программы окончательно вызовом std::abort()
.
Базовые исключения стандартной библиотеки
Далеко не всегда есть смысл создавать новый тип исключений, ведь в стандартной библиотеке их и так немало. А если вы всё же создаёте свои исключения, то сделайте их наследниками одного из базовых. Рекомендуется делать все типы исключений прямыми или косвенными наследниками std::exception
.
Обратим внимание на одну важную вещь. Все описываемые далее классы не содержат никакой магии. Это обычные и очень простые классы, которые вы могли бы реализовать и самостоятельно. Использовать их можно и без throw
, однако смысла в этом немного.
Их особенность в том, что разработчики договорились использовать эти классы для описания исключений, генерируемых в программе. Например, этот код абсолютно корректен, но совершенно бессмысленен:
Разберём основные типы исключений, описанные в стандартной библиотеке C++.
std::exception
Базовый класс всех исключений стандартной библиотеки. Конструктор не принимает параметров. Имеет метод what()
, возвращающий описание исключения. Как правило, используются производные классы, переопределяющие метод what()
.
std::logic_error : public std::exception
Исключение типа logic_error
выбрасывается, когда нарушены условия, сформулированные на этапе написания программы. Например, мы передали в функцию извлечения квадратного корня отрицательное число или попытались извлечь элемент из пустого списка.
Конструктор принимает сообщение в виде std::string
, которое будет возвращаться методом what()
.
Перечислим некоторые производные классы std::logic_error
. У всех них похожий интерфейс.
- std::invalid_argument. Исключение этого типа показывает, что функции передан некорректный аргумент, не соответствующий условиям.
Это исключение выбрасывают функции преобразования строки в число, такие как stol
, stof
, stoul
, а также конструктор класса std::bitset
:
- std::length_error. Исключение говорит о том, что превышен лимит вместимости контейнера. Может выбрасываться из методов, меняющих размер контейнеров
string
иvector
. Напримерresize
,reserve
,push_back
.
- std::out_of_range. Исключение говорит о том, что некоторое значение находится за пределами допустимого диапазона. Возникает при использовании метода
at
практически всех контейнеров. Также возникает при использовании функций конвертации в строки в число, таких какstol
,stof
,stoul
. В стандартной библиотеке есть исключение с похожим смыслом —std::range_error
.
std::runtime_error : public std::exception
std::runtime_error
— ещё один базовый тип для нескольких видов исключений. Он говорит о том, что исключение относится скорее не к предусмотренной ошибке, а к выявленной в процессе выполнения.
При этом, если std::logic_error
подразумевает конкретную причину ошибки — нарушение конкретного условия, — то std::runtime_error
говорит о том, что что-то идёт не так, но первопричина может быть не вполне очевидна.
Интерфейс такой же, как и у logic_error
: класс принимает описание ошибки в конструкторе и переопределяет метод what()
базового класса std::exception
.
Рассмотрим некоторые важные производные классы:
std::regex_error.
Исключение, возникшее в процессе работы с регулярными выражениями. Например, при неверном синтаксисе регулярного выражения.std::system_error.
Широкий класс исключений, связанных с потоками, вводом-выводом или файловой системой.std::format_error.
Исключение, возникшее при работе функцииstd::format
.
std::bad_alloc : public std::exception
У std::exception
есть и другие наследники. Самый важный — std::bad_alloc
. Его может выбрасывать операция new. Это исключение — слабое место многих программ и головная боль многих разработчиков, ведь оно может возникать практически везде — в любом месте, где есть динамическая аллокация. То есть при:
- вставке в любой контейнер;
- копировании любого контейнера, например, обычной строки;
- создании умного указателя unique_ptr или shared_ptr;
- копировании объекта, содержащего контейнер;
- прямом вызове new (надеемся, что вы так не делаете);
- работе с потоками ввода-вывода;
- работе алгоритмов;
- вызове корутин;
- в пользовательских классах и библиотеках — практически при любых операциях.
При обработке bad_alloc
нужно соблюдать осторожность и избегать других динамических аллокаций.
Возможный вывод: «Место закончилось после вставки 2640 элементов».
При аллокациях возможна также ошибка std::bad_array_new_length
, производная от bad_alloc
. Она возникает при попытке выделить слишком большое, слишком маленькое (меньше, чем задано элементов для инициализации) либо отрицательное количество памяти.
Также при аллокации можно запретить new выбрасывать исключение. Для этого пишем (std::nothrow)
после new
:
В случае ошибки операция будет возвращать нулевой указатель.
bad_alloc
настолько сложно учитывать, что многие даже не пытаются это делать. Мотивация такая: если память закончилась, то всё равно программе делать уже нечего. Лучше поскорей вызвать std::terminate
и завершиться.
Заключение
В этой части мы разобрали, как создавать исключения C++, какие они бывают и как с ними работать. Разобрали ключевые слова try
, catch
и throw
.
В следующей части запустим бенчмарк, разберём гарантии безопасности, спецификации исключений, а также узнаем, когда нужны исключения, а когда можно обойтись без них. И главное — узнаем, как они работают.
Исключения не так просты, как кажутся на первый взгляд. Они нарушают естественный ход программы и кратно увеличивают количество возможных путей исполнения. Но без них ещё сложнее.
C++ позволяет выразительно обрабатывать исключения, он аккуратен при удалении всех объектов и освобождении ресурсов. Будьте аккуратны и вы, и тогда всё получится. Каждому исключению — по обработчику.
Исключения — это лишь одна из многих возможностей C++. Глубже погрузиться в язык и узнать больше о нём, его экосистеме и принципах программирования поможет курс «Разработчик C++».
19К открытий24К показов