Исключения C++ — это мощный и элегантный способ обработки вещей, которые не должны происходить в вашем коде; и все же по прошествии примерно 30 лет с момента их появления они считаются противоречивыми, вплоть до того, что Руководство по стилю Google советует не использовать их без каких-либо… исключений.

В этой статье я выступаю за правильное использование исключений C++.

Но прежде всего я хочу сразу же отмести аргумент против использования исключений C++, который я слышу слишком часто и который не способствует разговору.

Исключения C++ легко использовать не по назначению.

Да, они. Как и любая мощная функция, предлагаемая каждым языком программирования, написанным поверх ассемблера, исключения C++ легко использовать неправильно. Вот почему инженеры-программисты несут ответственность за приобретение необходимого опыта и овладение наиболее подходящим инструментом для решения поставленной задачи.

Качественное ремесло не имеет смысла обвинять инструменты.

Почему я люблю исключения

Есть три основные причины, по которым мне нравится использовать исключения C++ в качестве инструментов обработки ошибок. Не в каком-то конкретном порядке, они:

  • Избавляя основную логику от необходимости рассматривать каждую возможную ошибку на месте, они повышают общую читабельность и производительность.
  • Разделяя код обработки ошибок, они повышают согласованность, разделение задач и гибкость пользовательского кода.
  • Предоставляя стандартизированный способ выражения условий ошибки, они уменьшают количество знаний, специфичных для контекста, необходимых для понимания кода.

У исключений также есть подводные камни, которые необходимо устранить (даже если они используются правильно):

  • После введения исключений непросто, а иногда и невозможно узнать, какие операции могут привести к досрочному завершению потока кода.
  • Точно так же может быть трудно или невозможно определить, какие исключения могут быть вызваны определенным кодом и, следовательно, какие из них следует обрабатывать.
  • Неперехваченные исключения являются скрытыми утверждениями. Они предназначены для обработки, но на самом деле они завершают основную программу.

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

Исключения ускоряют код

При обнаружении состояния ошибки генерация исключений происходит на много порядков медленнее, чем возврат кода ошибки. Согласно простым измерениям, они могут быть более чем в 100 раз медленнее. Однако эта стоимость оплачивается только при фактическом создании исключения, что не должно происходить очень часто. В обмен на это стоимость, оплачиваемая в неисключительном случае, равна нулю, а стоимость возврата и проверки кода состояния всегда оплачивается.

Рассмотрим следующий пример:

В строке 13 и далее вызовы change() выполняются без проблем. Передав этот код ассемблеру, легко увидеть, что здесь нет условного перехода, а вход в предложение catch{} осуществляется напрямую путем установки указателя инструкции на этот адрес только тогда, когда действительно генерируется исключение.

И наоборот, посмотрите на ту же программу, работающую с кодом ошибки:

Теперь каждый раз, когда мы вызываем change(), нам нужно возвращать что-то, в чем раньше не было необходимости, а затем проверять, не является ли это ошибкой. Это требуется всегда, независимо от того, насколько спорадической может быть проблема, обнаруженная в change() — например, обратите внимание, что в приведенном выше случае, если мы предоставляем предварительно проверенные входные данные, исключение может никогда не возникать во время весь срок действия программы.

В современных ЦП стоимость ветвления намного выше, чем время, затрачиваемое на обработку фактического CMP/JMP или эквивалентных кодов операций. Ветвление предотвращает оптимизацию микрокода и стратегии предиктивного выполнения, которые делают современные процессоры намного быстрее, чем старые, как показано в этом видео:

Таким образом, отказываясь от исключений, вы также отказываетесь от возможностей оптимизации, предлагаемых большой частью вашей кодовой базы, которая не имеет ответвлений.

Читабельность

Два предыдущих фрагмента также показывают, что код, использующий исключения, объективно более удобочитаем, чем код, использующий коды ошибок. Хотя конкретный пример можно подвергнуть рефакторингу, чтобы сделать его немного лучше и избежать повторений, на практике обычно необходимо иметь пользовательские фрагменты кода управления ошибками прямо в том месте, где обнаруживается каждая отдельная ошибка.

