Министерство образования Российской Федерации РОСТОВСКИЙ ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ
Надолин К.А. «ОБЪЕКТНО-ОРИЕНТИРОВА...
25 downloads
269 Views
451KB Size
Report
This content was uploaded by our users and we assume good faith they have the permission to share this book. If you own the copyright to this book and it is wrongfully on our website, we offer a simple DMCA procedure to remove your content from our site. Start by pressing the button below!
Report copyright / DMCA form
Министерство образования Российской Федерации РОСТОВСКИЙ ГОСУДАРСТВЕННЫЙ УНИВЕРСИТЕТ
Надолин К.А. «ОБЪЕКТНО-ОРИЕНТИРОВАННОЕ ПРОГРАММИРОВАНИЕ НА С++»
ОБРАБОТКА ИСКЛЮЧИТЕЛЬНЫХ СИТУАЦИЙ
Методические указания к спецкурсу для студентов механико-математического факультета специальности "Прикладная математика"
РОСТОВ-НА-ДОНУ
2003
Надолин К.А. «ОБЪЕКТНО-ОРИЕНТИРОВАННОЕ ПРОГРАММИРОВАНИЕ НА С++». Обработка исключительных ситуаций. Методические указания к спецкурсу для студентов механико-математического факультета специальности "Прикладная математика" // Ростов-на-Дону: УПЛ РГУ, 2003. 24 с.
Печатается по решению кафедры математического моделирования (протокол № 1 от 27 января 2003 г.) механико-математического факультета РГУ.
© Надолин К.А., Ростовский государственный университет, 2003.
2
СЛОЖНОСТЬ ЗАДАЧИ ОБРАБОТКИ ОШИБОЧНЫХ СИТУАЦИЙ Концепция программирования с защитой от ошибок предполагает включение в код программного модуля средств контроля его нормального выполнения. Это обеспечивает живучесть и корректную работу программ. Проверить успешность того или иного действия в модуле обычно легко, но как поступить, если возникла ошибка? Пусть, например, при выполнении функции конкатенации строк строка-результат не может полностью вместить все символы присоединяемой строки. Не составит труда запрограммировать эту проверку, но что делать? Возможны, например, следующие варианты решения: • Ситуация фатальная. Следует прервать выполнение программы. • Ничего особенного. Операция выполняется. Сигнализировать о происшествии не требуется. Строка-результат формируется в пределах уместившихся символов. • Ничего особенного. Аргументы остаются без изменения, операция не выполняется. Сигнализировать о происшествии не требуется. • Ситуация неприятная. Требуется сигнализировать о ее возникновении. Перечень возможных решений, очевидно, может быть продолжен, поскольку вопрос о том, как оценивать данную ситуацию, зависит от программируемой задачи. Главная проблема обработки исключительных ситуаций состоит в том, что разработчик библиотеки классов может легко обнаружить возникновение ошибки при выполнении программы, но ему сложно принять решение, как с ней поступить. Пользователь наоборот знает, что предпринять в случае ошибки, но не имеет возможности ее обнаружить (иначе ошибка была бы обработана в пользовательском коде, и от библиотеки ничего бы не потребовалось) [1]. В языке С++ предусмотрены специальные средства для решения этой проблемы, базирующиеся на понятии исключения. Стандарт ANSI С формулирует концепцию и описывает интерфейс языка для обнаружения и обработки исключительных ситуаций, определяя тем самым стиль надежного программирования на С++. Главная идея состоит в том, что функция, сталкивающаяся с неразрешимой проблемой, порождает (вызывает, генерирует, выбрасывает, заявляет) исключение в надежде на то, что вызвавшая ее (прямо или косвенно) функция распознает и решит эту проблему (перехватит и обработает исключение). Исключение есть способ идентификации ситуации. (Для этой цели в С++ может использоваться имя типа (встроенного или пользовательского) и, возможно, имя переменной). Функция, способная обработать определенный тип ситуаций, должна быть соответствующим образом оформлена. 3
Рассмотрим, например, как может быть определена и обработана ошибка, связанная с выходом индекса за границы массива в классе Vector: template class Vector { private: T* v; int int unsigned
lbound; ubound; max_size;
public: // . . . T& operator[]( int ) { if( i < lbound || ubound < i ) throw "Index out of range."; return v[i-lbound]; }; // . . . }; // class Vector Здесь генерируется исключение типа const char*, который соответствует постоянной строке "Index out of range". Ключевое слово throw отвечает оператору языка С++, «выбрасывающему» исключение. Функция, предполагающая контроль индексов, должна содержать программный код, использующий векторный тип в так называемом try-блоке: void f( Vector& w ) { // . . . try { do_something(w); } catch ( const char* s ) { // здесь пользователь имеет возможность // обработать исключительную ситуацию // например, так cerr << “Произошла ошибка: ” << s << “\n”; exit(1); } // Здесь продолжается выполнение при нормальном // завершении функции do_something() } 4
Слова try и catch являются ключевыми словами языка С++. Конструкция catch ( . . . . . ) { . . . . . } называется обработчиком исключения и должна размещаться сразу за tryблоком, либо за другим (предшествующим) обработчиком исключения. В круглых скобках обработчика содержится имя типа, идентифицирующее исключительную ситуацию и, возможно, имя аргумента, который может использоваться подобно аргументу функции и передавать обработчику дополнительную информацию, связанную с исключением – значение исключения. Процесс генерации и последующей обработки исключений требует поиска соответствующего обработчика от точки возникновения исключения через последовательность стековых фреймов вызовов функций до фрейма функции, имеющей искомый обработчик. Говорят, что происходит раскручивание стека вызовов. ПРОГРАММНЫЙ СТЕК И ЕГО ИЗМЕНЕНИЕ Для лучшего понимания механизма обработки исключения необходимо подробнее рассмотреть процесс изменения программного стека во время выполнения программы. Стек представляет собой область оперативной памяти компьютера, которая используется для размещения информации, связанной с вызовом функций, а также для хранения автоматических локальных переменных. Перед вызовом функции в стек заносится адрес возврата, кадр состояния (фрейм), содержащий значения регистров и позволяющий перед возвратом восстановить состояние вычислительного процесса и, при необходимости, значения параметров. После входа в программную секцию функции, со стека снимаются значения параметров и помещаются автоматические локальные переменные. Перед возвратом из функции со стека снимаются локальные переменные, кадр состояния и адрес возврата, затем в стек заносится возвращаемое функцией значение. По кадру состояния восстанавливаются регистры, после чего управление передается по адресу возврата. Таким образом, в процессе выполнения программы стек увеличивается и уменьшается. Раскручиванием программного стека называется процесс удаления из него значений, в результате которого уничтожаются стековые фреймы вызванных функций. Увеличение программного стека происходит в направлении уменьшения адресов выделенной физической памяти компьютера. Код программы, напротив, размещается в младших адресах. Далее следует область данных, где размещаются внешние и статические переменные и константы, а затем динамически распределяемая память («куча»). При использовании больших локальных массивов данных или при значительной глубине рекурсии возможно переполнение стека, т.е. разрастание его до других разделов памяти. 5
Рассмотрим схематический программный код и изменение стека в процессе его выполнения. Младшие адреса памяти • Код программы • Статические данные • Куча
void func2( ) { int i=256; } void func1( ) { int k=128; func2(); } void main( void ){ int j=64; // Когда поток управления // достигает этого места, в стеке // размещена переменная j func1(); }
вершина стека
j=64 Стек Старшие адреса памяти
void func2( ) { int i=256; } void func1( ) { int k=128; // Когда поток управления достигает // этого места, то, двигаясь вглубь стека, // увидим локальную переменную k, // фрейм функции func1(), адрес возврата // в main(), переменную j func2(); } void main( void ){ int j=64; func1(); }
6
Младшие адреса памяти • Код программы • Статические данные • Куча
вершина стека
k=128 фрейм для func1() адрес возврата в main() j=64 Стек Старшие адреса памяти
void func2( ) { int i=256; // Когда поток управления достигает этого // места, то, двигаясь вглубь стека, // увидим локальную переменную i, фрейм // функции func2(), адрес возврата в // func1(), переменную k, фрейм функции // func1(), адрес возврата в main(), // переменную j } void func1( ) { int k=128; func2(); } void main( void ){ int j=64; func1(); }
Младшие адреса памяти • Код программы • Статические данные • Куча
вершина стека
i=256 фрейм для func2() адрес возврата в func1() k=128 фрейм для func1() адрес возврата в main() j=64 Стек Старшие адреса памяти
Имеется несколько механизмов раскручивания программного стека [2]: • естественное раскручивание в процессе выполнения программы; • раскручивание, связанное с обработкой исключительных ситуаций; • раскручивание путем применения функции longjmp(); • и, наконец, путем прямой модификации регистра указателя стека CPU Важно заметить, что в случае обработки исключений, как и при естественном разматывании стека, удаление автоматических локальных объектов сопровождается вызовом для них деструкторов. ОСОБЕННОСТИ МЕХАНИЗМА ОБРАБОТКИ ИСКЛЮЧЕНИЙ В ЯЗЫКЕ С++ Вышеизложенное объясняет следующие обработки исключений в языке С++ [1,2]:
особенности
механизма
1) Обрабатываются только исключительные ситуации, явно генерируемые некоторой функцией; 2) Поддерживается окончательная модель обработки. Это означает, что после возникновения исключения невозможно продолжение выполнения программы с точки исключения; 3) Обработка исключения возможна только в функции, вызванной до его возникновения и еще не завершившейся. После выбрасывания исключения 7
4)
5)
6)
7)
управление должно быть передано некоторому программному блоку, принадлежащему функции, еще находящейся в стеке вызовов, путем его разматывания; Если заявлено исключение, для которого нет обработчика в цепочке вызовов, программа будет завершена. В процессе поиска обработчика программный стек будет раскручен до конца; Если обработчик «поймал» исключение, то обработка этого же исключения другими обработчиками, которые могут для него существовать, невозможна. Другими словами, действует первый подходящий обработчик, встретившийся в процессе разматывания стека; Если после заявления исключения управление передано catch-блоку, то вне зависимости от результата последующих действий исключение считается обработанным; Обработчик, как и обычная функция, может заявить исключение. Более того, в нем может использоваться оператор throw без параметра, что означает повторное генерирование исключения, обрабатываемого в данный момент; СРАВНЕНИЕ ИСКЛЮЧЕНИЙ С ТРАДИЦИОННЫМИ СПОСОБАМИ ОБРАБОТКИ ОШИБОК
Сравним традиционные подходы к обработке ошибки и концепцию генерирования исключительных ситуаций на примере обнаружении выхода индекса в операторе [ ] класса Vector за допустимые границы. Рассмотрим возможные традиционные варианты обработки ошибки: 1. Завершить программу, выдав сообщение об ошибке. 2. Возвратить условленное значение, обозначающее ошибку. 3. Возвратить значение, как при нормальном завершении, выставив некоторый (внешний) признак ошибки. 4. Вызвать функцию, предназначенную для вызова в случае ошибки (error handler functions). Для обработки исключений случай 1 фактически реализуется по умолчанию, когда заявленное событие не обрабатывается. Однако во многих ситуациях при возникновении ошибок можно и нужно поступать более изобретательно. Реализовать случай 2 не всегда возможно. Например, в нашем случае нет приемлемого возвращаемого значения для обозначения ошибки – любое значение типа T является корректным результатом для оператора [ ]. Кроме того, этот подход весьма утомителен, т.к. при каждом использовании оператора [ ] следовало бы проверять возвращенное значение. Поэтому такой подход редко используется для систематической проверки возникновения всех ошибок. Оставить программу с обозначенной, но не обработанной ошибкой, что соответствует случаю 3, опасно, так как вызывающая функция может не заметить, что в вызываемой функции оказалось не все в порядке. Например, 8
многие функции библиотеки С устанавливают глобальную переменную errno для индикации ошибки. Поэтому в программах без последовательных проверок errno будут появляться ошибки, вызванные ошибочными значениями, возвращаемые предыдущими вызовами. Более того, использование одной глобальной переменной для различных ошибок недопустимо, если присутствует параллелизм. (Мы помним, что параллелизм есть одна из характерных черт объектно-ориентированного программирования). Из традиционных подходов случай 4 наиболее гибкий и концептуально целостный. Он часто используется и в определенном смысле близок к обработке исключений. Недостатком этого подхода является, отсутствие единого стандарта на реализацию функций-обработчиков (error handler functions). Пользователю библиотеки с такой дисциплиной обработки ошибок требуется дополнительные усилия на ее освоение. Заметим, что от автора библиотеки классов с такой обработкой ошибок также требуются дополнительные усилия, поскольку необходимо хорошо продумать вопрос использования функций-обработчиков по умолчанию, применяемых при отсутствии пользовательских реализаций. Механизм обработки исключений является альтернативой традиционным методам во многих случаях, когда последние неприменимы, недостаточны, чреваты ошибками или некрасивы. Он определяет стандартный способ для явного отделения «вспомогательного» кода обработки от «обычного» кода выполнения, делая программу более читабельной и легче контролируемой. Формальный стиль, устанавливаемый средствами языка, упрощает взаимодействие между независимо написанными фрагментами программы. Вместе с тем необходимо понимать, что «обработка исключительных ситуаций остается сложной задачей и механизм обработки ситуаций – хотя и больше формализован, чем заменяемые им средства – все еще остается относительно неструктурированным по сравнению со средствами языка для локального управления выполнением в программе» [1]. ГЕНЕРИРОВАНИЕ И РАСПОЗНАВАНИЕ ИСКЛЮЧЕНИЙ. Исключение выступает одновременно как переменная и как тип данных (или как объект и как класс). Оператор throw выбрасывает объект, а catchобработчик ловит класс (или throw выбрасывает переменную, а catch ловит ее тип). При генерации исключения функции библиотеки исполняющей системы осуществляют следующие действия: 1) Создается и запоминается копия объекта (переменной). Это означает, что если в точке генерации исключения оказывается недоступен копирующий конструктор (например, он не является public, а исключение заявляется не функцией-другом), то возникает сообщение об ошибке. 2) Разматывает стек, вызывая деструкторы локальных объектов, выходящих из области видимости 9
3) Передается управление ближайшему catch-обработчику, совместимому с типом выброшенного исключения. При этом копия объекта-исключения передается, если это предусмотрено, обработчику в качестве параметра. Обработчик считается найденным, а исключение обработанным, если: a) тип исключения соответствует типу, ожидаемому в обработчике. Переменной (объекту) типа T соответствует обработчик, перехватывающий T, const T, T&, const T&. b) тип обработчика является заявленного исключения
публичным
базовым
классом
для
c) обработчик ожидает указатель, и исключение является указателем, который может быть преобразован к типу обработчика по стандартным правилам преобразования указателей d) встретился обработчик по умолчанию. Обработчик по умолчанию вызывается для исключения любого типа и имеет вид catch( ... ){ // тело обработчика } Очевидно, что обработчик по умолчанию должен располагаться последним среди обработчиков данного try-блока. Рассмотрим пример. Пусть класс Vector должен обнаруживать и сообщать об ошибках двух видов – ошибках индексации и ошибках распределения памяти: class Vector { private: int lbound, ubound; int *v; public: class Range{}; class Memory{}; // . . . . . Vector(int); int& operator[](int); // . . . . . }; Vector::Vector(int size) { if(!(v=new int[size])) throw Memory(); else { lbound=0; ubound = size-1; } } 10
int& Vector::operator[](int i) { if( i < lbound || ubound < i ) throw Range(); else return v[i-lbound]; } Пользователь класса Vector может различить два исключения, включив два обработчика: void f(void) { try{ use_vectors(); } catch( Vector::Range ){ // тело обработчика ошибки индексации } catch( Vector::Memory ){ // тело обработчика ошибки выделения памяти } } Если удается пройти обработчик, то выполняется код за обработчиками. Например: void f(void) { try{ use_vectors(); // на эту часть кода мы попадаем, // если не возникло исключения при // вызове функции use_vectors() } catch( Vector::Range ){ // здесь исправляем индекс // и пытаемся продолжить f(); } catch( Vector::Memory ){ cerr << “Memory error. Продолжение невозможно.\n”; exit(1); } 11
}
// на эту часть кода мы попадаем, если не возникло // исключения, или после обработчика исключения Range.
Функция не обязана обрабатывать все возможные исключения. Например: void f1(void) { try{ f2(v); } catch( Vector::Memory ){ // . . . . . } } void f2( Vector& v ) { try{ use_vectors(); } catch( Vector::Range ){ // . . . . . } } Здесь f2() перехватывает ошибки Range в use_vectors() и пропускает ошибки Memory, которые обрабатываются в f1(). Исключение считается обработанным сразу после входа в обработчик. Поэтому исключение, возникающее в обработчике должно обрабатываться программным кодом, вызывающим try-блок. Пусть ExeptionType имя класса, тогда код try{
do_something();
} catch( ExeptionType ){ // . . . . . throw ExeptionType(); } не приводит к возникновению бесконечного цикла. Обработчики ситуаций могут быть вложенными. Например, try { // . . . . . } 12
catch( type ) { try { // код, в котором возможно // возникновение ситуации типа type } catch( type ) { // . . . . . } } (Объясните, почему не происходит зацикливания в этом примере). Заметим, что такой стиль программирования усложняет понимание программного кода и его следует избегать. Обычно исключение не только сигнализирует об ошибке, но и содержит некоторую информацию о происшедшем. Например, при нарушении индексации полезно передать значение индекса, вызвавшее ситуацию: class Vector { // . . . . . public: class Range{ public: int index; Range(int i) : index(i){} }; // . . . . . int& operator[](int); // . . . . . }; int& Vector::operator[ ](int i) { if( i < lbound || ubound < i ) throw Range(i); else return v[i-lbound]; } void f( Vector& v ) { // . . . . . try{ use_vectors(v); } catch( Vector::Range r ){ 13
cerr << “Bad index: “ << r.index << ‘\n’; // . . . . . } // . . . . . } ИСКЛЮЧЕНИЯ В ШАБЛОНАХ. Рассмотрим пример template class Vector { private: T* v; int lbound; int ubound; public: class Range{ }; // . . . T& operator[]( int ) { if( i < lbound || ubound < i ) throw Range(); return v[i-lbound]; }; // . . . }; // class Vector Здесь в шаблоне класса содержится тип, используемый для исключений. Каким образом это проявляется в параметризованных классах, полученных из этого шаблона? Очевидно, в каждом таком классе мы имеем свой тип исключения, для которого должен использоваться свой обработчик: void f( Vector& vi, Vector<double>& vd ) { try { // . . . . . } catch( Vector::Range ) { // . . . . . } catch( Vector<double>::Range ) { // . . . . . } } 14
Если нам требуется использовать один тип исключения для всех параметризованных классов, то следует просто сделать класс Range внешним. ИЕРАРХИЯ ИСКЛЮЧЕНИЙ Часто исключения естественным образом группируются в семейства. Например, с ситуациями, возникающими в функциях математической библиотеки, можно связать семейство исключений MathError, включающее исключения Overflow, Underflow, Zerodivide и т.п. Один из способов такой группировки состоит в определении перечислимого типа данных MathError с соответствующими значениями: enum MathError { Overflow, Underflow, Zerodivide, /*. . . . .*/ }; // . . . . . try { // . . . . . MathError m; throw m; } catch( MathError m ) { switch (m) { case Overflow : /* . . . . . */ break; case Underflow : /* . . . . . */ break; case Zerodivide : /* . . . . . */ break; default : /* . . . . . */; } } Объектно-ориентированный подход подразумевает механизмов наследования и виртуальных функций: class MathError { }; class Overflow : public MathError { }; class Underflow : public MathError { }; class Zerodivide : public MathError { }; // . . . . . try { // . . . . . } catch( Overflow ) { // обработка Overflow и его потомков 15
использование
} catch( MathError ) { // обработка любого MathError, кроме Overflow и его потомков } Здесь все исключения, кроме Overflow, обрабатываются единообразно, как MathError. Если требуется полиморфизм, то исключение в обработчик можно передавать по ссылке, либо использовать указатели. Наградой за построение иерархии исключений является ее технологичность при сопровождении больших программ. Представьте себе, например, обработку всех исключений для стандартной математической библиотеки. Без группировки на основе наследования это приводит к длинным последовательностям обработчиков: try { // . . . . . } catch( Overflow ) { /* . . . . . */ } catch( Underflow ) { /* . . . . . */ } catch( Zerodivide ) { /* . . . . . */ } // . . . . . Весьма часто реакция на разные исключения будет одинаковой. Такая ситуация вызывает раздражение из-за дублирования кода и содержит опасность пропуска некоторых исключений в последовательности обработчиков. Кроме того, добавление нового исключения требует модификации и перекомпиляции уже работающих программ, что чревато внесением ошибок в ранее отлаженный код и требует его нового тестирования. Представьте себе, например, что добавлено исключение в стандартную библиотеку. Тогда модули, в которых должны обрабатываться все исключения, должны быть модифицированы и перекомпилированы. Но для больших библиотек это неприемлемо, поскольку исходный код библиотеки или некоторой ее части может оказаться недоступным. Использование наследования позволяет вводить исключения, принадлежащие различным группам. Например: class network_file_err : public network_err, public file_system_err { // . . . . . }; Исключение network_file_err может быть работающими с сетевыми исключениями:
16
отловлено
как
функциями,
void f() { try { // . . . . . } catch( network_err ) { // . . . . . } } так и функциями, обрабатывающими исключения файловой системы: void g() { try { // . . . . . } catch( file_system_err ) { // . . . . . } } Это важно, так как сервис (например, сетевой) может быть прозрачен настолько, что программист, пишущий g() даже не будет подозревать о существовании сети. Заметим, что при организации иерархии нет необходимости иметь общий базовый класс для всех исключений, поскольку имеется возможность перехвата всех исключений обработчиком по умолчанию. Однако если используется общий базовый класс для всех исключительных ситуаций, то необходимо сделать его пустым, за исключением виртуального деструктора, и строго придерживаться принципов наследования. РАСПРЕДЕЛЕНИЕ РЕСУРСОВ ПРИ ИСПОЛЬЗОВАНИИ ИСКЛЮЧЕНИЙ Когда требуется использовать некоторый ресурс, например, динамически распределить память или получить доступ к файлу, то важно после захвата и использования этого ресурса его освободить. Обычная ситуация такова, что ресурсы распределяются в начале работы некоторой функции и освобождаются перед ее завершением: void use_file( const char* filename ) { FILE* f = fopen( filename, “w” ); 17
// используем f fclose( f ); } Однако подобная последовательность действий требует корректировки, когда мы предполагаем обработку исключений. Действительно, если после вызова fopen(), но перед обращением к fclose(), в функции use_file() возникает исключительная ситуация, то мы покидаем функцию use_file() не закрыв файла. Можно рассмотреть такое, например, решение: void use_file( const char* filename ) { FILE* f = fopen( filename, “w” ); try { // используем f } catch( . . . ) { fclose( f ); throw; } fclose( f ); } Здесь код, работающий с файлом, целиком заключен в try-блок, у которого обработчик по умолчанию при возникновении исключения закрывает файл и повторно заявляет то же самое исключение. Однако такое решение выглядит не элегантно. В чем же состоит правильный подход? Заметим, что общая схема действий выглядит так: void get_resources( ) { // выделить ресурс 1 // . . . . . // выделить ресурс N // использование ресурсов // освободить ресурс N // . . . . . // освободить ресурс 1 } 18
причем, часто важно освобождать ресурсы в порядке, обратном их выделению. Это очень напоминает создание автоматических локальных переменных, размещение которых в стеке сопровождается вызовом конструктора, а уничтожение – деструктора. Возникает идея решить проблемы выделения и освобождения ресурсов, используя объекты классов с конструкторами и деструкторами, причем в конструкторе ресурс выделяется, а в деструкторе освобождается. Использование автоматических локальных переменных обеспечит корректное управление ресурсами, в том числе и при исключениях. Например, для работы с файлами можно определить класс FilePointer, работающий как FILE*: class FilePointer { private: FILE* fptr; public: FilePointer( const char* filename, const char* attr ) { fptr = fopen( filename, attr ); } FilePointer( FILE* pf ) { fptr = pf; } ~FilePointer( ) { fclose( fptr ); } operator FILE*( ) { return fptr; } }; Переменную типа FilePointer мы можем создавать, имея либо указатель FILE*, либо параметры для функции fopen(). В любом случае, автоматическая локальная переменная типа FilePointer будет уничтожена при выходе из области видимости, и ее деструктор закроет файл. Функция use_file() сокращается до минимума: void use_file( const char* filename ) { FilePointer f( filename, “w” ); // используем f } и деструктор будет вызван независимо от того, завершается функция нормально или возникает исключительная ситуация. Описанная выше техника управления ресурсами путем инициализации переменных является достаточно общим приемом, основанным на свойствах конструкторов и деструкторов и их взаимодействием с системой обработки исключений в С++. Объект не считается созданным, пока не выполнен его конструктор. Только в этом случае последующее удаление объекта из стека вызовет его деструктор. Объект, поля которого в свою очередь являются 19
объектами, считается созданным только после создания всех его полей. Создавая объект, правильно написанный конструктор при неудаче должен (насколько это возможно) восстановить состояние системы и не оставлять «полусозданный» объект. Это можно обеспечить путем управления ресурсами через инициализацию. Представьте себе класс X, конструктор которого должен получить два ресурса: файл file и его блокировку lock. Конструктор может и не получить какой-либо из требуемых ресурсов; в этом случае он возбуждает исключение. Чтобы не усложнять жизни программисту, конструктор класса X не должен нормально завершаться, открыв файл, но не получив его блокировки. Можно использовать два класса FilePointer и LockPointer для представления полученных ресурсов (если ресурсы одного типа, то можно ограничиться одним классом). Выделение ресурса реализуется путем инициализации локального объекта, представляющего ресурс: class X { private: FilePointer a; LockPointer b; // . . . . . public: X( const char* aa, const char* bb ) : a( aa ), // выделение 1-го ресурса b( bb ) // выделение 2-го ресурса {} // . . . . }; Теперь при создании локального объекта реализация класса позаботится обо всей «бухгалтерии». Например, исключение возникло при создании a, но еще до создания b. В этом случае будет вызван деструктор для переменной a, но не для b. Это означает, что если мы используем рассмотренную простую модель управления ресурсами, то все будет нормально и, что особенно важно, автору конструктора не нужно писать код для обработки исключений. Наиболее часто запрашиваемый ресурс – это динамическая память. Обычно выделение памяти выглядит так: class X { private: int* p; 20
// . . . . . public: X( int s ) { p = new int[s]; init( ); } ~X( ) { delete p; } // . . . . . }; Однако такая техника при использовании совместно с исключениями может привести к «утечке» памяти. В частности, если исключение заявлено функцией init(), то выделенная память не будет освобождена, поскольку объект полностью не создан и деструктор для него вызван не будет. Более безопасный вариант может выглядеть следующим образом: template class MemPtr { private: T* p; public: MemPtr( unsigned s ) { p = new T[s]; } ~MemPtr( ) { delete p; } operator T*( ) { return p; } }; class X { private: MemPtr ip; // . . . . . public: X( int s ) : ip( s ) { init( ); } }; Освобождение памяти, занятой под массив, на который указывает ip, теперь выполняется в MemPtr. Теперь, если функция init() заявит исключение, память будет освобождена при (неявном) вызове деструктора для объекта ip. Заметим также, что правила распределения памяти С++ гарантируют, что конструктор класса X не будет вызван, если для объекта ip не выделено памяти. Таким образом, не надо беспокоиться, что для несуществующего объекта может быть вызван деструктор.
21
СПЕЦИФИКАЦИЯ ИНТЕРФЕЙСА ФУНКЦИИ, ЗАЯВЛЯЮЩЕЙ ИСКЛЮЧЕНИЯ Возможность возбуждения и обработки исключений функцией должна учитываться при программировании взаимодействия этой функции с другими программными модулями и в первую очередь, вызывающими ее функциями. В языке С++ заголовок функции включает спецификацию исключений, которые данная функция может генерировать: void f( int a ) throw( Range, Size, int, char* ) { // тело функции } Здесь указано, что функция f() может заявить (прямо или косвенно) исключения четырех заданных типов или производные от них, но не другие. Это эквивалентно коду функции вида: void f( int a ) { try { // тело функции } catch( Range( ) ) { throw; // повторно заявить то же исключение } catch( Size( ) ) { throw; // повторно заявить то же исключение } catch( int ) { throw; // повторно заявить то же исключение } catch( char* ) { throw; // повторно заявить то же исключение } catch( . . . ) { unexpected( ); } } Предполагается, что функция, определяемая без указания спецификации исключительных ситуаций, может заявить исключение любого типа. Функция, которая не может вообще генерировать исключений, должна описываться с пустым списком: int g( void ) throw( ); // функция не заявляет каких-либо исключений
22
Спецификация исключения, однако, не контролируется на этапе компиляции. Например, код void f( void ) throw( int ){ throw “This error message has type char* ”; } не вызовет даже предупреждающего сообщения компилятора. Несоответствие фактического типа заявленного исключения специфицированным будет обнаружено только во время исполнения программы, что приведет к вызову функции unexpected(). По умолчанию функция unexpected() вызывает другую предопределенную функцию terminate(). Функция terminate() вызывается также, если исключение заявлено, но не обработано или если средства обработки исключений обнаружили разрушение стека. Прототипы функций terminate() и unexpected() содержатся в заголовочном файле except.h. По умолчанию функция terminate() просто завершает программу, вызывая функцию abort(). Однако существует возможность установить собственные функции terminate() и unexpected(), используя set_terminate() и set_unexpected() соответственно [2]. Инкапсуляция и возможность подмены реакции на необработанное или неожиданное исключение оказывается весьма полезной при интегрировании системы в новую среду. Например, при переносе несетевого приложения в распределенную вычислительную среду необходимо позаботиться об обработке сетевых исключений. Иногда проблему можно решить, переопределив функцию unexpected(). Рассмотрим предложенный в [1] способ адаптации некоторой функции g() к использованию в сети. Во-первых, определим класс, позволяющий манипулировать значением функции unexpected() как ресурсом, согласно рассмотренному выше приему управления ресурсами путем инициализации локальных переменных: #include <except.h> class STC { unexpected_function old; public : STC( unexpected_function f) { old = set_unexpected( f ); } ~STC( ) { set_unexpected( old ); } }; Тип unexpected_function и прототип функции set_unexpected() описаны в заголовочном файле except.h следующим образом: typedef void( _RTLENTRY * unexpected_function ) ( ) ; unexpected_function _RTLENTRY set_unexpected( unexpected_function ); 23
Здесь используется макрос _RTLENTRY, определяющий правила вызова, используемые стандартной библиотекой исполняющей системы (Standard Runtime Library) [2]. Задание1 Прокомментируйте оператор typedef, расшифровав смысл имени unexpected_function. Во-вторых, определим функцию, подменяющую unexpected(): void rethrow( ) { throw; } и, наконец, создадим версию функции g(), которая должна работать в сетевом окружении с учетом обработки исключительных ситуаций: void network_g( ) { STC a( &rethrow ); // теперь при вызове unexpected() вызывается rethrow() g(); } Теперь при возникновении непредусмотренных исключений они будут генерироваться повторно и передаваться вовне, не вызывая завершения программы по terminate(). ЛИТЕРАТУРА 1. Страуструп Б. Язык программирования С++, 3-е изд. / СПб.; М.: «Невский Диалект» – «Издательство БИНОМ», 1999г. – 991 с. [п.2.4.2, стр.65-66, гл.14, стр.407-442] (2-е изд. / Киев: Диа-Софт, 1993.- Ч.2. - 296 с. [гл.9, стр.77-111]). 2. Бабэ Б. Просто и ясно о Borland C++. — М.:БИНОМ, 1996. – 416 с. (гл.9, стр.305-350)
1
Имя unexpected_function определено как «указатель вида _RTLENTRY на функцию, не возвращающую значения».
24