Исключения в C++: безопасность, спецификации, бенчмарки
Разберём гарантии безопасности и выясним, как устроены исключения в C++, как они работают влияют на скорость выполнения программы.
6К открытий9К показов
Продолжаем разговор об исключениях в C++.
- Гарантии безопасности
- Спецификации исключений в C++
- Стоит ли избегать исключений
- Как работают исключения
- Заключение
- Полезные ссылки
Георгий Осипов
Один из авторов курса «Разработчик C++» в Яндекс Практикуме, разработчик в Лаборатории компьютерной графики и мультимедиа ВМК МГУ
В первой части мы разобрали, как создавать исключения и работать с ними, а также какими они бывают. Разобрали ключевые слова try
, catch
и throw
, синтаксис выбрасывания и обработки исключений, а ещё особые случаи, связанные с исключениями.
Вторая часть статьи больше подойдёт продвинутым программистам, которые хотят глубже разобраться в теме исключений. Однако никаких специальных знаний не требуется. Во второй части мы разберём:
- гарантии безопасности;
- спецификации исключений;
- как исключения влияют на скорость выполнения;
- как устроены исключения в C++ и как они работают.
Также рассмотрим философский вопрос о нужности исключений и альтернативных подходах, запустим три бенчмарка и в результате увидим, что иногда исключения не только не замедляют программу, а даже ускоряют.
Углубить и систематизировать знания C++ поможет курс «Разработчик C++» в Яндекс Практикуме. Для тех, кто знает C++, но желает изучить работу по сети, Docker, Linux и множество вспомогательных инструментов, есть курс «C++ для бэкенда».
Гарантии безопасности
Исключения — ситуация нештатная. Но они существуют для того, чтобы как-то обработать эту ситуацию и продолжить выполнение программы.
Чтобы было ясно, как исключительная ситуация может повлиять на работу, существует специальное понятие — гарантия безопасности исключений. Это описание вреда, который может нанести исключение, приводимое в документации к функции или методу.
Выделяют четыре уровня безопасности исключений:
- Гарантия отсутствия исключения. Самая сильная гарантия. Означает, что исключение возникнуть не может, а значит, ничего не сломает.
- Строгая гарантия безопасности. Исключение может возникнуть, но в этом случае всё будет возвращено к тому, как было до вызова соответствующей функции. Иными словами, операция не удалась, но можно сделать вид, как будто её и не было.
- Базовая гарантия безопасности. При возникновении ошибки мы не можем вернуть всё как было, но всё равно останемся в корректном состоянии. Все инварианты будут сохранены, все ненужные ресурсы освобождены, ничего не утечёт. С объектом, из-за которого возникло исключение, можно продолжать работать.
- Отсутствие безопасности. Не можем гарантировать ничего. Если исключение произошло, лучше поскорее завершить работу программы.
Рассмотрим пример. Напишем собственную операцию push_back
для вставки в вектор.
Гарантия отсутствия безопасности
Вызов new
не опасен в отличие от перемещения элементов: мы ничего не знаем про конструктор перемещения неизвестного объекта T
. Он может выбрасывать исключение.
Эта реализация метода не даёт гарантий. Если во время перемещения объектов возникло исключение, то мы как минимум получим утечку памяти. Кроме того, у вектора будет неправильный размер, например, если перемещение прервалось на середине.
В примере мы использовали new
для простоты и наглядности. Реальный вектор должен выделять сырую память без инициализации.
Базовая гарантия безопасности
Улучшим нашу функцию, чтобы подняться с четвёртого уровня безопасности на третий. Это минимальный уровень, который допустимо использовать в программах.
Уже лучше: размер вектора будет корректен, и мы не допустим утечки. Однако такая вставка может привести, например, к обнулению вектора, если исключение возникло в самом первом перемещении.
Строгая гарантия безопасности
Над строгой гарантией нужно потрудиться.
У строгой гарантии есть неприятный эффект: нам пришлось отказаться от перемещений в пользу копирований. Таким образом, она отрицательно влияет на эффективность.
Можно сделать всё эффективно, если есть уверенность, что перемещение объектов типа T
не выбрасывает исключений. Стандартный std::vector
так и делает.
Гарантия отсутствия исключения
И наконец, достигнем вершины — напишем метод без исключений:
Возврат флага успеха — альтернатива исключениям. Функция очень похожа на предыдущий вариант. Однако при такой реализации мы ничего не сможем узнать о том, какая именно ошибка произошла.
Спецификации исключений в C++
Такой разный noexcept
Как говорилось выше, вставка в вектор может работать эффективнее, если есть гарантия, что перемещение объекта не выбрасывает исключений. Такую гарантию можно дать для произвольной функции, если пометить её словом noexcept
:
Если же мы хотим явно сказать, что функция выбрасывает исключение, можно написать noexcept(false)
:
Но это лишнее, ведь выбрасывающими по умолчанию считаются все функции, кроме деструкторов. Зато можно поместить внутрь скобок содержательное выражение времени компиляции:
Тут мы явно написали, какая функция noexcept
, а какая — нет, хотя могли бы вычислить. noexcept
допустимо использовать как операцию, определяющую, может ли выбрасывать содержимое скобок:
Вывод программы будет таким:
Заметьте, что мы пишем noexcept(g())
, а не noexcept(g)
. Последнее выражение всегда false
, поскольку само по себе выражение g
ничего не делает, а значит, ничего не выбрасывает.
Если в функции, помеченной как noexcept
, всё же возникло исключение, то оно приведёт к вызову std::terminate
и завершению программы.
А нужен ли noexcept?
Вряд ли что-то может ответить на поставленный вопрос красноречивее бенчмарка. В этом бенчмарке, который мы провели в сервисе QuickBench, созданы три практически одинаковых класса:
F_except
, у которого есть обычный конструктор перемещения;
F_noexcept
, у которого конструктор перемещения помечен какnoexcept
;F_noexcept2
— какF_noexcept
, но с деструктором, помеченным какnoexcept(false)
. Деструктор нужно явно помечать какnoexcept(false)
, иначе он считается невыбрасывающим.
Объекты этих классов добавляются в std::vector
. Его метод push_back
обеспечивает строгую гарантию исключений. Как вы уже знаете, в этом случае перемещение вектора возможно только тогда, когда перемещение его элементов не выбрасывает исключений.
Отличие заметное, хотя не такое существенное из-за оптимизаций в std::vector
. noexcept
позволил выбрать более эффективный алгоритм, но только в том случае, когда он применялся и для конструктора перемещения, и для деструктора.
Волшебный default
Рассмотрим три класса:
Казалось бы, разницы между ними нет, но попробуем изучить конструкцию T*()
на предмет выбрасывания исключений:
В этом примере мы также добавили четвёртый класс. Вывод программы такой:
Нас подвёл только один вариант, в котором конструктор объявлен как T2() {}
. Такой конструктор будет считаться выбрасывающим, хотя на вид он аналогичен записи T2() = default
.
Невыбрасывающий конструктор часто позволяет получить более эффективный код и применить больше оптимизаций. В легковесных классах он вообще может не генерировать никаких инструкций, в то время как выбрасывающий потребует лишней работы. Предпочитайте конструкцию = default
пустым скобкам.
Список исключений
В некоторых языках программирования c исключениями всё строже. Каждая функция снабжается списком исключений, которые она может выбрасывать. Была такая попытка и в C++ с самого момента его стандартизации:
У такого подхода есть преимущества:
- компилятор может убедиться, что все исключения обрабатываются;
- вы видите, что может, а что не может выбрасывать функция по её сигнатуре;
- обработчик можно искать в compile-time.
Однако на деле не всё так радужно. Всплыл ряд недостатков:
- Некоторые исключения, например,
std::bad_alloc
может выбрасывать почти любая функция. Везде писатьthrow(std::bad_alloc)
утомительно. - Непонятно, как быть с указателями на функции и
std::function
. Если мы хотим заранее всё знать об исключениях, то спецификации исключений должны быть в типе функции. Тогда неясно, как их преобразовывать. - Можно сделать спецификации нестрогими, но тогда непонятно, что они дают.
С похожими недостатками сталкиваются и в других языках программирования. В C++ недостатки перевесили, и комитет по стандартизации решил от явных спецификаций отказаться, оставляя только noexcept
. В Стандарте C++11 они объявлены устаревшими (deprecated), а позже вовсе исключены из языка.
Стоит ли избегать исключений
«Мы не используем исключения в C++», — cтайлгайд Google.
Не нужны или незаменимы
Исключения в C++ — удобный инструмент, не лишённый недостатков. Может быть, самый существенный из них — в том, что исключения прозрачны. Глядя на сигнатуру функции, нельзя понять, какие исключения она выбрасывает. Это можно понять, глядя в документацию. Но документация не проверяется автоматически, а значит, может ошибаться.
Код с исключениями иногда трудней читать и модифицировать. Может, где-то через функцию пролетает исключение, задуманное разработчиком, но узнать об этом, глядя на код, невозможно. Вы модифицируете функцию, внося, казалось бы, несущественные изменения, но тем самым нарушаете гарантии безопасности.
Вспомните пример с четырьмя реализациями push_back
. Программисту, который не знал нашей мотивации — обеспечение гарантий безопасности, — будет совершенно неочевидно, почему мы обновили поле mem
в начале функции, а не в другом месте. Ему покажется это прихотью, и он изменит способ на противоположный — просто для красоты кода. При этом корректная обработка исключений перестанет работать.
Но если пользоваться исключениями аккуратно, то данного недостатка можно избежать.
В некоторых местах без исключений трудно: например, это единственный способ прервать конструктор. Можно, конечно, ввести для объекта невалидное состояние, указывающее, что во время конструктора произошла ошибка. Но это будет усложнением класса.
Помимо конструкторов исключения практически незаменимы, когда вы делаете много похожих действий, каждое из которых может завершиться неудачей:
Без исключений только первая строка блока try
записывалась бы так:
При этом мы опустили вывод диагностики о местоположении ошибки и её сути. Конечно, можно было придумать сложный прокси-объект, который ведёт себя как JSON, но на самом деле находится в ошибочном состоянии и не выполняет никаких действий, и тем самым сократить количество строк.
Но решайте сами, следовать ли стайлгайду Google в этой части. Ещё один аргумент в пользу исключений в C++ вы найдёте ниже.
Строгий запрет
Некоторые компании и программисты предпочитают вообще не использовать исключения. Об этом лучше сказать компилятору специальной опцией. Это развяжет оптимизатору руки и позволит генерировать более простой и эффективный код в очень многих случаях.
Для GCC и clang это опция -fno-exceptions
. Её использование не значит, что исключений не возникнет. В частности, никто не может избавить вас от std::bad_alloc
и других исключений, выбрасываемых из библиотек. Однако чаще всего исключение будет приводить к вызову std::terminate
.
Если на радикальные меры идти не хочется, ваш друг — noexcept
. Он работает не хуже, чем -fno-exceptions
, если его поставить везде, где нужно. noexcept
также хорошо помогает оптимизатору и убирает оверхед при вызове функций, помеченных этим ключевым словом.
Назад в будущее
У исключений есть альтернатива, известная ещё корифеям, — возврат флага или ошибки из функции. Если вы пишете процедуру, то проще всего вернуть флаг успеха:
Чтобы компилятор проверял, что вызывающая функция точно обрабатывает ошибку, добавим [[nodiscard]]
:
Подобного сервиса — проверки, что ошибка обрабатывается, — нет даже у исключений. Если булевого флага недостаточно, функция может возвращать объект ошибки, содержащий информацию о её причинах. В этом случае [[nodiscard]]
можно поставить прямо в класс:
Когда функция должна сама по себе вернуть значение, такой способ не подходит. Решение есть — специальный тип std::expected
, но он будет добавлен в язык только в 2023 году. Предполагается, что этот объект будет хранить либо возвращённое значение, либо код ошибки, когда операция не удалась.
Универсальный солдат
Если ждать до 2023-го не хочется, можно реализовать expected
самостоятельно или использовать простую замену: передать функции объект ошибки по ссылке, предлагая записать в него информацию.
Это самый универсальный и удобный способ для пользователя функции. Так мы предлагаем ему выбор: хочет ли он ловить исключение или предпочитает обрабатывать объект ошибки. Такой способ предусмотрен во многих библиотеках, в том числе в std. Рассмотрим примеры.
- std::filesystem. Функции работы с файловой системой часто могут завершаться с ошибкой. Например, вы хотите переименовать файл, но его только что удалили. В этом и подобных случаях выбрасывается
std::filesystem::filesystem_error
, подклассstd::system_error
, который в свою очередь расширяет подклассstd::runtime_error
иstd::exception
.
Но если вы указали дополнительный параметр типаstd::error_code&
, то исключение не будет выброшено — вместо этого ошибка запишется в переданный объект:std::error_code ec;std::filesystem::rename(old_path, new_path, ec);if (!ec) { std::cerr << “Ошибка переименования” << std::endl;} - Потоки ввода-вывода. Для них используется немного другой механизм. По умолчанию потоки не выбрасывают, и вы вынуждены проверять все операции на корректность.
Если вызвать метод потокаexceptions
, то можно попросить поток сигнализировать при появлении флагов ошибок. Будет выбрасываться исключениеstd::ios_base::failure
(также подвидstd::system_error
). У метода есть эффект, даже если он вызван уже после возникновения ошибки:#include #include int main(){ std::ifstream f(“abracadabra”); try { f.exceptions(f.failbit); } catch (const std::ios_base::failure& e) { std::cout << “Ошибка потока: ” << e.what() << ‘\n’ << “Код: ” << e.code() << ‘\n’; }}Передавать в метод exceptions флаг eofbit не рекомендуется. Достижение конца файла — штатная, а не исключительная ситуация. Последующие чтения всё равно установят флаг fail.
А что со скоростью?
Бытует мнение, что исключения — это всегда медленно. Ведь у всех этих конструкций try
наверняка большой оверхед. Даже если try
-блоков нет, компилятор всё равно должен быть в любой момент готов обработать исключение при вызове любой функции, не помеченной как noexcept
.
Проверим, так ли это. Лучший способ — бенчмарк. Будем измерять две функции, выполняющие похожую работу:
void f()
— не помечена какnoexcept
. Рекурсивно вызывает себя. После 10 миллионов вызовов выбрасывает исключение.bool g()
— помечена какnoexcept
. Рекурсивно вызывает себя. После 10 миллионов вызовов возвращаетfalse,
и цепочка вызовов прерывается.
Результат во многом зависит от компилятора, оптимизаций и наличия у функций атрибута noinline
. Но в большинстве случаев версия с исключениями по крайней мере не хуже. Вот бенчмарк, где версия с исключениями работает в полтора раза быстрее. При использовании clang преимущество скромнее — всего 10%.
Может показаться, что пример искусственный. В какой-то степени так и есть, но это не меняет тенденции. Без исключений в C++ нам нужно было проверять каждый результат возврата, а это дорогостоящая инструкция условного перехода. Трудно поверить, но при использовании исключений такая инструкция при вызове не требуется. Иногда это может дать неплохой выигрыш.
Сравним дизассемблер. Для наглядности добавим больше рекурсивных вызовов в каждую функцию. Так выглядит вариант с исключениями:
А вот начало функции с проверками. Не будем приводить код полностью:
Неудивительно, что исключения в этом случае победили. Однако не нужно думать, что так будет всегда. Главный минус исключений с точки зрения производительности — в том, что они мешают оптимизации.
Профессионалы советуют: везде, где можно, ставьте noexcept
. Иначе производительность может сильно пострадать. Это причина, почему многие крупные компании вообще не используют исключения.
Как работают исключения
Исключения в компиляторах можно реализовать по-разному. Реализации стремятся к нулевому оверхеду, который формулируется так:
Мы не должны платить производительностью до тех пор, пока исключение не выбрасывается. Код, в котором исключение не произошло, должен работать так же быстро, как если бы исключение не могло произойти.
Современные компиляторы (а вернее, генераторы кода) в полной мере этому принципу не следуют, в чём можно убедиться, изучив ассемблерный код, который они выдают. Но возникающий оверхед минимальный, а где-то отсутствует вовсе. Подумаем, что обязан делать процессор для того, чтобы обеспечить возможность исключений.
Посадочные площадки
Исключение может возникнуть практически где угодно, а именно при любом вызове функции, не помеченной как noexcept
. Во всех этих случаях компилятор должен знать, что ему делать дальше. Рассмотрим две строчки:
Они могут прерваться в девяти местах:
- два раза — при конвертации
const char* в std::string
; возможное исключениеbad_alloc
; - три раза — при сложении строк; возможное исключение
bad_alloc
; - один раз — в
std::to_string
; возможное исключениеbad_alloc
; - один раз — в
std::vector::at
; возможное исключениеstd::out_of_range
; - два раза — в
std::ostream::operator <<
; возможное исключениеstd::ios_base::failure
. Даже если мы не устанавливали для потока флаг выброса исключений, компилятор всё равно должен быть готов к такому повороту событий — операция<<
не помечена какnoexcept
.
Для этих девяти мест компилятор должен предусмотреть пути отхода. А именно установить, какие временные объекты он должен удалить в каждом случае и что делать дальше. Для этого в бинарном коде программы создаётся посадочная площадка (landing pad). Процессор приземляется на ней только в случае исключения.
Вызов функции обычно превращается в процессорную инструкцию call
. При этом в стек кладётся адрес места, куда нужно вернуться после инструкции ret
, завершающей вызов. Если вызываемая функция помечена как noexcept
, то этого достаточно. Если нет, процессору нужно знать, куда идти, если в процессе выполнения вызова произошло непойманное исключение. Он идёт на посадочную площадку.
Таким образом для возможности обработки исключений каждый вызов требует максимум одну дополнительную инструкцию — запоминание адреса экстренной посадочной площадки. Сущая мелочь.
Выбор обработчика
Мы так увлеклись оверхедом, что совсем забыли: исключения нужны, чтобы их ловить. А ловят их обработчики. Чтобы исключение нашло свой обработчик, при выполнении raise
нужно сохранить информацию о типе. Причём эта информация будет обрабатываться во время выполнения программы (run-time).
В C++ для этого есть свой механизм — динамическая идентификация типа данных, или RTTI. Чтобы мы могли поймать исключение по базовому типу, в run-time должна быть доступна информация обо всех предках выброшенного исключения.
Таким образом, raise
делает следующее:
- сохраняет объект исключения;
- сохраняет информацию о типе;
- возможно, сохраняет что-то ещё (например, ссылку на деструктор объекта исключения);
- если надо, запускает
terminate
; - если не надо, запускает раскрутку стека.
Раскрутка стека делает следующее:
- приземляется на посадочную площадку;
- удаляет временные и автоматические объекты;
- проверяет все доступные обработчики на соответствие типа;
- если надо, запускает
terminate
; - если не надо (и обработчик не найден), продолжает раскрутку стека.
Обработчик делает следующее:
- выполняет пользовательский код;
- если исключение не переброшено, то деаллоцирует его.
И снова бенчмарк
Чтобы проверить, что информация о типе действительно обрабатывается в run-time, проведём ещё один любопытный бенчмарк. Создадим тип F
очень простого вида:
А также шаблон template<int> struct G
. Его особенность — в том, что у типа G<0>
очень много потомков, 2048 штук. В функции f
всё просто: будем бросать исключение типа F
и затем ловить исключение типа F
. В функции g
будем бросать исключение типа G<0>
, а ловить по типу одного из его многочисленных потомков — G<1111>
.
Если наше предположение верно, то программе потребуется гораздо меньше времени для поиска обработчика исключения типа F
. Напротив, чтобы убедиться, что обработчик типа G<1111>
соответствует выброшенному исключению типа G<0>
, придётся потрудиться.
Запустим бенчмарк и убедимся в своей правоте:
Поимка объекта по глубокому производному классу заняла в 32 раза больше времени. Таким образом мы убедились, что проверка исключения на соответствие типу действительно происходит в run-time.
Мы рассмотрели подкапотную исключений и увидели, что часть важной работы происходит в run-time, что несвойственно для C++. Может быть, это даже не очень эффективно, но, как было сказано выше, при обработке исключений эффективность отодвигается на второй план. Ведь это исключительная ситуация, и в том, чтобы потратить несколько десятков тысяч лишних инструкций, нет ничего страшного.
Заключение
Мы проделали большую работу. Разобрались в тонкостях исключений в C++, гарантиях, спецификациях. Узнали, что, вопреки распространённому мнению, исключения — не всегда медленно.
Да, исключения мешают оптимизатору, но если не забыть noexcept
для критически важных функций, то этот эффект сведётся к минимуму.
Некоторые компании избегают исключений в своём коде. Но только вам решать, отказываться от них или нет. При умелом использовании исключения не принесут вреда, а польза может быть огромна. В одном можете быть уверены: после прочтения этой статьи умелое использование исключений — в ваших руках.
Полезные ссылки
- Курс «Разработчик C++» в Яндекс Практикуме
- Дополнительный курс «C++ для бэкенда» в Яндекс Практикуме
- Блок try в энциклопедии cppreference
- Конструкция throw в энциклопедии cppreference
- Спецификации исключений в энциклопедии cppreference
- Стандартные типы исключений в энциклопедии cppreference
- Доклад «Исключения C++ через призму компиляторных оптимизаций» на конференции C++ Russia 2019 Piter
- Сервис микробенчмарков Quick C++ Benchmark
- Раздел «Исключения» стайлгайда Google
6К открытий9К показов