В соответствии с принципом минимальной незавершенности необходимо открыть контекст, в котором нормальный поток текущей обработки приостанавливается для обработки ошибки, а затем необходимо возобновить предыдущий логический поток после устранения ошибки ( например, в строке 21) создает области большей неполноты в коде, что делает его количественно менее читабельным.

Гибкость

Отделяя задачи, связанные с потоком программы, от управления ошибками, исключения повышают связность пользовательского кода. В первом примере строки с 13 по 16 полностью посвящены ходу программы, а все управление ошибками делегировано пункту catch{}; и наоборот, в simple_error_code.cpp тело checkAndMerge needs должно иметь дело с управлением ошибками во всем теле.

Любое изменение структуры ошибки (т. е. изменение имени символов кода ошибки) или ее представления (т. е. способа ее сообщения) потребует более значительных изменений в файле simple_error_code.cpp, что означает, что его код менее гибкий. по определению.

Это еще более очевидно, когда функция, через которую проходит ошибка, не имеет контекста, чтобы знать, как ею управлять. В следующем примере changeAndMerge больше не может решать, что делать при обнаружении ошибки; функция на более высоком уровне решает сохранить статус ошибки в транзитном объекте, который является конечным получателем операций, которые changeAndMerge хочет выполнить.

Как видите, changeAndMerge (в строке 11) совершенно не зависит ни от того, что происходит в change(), ни от того, что будет сделано для управления потенциальными ошибками. Все эти знания хранятся в updateContext(), которая знает, что делать с правильным результатом (строка 20) и управляет любой ошибкой, которая может возникнуть в процессе (строка 24).

Любые изменения в том, как генерируется ошибка в change(), или в том, как она обрабатывается в updateContext(), окажут минимальное влияние на код, что сделает его максимально гибким.

В отличие от этого, рассмотрим этот фрагмент кода, где changeAndMerge() должен обрабатывать любую ошибку, которую может создать change(), а updateContext() знает только об ошибках, которые могут быть сгенерированы changeAndMerge().

Здесь управление ошибками полностью перемешано с обычным потоком. Любое изменение в changeAndMerge() требует знания того, как updateContext() хочет обрабатывать свой код возврата, и как change() будет сообщать об ошибках. Помимо максимальной неполноты, это также снижает гибкость кода: поскольку задача обработки ошибок и задача фактического выполнения обычных операций не разделены, любое изменение общей логики управления ошибками требует переделки большей ее части.

Что еще хуже, даже изменения в обычном потоке операций теперь стали более обременительными. Что, если мы введем еще один вызов, который может завершиться ошибкой после строки 36? Должны ли мы добавить другой код возврата для changeAndMerge()? Потребуется ли updateContext() для выполнения дополнительных операций после строки 49?

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

Сплоченность в обработке исключений

Исключения предназначены для сбора всей информации, необходимой для устранения и/или сообщения об ошибке в том месте, где она обнаружена.

  • Код обнаружения имеет весь необходимый контекст для хранения любой необходимой информации.
  • Код управления имеет весь контекст, необходимый для устранения исключительной ситуации.
  • Две области кода взаимодействуют через стандартизированный интерфейс, сам объект исключения, который разрывает любую связь между ними.

Напротив, возврат кода ошибки требует, чтобы вызывающая сторона использовала свои собственные знания о контексте, в котором произошла ошибка; это означает, что ошибка должна быть устранена немедленно на месте вызывающего абонента. Это становится проблемой, когда вызывающая сторона не имеет контекста для фактического устранения ошибки. В этот момент вызывающая сторона должна передать расширенный контекст логике программы более высокого уровня; в конце концов, вся логическая цепочка от самого верхнего уровня до места обнаружения ошибки требует некоторого знания окружающего контекста как в восходящем, так и в нисходящем направлениях, чтобы эффективно справляться с соответствующими условиями ошибки.

