тема 27-28. Отладка приложений. Организация обработки исключений. Корректность и устойчивость программных систем Корректность и устойчивость
Скачать 44.54 Kb.
|
Отладка приложений. Организация обработки исключений. Корректность и устойчивость программных систем Корректность и устойчивость – два основных качества программной системы, без которых все остальные ее достоинства не имеют особого смысла. Понятие корректности программной системы имеет смысл только тогда, когда задана спецификация этой системы. В зависимости от того, как формализуется спецификация, уточняется понятие корректности. Корректность – это способность программной системы работать в строгом соответствии со своей спецификацией. Отладка – процесс, направленный на достижение корректности. Во время работы системы могут возникать ситуации, выходящие за пределы, предусмотренные спецификацией. Такие ситуации называются исключительными. Устойчивость – это способность программной системы должным образом реагировать на исключительные ситуации. Обработка исключительных ситуаций – процесс, направленный на достижение устойчивости. Сложность нынешних сетевых операционных систем, систем управления хранилищами данных, прикладных систем программирования на порядки превосходит сложность OS-360, так что, несмотря на прогресс, достигнутый в области технологии программирования, проблемы, стоящие перед разработчиками, не стали проще. Жизненный цикл программной системы Под «жизненным циклом» понимается период от замысла программного продукта до его «кончины». Обычно рассматриваются следующие фазы этого процесса: Проектирование Разработка Развертывание и Сопровождение Все это называется циклом, поскольку после каждой фазы возможен возврат к предыдущим этапам. В объектной технологии этот процесс является бесшовным, все этапы которого тесно переплетены. Не следует рассматривать его как однонаправленный – от проектирования к сопровождению. Чаще всего, ситуация обратная: уже существующая реализация системы, прошедшая сопровождение, существующие библиотеки компонентов оказывают решающее влияние на то, какой будет новая система, каковы будут ее спецификации. Вот некоторые типовые правила, характерные для процесса разработки ПО: Уделяйте этапу проектированию самое пристальное внимание. Успех дела во многом определяется первым этапом. Нет смысла торопиться с переходом на последующие этапы, пока не составлены ясные и четкие спецификации. Ошибки этого этапа самые дорогие и трудно исправляемые. Помните о тех, для кого разрабатывается программный продукт. Идите «в люди», чтобы понять, что нужно делать. Вместе с тем не следует полностью полагаться на пользователей, – их опыт консервативен, новые идеи могут часто приходить от разработчиков, а не от пользователей. Разработка не начинается «с нуля». Только используя уже готовые компоненты можно своевременно создать новую систему. Работая над проектом, думайте о будущем, создавайте компоненты, допускающие их повторное использование в других проектах. Создавайте как можно раньше прототип свой системы и передавайте его пользователям в опытную эксплуатацию. Это поможет устранить множество недостатков и ошибок в заключительной версии программного продукта. Какие бы хорошие спецификации не были написаны, какими бы хорошими технологиями и инструментами не пользовались разработчики, какими бы профессионалами они не были – этого еще не достаточно для успеха дела. Необходимым условием является управление проектом, наличие специальных средств управления проектом. Но и этого не достаточно. Третьим важным фактором является существование команды. Коллектив разработчиков должен представлять собой единую команду. Умение работать в команде так же важно, как и профессиональные навыки разработчика. Три закона программотехники Первый закон (закон для разработчика) Корректность системы – недостижима. Каждая последняя найденная ошибка является предпоследней. Этот закон отражает сложность нетривиальных систем. Разработчик всегда должен быть готов к тому, что в работающей системе имеются ситуации, в которых система работает не в точном соответствии со своей спецификацией, так что от него может требоваться либо очередное изменение системы, либо ее спецификации. Второй закон (закон для пользователя) Не бывает некорректных систем. Каждая появляющаяся ошибка при эксплуатации системы – это следствие незнания спецификации системы. Есть два объяснения справедливости второго закона. Несерьезное объяснение состоит в том, что любая система, чтобы она не делала, при любом постусловии корректна по отношению к предусловию False, поскольку невозможно подобрать ни один набор входных данных, удовлетворяющих этому предусловию. Так что все системы корректны, если задать False в качестве их предусловия. Если вам пришлось столкнуться с системой, предусловие которой близко к False, то лучшее, что можно сделать, это отложить ее в сторону и найти другую систему. Более поучительна реальная ситуация, подтверждающая второй закон и рассказанная мне в былые годы Виталием Кауфманом – специалистом по тестированию трансляторов. В одной серьезной организации была разработана серьезная прикладная система, имеющая для них большое значение. К сожалению, при ее эксплуатации сплошь и рядом возникали ошибки, из-за которых организация вынуждена была отказаться от использования системы. Разработчики обратились к нему за помощью. Он, исследуя систему, не внес в нее ни строчки кода. Единственное, что он сделал, это описал точную спецификацию системы, благодаря чему стала возможной нормальная эксплуатация системы. Третий закон (закон чечако) Если спецификацию можно нарушить, – она будет нарушена. Новичок (чечако) способен «подвесить» любую систему. Неквалифицированный пользователь в любом контексте всегда способен выбрать наименее подходящее действие, явно не удовлетворяющее спецификации, ориентированной на «разумное» поведение пользователей. Полезным практическим следствием этого закона является привлечение к этапу тестирования системы неквалифицированного пользователя – «человека с улицы». Отладка Что должно делать для создания корректного и устойчивого программного продукта? Как минимум, необходимо: создать надежный код, корректность которого предусматривается с самого начала; отладить этот код; предусмотреть в нем обработку исключительных ситуаций. Создание надежного кода Большинство вопросов, затрагиваемых в этой лекции, в том числе и проблемы создания надежного кода, заслуживают отдельного и глубокого рассмотрения. К сожалению, придется ограничиться лишь высказыванием ряда тезисов. Для повышения надежности нужно уменьшить сложность системы и главное в этом процессе – это повторное использование. В идеале большая часть системы должна быть собрана из уже готовых компонентов. Объектная технология проектирования вносит свой вклад в повышение надежности кода. Наследование и универсализация позволяют, не изменяя уже существующие классы, создать новые классы, новые типы данных, придающие проектируемой системе новые свойства при минимальных добавлениях нового кода. Статический контроль типов позволяет выявить многие ошибки еще на этапе компиляции. Динамическое связывание и полиморфизм позволяет автоматически включать объекты классов-потомков в уже существующие схемы работы – методы родителя могут вызывать методы потомков, ничего не зная о появлении новых потомков. Автоматическая сборка мусора позволяет снять с разработчика обязанности управления освобождением памяти и предотвратить появление крайне неприятных и опасных ошибок, связанных с некорректным удалением объектов. Крайне важную роль в создании надежного кода играют спецификации методов класса, класса в целом, системы классов. Спецификации являются частью документации, встроенной в проект и являющейся важной его частью. Их существование облегчает не только создание корректного кода, соответствующего спецификации, но и создание системы тестов, проверяющих корректность кода. Нужно сказать, что существуют специальные инструментальные средства, поддерживающие автоматическое создание тестов на основе спецификаций. Незаменима роль спецификаций на этапе сопровождения и повторного использования компонентов. Невозможно повторно использовать компонент, если у него нет ясной и полной спецификации. Искусство отладки Нужно стараться создавать надежный код. Но без отладки пока обойтись невозможно. Роль тестеров в современном процессе разработки ПО велика. Отладка это некоторый детективный процесс. Программа, в которую внесены изменения, подозревается в том, что она работает некорректно. Презумпция невиновности здесь не применима. Если удается предъявить тест, на котором программа дает неверный результат, то доказано, что подозрения верны. Втайне мы всегда надеемся, что программа работает правильно. Но цель тестирования другая, попытаться опровергнуть это предположение. Отладка может доказать некорректность программы, но она не может доказать ее правильность. Отладка не гарантирует корректности программы, даже если все тесты прошли успешно. Искусное тестирование создает высокую степень уверенности в корректности программы. Часть ошибок программы ловится автоматически еще на этапе компиляции. Сюда относятся все синтаксические ошибки, ошибки несоответствия типов и некоторые другие. Это простые ошибки и их исправление, как правило, не вызывает трудностей. В отладке нуждается синтаксически корректная программа, результаты вычислений которой получены, но не соответствуют требуемым спецификациям. Чаще всего, еще не отлаженная программа на одних исходных данных работает правильно, на других дает ошибочный результат. Искусство отладки состоит в том, чтобы обнаружить все ситуации, в которых работа программы приводит к ошибочным вычислениям. Как и во всякой детективной деятельности в ходе отладки необходим сбор улик, для чего применяется две группы средств. Первая группа позволяет контролировать ход вычислительного процесса: порядок следования операторов в методах, порядок вызова самих методов, условия окончания циклов, правильность переходов. Вторая группа средств позволяет контролировать изменение состояния вычислительного процесса (значения свойств объектов) в процессе выполнения. Есть и другая классификация. Средства, используемые при отладке, можно разделить на инструментарий, предоставляемой средой разработки Visual Studio .Net и программные средства, предоставляемые языком и специальными классами библиотеки FCL. Начнем рассмотрение с программных средств. Отладочная печать и условная компиляция Одним из основных средств отладки является отладочная печать, позволяющая получить данные о ходе и состоянии процесса вычислений. Обычно разрабатываются специальные отладочные методы, вызываемые в критических точках программы – на входе и выходе программных модулей, на входе и выходе циклов и так далее. Искусство отладки в том и состоит, чтобы получить нужную информацию о прячущихся ошибках, проявляющихся возможно только в редких ситуациях. Хотелось бы иметь легкий механизм управления отладочными методами, позволяющий включать при необходимости те или иные методы. Для этого можно воспользоваться механизмом условной компиляции, встроенным в язык C#. Этот механизм состоит из двух частей. К проекту, точнее к конфигурации проекта можно добавить специальные константы условной компиляции. Вызов отладочного метода может быть сделан условным. Если соответствующая константа компиляции определена, то происходит компиляция вызова метода, и он будет вызываться при выполнении проекта. Если же константа не определена (выключена), то вызов метода даже не будет компилироваться и никаких динамических проверок – вызывать метод или нет – делаться не будет. В ходе работы с проектом можно легко переключаться с одной конфигурации на другую, после чего она становится активной, можно изменять настройки конфигурации, можно создать собственные конфигурации проекта. По умолчанию проект создается в двух конфигурациях – Debug и Release, первая из которых предназначена для отладки, вторая – для окончательных вычислений. Первая – не предполагает оптимизации и в ней определены две константы условной компиляции – DEBUG и TRACE, во второй – определена только константа TRACE. Отладочная версия может содержать вызовы, зависящие от константы DEBUG, которые будут отсутствовать в финальной версии. Используя страницу свойств, к конфигурации проекта можно добавлять новые константы компиляции. Можно также задавать константы условной компиляции в начале модуля проекта вперемешку с предложениями using. Предложение define позволяет определить новую константу: #define COMPLEX В языке С++, где имеется подобный механизм, определен специальный препроцессорный IF-оператор, анализирующий, задана константа или нет. В языке C# используется вместо этого гораздо более мощный механизм атрибутов. Как известно, методы C# обладают набором атрибутов, придающих методу разные свойства. Среди встроенных атрибутов языка есть атрибут Conditional, аргументом которого является строка, задающая имя константы: [Conditional (“COMPLEX”)] public void ComplexMethod () {…} Если константа условной компиляции COMPLEX определена для активной конфигурации проекта, то произойдет компиляция вызова метода ComplexMethod, когда он встретится в тексте программы. Если же такая константа отсутствует в конфигурации, то вызов метода игнорируется. На методы, для которых возможно задание атрибута Conditional, накладывается ряд ограничений. Метод не должен быть: Функцией, возвращающей значение. Методом интерфейса. Методом с модификатором override. Возможно его задание для virtual-метода. В этом случае атрибут наследуется методами потомков. Атрибут Conditional обычно с аргументом DEBUG сопровождает модули, написанные для целей отладки. Но использование атрибута Conditional не ограничивается интересами отладки. Зачастую проект может использоваться в нескольких вариантах, например облегченном и более сложном. Методы, вызываемые в сложных ситуациях, например ComplexMethod, имеющий атрибут условной компиляции, будут вызываться только в той конфигурации, где определена константа COMPLEX. Классы Debug и Trace Атрибут условной компиляции Conditional характеризует метод, но не отдельный оператор. Иногда хотелось бы иметь условный оператор печати, не создавая специального метода, как это было сделано в предыдущем примере. Такую возможность и многие другие полезные свойства предоставляют классы Debug и Trace. Классы Debug и Trace – это классы-двойники. Оба класса находятся в пространстве имен Diagnostics, имеют идентичный набор статических свойств и методов с идентичной семантикой. В чем же разница? Методы класса Debug имеют атрибут условной компиляции с константой DEBUG, действуют только в Debug-конфигурации проекта и игнорируются в Release-конфигурации. Методы класса Trace включают два атрибута Conditional с константами DEBUG и TRACE и действуют в обеих конфигурациях. Одна из основных групп методов этих классов – методы печати данных: Write, WriteIf, WriteLine, WriteLineIf. Методы перегружены, в простейшем случае позволяют выводить некоторое сообщение. Методы со словом If позволяют сделать печать условной, задавая условие печати в качестве первого аргумента метода, что иногда крайне полезно. Методы со словом Line позволяют дополнять сообщение символом перехода на новую строку. По умолчанию методы обоих классов направляют вывод в окно Output. Однако это не всегда целесообразно, особенно для Release-конфигурации. Замечательным свойством методов классов Debug и Trace является то, что они могут иметь много «слушателей», направляя вывод каждому из них. Свойство Listeners этих классов возвращает разделяемую обоими классами коллекцию слушателей – TraceListenerCollection. Как и всякая коллекция она имеет ряд методов для добавления в коллекцию новых слушателей: Add, AddRange, Insert, возможность удаления слушателей из коллекции: Clear, Remove, RemoveAt и другие методы. Объекты этой коллекции в качестве предка имеют абстрактный класс TraceListener. Библиотека FCL включает три неабстрактных потомка этого класса: DefaultTraceListener – слушатель этого класса, добавляемый в коллекцию по умолчанию, направляет вывод, поступающий при вызове методов классов Debug и Trace, в окно Output; EventLogTraceListener – посылает сообщения в журнал событий Windows; TextWriterTraceListener – направляет сообщения объектам класса TextWriter или Stream, обычно один из объектов этого класса направляет вывод на консоль, другой – в файл. Можно и самому создать потомка абстрактного класса, предложив например XML-слушателя, направляющего вывод в соответствующий XML-документ. Как видите, система управления выводом очень гибкая, позволяющая получать и сохранять информацию о ходе вычислений в самых разных местах. Помимо свойства Listeners и методов печати, классы Debug и Trace имеют и другие важные методы и свойства: Assert и Fail, проверяющие корректность хода вычислений – о них мы поговорим особо. Flush – метод, отправляющий содержание буфера слушателю (в файл, на консоль и так далее). Следует помнить, что данные буферизуются, поэтому применение метода Flush зачастую необходимо, иначе метод может завершиться, а данные останутся в буфере. AutoFlush – булево свойство, указывающее, следует ли после каждой операции записи данные из буфера направлять в соответствующий канал. По умолчанию свойство выключено и происходит только буферизация данных. Close – метод, опустошающий буфера и закрывающий всех слушателей, после чего им нельзя направлять сообщения. Отладка и инструментальная среда Visual Studio .Net Инструментальная среда студии предоставляет программисту самый широкий спектр возможностей слежения за ходом вычислений и отслеживания состояний, в котором находится процесс вычислений. Поскольку все современные инструментальные среды организованы сходным образом и хорошо известны работающим программистам, я позволю себе не останавливаться на описании возможностей среды. Обработка исключительных ситуаций Какой бы надежный код не был написан, сколь бы тщательной не была отладка, в версии, переданной в эксплуатацию и на сопровождение, при ее запусках будут встречаться нарушения спецификаций. Причиной этого являются выше упомянутые законы программотехники. В системе остается последняя ошибка, находятся пользователи, не знающие спецификаций, и если спецификацию можно нарушить, то это событие когда-нибудь, да и произойдет. В таких исключительных ситуациях продолжение выполнения программы либо становится невозможным (попытка выполнить неразрешенную операцию деления на ноль, попытки записи в защищенную область памяти, попытка открытия несуществующего файла, попытка получить несуществующую запись базы данных), либо выполнение программы становится неразумным из-за того, что в возникшей ситуации применение алгоритма приведет к ошибочным результатам. В языках программирования для обработки исключительных ситуаций предлагались самые разные подходы. Обработка исключений в языках C/C++ Для стиля программирования на языке C характерно описание методов класса как булевых функций, возвращающих true в случае нормального завершения метода и false – при возникновении исключительной ситуации. Вызов метода встраивался в If-оператор, обрабатывающий ошибку в случае неуспеха завершения метода: bool MyMethod(…){…} if !MyMethod(){// обработка ошибки} {//нормальное выполнение} Недостатки этой схемы понятны. Во-первых, недостаточно информации о причине возникновения ошибки, поэтому либо через поля класса, либо через аргументы метода нужно передавать дополнительную информацию. Во-вторых, блок обработки встраивается в каждый вызов, что приводит к раздуванию кода. Поэтому в C/C++ применяется схема try/catch блоков, суть которой в следующем. Участок программы, в котором может возникнуть исключительная ситуация оформляется в виде охраняемого try-блока. Если при его выполнении возникает исключительная ситуация, то происходит прерывание выполнения try-блока c классификацией исключения. Это исключение начинает обрабатывать один из catch-блоков, соответствующий типу исключения. В C/C++ применяются две такие схемы. Одна из них – схема с возобновлением – соответствует так называемым структурным или С-исключениям. Вторая схема – без возобновления – соответствует С++ исключениям. В первой схеме обработчик исключения – catch-блок – возвращает управление в некоторую точку try-блока. Во второй схеме управление не возвращается в try-блок. С некоторыми синтаксическими отличиями схема с возобновлением применяется в языках VB/VBA. Схема обработки исключений в C# Язык C# наследовал схему исключений языка С++, внеся в нее свои коррективы. Рассмотрим схему подробнее и начнем с синтаксиса конструкции try-catch-finally: try {…} catch (T1 e1) {…} … catch(Tk ek) {…} finally {…} Всюду в тексте модуля, где синтаксически допускается использование блока, этот блок можно сделать охраняемым, добавив ключевое слово try. Вслед за try-блоком могут следовать catch-блоки, называемые блоками-обработчиками исключительных ситуаций, их может быть несколько, они могут и отсутствовать. Завершает эту последовательность finally-блок – блок финализации, который также может отсутствовать. Вся эта конструкция может быть вложенной – в состав try-блока может входить конструкция try-catch-finally. Выбрасывание исключений. Создание объектов Exception В теле try-блока может возникнуть исключительная ситуация, приводящая к выбрасыванию исключений. Формально выбрасывание исключения происходит при выполнении оператора throw. Этот оператор, чаще всего, выполняется в недрах операционной системы, когда система команд или функция API не может выполнить свою работу. Но этот оператор может быть частью программного текста try-блока и выполняться, когда в результате проведенного анализа становится понятным, что дальнейшая нормальная работа невозможна. Синтаксически оператор throw имеет вид: throw[выражение] Выражение throw задает объект класса, являющегося наследником класса Exception. Обычно это выражение new, создающее новый объект. Если это выражение отсутствует, то повторно выбрасывается текущее исключение. Если исключение выбрасывается операционной системой, то она сама классифицирует исключение, создает объект соответствующего класса и автоматически заполняет его поля. В рассматриваемой нами модели исключения являются объектами, класс которых является наследником класса Exception. Этот класс и многочисленные его наследники является частью библиотеки FCL, хотя и разбросаны по разным пространствам имен. Каждый класс задает определенный тип исключения в соответствии с классификацией, принятой в Framework .Net. Вот лишь некоторые классы исключений из пространства имен System: Argument Exception, ArgumentOutOfRangeException, ArithmeticException , BadImageFormatException, DivideByZeroException, OverflowException. В пространстве имен System.IO собраны классы исключений, связанных с проблемами ввода-вывода: DirectoryNotFoundException, FileNotFoundException и многие другие. Имена всех классов исключений заканчиваются словом Exception. Разрешается создавать собственные классы исключений, наследуя их от класса Exception. При выполнении оператора throw создается объект te, класс TE которого характеризует текущее исключение, а поля содержат информацию о возникшей исключительной ситуации. Выполнение оператора throw приводит к тому, что нормальный процесс вычислений на этом прекращается. Если это происходит в охраняемом try-блоке, то начинается этап «захвата» исключения одним из обработчиков исключений. Захват исключения Блок catch – обработчик исключения имеет следующий синтаксис: catch (T e) {…} Класс T, указанный в заголовке catch-блока, должен принадлежать классам исключений. Блок catch с формальным аргументом e класса T потенциально способен захватить текущее исключение te класса TE, если и только если объект te совместим по присваиванию c объектом e. Другими словами потенциальная способность захвата означает допустимость присваивания e = te, что возможно, когда класс TE является потомком класса T. Обработчик, класс T которого является классом Exception, является универсальным обработчиком, потенциально он способен захватить любое исключение, поскольку все они являются его потомками. Потенциальных захватчиков может быть много, исключение захватывает лишь один – тот из них, кто стоит первым в списке проверки. Каков порядок проверки? – довольно естественный. Вначале проверяются обработчики в порядке следования их за try-блоком и первый потенциальный захватчик становится активным, захватывая исключение и выполняя его обработку. Отсюда становится ясно, что порядок следования в списке catch-блоков крайне важен. Первыми идут наиболее специализированные обработчики, далее по мере возрастания универсальности. Так вначале должен идти обработчик исключения DivideByZeroException, а уже за ним –ArithmeticException. Универсальный обработчик, если он есть, должен стоять последним. За этим наблюдает статический контроль типов. Если потенциальных захватчиков в списке catch-блоков нет (сам список может отсутствовать), то происходит переход к списку обработчиков охватывающего блока. Напомню, что try-блок может быть вложен в другой try-блок. Когда же будет исчерпаны списки вложенных блоков, а потенциальный захватчик не будет найден, то произойдет подъем по стеку вызовов. Параллельная работа обработчиков исключений Обработчику исключения – catch-блоку, захватившему исключение, передается текущее исключение. Анализируя свойства этого объекта, обработчик может понять причину, приведшую к возникновению исключительной ситуации, попытаться ее исправить и в случае успеха продолжить вычисления. Заметьте, в принятой C# схеме без возобновления обработчик исключения не возвращает управление try-блоку, а сам пытается решить проблемы. После завершения catch-блока выполняются операторы текущего метода, следующие за конструкцией try-catch-finally. Зачастую, обработчик исключения не может исправить ситуацию или может выполнить это лишь частично, предоставив решение оставшейся части проблем вызвавшему методу – предшественнику в цепочке вызовов. Механизм, реализующий такую возможность – это тот же механизм исключений. Как правило, в конце своей работы, обработчик исключения выбрасывает исключение, выполняя оператор throw. При этом у него есть две возможности – повторно выбросить текущее исключение, или выбросить новое исключение, содержащее дополнительную информацию. Таким образом обработку возникшей исключительной ситуации могут выполнять несколько обработчиков, принадлежащие разным уровням цепочки вызовов. Блок finally До сих пор ничего не было сказано о важном участнике схемы обработки исключений – блоке finally. Напомню, рассматриваемая схема является схемой без возобновления. Это означает, что управление вычислением неожиданно покидает try-блок. Просто так этого делать нельзя – нужно выполнить определенную чистку. Прежде всего удаляются все локальные объекты, созданные в процессе работы блока. В языке С++ эта работа требовала вызова деструкторов объектов. В C# благодаря автоматической сборке мусора освобождением памяти можно не заниматься, достаточно освободить стек. Но в блоке try могли быть заняты другие ресурсы – открыты файлы, захвачены некоторые устройства. Освобождение ресурсов, занятых try-блоком, выполняет finally-блок. Если он присутствует, он выполняется всегда, сразу же после завершения работы try-блока, как бы последний не завершался. Блок try может завершиться вполне нормально без всяких происшествий и управление достигнет конца блока, выполнение может прервано оператором throw, управление может передано другому блоку из-за выполнения таких операторов как goto, return – во всех этих случаях прежде чем управление будет передано по предписанному назначению ( в том числе прежде чем произойдет захват исключения) предварительно будет выполнен finally-блок, освобождающий ресурсы, занятые try-блоком, параллельно будет происходить освобождение стека от локальных переменных. Схема Бертрана обработки исключительных ситуаций Схема обработки исключительных ситуаций, предложенная в языке C# обладает одним существенным изъяном – ее можно применять некорректно. Она позволяет в случае возникновения исключительной ситуации уведомить о ее возникновении и спокойно продолжить работу, что в конечном счете приведет к неверным результатам. Из двух зол – прервать вычисление с уведомлением о невозможности продолжения работы, или закончить вычисления с ошибочным результатом вычисления – следует выбирать первое. Некорректно примененная схема C# приведет к ошибочным результатам. Приведу несколько примеров. Представьте, оформляется заказ на отдых где-нибудь на Канарах. В ходе оформления возникает исключительная ситуация – нет свободных мест в гостинице – обработчик исключения посылает уведомление с принесением извинений, но оформление заказа продолжается. Вероятнее, предпочтительнее отказаться от отдыха на Канарах, и выбрать другое место, чем оказаться без крыши над головой, ночуя на берегу океана. Эта ситуация не является критически важной. А что если в процессе подготовки операции выясняется, что проведение ее в данном случае опасно. Никакие извинения не могут избавить от вреда, нанесенного операцией. Операция должна быть отменена. Бертран Мейер в книге [1], в которой все механизмы, используемые в объектной технологии, тщательно обосновываются, предложил следующую схему обработки исключительных ситуаций. В основе ее лежит подход к проектированию программной системы на принципах Проектирования по Контракту. Модули программной системы, вызывающие друг друга заключают между собой контракты. Вызывающий модуль обязан обеспечить истинность предусловия, необходимого для корректной работы вызванного модуля. Вызванный модуль обязан гарантировать истинность постусловия по завершению своей работы. Если в вызванном модуле возникает исключительная ситуация, то это означает, что он не может выполнить свою часть контракта. Что должен делать обработчик исключительной ситуации? – у него только две возможности – Retry и Rescue. Первая (Retry)– попытаться внести некоторые коррективы – и вернуть управление охраняемому модулю, который может предпринять очередную попытку выполнить свой контракт. Модуль может, например в следующей попытке запустить другой алгоритм, использовать другой файл, другие данные. Если все закончится успешно, работа модуля соответствует его постусловию, то появление исключительной ситуации можно рассматривать как временные трудности, успешно преодоленные. Если же ситуация возникает вновь и вновь, тогда обработчик события применяет вторую стратегию (Rescue), выбрасывая исключение и передавая управление вызывающему модулю, который и должен теперь попытаться исправить ситуацию. Важная тонкость в схеме, предложенной Бертраном, состоит в том, что исключение, выбрасываемое обработчиком исключения, следует рассматривать не как панику, не как бегство, а как отход на заранее подготовленные позиции. Обработчик исключения должен позаботиться о восстановлении состояния, предшествующего вызову модуля, приведшего к исключительной ситуации, что гарантирует нахождение всей системы в корректном состоянии. Схема Бертрана является схемой с возобновлением, и она наиболее точно описывает разумную стратегию обработки исключительных ситуаций. Не следует думать, что эта схема не может быть реализована на C#, просто она требует понимания сути и определенной структурной организации модуля. Приведу возможную реализацию такой схемы на C#: public void Pattern() { do { try { bool Danger = false; Success = true; MakeJob(); Danger = CheckDanger(); if (Danger) throw (new MyException()); MakeLastJob(); } catch (MyException me) { if(count > maxcount) throw(new MyException("Три попытки были безуспешны")); Success = false; count++; //корректировка ситуации Console.WriteLine("Попытка исправить ситуацию!"); level +=1; } }while (!Success); } Приведу краткие комментарии к этой процедуре, которую можно рассматривать как некоторый образец организации обработки исключительной ситуации: Конструкция try-catch блоков помещается в цикл do-while(!Success), завершаемый в случае успешной работы охраняемого блока, за чем следит булева переменная Success. В данном образце предполагается, что в теле охраняемого блока анализируется возможность возникновения исключительной ситуации и в случае обнаружения опасности выбрасывается собственное исключение, класс которого задан программно. В соответствии с этим тело try-блока содержит вызов метода MakeJob, выполняющего некоторую часть работы, после чего вызывается метод CheckDanger, выясняющий, не возникла ли опасность нарушения спецификации и может ли работа быть продолжена. Если все нормально, то выполняется метод MakeLastJob, выполняющий заключительную часть работы. Управление вычислением достигает конца try-блока, он успешно завершается и, поскольку остается истинной переменная Success, значение true которой установлено в начале try-блока, то цикл while, окаймляющий охраняемый блок и его обработчиков исключений, успешно завершается. Если в методе CheckDanger выясняется, что нормальное продолжение вычислений невозможно, то выбрасывается исключение класса MyException. Это исключение перехватывает обработчик исключения, стоящий за try-блоком, поскольку класс MyException указан, как класс формального аргумента. Для простоты приведен только один catch-блок. В общем случае их может быть несколько, но все они строятся по единому образцу. Предполагается, что обработчик исключения может сделать несколько попыток исправить ситуацию, после чего повторно выполняется охраняемый блок. Если же число попыток, за которым следит переменная count, превосходит максимально допустимое, то обработчик исключения выбрасывает новое исключение, задавая дополнительную информацию, передавая тем самым обработку ошибки на следующий уровень – вызываемой программе. Когда число попыток еще не исчерпано, то обработчик исключения переменной Success дает значение false, гарантирующее повтор выполнения try-блока, увеличивает счетчик числа попыток и пытается исправить ситуацию. Как видите, эта схема реализует два корректных исхода обработки исключительной ситуации – Retry и Rescue – повтору с надеждой выполнить обязательства, и передачи управления вызывающей программе, чтобы она предприняла попытки исправления ситуации, когда вызванная программа не могла с этим справиться. Доведем этот образец до реально работающего кода, где угроза исключения зависит от значения генерируемого случайного числа, а обработчик исключения может изменять границы интервала, повышая вероятность успеха. Определим первым делом собственный класс исключений: public class MyException :Exception { public MyException() {} public MyException (string message) : base(message) {} public MyException (string message, Exception e) : base(message, e) {} } Минимально, что нужно сделать, определяя свои исключения, – это задать три конструктора класса, вызывающие соответствующие конструкторы базового класса Exception. В классе Excepts, методом которого является наш образец Pattern, определим следующие поля класса: Random rnd = new Random(); int level = -10; bool Success; //true - нормальное завершение int count =1; // число попыток выполнения const int maxcount =3; Определим теперь методы, вызываемые в теле охраняемого блока: void MakeJob() { Console.WriteLine("Подготовительные работы завершены"); } bool CheckDanger() { //проверка качества и возможности продолжения работ int low = rnd.Next(level,10); if ( low > 6) return(false); return(true); } void MakeLastJob() { Console.WriteLine("Все работы завершены успешно"); } В классе Testing зададим метод, вызывающий метод Pattern: public void TestPattern() { Excepts ex1 = new Excepts(); try { ex1.Pattern(); } catch (Exception e) { Console.WriteLine("исключительная ситуация при вызове Pattern"); Console.WriteLine(e.ToString()); } } Обратите внимание, вызов метода Pattern находится внутри охраняемого блока. Поэтому, когда Pattern не справится с обработкой исключительной ситуации, ее обработку возьмет на себя универсальный обработчик, стоящий за try-блоком. Показаны три варианта запуска метода TestPattern. В одном из них исключительной ситуации при вызове метода Pattern вообще не возникало, в другом – ситуация возникала, но коррекция обработчика исключения помогла и при повторе выполнения охраняемого блока в Pattern все прошло нормально. В третьем варианте метод Pattern не смог справиться с исключительной ситуацией, и она обрабатывалась в catch-блоке метода TestPattern. Класс Exception Рассмотрим устройство базового класса Exception, что поможет понять, какую информацию может получить обработчик исключения, когда ему передается объект, задающий текущее исключение. Основными свойствами класса являются: Message – строка, задающая причину возникновения исключения. Значение этого свойства устанавливается при вызове конструктора класса, когда создается объект, задающий исключение. HelpLink – ссылка (URL) на файл, содержащий подробную справку о возможной причине возникновения исключительной ситуации и способах ее устранения. InnerException – ссылка на внутреннее исключение. Когда обработчик исключение выбрасывает новое исключение для передачи обработки на следующий уровень, то текущее исключение становится внутренним для вновь создаваемого исключения. Source – имя приложения, ставшего причиной исключения. StackTrace – цепочка вызовов – методы, хранящиеся в стеке вызовов в момент возникновения исключения. TargetSite – метод, выбросивший исключение. Из методов класса отметим метод GetBaseException, – при подъеме по цепочке вызовов позволяет получить исходное исключение – первопричину возникновения последовательности выбрасываемых исключений. Класс имеет четыре конструктора, из которых три уже упоминалось. Один из них – конструктор без аргументов, второй – принимает строку, становящуюся свойством Message, третий – имеет еще один аргумент – исключение, передаваемое свойству InnerException. В предыдущий пример внесем некоторые изменения. В частности, добавим еще один аргумент при вызове конструктора исключения в catch-блоке метода Pattern: throw(new MyException("Все попытки Pattern безуспешны", me)); В этом случае у создаваемого исключения заполняется свойство InnerExceptions. Для слежения за свойствами исключений добавил метод печати всех свойств, вызываемый во всех обработчиках исключений: static public void PrintProperties(Exception e) { Console.WriteLine("Свойства исключения:"); Console.WriteLine("TargetSite = {0}", e.TargetSite); Console.WriteLine("Source = {0}", e.Source); Console.WriteLine("Message = {0}",e.Message); if (nerException == null) Console.WriteLine("InnerException = null"); else Console.WriteLine("InnerException = {0}", nerException.Message); Console.WriteLine("StackTrace = {0}", e.StackTrace); Console.WriteLine("GetBaseException = {0}", e.GetBaseException()); } Корректное применение механизма исключений должно поддерживаться целенаправленными усилиями программиста. Следует помнить о двух важных правилах: обработка исключений должна быть направлена не столько на уведомление о возникновении ошибки, сколько на корректировку возникшей ситуации; если исправить ситуацию не удается, то программа должна быть прервана, не приводя к получению некорректных результатов, не удовлетворяющих спецификациям программы. |