Объектноориентированное программирование
Скачать 1.73 Mb.
|
10.1 Перехват исключений В языке C++ исключения обрабатываются в предложениях catch . Когда какая-то инструкция внутри try- блока возбуждает исключение, то про- сматривается список последующих предложений catch в поисках такого, который может его обработать. Catch -обработчик состоит из трех частей: ключевого слова catch , объ- явления одного типа или одного объекта, заключенного в круглые скобки (оно называется объявлением исключения), и составной инструкции. Если для обработки исключения выбрано некоторое catch -предложение, то вы- полняется эта составная инструкция. Рассмотрим catch -обработчики ис- ключений pushOnFull и popOnEmpty в функции main() более подробно: catch ( pushOnFull ) { cerr << "trying to push value on a full stack\n"; return errorCode88; } catch ( popOnEmpty ) { cerr << "trying to pop a value on an empty stack\n"; return errorCode89; } В обоих catch -обработчиках есть объявление типа класса; в первом это pushOnFull , а во втором – popOnEmpty . Для обработки исключения выби- рается тот обработчик, для которого типы в объявлении исключения и в воз- бужденном исключении совпадают. Например, когда функция-член pop() 82 класса iStack возбуждает исключение popOnEmpty , то управление попа- дает во второй обработчик. После вывода сообщения об ошибке в cerr , функция main() возвращает код errorCode89 А если catch -обработчики не содержат инструкции return , с какого ме- ста будет продолжено выполнение программы? После завершения обработ- чика выполнение возобновляется с инструкции, идущей за последним catch -обработчиком в списке. В нашем примере оно продолжается с ин- струкции return в функции main() . После того как catch- обработчик popOnEmpty выведет сообщение об ошибке, main() вернет 0. int main() { iStack stack( 32 ); try { stack.display(); for ( int x = 1; ix < 51; ++ix ) { // то же, что и раньше } } catch ( pushOnFull ) { cerr << "trying to push value on a full stack\n"; } catch ( popOnEmpty ) { cerr << "trying to pop a value on an empty stack\n"; } // исполнение продолжается отсюда return 0; } Говорят, что механизм обработки исключений в C++ невозвратный: по- сле того как исключение обработано, управление не возобновляется с того места, где оно было возбуждено. В нашем примере управление не возвра- щается в функцию-член pop() , возбудившую исключение. 10.2 Объекты-исключения Объявлением исключения в catch -обработчике могут быть объявления типа или объекта. В каких случаях это следует делать? Тогда, когда необхо- димо получить значение или как-то манипулировать объектом, созданным в выражении throw . Если классы исключений спроектированы так, что в объектах-исключениях при возбуждении сохраняется некоторая информа- ция и если в объявлении исключения фигурирует такой объект, то инструк- ции внутри catch -обработчика могут обращаться к информации, сохранен- ной в объекте выражением throw 83 Изменим реализацию класса исключения pushOnFull , сохранив в объ- екте-исключении то значение, которое не удалось поместить в стек. Catch -обработчик, сообщая об ошибке, теперь будет выводить его в cerr Для этого мы сначала модифицируем определение типа класса pushOnFull следующим образом: // новый класс исключения: // он сохраняет значение, которое не удалось поместить в стек class pushOnFull { public: pushOnFull( int i ) : _value( i ) { } int value { return _value; } private: int _value; }; Новый закрытый член _ value содержит число, которое не удалось по- местить в стек. Конструктор принимает значение типа int и сохраняет его в члене _ data . Вот как вызывается этот конструктор для сохранения значе- ния из выражения throw : void iStack::push( int value ) { if ( full() ) // значение, сохраняемое в объекте-исключении throw pushOnFull( value ); // ... } У класса pushOnFull появилась также новая функция-член value() , ко- торую можно использовать в catch -обработчике для вывода хранящегося в объекте-исключении значения: catch ( pushOnFull eObj ) { cerr << "trying to push value << "eObj.value() << "on a full stack\n"; } Обратите внимание, что в объявлении исключения в catch -обработчике фигурирует объект eObj , с помощью которого вызывается функция-член value() класса pushOnFull Объект-исключение всегда создается в точке возбуждения, даже если выражение throw – это не вызов конструктора и, на первый взгляд, не должно создавать объекта. Например: enum EHstate { noErr, zeroOp, negativeOp, severeError }; enum EHstate state = noErr; 84 int mathFunc( int i ) { if ( i == 0 ) { state = zeroOp; throw state; // создан объект-исключение } // иначе продолжается обычная обработка } В этом примере объект state не используется в качестве объекта-ис- ключения. Вместо этого выражением throw создается объект-исключение типа EHstate , который инициализируется значением глобального объекта state . Как программа может различить их? Для ответа на этот вопрос мы должны присмотреться к объявлению исключения в catch -обработчике бо- лее внимательно. Это объявление ведет себя почти так же, как объявление формального параметра. Если при входе в catch -обработчик исключения выясняется, что в нем объявлен объект, то он инициализируется копией объекта-исключе- ния. Например, следующая функция calculate() вызывает определенную выше mathFunc() . При входе в catch- обработчик внутри calculate() объект eObj инициализируется копией объекта-исключения, созданного выражением throw void calculate( int op ) { try { mathFunc( op ); } catch ( EHstate eObj ) { // eObj - копия сгенерированного объекта-исключения } } Объявление исключения в этом примере напоминает передачу пара- метра по значению. Объект eObj инициализируется значением объекта-ис- ключения точно так же, как переданный по значению формальный параметр функции – значением соответствующего фактического аргумента. Как и в случае параметров функции, в объявлении исключения может фигурировать ссылка. Тогда catch -обработчик будет напрямую ссылаться на объект-исключение, сгенерированный выражением throw , а не создавать его локальную копию: void calculate( int op ) { try { mathFunc( op ); } catch ( EHstate &eObj ) { // eObj ссылается на сгенерированный объект-исключение } } 85 Для предотвращения ненужного копирования больших объектов приме- нять ссылки следует не только в объявлениях параметров типа класса, но и в объявлениях исключений того же типа. В последнем случае catch -обработчик сможет модифицировать объект- исключение. Однако переменные, определенные в выражении throw , оста- ются без изменения. Например, модификация eObj внутри catch -обработ- чика не затрагивает глобальную переменную state , установленную в выра- жении throw : void calculate( int op ) { try { mathFunc( op ); } catch ( EHstate &eObj ) { // исправить ошибку, вызвавшую исключение eObj = noErr; // глобальная переменная state не изменилась } } Catch -обработчик переустанавливает eObj в noErr после исправления ошибки, вызвавшей исключение. Поскольку eObj – это ссылка, можно ожи- дать, что присваивание модифицирует глобальную переменную state . Од- нако изменяется лишь объект-исключение, созданный в выражении throw , поэтому модификация eObj не затрагивает state 10.3 Раскрутка стека Поиск catch -обработчикадля возбужденного исключения происходит следующим образом. Когда выражение throw находится в try -блоке, все ассоциированные с ним предложения catch исследуются с точки зрения того, могут ли они обработать исключение. Если подходящее предложение catch найдено, то исключение обрабатывается. В противном случае поиск продолжается в вызывающей функции. Предположим, что вызов функции, выполнение которой прекратилось в результате исключения, погружен в try -блок; в такой ситуации исследуются все предложения catch , ассоции- рованные с этим блоком. Если один из них может обработать исключение, то процесс заканчивается. В противном случае переходим к следующей по порядку вызывающей функции. Этот поиск последовательно проводится во всей цепочке вложенных вызовов. Как только будет найдено подходящее предложение, управление передается в соответствующий обработчик. В нашем примере первая функция, для которой нужен catch -обработ- чик, – это функция-член pop() класса iStack . Поскольку выражение throw внутри pop() не находится в try -блоке, то программа покидает pop() , не 86 обработав исключение. Следующей рассматривается функция, вызвавшая pop() , то есть main() . Вызов pop() внутри main() находится в try- блоке, и далее исследуется, может ли хотя бы одно ассоциированное с ним пред- ложение catch обработать исключение. Поскольку обработчик исключения popOnEmpty имеется, то управление попадает в него. Процесс, в результате которого программа последовательно покидает составные инструкции и определения функций в поисках предложения catch , способного обработать возникшее исключение, называется раскрут- кой стека. По мере раскрутки прекращают существование локальные объ- екты, объявленные в составных инструкциях и определениях функций, из которых произошел выход. C++ гарантирует, что во время описанного про- цесса вызываются деструкторы локальных объектов классов, хотя они исче- зают из-за возбужденного исключения. Если в программе нет предложения catch , способного обработать ис- ключение, оно остается необработанным. Но исключение – это настолько серьезная ошибка, что программа не может продолжать выполнение. По- этому, если обработчик не найден, вызывается функция terminate() из стандартной библиотеки C++. По умолчанию terminate() активизирует функцию abort() , которая аномально завершает программу. (В большин- стве ситуаций вызов abort() оказывается вполне приемлемым решением. Однако иногда необходимо переопределить действия, выполняемые функ- цией terminate() Вы уже, наверное, заметили, что обработка исключений и вызов функ- ции во многом похожи. Выражение throw ведет себя аналогично вызову, а предложение catch чем-то напоминает определение функции. Основная разница между этими двумя механизмами заключается в том, что информа- ция, необходимая для вызова функции, доступна во время компиляции, а для обработки исключений – нет. Обработка исключений в C++ требует языковой поддержки во время выполнения. Например, для обычного вызова функции компилятору в точке активизации уже известно, какая из перегру- женных функций будет вызвана. При обработке же исключения компилятор не знает, в какой функции находится catch -обработчик и откуда возобно- вится выполнение программы. Функция terminate() предоставляет меха- низм времени выполнения, который извещает пользователя о том, что под- ходящего обработчика не нашлось. 87 10.4 Повторное возбуждение исключений Может оказаться так, что в одном предложении catch не удалось пол- ностью обработать исключение. Выполнив некоторые корректирующие действия, catch -обработчик может решить, что дальнейшую обработку сле- дует поручить функции, расположенной «выше» в цепочке вызовов. Пере- дать исключение другому catch -обработчику можно с помощью повтор- ного возбуждения исключения. Для этой цели в языке предусмотрена кон- струкция throw , которая вновь генерирует объект-исключение. Повторное возбуждение возможно только внутри составной инструкции, являющейся частью catch -обработчика: catch ( exception eObj ) { if ( canHandle( eObj ) ) // обработать исключение return; else // повторно возбудить исключение, чтобы его перехватил другой // catch-обработчик throw; } При повторном возбуждении новый объект-исключение не создается. Это имеет значение, если catch -обработчик модифицирует объект, прежде чем возбудить исключение повторно. В следующем фрагменте исходный объект-исключение не изменяется. Почему? enum EHstate { noErr, zeroOp, negativeOp, severeError }; void calculate( int op ) { try { // исключение, возбужденное mathFunc(), имеет значение zeroOp mathFunc( op ); } catch ( EHstate eObj ) { // что-то исправить // пытаемся модифицировать объект-исключение eObj = severeErr; // предполагалось, что повторно возбужденное исключение будет // иметь значение severeErr throw; } } Так как eObj не является ссылкой, то catch -обработчик получает копию объекта-исключения, так что любые модификации eObj относятся к локаль- ной копии и не отражаются на исходном объекте-исключении, передавае- мом при повторном возбуждении. Таким образом, переданный далее объект по-прежнему имеет тип zeroOp 88 Чтобы модифицировать исходный объект-исключение, в объявлении ис- ключения внутри catch -обработчика должна фигурировать ссылка: catch ( EHstate &eObj ) { // модифицируем объект-исключение eObj = severeErr; // повторно возбужденное исключение имеет значение severeErr throw; } Теперь eObj ссылается на объект-исключение, созданный выражением throw , так что все изменения относятся непосредственно к исходному объ- екту. Поэтому при повторном возбуждении исключения далее передается модифицированный объект. Таким образом, другая причина для объявления ссылки в catch -обработ- чике заключается в том, что сделанные внутри обработчика модификации объекта-исключения в таком случае будут видны при повторном возбужде- нии исключения 10.5 Обработка исключений в С В эпоху расцвета процедурного программирования синтаксис работы с ошибками был тривиален и основывался на том, что вернула функция. Если функция возвращала TRUE – все хорошо, если же FALSE – то произо- шла ошибка. При этом сразу выделились два подхода к работе с ошиб- ками: Подход два в одном – функция возвращает FALSE или нулевой указа- тель как для ожидаемой, так и для неожиданной ошибки. Такой подход как правило применялся в API общего назначения и коде пользовательских про- грамм, когда большую часть ошибок можно было смело считать фаталь- ными и падать. Для тех редких случаев когда делить было все же нужно использовалась некая дополнительная машинерия вида GetLastError() Фрагмент кода того времени, копирующего данные из одного файла в дру- гой и возвращающего ошибку в случае возникновения любых проблем: BOOL Copy( CHAR* sname, CHAR* dname ) { FILE *sfile = 0, *dfile = 0; void* mem = 0; UINT32 size = 0, written = 0; BOOL ret = FALSE; sfile = fopen( sname, "rb" ); if( ! sfile ) goto cleanup; dfile = fopen( dname, "wb" ); 89 if( ! dfile ) goto cleanup; mem = malloc( F_CHUNK_SIZE ); if( ! mem ) goto cleanup; do { size = fread( sfile, mem, F_CHUNK_SIZE ); written = fwrite( dfile, mem, size ); if( size != written ) goto cleanup; } while( size ) ret = TRUE; cleanup: // Аналог деструктора. if( sfile) fclose( sfile ); if( dfile) fclose( dfile ); if( mem ) free( mem ); return ret; // Ожидаемая ошибка. } Подход разделения ошибок, при котором функция возвращает FALSE в случае неожиданной ошибки, а ожидаемую ошибку возвращает отдель- ным возвращаемым значением (в примере это error), если нужно. Такой под- ход применялся в более надежном коде, например apache, и подразумевал разделение на ожидаемые ошибки (файл не получилось открыть потому что его нет) и неожиданные (файл не получилось открыть потому, что закончи- лась память и не получилось выделить 20 байт чтобы скопировать строку с именем). Фрагмент того же код, но уже разделяющего неожиданную ошибку (возврат FALSE ) и ожидаемую (возврат HANDLE ). BOOL Copy( CHAR* sname, CHAR* dname, OUT HANDLE* error ) { HANDLE sfile = 0, dfile = 0, data = 0; UINT32 size = 0; ENSURE( PoolAlloc() ); // Обработка неожиданной ошибки. ENSURE( FileOpen( sname, OUT& sfile, OUT error ) ); REQUIRE( SUCCESS( error ) ); // Обработка ожидаемой ошибки. ENSURE( FileOpen( dname, OUT& dfile, OUT error ) ); REQUIRE( SUCCESS( error ) ); ENSURE( MemAlloc( OUT& data ) ); REQUIRE( SUCCESS( error ) ); do { ENSURE( FileRead( sfile, F_CHUNK_SIZE, OUT& data, OUT error ) ); REQUIRE( SUCCESS( error ) ); ENSURE( FileWrite( dfile, & data ) ); REQUIRE( SUCCESS( error ) ); ENSURE( MemGetSize( OUT& size ) ) } while( size ); 90 ENSURE( PoolFree() ); // Пул обеспечивает аналог деструкторов и RAII. return TRUE; } Через некоторое время разработчики заметили, что большинство успеш- ных решений использует ООП и решили, что неплохо бы его вынести в син- таксис языка, дабы писать больше кода по делу и меньше – повторяющегося кода для поддержки архитектуры. Давайте возьмем код выше и посмотрим, как он трансформировался по- сле добавления ООП в синтаксис языков программирования. Конструиро- вание и уничтожение объектов ( fopen , fclose ) стало конструкторами и де- структорами. Переброс неожиданной ошибки ( BOOL ret в первом примере, макрос ENSURE во втором) однозначно стал исключением. А вот с ожидаемой ошибкой случилось самое интересное – случился вы- бор. Можно было использовать возвращаемое значение – теперь, когда за- боту о неожиданных ошибках взяли на себя исключения, возвращаемое зна- чение снова стало в полном распоряжении программиста. А можно было ис- пользовать исключения другого типа – если функции копирования файлов самой не нужно обрабатывать ожидаемые ошибки то логично вместо if и REQUIRE просто ничего не делать – и оба типа ошибок уйдут вверх по стеку. Соответственно, у программистов снова получилось два варианта: Подход только исключения – ожидаемые и неожиданные ошибки – это разные типы исключений. void Copy( string sname, string dname ) { file source( sname ); file destination( sname ); source.open( "rb" ); destination.open( "wb" ); data bytes; do { bytes = source.read( F_CHUNK_SIZE ); destination.write( bytes ) } while( bytes.size() ) } Комбинированный подход – использование исключений для неожи- данных ошибок и кодов возврата / nullable типов для ожидаемых: bool Copy( string sname, string dname ) { file source( sname ); file destination( sname ); if( ! source.open( "rb" ) || ! destination.open( "wb" ) ) return false; 91 data bytes; do { bytes = source.read( F_CHUNK_SIZE ); if( bytes.isValid() ) { if( ! destination.write( bytes ) ) return false; } } while( bytes.isValid() && bytes.size() ) } Итак, если внимательно посмотреть на два приведенных выше фраг- мента кода то становится не совсем понятно почему выжил второй. Кода в нем объективно больше. Выглядит менее красиво. Если функция возвра- щает объект – то использовать коды возврата совсем неудобно. Вопрос – почему коды возврата вообще выжили в языках с поддержкой объектно- ориентированного программирования и исключений на уровне синтаксиса? Что я могу по этому поводу сказать: Первые реализации исключений, особенно в C++, были не очень удобны для ежедневного использования. Например, бросание исключения во время обработки другого исключения приводил к завершению программы. Или же бросание исключения в конструкторе приводило к тому, что деструктор не вызывался. Разработчикам API забыли объяснить для чего нужны исключения. В ре- зультате первое время не было даже деления на ожидаемые ( checked ) и неожиданные ( unchecked ), а API комбинировали как исключения, так и коды возврата. В большинстве языков для исключений забыли добавить семантику «иг- норировать ожидаемую ошибку». В результате на практике код, использу- ющий исключения как для ожидаемых так и для неожиданных ошибок, с невероятной скоростью обрастал try и catch везде, где только можно. |