Способ разрешения этой ситуации без механизма генерации исключений заключается в возвращении объекта result, который инкапсулирует либо желаемый вывод, либо объект ошибки; он становится протоколом, посредством которого код обнаружения и код обработки могут обмениваться данными.

Однако эта модель усугубляет некоторые недостатки управления ошибками за счет возвращаемых кодов ошибок. Например, см. следующую минимальную реализацию управления ошибками на основе результатов.

Хотя это решение позволяет добавлять любую соответствующую информацию к объекту ошибки (здесь я все еще использую числовой код для простоты), и это может быть отделено от контекста, в котором была обнаружена ошибка, мы фактически усугубили наши проблемы:

  • Возврат безошибочного результата по обычному пути обходится дороже, чем раньше.
  • Проверка возвращаемого значения на наличие ошибок также стала более затратной из-за еще большего количества ветвлений.
  • Использование возвращенного объекта без ошибок потребует, по крайней мере, дополнительной ветки и разыменования памяти, что может вызвать дополнительные промахи в кэше, что еще больше поставит под угрозу возможность оптимизации во время выполнения.
  • При использовании нетривиального объекта для хранения контекста ошибки преимущество возврата структуры Result по сравнению с использованием оператора throw становится незначительным.

Но худшая проблема, которую я вижу в этом решении, заключается в том, что его нельзя использовать для разрыва связей между вызывающим и вызываемым в отношении управления ошибками. Если мы не можем гарантировать, что во всей программе используется один тип ошибки (т. е. полное удаление шаблонного типа ‹E› в объявлении Result), каждой нетривиальной функции потребуется переопределить тип ошибки, который она хочет вернуть вызывающей стороне. , и вызывающая сторона должна будет понять это и, возможно, повторно инкапсулировать в своем собственном состоянии ошибки.

Короче говоря, даже это решение, которое, на первый взгляд, возвращает некоторую гибкость, в конечном итоге делает программу более связанной и, следовательно, более жесткой.

Как избежать ловушек

Исключения C++ — мощный инструмент для обработки непредвиденных ситуаций в программах, но, как и все мощные инструменты, их использование требует особой осторожности.

Использовать только исключения… как исключение

Это самое важное правило, касающееся исключений C++: они должны генерироваться редко. В идеале, во время обычного запуска хорошо сформированная программа с обработанными входными данными и стабильной рабочей средой никогда не должна иметь необходимости в фактическом создании исключений.

Эмпирическое правило заключается в том, что исключения C++ следует использовать только в том случае, если ожидается сбой менее одной операции из тысячи или когда выполнение программы является медленным по замыслу (т. е. оно реагирует на ввод пользовательского интерфейса пользователем).

Более формально, исключения C++ должны использоваться только для обработки непредвиденных условий, где определение «неожиданности» должно быть четко установлено на более высоком уровне логики программы.

Например, представьте сканер файлов или сетевых адресов, который обнаружит существующие цели, идентифицируя успешную операцию после многих попыток, которые, как ожидается, потерпят неудачу. В этом случае сбои являются не ошибками, а ожидаемыми результатами, которые должны обрабатываться на самом низком возможном логическом уровне, прямо там, где выполняется операция ввода-вывода.

И наоборот, представьте себе случай, когда программа пытается открыть файл или сетевой адрес, указанный в качестве параметра командной строки или в конфигурации. Затем мы делаем ожидаем успешного завершения операции, и если этого не сделать, требуется, чтобы высшая программная логика отвечала за то, как справиться с проблемой.

В первом случае исключения C++ будут только мешать; во-вторых, они обеспечивают чистый и мощный метод, чтобы вернуть основную логику во главе, когда программа сталкивается с экстремальной ситуацией.

