Вступление
Есть одна область в разработке ПО под .NET (и не только под .NET), которая как бы в тени – на неё мало обращают внимания, точнее часто не придают должного значения – это Exceptions, их перехват и обработка. Нет, я вовсе не хочу сказать, что try/catch это такая уж редкость в разрабатываемых приложениях, скорее наоборот, «стандартная» конструкция
Является очень распространенной среди программистов «всех времён и народов», просто чаще всего дальше этой конструкции дело не идёт и разработчик, обернув «для надёжности» свой, безусловно без багов, код в эту конструкцию, на этом успокаивается и с чистой совестью оставляет это в релизе. То, что теме корректной обработки ошибок уделяют недостаточно внимания, говорит хотя бы малое количество литературы (по крайней мере на русском языке), с другой же стороны вопросы «как правильно обрабатывать Exception?» с завидной регулярностью появляются на профильных форумах и с такой же регулярностью начинаются споры нескольких гуру о том, как их лучше обрабатывать (чаще всего топикстартер ещё в начале спора уже начинает не понимать о чем речь и просто решает «ну их эти эксепшены, и так сойдёт»). Эти споры и натолкнули меня на мысль написать эту статью. В данной статье я постараюсь написать своё виденье ситуации на обработку Exceptions и ни в коем случае не претендую на «истину во всех инстанциях». Также я постараюсь затронуть основы этой темы и надеюсь, что эта статья будет полезна и новичкам, и профессионалам в этом вопросе.
Что такое исключения
Итак, пару слов что такое Exception (исключение) в .NET и как оно работает (если Вы имеете представление что такое Exception можете пропустить этот и возможно следующий абзац):
В целом, исключение–это «что-то», сообщающее нам о том, что в программе что-то пошло не так, как планировалось. В большинстве случаев это ошибка (хотя в принципе это не априори ошибка. Это может быть и ситуация, не несущая в себе ничего «ошибочного»). В среде .NET это класс, «выбрасываемый» CLR в случае возникновения ошибки, который программист может «поймать» и обработать должным образом (Вот именно это — «должным образом» — и порождает массу вопросов и споров). Исключение может быть передано только из функции, в которой оно было «выброшено», только в функцию, её вызвавшую. Далее это исключение передаётся вверх по стеку пока не будет поймано обработчиком, способным обработать это исключение (либо «завалить» программу если нет соответствующего обработчика). Как только обработчик найден, система начинает выполнение всех finally / fault в каждой функции, начиная с самой «нижней», пока не дойдёт до catch, где собственно и закончится обработка exception. Чтобы было понятнее покажу на примере:
Функция F4 выбросила исключение. Исключение «поднимается до первого catch (в F3, F2 нет catch, поэтому оно будет поймано в F1). После чего начинается выполнение finally (и fault) в F3, потом в F2 и наконец сам catch в F1.
User mode and SEH exceptions.
По большому счёту все исключения нужно разделить на 2 типа – user mode и SEH (Structured Exception Handling) исключения (User mode в свою очередь делятся на пользовательские и исключения из библиотек .NET и т.д., но это всё тривиальные вещи, т.к. их поведение идентично). Исключения user mode генерируются самой CLR или кодом программы, SEH (которые помечаются как CSE, Corrupted State Exceptions) возникают в недрах ОС или на аппаратном уровне, далее Windows уведомляет все потоки о произошедшем таком исключении.
Это в двух словах о том, что такое исключения, более детальную информацию вы можете найти в MSDN (http://msdn.microsoft.com/en-us/library/ms229014.aspx ), а также в множестве статей в интернете, где даётся общая информация об исключениях.
Обработка исключений
Теперь о главном – о том, как обрабатывать эти самые исключения. Как я уже говорил, этот вопрос провоцировал долгие споры на форумах, где каждый с пеной у рта доказывал оппонентам как их правильно обрабатывать и только так и не иначе. Я могу выделить 3 «лагеря» таких разработчиков, т.е. 3 различных подхода:
Первый подход –ловить все эксепшины, везде и всюду, обрамлять любой код в try/catch, что в принципе ничем не отличается от подхода среднестатистического индуса, которому рассказал его напарник по проекту, который узнал об эксепшинах от кого-то ещё и что они созданы для того, чтобы их перехватывать, мол чтобы твой код был идеальным – добавь ещё везде try/catch и всё будет в шоколаде.
Второй подход почти диаметрально противоположный – ловить только известные эксепшины и только там, где их можно корректно обработать
И наконец третий подход близок ко второму и сводится к тому, чтобы ловить все известные эксепшины, которые могут быть выкинуты тем или иным вызванным методом.
Про себя могу сказать, что я скорее придерживаюсь второго подхода, хотя не столь категорично.
Ловим всё.
Итак, для начала ещё раз на минуту вернёмся к теории. Повторюсь – исключение возникает тогда, когда происходит непредвиденная ситуация, далее оно «передаётся» вверх по стеку, до первого catch, где оно и «ловится», после чего опять «снизу-вверх» выполняются finally и fault. О чём это нам говорит и что мы хотим добиться? Говорит это нам о том, что, возникнув в «недрах» выполняемой программы, признак ошибки будет передаваться наверх и затрагивать все функции, через которые он проходит. Каждая из этих функций может иметь незаконченное действие, которое возможно должно быть либо завершено, либо нет и мы должны об этом помнить всегда. Что же мы хотим добиться? Вот здесь мы должны сами решить, в зависимости от условий и задачи, стоит ли продолжать выполнение программы или завершить её аварийно. Да-да, именно так, и я в теме исключений категоричен в одном – программа НЕ ДОЛЖНА продолжать выполнение, если её состояние было изменено! Всегда и всюду! Программа может что-то сообщить пользователю, записать в лог, отправить информацию об исключении разработчикам, но она должна быть перезапущена и точка. Нет ничего более страшного, чем программа, продолжающая своё выполнение с состоянием переменных, объектов, внешних ресурсов и прочего, не соответствующих прогнозируемым. Соблазн обернуть каждый вызов или всё в один try/catch довольно велик и чаще всего так и делают те, кто только начинает свой тернистый путь программиста или те, кто не придал должного значения разобраться для чего все эти exceptions вообще добавлены в .NET. Представляете, где-то в недрах программы произошла ошибка, а пользователь этого даже не заметит и продолжит работу с программой? Особенно если это прямой заказчик и проверяет сделанную работу – тогда «польза» от такого подхода вырастает в разы. Соблазн ещё больше т.к. программа действительно будет падать гораздо реже (и чем хуже она написана – тем сильнее этот «разрыв»)! Но программа будет падать в таких местах, в которых она падать не должна в принципе, состояния объектов будут противоречащими друг другу в принципе и понять откуда же началась вся эта канитель будет практически невозможно! Так что если Вам нужно сдать заказ заказчику, который решил Вами сманипулировать, отказывается выполнять условия контракта, задачу к резилу «раздул» в несколько раз по сравнению с начальной постановкой – try/catch с пустым catch везде и всюду это самое лучшее решение. Гарантирую, с этой программой он потом намучается так, что в следующий раз он 10 раз подумает прежде чем пытаться играть в одни ворота. Могу сказать, что только что мы рассмотрели первый подход (лепить try/catch везде и всюду). Кстати, то, что этот подход — это ещё хуже, чем вообще не перехватывать исключения, неявно говорит ещё и тот факт, что Microsoft в 4-й версии фремворка по умолчанию сделала так, что CSE (Corrupted State Exceptions) вообще не перехватываются catch. Разрешить этот перехват можно только явно, либо для всего приложения в конфиг файле (что наверное скорее сделано для совместимости с предыдущими версиями, когда программа была написана и оттерстирована скажем под 3.5, теперь её нужно скомпилировать под 4-й версией и оставить поведение в перехвате CSE исключений от 3.5), либо пометив атрибутом HandleProcessCorruptedStateExceptions все методы в цепочке перехвата CSE исключения. Что нам как-бы намекает, мол если выскочит CSE исключение типа AccessViolation – программа должна завершиться аварийно, потому что мы его обработать не можем. Ведь действительно, как мы можем обработать ситуацию, которая возникла где-то в недрах ОС?
Но есть ряд случаев когда я вполне приемлю и считаю оптимальными для использования try/catch без обработки эксепшина. Но об этом чуть попозже.
Ловим только известные
Второй подход гласит о том, чтобы перехватывать только известные исключения. В принципе, очень даже разумно на мой взгляд, главное не забыть их правильно обработать. Вернёмся к нашему основному принципу – состояние программы НЕ должно быть изменено. Т.е, перехватывая какое-либо исключение, мы должны быть уверены, что состояние программы не будет нарушено. Т.е. все транзакционные действия необходимо откатить назад (ещё раз, подумайте, можете ли Вы это сделать так, чтобы гарантированно при перехватываемом типе исключения состояние программы вернулось в первоначальное. Или стоит её всё-таки завалить?), все открытые в этом и вверх лежащих до первого catch операторах try внешние ресурсы закрыть и т.д.! Часто спрашивают – а как правильно написать обработку исключений для такого-то кода (и ниже дают листинг своего кода). На это можно ответить только одно – как нужно по условию задачи! Нет правил, которые обязывают в таком-то коде обрабатывать исключения именно так. Например, помню я видел пример, где у человека блютуз ищет устройства в округе (кстати, области с чем-то внешним и нестабильным, например как в данном случае поиск внешних устройств, какие-то внешние интернет-сервисы, неуправляемые ресурсы и прочее – это те области, в которых на перехват исключений нужно уделить особое внимание). Так вот, эти устройства по какой-то причине либо могут быть «обернуты» в класс и потом добавлены в коллекции, либо нет. Нам неинтересны эти причины, толи это ошибка в том фреймворке, который работает с блютузом, толи какие-то свои ньюансы в стеке блютуза, неважно. Важно что либо выскакивает исключение либо нет и понять когда оно выскочет нам тяжело или невозможно. Итак, следующий код находит девайс и пытается создать объект этого класса-обертки для девайса
//здесь перебираем каждый id, вызываем какую-то unmanaged-функцию, которая заполняет какие-то маркеры касательно этого блютуза и потом на основе этих маркеров пытаемся создать объект (вообще лично мне с блютузом приходилось работать и что это за такой извращенный подход (API какого стека) я так и не понял :) ):
И вот на создании девайса ИНОГДА, по непонятным причинам (если бы было по понятным то нужно просто делать проверку if-ом. Ждать exception там, где это можно проверить if-ом считаю неприемлимым подходом) вылетает исключение.
Немного лирики.
Теперь по поводу перехвата исключения в этом простейшем случае.
Ситуация №1 – будет ли найден и в дальнейшем как-то использован девайс или нет – для нас КРИТИЧНО! Программа не должна продолжать работу если хотя бы один девайс не был «обернут» в Device. Вот такое вот ТЗ. Что мы в данном случае делаем? Да ничего, по сути так и оставляем. Программа наткнётся на невозможность создания девайса и завалится. Это и будет её корректное поведение. Что хорошего будет если она продолжит работу и будет представлять исковерканные данные? Можно конечно обернуть в catch что сообщить friendly-error пользователю и закрыться. Но это уже детали UI, а не обработки исключений…
Ситуация №2 – для нас некритично будут ли найдены все девайсы или нет, что нашлось то и нашлось, остальные в пролёте.
Так это же тот же «ужас», скажете Вы, который я критиковал пару абзацев выше. Да, но в данном случае ничего смертельного не происходит. Это дозволено нашей постановкой задачи. Главное быть уверенным что состояние выполнения программы не было нарушено и внешние ресурсы были закрыты (пусть в данном случае для простоты они будут считаться закрытыми). Ещё раз – в этом коде ничего плохого нет если это дозволено постановкой задачи и не нарушает целостности. Если Вас бросает в холодный пот от вида такой конструкции, Вы сразу ставите «вердикт» разработчику, который написал такой код, что он «ламмер» — то я Вам сочувствую. В украинском языке есть поговорка – «шо занадто то нездраво», что переводится как «если чего-то слишком много – то это ненормально». Также, как и девушка-отличница падает в обморок от услышанного матерного слова, так и некоторые рвут и мечут, видя такой код. На самом деле по-моему ненормально человека сразу относить к типу «быдло» по одному оброненному в сердцах матерному слову и точно также ненормально оценивать разработчика как профессонала, только увидев такой код. И вообще, прежде всего, перед тем, как навешивать ярлык, прежде всего стоит разобраться почему человек написал именно так, и, как говорится, ещё неизвестно кто из нас дурак Точно также я негативно отношусь к категорическому непринятию программирования, отличного от ООП; часто замечал, как ООП наворочено там, где процедурный подход был бы куда изящнее, но нет же, ООП — это модно и круто, а процедурное – это прошлый век, как же так, вот в книжках же пишут – используйте ООП! Считаю, что гибкость и смелость отойти от принятых шаблонов – это одно из основных положительных качеств хорошего разработчика.
Приведу ещё пример, где такой код относительно уместен. Например, работа с сокетами. Мы пробуем открыть сокет и возращаем true если Open был успешен и false в противном случае. Вот приблизительный код:
Вариант вполне рабочий и довольно надёжный. Но здесь возможны ньюансы. Например, открытие сокета может «бросить» SecurityException. Возможно, его нужно отловить и как-то обработать. Или завершить работу программы. Потому что возможно не хватает каких-то прав и эти права сами по себе не появятся и мы всегда будем получать false что наверняка нас не устраивает (и опять же, если для нас это КРИТИЧНО и вся работа программы завязана на этом сокете – такой код врядли будет подходящим).
Также «заглушки» из try/catch часто вполне подходят для подключения каких-нибудь сторонних компонентов приложения, например плагинов, написанных другими разработчиками. Плагин не должен валить всё приложение, он должен быть полностью инкапсулирован от остального приложения и заглушка try/catch просто заглушит произошедшую внутри плагина ошибку.
Попадался мне на глаза ещё и следующий код:
Это наверное ещё хуже, чем просто «замаскировать» ошибку. Это вообще может запутать ситуацию до той степени, когда проще будет написать заново. Где-то выше ловится IOException и все силы бросаются на исправление IO проблемы, хотя проблема может быть совсем в другом. Я не знаю, что разработчики хотят сказать подобным кодом, но когда такое встречается в готовых используемых компонентах – это страшная головная боль. К сожалению, модель исключений в .NET позволяет такое писать и априори приходится относиться с недоверием к любому исключению, выброшенному каким-то компонентом. Я даже не могу представить зачем такая конструкция может понадобиться.
Рассмотрим ещё один пример. Пусть наша программа время от времени пишет какую-то информацию, дёргая веб-сервис. Веб-сервисы – это одна из тех вещей, доступность которых мы контроллировать не можем. Иногда может обвалиться канал у нас, иногда может хостинг делать какие-то перетрубации у себя на серверах и т.д. Т.к. нашей программе нужно куда-то писать, то мы решили, что она будет в таких случаях писать в обычный текстовый файл, а потом этот файл по ftp/по почте/распечатывается и передаётся почтовыми голубями тому, кому эта информация нужна.
Пусть метод CallWebService(string s) в своей реализации дергает этот самый веб-сервис.
Тогда:
Почему мы отлавливаем только System.ServiceModel.EndpointNotFoundException? Потому что мы знаем как его обработать и знаем почему оно могло возникнуть! Потому что если мы не меняли IP адрес / домен для нашего веб-сервиса и не трогали имя веб-метода – то значит он просто временно недоступен. Если же мы будем отлавливать все исключения и писать в резервный файл то возможно будет ситуация когда веб-сервис вообще не будет вызываться. Например, параметры веб-метода изменились (добавился ещё один параметр), то его можно хоть до посинения вызывать это веб-метод, но пока или на сервере, или на клиенте не поменяется сигнатура метода – мы будет попадать в общий catch, писать в файл и даже не знать что не так). Ровно также как и в случае попытка деления на ноль –например, какой-то наш метод с завидной регулярностью пытается что-то поделить на ноль. Если данная операция критична -правильно будет в таком случае отправить фидбек разработчику о том, что было поймано DivideByZeroException и закрыть программу, если это невозможно исправить во время выполнения. Потому что если перехватывать общий Exception и типа «выправлять» ситуацию, то мы можем хоть с бубном плясать над нашим кодом, но деление на ноль всегда было, есть и будет невозможным и ошибка всегда будет вылазить время от времени. Наша задача – разобраться почему такое происходит и что не так в нашей функции, а не закрывать на это глаза.
Ловим все известные
Третий подход к перехвату исключений – перехватывать все исключения, которые может «выкинуть» метод. Сторонники этого подхода аргументируют это тем, что исключения это часть контракта метода (что по сути так и есть) и он (контракт) должен быть реализован в полной мере. Это довольно веский аргумент, но с другой стороны – зачем перехватывать то, что мы не знаем как обработать. Да, мы реализуем полностью контракт метода, это конечно хорошо, но дальше же нужно что-то делать с этим. Возьмите любой метод из MSDN и посмотрите какие исключения он может выкидывать. Вы уверены, что все они в принципе возможны в Вашем случае? Например, System.IO.File.Delete имеет следующий перечень исключений:
ArgumentException
path is a zero-length string, contains only white space, or contains one or more invalid characters as defined by InvalidPathChars.
ArgumentNullException
path is null.
DirectoryNotFoundException
The specified path is invalid (for example, it is on an unmapped drive).
IOException
The specified file is in use.
-or-
There is an open handle on the file, and the operating system is Windows XP or earlier. This open handle can result from enumerating directories and files. For more information, see How to: Enumerate Directories and Files.
NotSupportedException
path is in an invalid format.
PathTooLongException
The specified path, file name, or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters.
UnauthorizedAccessException
The caller does not have the required permission.
-or-
path is a directory.
-or-
path specified a read-only file.
Например, вы знаете, что PathTooLongException не будет выкинут ни при каких обстоятельствах (у Вас жестко прописан путь по условию задачи). Так зачем его перехватывать и обрабатывать? Это ни к чему, кроме как к разрастанию кода, не приведёт.Также (повторюсь) зачем перехватывать то, что не знаете как обработать? Но заглядывать в документацию и просматривать список возможных исключений – считаю хорошим тоном. Точно также считаю хорошим тоном стремиться обработать как можно большее количество исключений. Но попытка перехватить все только ради реализации контракта мне непонятна.
Ещё пару слов
Меня как-то один коллега спросил – а почему Microsoft сделал так неудобно в 4-й версии фреймворка с перехватом CSE исключений? Как я писал выше, перехват таких исключений в .NET Framework 4 возможен только в случае если или всё приложение помечено как перехватывающее CSE (что возвращает нас «на шаг назад», к поведению версий 3.5 и ниже), или КАЖДЫЙ метод помечать как HandleProcessCorruptedStateExceptions по всей цепочке вывовов. Т.е. если мы хотим поймать исключение в главной функции, а CSE-исключение возникло где-то в недрах 10-й функции по цепочке вызовов (т.е function1 вызывает function2, function2 вызывает function3 и далее...), то все эти функции прийдётся пометить как HandleProcessCorruptedStateExceptions, иначе вызов не передастся выше если хотя бы одна из них не будет помечена этим атрибутом. Так вот мой ответ – а зачем Вам перехватывать CSE исключения? Как Вы их сможете обработать? В ответ мне говорили мол можно будет залогировать это исключение. Ну залогируете его, и что дальше? Что это Вам даст кроме нескольких часов колупания в коде в поисках почему оно могло возникнуть. Причину вы конечно же так и не найдёте, да и как вы её можете найти в своём коде если она возникла где-то в недрах ОС. Что Вы хотите исправить? То, что у пользователя стоит сборка Windows ZverCD?
Скажу пару слов про пользовательские исключения (это те классы-исключения, которые создаются самим пользователем, наследуясь в общем случае от System.Exception). Когда их следует создавать и как использовать? Моё мнение – их следует создавать создавать тогда, когда хотите именно их как-то по особенному обрабатывать. Например, у Вас есть текстовый файл, в котором просто обязан быть какой-то тег. Чтобы было понятнее – есть шаблон письма, в котором вместо имени должно быть [NAME], и это [NAME] программа должна заменить на реальное имя, введенное пользователем в программе. Конечно, есть возможность проверять if-ом есть ли такой тег в шаблоне, но по большому счёту если вы создаёте инкапсулированный компонент – он должен выкидывать что-то типа InvalidTagException в случае, если этот тег не найден, который может быть перехвачен и обработан и пользователю можно сообщить о том, что шаблон невалидный. Особенно прошу обратить внимание на создание пользовательских исключений если вы пишете какую-то библиотеку, которую будут использовать другие разработчики.
Также перехват исключений очень помогает при начальных этапах использования программы, когда программа ещё не оттестирована как следует и нет возможности использовать дебаггер.
Exceptions содержат полную информацию о произошедшей ошибке, которую таким образом можно залоггировать и принять меры к выходу релиза.
Вместо послесловия
Перехват исключений — это мощный механизм, который позволяет сделать Вашу программу более стабильной. Но, как и ряд других «достижений прогресса» в программировании они сыграли и негативную роль. Как и то, что программу сейчас (и уже давно) можно накидать в графическом редакторе и поцепить обработчики на элементы, что позволяет «программировать» практически любому. Это позволяет не думать и даже не понимать как вообще это всё работает. И, понятное дело, любая мелочь, которая идёт «не по плану», ставит такого «разработчика» в тупик. Так и с перехватом исключений – большинство почему-то поняли это дословно, что это перехват (исправление) ошибок. На самом деле перехват исключений вовсе не исправляет ошибок, скорее наоборот, он их обозначивает. И то, что многие пытаются их скрыть вместо того, чтобы обработать – это по большей степени вина не исключений. Но сама эта возможность «распологает» к написанию такого вот кода. Ошибки в логике не могут быть исправлены ничем, ни исключениями, ни чем-то другим. Только самим разработчиком. И так будет всегда какие бы средства не появлялись, до тех пор наверное, пока не появятся элементы искусственного интеллекта в логике программ. Но сейчас программу пишет разработчик, а не она сама себя. И он для того и нужен, что сконструировать эту логику. Аргументы типа «программ без багов не бывает» в оправдание «заглушек» из try/catch – не принимаются. Да, может без багов их и не бывает, но мы должны стремиться к этому, а не пытаться их спрятать за ширму.
Вопросы и комментарии направляйте по адресу oleg.shastitko@gmail.com
Список литературы
Design Guidelines for Exceptions (http://msdn.microsoft.com/en-us/library/ms229014.aspx)
Handling Corrupted State Exceptions (http://msdn.microsoft.com/en-us/magazine/dd419661.aspx )
A Crash Course on the Depths of Win32™ Structured Exception Handling (http://www.microsoft.com/msj/0197/exception/exception.aspx) (а ведь ещё 1997-го года )
All about Corrupted State Exceptions in .NET4 (http://dotnetslackers.com/articles/net/All-about-Corrupted-State-Exceptions-in-NET4.aspx)
Есть одна область в разработке ПО под .NET (и не только под .NET), которая как бы в тени – на неё мало обращают внимания, точнее часто не придают должного значения – это Exceptions, их перехват и обработка. Нет, я вовсе не хочу сказать, что try/catch это такая уж редкость в разрабатываемых приложениях, скорее наоборот, «стандартная» конструкция
try
{
// код
}
catch (Exception ex)
{
}Является очень распространенной среди программистов «всех времён и народов», просто чаще всего дальше этой конструкции дело не идёт и разработчик, обернув «для надёжности» свой, безусловно без багов, код в эту конструкцию, на этом успокаивается и с чистой совестью оставляет это в релизе. То, что теме корректной обработки ошибок уделяют недостаточно внимания, говорит хотя бы малое количество литературы (по крайней мере на русском языке), с другой же стороны вопросы «как правильно обрабатывать Exception?» с завидной регулярностью появляются на профильных форумах и с такой же регулярностью начинаются споры нескольких гуру о том, как их лучше обрабатывать (чаще всего топикстартер ещё в начале спора уже начинает не понимать о чем речь и просто решает «ну их эти эксепшены, и так сойдёт»). Эти споры и натолкнули меня на мысль написать эту статью. В данной статье я постараюсь написать своё виденье ситуации на обработку Exceptions и ни в коем случае не претендую на «истину во всех инстанциях». Также я постараюсь затронуть основы этой темы и надеюсь, что эта статья будет полезна и новичкам, и профессионалам в этом вопросе.
Что такое исключения
Итак, пару слов что такое Exception (исключение) в .NET и как оно работает (если Вы имеете представление что такое Exception можете пропустить этот и возможно следующий абзац):
В целом, исключение–это «что-то», сообщающее нам о том, что в программе что-то пошло не так, как планировалось. В большинстве случаев это ошибка (хотя в принципе это не априори ошибка. Это может быть и ситуация, не несущая в себе ничего «ошибочного»). В среде .NET это класс, «выбрасываемый» CLR в случае возникновения ошибки, который программист может «поймать» и обработать должным образом (Вот именно это — «должным образом» — и порождает массу вопросов и споров). Исключение может быть передано только из функции, в которой оно было «выброшено», только в функцию, её вызвавшую. Далее это исключение передаётся вверх по стеку пока не будет поймано обработчиком, способным обработать это исключение (либо «завалить» программу если нет соответствующего обработчика). Как только обработчик найден, система начинает выполнение всех finally / fault в каждой функции, начиная с самой «нижней», пока не дойдёт до catch, где собственно и закончится обработка exception. Чтобы было понятнее покажу на примере:
void F1()
{
try
{
F2();
}
catch
{
//...
}
}
void F2()
{
try
{
F3();
}
finally
{
//...
}
}
void F3()
{
try
{
F4();
}
finally
{
//...
}
}
void F4()
{
throw new Exception("Something wrong");
}
Функция F4 выбросила исключение. Исключение «поднимается до первого catch (в F3, F2 нет catch, поэтому оно будет поймано в F1). После чего начинается выполнение finally (и fault) в F3, потом в F2 и наконец сам catch в F1.
User mode and SEH exceptions.
По большому счёту все исключения нужно разделить на 2 типа – user mode и SEH (Structured Exception Handling) исключения (User mode в свою очередь делятся на пользовательские и исключения из библиотек .NET и т.д., но это всё тривиальные вещи, т.к. их поведение идентично). Исключения user mode генерируются самой CLR или кодом программы, SEH (которые помечаются как CSE, Corrupted State Exceptions) возникают в недрах ОС или на аппаратном уровне, далее Windows уведомляет все потоки о произошедшем таком исключении.
Это в двух словах о том, что такое исключения, более детальную информацию вы можете найти в MSDN (http://msdn.microsoft.com/en-us/library/ms229014.aspx ), а также в множестве статей в интернете, где даётся общая информация об исключениях.
Обработка исключений
Теперь о главном – о том, как обрабатывать эти самые исключения. Как я уже говорил, этот вопрос провоцировал долгие споры на форумах, где каждый с пеной у рта доказывал оппонентам как их правильно обрабатывать и только так и не иначе. Я могу выделить 3 «лагеря» таких разработчиков, т.е. 3 различных подхода:
Первый подход –ловить все эксепшины, везде и всюду, обрамлять любой код в try/catch, что в принципе ничем не отличается от подхода среднестатистического индуса, которому рассказал его напарник по проекту, который узнал об эксепшинах от кого-то ещё и что они созданы для того, чтобы их перехватывать, мол чтобы твой код был идеальным – добавь ещё везде try/catch и всё будет в шоколаде.
Второй подход почти диаметрально противоположный – ловить только известные эксепшины и только там, где их можно корректно обработать
И наконец третий подход близок ко второму и сводится к тому, чтобы ловить все известные эксепшины, которые могут быть выкинуты тем или иным вызванным методом.
Про себя могу сказать, что я скорее придерживаюсь второго подхода, хотя не столь категорично.
Ловим всё.
Итак, для начала ещё раз на минуту вернёмся к теории. Повторюсь – исключение возникает тогда, когда происходит непредвиденная ситуация, далее оно «передаётся» вверх по стеку, до первого catch, где оно и «ловится», после чего опять «снизу-вверх» выполняются finally и fault. О чём это нам говорит и что мы хотим добиться? Говорит это нам о том, что, возникнув в «недрах» выполняемой программы, признак ошибки будет передаваться наверх и затрагивать все функции, через которые он проходит. Каждая из этих функций может иметь незаконченное действие, которое возможно должно быть либо завершено, либо нет и мы должны об этом помнить всегда. Что же мы хотим добиться? Вот здесь мы должны сами решить, в зависимости от условий и задачи, стоит ли продолжать выполнение программы или завершить её аварийно. Да-да, именно так, и я в теме исключений категоричен в одном – программа НЕ ДОЛЖНА продолжать выполнение, если её состояние было изменено! Всегда и всюду! Программа может что-то сообщить пользователю, записать в лог, отправить информацию об исключении разработчикам, но она должна быть перезапущена и точка. Нет ничего более страшного, чем программа, продолжающая своё выполнение с состоянием переменных, объектов, внешних ресурсов и прочего, не соответствующих прогнозируемым. Соблазн обернуть каждый вызов или всё в один try/catch довольно велик и чаще всего так и делают те, кто только начинает свой тернистый путь программиста или те, кто не придал должного значения разобраться для чего все эти exceptions вообще добавлены в .NET. Представляете, где-то в недрах программы произошла ошибка, а пользователь этого даже не заметит и продолжит работу с программой? Особенно если это прямой заказчик и проверяет сделанную работу – тогда «польза» от такого подхода вырастает в разы. Соблазн ещё больше т.к. программа действительно будет падать гораздо реже (и чем хуже она написана – тем сильнее этот «разрыв»)! Но программа будет падать в таких местах, в которых она падать не должна в принципе, состояния объектов будут противоречащими друг другу в принципе и понять откуда же началась вся эта канитель будет практически невозможно! Так что если Вам нужно сдать заказ заказчику, который решил Вами сманипулировать, отказывается выполнять условия контракта, задачу к резилу «раздул» в несколько раз по сравнению с начальной постановкой – try/catch с пустым catch везде и всюду это самое лучшее решение. Гарантирую, с этой программой он потом намучается так, что в следующий раз он 10 раз подумает прежде чем пытаться играть в одни ворота. Могу сказать, что только что мы рассмотрели первый подход (лепить try/catch везде и всюду). Кстати, то, что этот подход — это ещё хуже, чем вообще не перехватывать исключения, неявно говорит ещё и тот факт, что Microsoft в 4-й версии фремворка по умолчанию сделала так, что CSE (Corrupted State Exceptions) вообще не перехватываются catch. Разрешить этот перехват можно только явно, либо для всего приложения в конфиг файле (что наверное скорее сделано для совместимости с предыдущими версиями, когда программа была написана и оттерстирована скажем под 3.5, теперь её нужно скомпилировать под 4-й версией и оставить поведение в перехвате CSE исключений от 3.5), либо пометив атрибутом HandleProcessCorruptedStateExceptions все методы в цепочке перехвата CSE исключения. Что нам как-бы намекает, мол если выскочит CSE исключение типа AccessViolation – программа должна завершиться аварийно, потому что мы его обработать не можем. Ведь действительно, как мы можем обработать ситуацию, которая возникла где-то в недрах ОС?
Но есть ряд случаев когда я вполне приемлю и считаю оптимальными для использования try/catch без обработки эксепшина. Но об этом чуть попозже.
Ловим только известные
Второй подход гласит о том, чтобы перехватывать только известные исключения. В принципе, очень даже разумно на мой взгляд, главное не забыть их правильно обработать. Вернёмся к нашему основному принципу – состояние программы НЕ должно быть изменено. Т.е, перехватывая какое-либо исключение, мы должны быть уверены, что состояние программы не будет нарушено. Т.е. все транзакционные действия необходимо откатить назад (ещё раз, подумайте, можете ли Вы это сделать так, чтобы гарантированно при перехватываемом типе исключения состояние программы вернулось в первоначальное. Или стоит её всё-таки завалить?), все открытые в этом и вверх лежащих до первого catch операторах try внешние ресурсы закрыть и т.д.! Часто спрашивают – а как правильно написать обработку исключений для такого-то кода (и ниже дают листинг своего кода). На это можно ответить только одно – как нужно по условию задачи! Нет правил, которые обязывают в таком-то коде обрабатывать исключения именно так. Например, помню я видел пример, где у человека блютуз ищет устройства в округе (кстати, области с чем-то внешним и нестабильным, например как в данном случае поиск внешних устройств, какие-то внешние интернет-сервисы, неуправляемые ресурсы и прочее – это те области, в которых на перехват исключений нужно уделить особое внимание). Так вот, эти устройства по какой-то причине либо могут быть «обернуты» в класс и потом добавлены в коллекции, либо нет. Нам неинтересны эти причины, толи это ошибка в том фреймворке, который работает с блютузом, толи какие-то свои ньюансы в стеке блютуза, неважно. Важно что либо выскакивает исключение либо нет и понять когда оно выскочет нам тяжело или невозможно. Итак, следующий код находит девайс и пытается создать объект этого класса-обертки для девайса
// здесь мы получаем список ID устройств
var idList = DiscoverAllIDs();//здесь перебираем каждый id, вызываем какую-то unmanaged-функцию, которая заполняет какие-то маркеры касательно этого блютуза и потом на основе этих маркеров пытаемся создать объект (вообще лично мне с блютузом приходилось работать и что это за такой извращенный подход (API какого стека) я так и не понял :) ):
foreach (var id in idList)
{
int v1 = -1, v2 = -1;
Double v3 = -1;
String v4 = String.Empty;
// это сам вызов unmanaged функции. Она заполняет наши 4 переменные
GetDevice(id, out v1, out v2, out v3, out v4);
// а здесь уже пробуем создать объект
Device d = new Device(v1, v2, v3, v4);
}И вот на создании девайса ИНОГДА, по непонятным причинам (если бы было по понятным то нужно просто делать проверку if-ом. Ждать exception там, где это можно проверить if-ом считаю неприемлимым подходом) вылетает исключение.
Немного лирики.
Теперь по поводу перехвата исключения в этом простейшем случае.
Ситуация №1 – будет ли найден и в дальнейшем как-то использован девайс или нет – для нас КРИТИЧНО! Программа не должна продолжать работу если хотя бы один девайс не был «обернут» в Device. Вот такое вот ТЗ. Что мы в данном случае делаем? Да ничего, по сути так и оставляем. Программа наткнётся на невозможность создания девайса и завалится. Это и будет её корректное поведение. Что хорошего будет если она продолжит работу и будет представлять исковерканные данные? Можно конечно обернуть в catch что сообщить friendly-error пользователю и закрыться. Но это уже детали UI, а не обработки исключений…
Ситуация №2 – для нас некритично будут ли найдены все девайсы или нет, что нашлось то и нашлось, остальные в пролёте.
foreach (var id in idList)
{
int v1 = -1, v2 = -1;
Double v3 = -1;
String v4 = String.Empty;
GetDevice(id, out v1, out v2, out v3, out v4);
try
{
Device d = new Device(v1, v2, v3, v4);
}
catch
{
}
}Так это же тот же «ужас», скажете Вы, который я критиковал пару абзацев выше. Да, но в данном случае ничего смертельного не происходит. Это дозволено нашей постановкой задачи. Главное быть уверенным что состояние выполнения программы не было нарушено и внешние ресурсы были закрыты (пусть в данном случае для простоты они будут считаться закрытыми). Ещё раз – в этом коде ничего плохого нет если это дозволено постановкой задачи и не нарушает целостности. Если Вас бросает в холодный пот от вида такой конструкции, Вы сразу ставите «вердикт» разработчику, который написал такой код, что он «ламмер» — то я Вам сочувствую. В украинском языке есть поговорка – «шо занадто то нездраво», что переводится как «если чего-то слишком много – то это ненормально». Также, как и девушка-отличница падает в обморок от услышанного матерного слова, так и некоторые рвут и мечут, видя такой код. На самом деле по-моему ненормально человека сразу относить к типу «быдло» по одному оброненному в сердцах матерному слову и точно также ненормально оценивать разработчика как профессонала, только увидев такой код. И вообще, прежде всего, перед тем, как навешивать ярлык, прежде всего стоит разобраться почему человек написал именно так, и, как говорится, ещё неизвестно кто из нас дурак Точно также я негативно отношусь к категорическому непринятию программирования, отличного от ООП; часто замечал, как ООП наворочено там, где процедурный подход был бы куда изящнее, но нет же, ООП — это модно и круто, а процедурное – это прошлый век, как же так, вот в книжках же пишут – используйте ООП! Считаю, что гибкость и смелость отойти от принятых шаблонов – это одно из основных положительных качеств хорошего разработчика.
Приведу ещё пример, где такой код относительно уместен. Например, работа с сокетами. Мы пробуем открыть сокет и возращаем true если Open был успешен и false в противном случае. Вот приблизительный код:
try
{
_stream.Open();
return true;
}
catch
{
return false;
}Вариант вполне рабочий и довольно надёжный. Но здесь возможны ньюансы. Например, открытие сокета может «бросить» SecurityException. Возможно, его нужно отловить и как-то обработать. Или завершить работу программы. Потому что возможно не хватает каких-то прав и эти права сами по себе не появятся и мы всегда будем получать false что наверняка нас не устраивает (и опять же, если для нас это КРИТИЧНО и вся работа программы завязана на этом сокете – такой код врядли будет подходящим).
Также «заглушки» из try/catch часто вполне подходят для подключения каких-нибудь сторонних компонентов приложения, например плагинов, написанных другими разработчиками. Плагин не должен валить всё приложение, он должен быть полностью инкапсулирован от остального приложения и заглушка try/catch просто заглушит произошедшую внутри плагина ошибку.
Попадался мне на глаза ещё и следующий код:
try
{
FileStream fileStream = new FileStream(filename, FileMode.Create);
}
catch (Exception ex)
{
throw new System.IO.IOException("File Create Error");
}Это наверное ещё хуже, чем просто «замаскировать» ошибку. Это вообще может запутать ситуацию до той степени, когда проще будет написать заново. Где-то выше ловится IOException и все силы бросаются на исправление IO проблемы, хотя проблема может быть совсем в другом. Я не знаю, что разработчики хотят сказать подобным кодом, но когда такое встречается в готовых используемых компонентах – это страшная головная боль. К сожалению, модель исключений в .NET позволяет такое писать и априори приходится относиться с недоверием к любому исключению, выброшенному каким-то компонентом. Я даже не могу представить зачем такая конструкция может понадобиться.
Рассмотрим ещё один пример. Пусть наша программа время от времени пишет какую-то информацию, дёргая веб-сервис. Веб-сервисы – это одна из тех вещей, доступность которых мы контроллировать не можем. Иногда может обвалиться канал у нас, иногда может хостинг делать какие-то перетрубации у себя на серверах и т.д. Т.к. нашей программе нужно куда-то писать, то мы решили, что она будет в таких случаях писать в обычный текстовый файл, а потом этот файл по ftp/по почте/распечатывается и передаётся почтовыми голубями тому, кому эта информация нужна.
Пусть метод CallWebService(string s) в своей реализации дергает этот самый веб-сервис.
Тогда:
try
{
// пробуем вызвать веб-сервис
CallWebService(message);
}
catch (System.ServiceModel.EndpointNotFoundException ex)
{
// здесь пишем уже в файл
}Почему мы отлавливаем только System.ServiceModel.EndpointNotFoundException? Потому что мы знаем как его обработать и знаем почему оно могло возникнуть! Потому что если мы не меняли IP адрес / домен для нашего веб-сервиса и не трогали имя веб-метода – то значит он просто временно недоступен. Если же мы будем отлавливать все исключения и писать в резервный файл то возможно будет ситуация когда веб-сервис вообще не будет вызываться. Например, параметры веб-метода изменились (добавился ещё один параметр), то его можно хоть до посинения вызывать это веб-метод, но пока или на сервере, или на клиенте не поменяется сигнатура метода – мы будет попадать в общий catch, писать в файл и даже не знать что не так). Ровно также как и в случае попытка деления на ноль –например, какой-то наш метод с завидной регулярностью пытается что-то поделить на ноль. Если данная операция критична -правильно будет в таком случае отправить фидбек разработчику о том, что было поймано DivideByZeroException и закрыть программу, если это невозможно исправить во время выполнения. Потому что если перехватывать общий Exception и типа «выправлять» ситуацию, то мы можем хоть с бубном плясать над нашим кодом, но деление на ноль всегда было, есть и будет невозможным и ошибка всегда будет вылазить время от времени. Наша задача – разобраться почему такое происходит и что не так в нашей функции, а не закрывать на это глаза.
Ловим все известные
Третий подход к перехвату исключений – перехватывать все исключения, которые может «выкинуть» метод. Сторонники этого подхода аргументируют это тем, что исключения это часть контракта метода (что по сути так и есть) и он (контракт) должен быть реализован в полной мере. Это довольно веский аргумент, но с другой стороны – зачем перехватывать то, что мы не знаем как обработать. Да, мы реализуем полностью контракт метода, это конечно хорошо, но дальше же нужно что-то делать с этим. Возьмите любой метод из MSDN и посмотрите какие исключения он может выкидывать. Вы уверены, что все они в принципе возможны в Вашем случае? Например, System.IO.File.Delete имеет следующий перечень исключений:
ArgumentException
path is a zero-length string, contains only white space, or contains one or more invalid characters as defined by InvalidPathChars.
ArgumentNullException
path is null.
DirectoryNotFoundException
The specified path is invalid (for example, it is on an unmapped drive).
IOException
The specified file is in use.
-or-
There is an open handle on the file, and the operating system is Windows XP or earlier. This open handle can result from enumerating directories and files. For more information, see How to: Enumerate Directories and Files.
NotSupportedException
path is in an invalid format.
PathTooLongException
The specified path, file name, or both exceed the system-defined maximum length. For example, on Windows-based platforms, paths must be less than 248 characters, and file names must be less than 260 characters.
UnauthorizedAccessException
The caller does not have the required permission.
-or-
path is a directory.
-or-
path specified a read-only file.
Например, вы знаете, что PathTooLongException не будет выкинут ни при каких обстоятельствах (у Вас жестко прописан путь по условию задачи). Так зачем его перехватывать и обрабатывать? Это ни к чему, кроме как к разрастанию кода, не приведёт.Также (повторюсь) зачем перехватывать то, что не знаете как обработать? Но заглядывать в документацию и просматривать список возможных исключений – считаю хорошим тоном. Точно также считаю хорошим тоном стремиться обработать как можно большее количество исключений. Но попытка перехватить все только ради реализации контракта мне непонятна.
Ещё пару слов
Меня как-то один коллега спросил – а почему Microsoft сделал так неудобно в 4-й версии фреймворка с перехватом CSE исключений? Как я писал выше, перехват таких исключений в .NET Framework 4 возможен только в случае если или всё приложение помечено как перехватывающее CSE (что возвращает нас «на шаг назад», к поведению версий 3.5 и ниже), или КАЖДЫЙ метод помечать как HandleProcessCorruptedStateExceptions по всей цепочке вывовов. Т.е. если мы хотим поймать исключение в главной функции, а CSE-исключение возникло где-то в недрах 10-й функции по цепочке вызовов (т.е function1 вызывает function2, function2 вызывает function3 и далее...), то все эти функции прийдётся пометить как HandleProcessCorruptedStateExceptions, иначе вызов не передастся выше если хотя бы одна из них не будет помечена этим атрибутом. Так вот мой ответ – а зачем Вам перехватывать CSE исключения? Как Вы их сможете обработать? В ответ мне говорили мол можно будет залогировать это исключение. Ну залогируете его, и что дальше? Что это Вам даст кроме нескольких часов колупания в коде в поисках почему оно могло возникнуть. Причину вы конечно же так и не найдёте, да и как вы её можете найти в своём коде если она возникла где-то в недрах ОС. Что Вы хотите исправить? То, что у пользователя стоит сборка Windows ZverCD?
Скажу пару слов про пользовательские исключения (это те классы-исключения, которые создаются самим пользователем, наследуясь в общем случае от System.Exception). Когда их следует создавать и как использовать? Моё мнение – их следует создавать создавать тогда, когда хотите именно их как-то по особенному обрабатывать. Например, у Вас есть текстовый файл, в котором просто обязан быть какой-то тег. Чтобы было понятнее – есть шаблон письма, в котором вместо имени должно быть [NAME], и это [NAME] программа должна заменить на реальное имя, введенное пользователем в программе. Конечно, есть возможность проверять if-ом есть ли такой тег в шаблоне, но по большому счёту если вы создаёте инкапсулированный компонент – он должен выкидывать что-то типа InvalidTagException в случае, если этот тег не найден, который может быть перехвачен и обработан и пользователю можно сообщить о том, что шаблон невалидный. Особенно прошу обратить внимание на создание пользовательских исключений если вы пишете какую-то библиотеку, которую будут использовать другие разработчики.
Также перехват исключений очень помогает при начальных этапах использования программы, когда программа ещё не оттестирована как следует и нет возможности использовать дебаггер.
Exceptions содержат полную информацию о произошедшей ошибке, которую таким образом можно залоггировать и принять меры к выходу релиза.
Вместо послесловия
Перехват исключений — это мощный механизм, который позволяет сделать Вашу программу более стабильной. Но, как и ряд других «достижений прогресса» в программировании они сыграли и негативную роль. Как и то, что программу сейчас (и уже давно) можно накидать в графическом редакторе и поцепить обработчики на элементы, что позволяет «программировать» практически любому. Это позволяет не думать и даже не понимать как вообще это всё работает. И, понятное дело, любая мелочь, которая идёт «не по плану», ставит такого «разработчика» в тупик. Так и с перехватом исключений – большинство почему-то поняли это дословно, что это перехват (исправление) ошибок. На самом деле перехват исключений вовсе не исправляет ошибок, скорее наоборот, он их обозначивает. И то, что многие пытаются их скрыть вместо того, чтобы обработать – это по большей степени вина не исключений. Но сама эта возможность «распологает» к написанию такого вот кода. Ошибки в логике не могут быть исправлены ничем, ни исключениями, ни чем-то другим. Только самим разработчиком. И так будет всегда какие бы средства не появлялись, до тех пор наверное, пока не появятся элементы искусственного интеллекта в логике программ. Но сейчас программу пишет разработчик, а не она сама себя. И он для того и нужен, что сконструировать эту логику. Аргументы типа «программ без багов не бывает» в оправдание «заглушек» из try/catch – не принимаются. Да, может без багов их и не бывает, но мы должны стремиться к этому, а не пытаться их спрятать за ширму.
Вопросы и комментарии направляйте по адресу oleg.shastitko@gmail.com
Список литературы
Design Guidelines for Exceptions (http://msdn.microsoft.com/en-us/library/ms229014.aspx)
Handling Corrupted State Exceptions (http://msdn.microsoft.com/en-us/magazine/dd419661.aspx )
A Crash Course on the Depths of Win32™ Structured Exception Handling (http://www.microsoft.com/msj/0197/exception/exception.aspx) (а ведь ещё 1997-го года )
All about Corrupted State Exceptions in .NET4 (http://dotnetslackers.com/articles/net/All-about-Corrupted-State-Exceptions-in-NET4.aspx)