На самом деле это причина того, что стандартная библиотека ввода-вывода имеет необязательные исключения. Пользователь может решить, должны ли сбои в определенном потоке возвращать код ошибки или вызывать исключение; это связано с тем, что тот факт, что ошибка в потоке является неожиданным условием, зависит только от того, что ожидает пользовательский код, и неизвестно заранее разработчику библиотеки.

Всегда ожидайте неожиданного

После того, как исключения введены в кодовую базу, пользовательский код всегда должен быть готов либо перехватить их, либо быть прерванным в любое время.

Это означает, что такие программы должны гарантировать, что инварианты всегда соблюдаются, а все состояния сущностей всегда непротиворечивы, или, в случае, если им нужно временно нарушить инварианты и перевести сущности в неопределенное состояние, они должны принять меры для предотвращения прерывания процесса.

Хотя это ловушка при наивном использовании исключений, я не считаю это недостатком. Написание программ, которые соблюдают этот контракт, неизменно лучше, чем его невыполнение. Это усилия по проектированию, которые приводят к более надежным, гибким и читаемым результатам. После освоения это бескомпромиссный дизайн, где превосходное качество приводит к более высокой производительности и сокращению общего времени разработки.

Рассмотрим следующий пример:

Функция doThings() не защищена от исключений; если операция в строке 9 завершается неудачно, результат makeFirst() будет просочиться.

Правильный подход в современном C++ заключается в использовании типов, которые автоматически управляют своей областью действия; это называется идиомой выделение ресурсов — это инициализация (RAII). Например:

Теперь doThings() не просто защищен от исключений; если она когда-либо станет намного большей функцией, из нее можно будет безопасно вернуться в любой момент, не отслеживая текущий статус firstData и secondData. Для принципа минимальной незавершенности это более читаемое решение, а также более гибкое и менее подверженное ошибкам, чем любой код, который зависит от того, не прерывается ли он в какой-либо точке.

Знайте свои исключения

Одним из критических замечаний к системе исключений C++ является то, что становится невозможно узнать, какое исключение может быть выброшено какой-либо функцией очень быстро, независимо от того, насколько тщательно задокументированы возможные исключения. Таким образом, невозможно знать, что и где ловить, чтобы сделать кусок кода свободным от исключений.

Эта критика полностью упускает из виду суть управления ошибками с помощью системы исключений C++.

Код обработки ошибок должен обрабатывать не какие-либо исключения, которые может вызвать управляемый код, а только и все исключения, которые он знает, как обрабатывать, независимо от того, может ли управляемый код на самом деле выбрасывать эти исключения или нет.

Это потому что:

  1. Управляемый код может не генерировать определенное исключение сейчас, но может сделать это в будущем. Важно, что, если исключение имеет значение в контексте менеджера, то, что с ним делать, уже определено на ранней стадии разработки, еще до того, как пользовательский код получит шанс фактически столкнуться с этим условием.
  2. Современный C++ — гораздо более динамичный язык, чем был в прошлом. Благодаря простым в использовании функциональным расширениям, таким как std::function и лямбда-выражения, становится все более возможным запускать произвольный код на любом уровне логики. Таким образом, программы на C++ должны быть спроектированы так, чтобы быть устойчивыми к неожиданному поведению, и не полагаться на код более глубокого уровня, чтобы иметь определенные ограничения, если только в этом направлении не предпринимаются явные действия.
  3. По своему замыслу система исключений C++ предназначена для инкапсуляции всего контекста, необходимого для того, чтобы узнать, что пошло не так в произвольной точке выполнения, и доставить эту информацию любому действующему лицу, заинтересованному в решении проблемы, или завершить программу, если никто не может. Обработчики исключений должны быть написаны в соответствии с этим дизайном, а не навязывать ему другие модели.

Неперехваченные исключения — это скрытые утверждения

Немного более изощренная критика системы исключений C++ расширяет предыдущий пункт: поскольку невозможно определить, какие исключения могут быть вызваны произвольным участком кода в программе, вполне вероятно, что неперехваченное исключение приведет к неожиданному завершению программы.

Опять же, эта критика справедлива только против очень наивного использования системы; язык и стандартная библиотека предоставляют несколько инструментов, которые предотвращают возникновение проблем.

Первое эмпирическое правило заключается в том, что все определяемые пользователем исключения должны быть производными от std::exception или std::runtime_error, когда это уместно.

Класс std::exception предоставляет виртуальный метод what(), который дает любой пользовательской системе ошибок возможность отображать понятное человеку описание. Таким образом, любой код, которому необходимо обработать любую возможную ошибку, может вернуться к перехвату std::exception, если все остальное не работает, и выдать разумное предупреждение для чтения человеком, так что программа может быть исправлена, чтобы обработайте ошибку правильно.

Если код, который пишется, не является частью библиотеки, но предназначен для использования только в конкретном приложении, также можно создать совершенно отдельную иерархию классов исключений; перехват std::exception предоставляется для перехвата любого стандартного конкретного исключения, в то время как перехват базового класса ошибок для конкретного приложения предоставляется для управления ошибками для конкретного приложения.

В качестве последней линии защиты от недисциплинированного стороннего кода, который может генерировать исключения, не соответствующие каким-либо требованиям к дизайну, стандартная библиотека предоставляет std::current_exception некоторую информацию о любом объекте, пойманном последней защитой канавы, предложением catch(…).

Не то чтобы вы всегда должны ловить любое возможное исключение. Сбой программы из-за неперехваченного исключения не является ошибкой; это особенность. У вас есть возможность поймать что-либо на самом высоком логическом уровне приложения (т. е. в main() или в первом месте, где вы можете сообщить о фатальной ошибке через установленную систему регистрации или оповещения), но подходит ли вам этот вариант или нет, зависит от контекст.

В некоторых случаях может быть лучшим вариантом проектирования, чтобы исключение не было перехвачено и чтобы стандартная библиотека C++ печатала значимое сообщение о завершении, или наблюдала за развитием исключения с помощью отладки post mortem.

Является ли надежность программы настолько важной, что она никогда не должна выходить из строя, какой бы ужасной ни была неожиданная ситуация? Или точность результата настолько важна, что лучше остановить программу, столкнувшуюся с непредвиденной ситуацией, и разрешить ситуацию человеку-наблюдателю?

Система исключений C++ предоставляет разработчикам инструменты, позволяющие решить, какое решение для них лучше.

Исключения полезны для вас?

Исключения C++ — отличное средство для обработки исключительных условий, возникающих во время выполнения программы.

Они также являются частью спецификации языка и стандартной библиотеки: они разработаны как единственный правильный способ обработки сбоев при создании объекта и сообщения о других критических проблемах, с которыми сталкиваются стандартные операторы (как new) и библиотечные функции.

Таким образом, вы должны уже помнить об исключениях и предпринимать все необходимые шаги для написания кода, защищенного от исключений.

По этой причине добавление доменных исключений является не только естественным, но и предпочтительным способом обработки непредвиденных ситуаций в C++.

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

Не все ошибки являются исключениями. В некоторых случаях мы ожидаем возникновения ошибок; вот где не следует использовать исключения C++, и нужна другая, более локализованная и прямая обработка ошибок.

Соображения производительности также играют роль: независимо от того, ожидаем ли возникновения определенной ошибки или нет, если она возникает более чем в 0,1 % случаев, механизм, необходимый для обработки исключений C++, может оказаться слишком дорогим.

Поскольку не всегда возможно узнать, считается ли определенное состояние ошибки исключительным или нет, может также потребоваться предоставить пользовательскому коду возможность выбора генерировать исключение или нет, как в случае со стандартной библиотекой ввода-вывода.

Но каким бы ни был выбор, исключения C++ — мощная, неотъемлемая часть языка, эффективно и элегантно решающая класс сложных инженерных задач. Их не следует исключать априори из набора инструментов, которые разрешено использовать разработчикам программного обеспечения, независимо от того, насколько «легко ими злоупотреблять».