The Russian Electronic Developer Magazine | |
Русский электронный журнал разработчика | |
"Точно так же все верят в свою исключительность..."
А. Макаревич
Дмитpий Завалишин
( Dmitry Zavalishin <dz@phantom.ru> )
$Id: except.txt 1.7 1997/03/10 10:52:08 dz Exp $
Банальность на тему: ничто не дается даром. С использованием exceptions исчезает одна головная боль, и приходит несколько других. Поначалу кажется, что новые проблемы еще хуже старых, однако при ближайшем рассмотрении многие из них оказываются не столько проблемами, сколько результатом попытки смешения стилей. Автор Си++ справедливо утверждал, что программировать на нем можно опираясь на несколько совершенно разных подходов. Я добавлю, что держать себя в рамках одного из них - весьма полезно. Это следует как из соображений абстрактых (никому еще от эклектики особого добра не было), так и сугубо практических. Поскольку почти все люди, способные внять абстрактным доводам способны их же и породить самостоятельно :), остановлюсь на вторых. Да оно и интереснее, в данном случае.
Hе знаю, что сподвигло автора концепции исключительных ситуаций ее придумать, но лично меня к этому дело привела жестокая лень писать в сотнях закоулков одно и то же снова и снова: "if( do_that() == Err ) return Err;". Ладно бы просто лень набить лишний if - так ведь дело этим не кончается. Hужно еще и не забыть освободить ресурсы, занятые данной функцией, а если они захватываются по очереди и их много - и вообще застрелиться можно. Самый простой код в этом случае выглядит примерно так:
Пример 1. int do_that() { int got = 0; if( get_r1() != Err ) { got++; if( get_r2() != Err ) { got++; if( get_r3() != Err ) { got++; ... здесь, собственно, работаем, используем ресурсы ... } } } switch( got ) { case 3: release_r3(); // fall through! case 2: release_r2(); // fall through! case 1: release_r1(); // fall through! } }
Забегая чуток вперед, покажу, как это будет выглядеть с примением предлагаемых мной механизмов:
Пример 2. void do_that() { r1_allocator r1; r2_allocator r2; r3_allocator r3; ... здесь, собственно, работаем, используем ресурсы ... }И все. Главная цель - сделать так, чтобы минимизировать "рабочий" код в основной части программы, затолкав его в "служебную", причем заодно достигается и существенно большее его повторное использование - вместо того, чтобы писать в каждой функции, использующей данные ресурсы, ловушки для их выделения и освобождения, все это один раз пишется в классе-аллокаторе. Hу да по порядку.
"Шахтеры делают ЭТО под землей"
(C) Yuri PQ :)
Пример 3. try { ...какой-то код, при исполнении которого могут возникнуть проблемы... } catch( тип_облома ) { ...что сделать, если в теле блока try, таки, случились неприятности... }А обрадовать "вышестоящий" try тем, что неприятность случилась можно оператором throw ("швырнуть"). Вот маленький пример:
Пример 4. class io_exception {}; class record{ ... }; // Прочесть новую запись из потока, // построить и вернуть объект record::record( istream &i ) { ... i.read( .... ); if( i.bad() ) throw io_exception(); ... } other_func() { try { rec_list.insert( new record( data_stream ) ); } catch( io_exception ) { print_error("Can't read a record"); throw; } }В этом примере конструктор класса record пытается прочесть запись, и бросает исключение, если ему это не удается. Обрабатывая исключение программа прерывает исполнение текущей функции (прямо посреди вычисления операндов метода insert!), и начинает откручиват назад вызовы функций до тех пор, пока не встретит ловушку (catch) с соответствующим параметру throw типом выражения в скобках. Если ловушка найдена - ей и передается управление. Прошу отметить - очень важно, что Exception может прервать и отменить даже работу конструктора. Это сильно повышает выразительность языка. Ведь если бы не было исключений, пришлось бы писать код гораздо менее лаконичный и наполнять его кучей затуманивающих суть происходящего проверок:
Пример 5 - тот-же код, что и в примере 4, но без использования исключений. // Прочесть новую запись из потока, // построить и вернуть объект bool record::read( istream &i ) { ... i.read( .... ); if( i.bad() ) return Err; return Ok; } other_func() { record r = new record( data_stream ) if( r == NULL ) return Err; if( r.read() == Err ) { delete r; print_error("Can't read a record"); return Err; } if( rec_list.insert( r ) == Err ) { print_error("Can't insert a record"); return Err; } return Ok; }
Посмотрите - то, что в варианте с исключениями выражалось одним простым оператором rec_list.insert( new record( data_stream ) ) теперь тянет на несколько строк, по которым рамазана и без того тощая его сущность. Конечно, читатель имеет право возразить - зато в варианте с исключениями торчит этот безумный try и огромный catch-хвост свисает, так что экономия не так и велика. Поверьте, пока что, на слово - в примерах обработка исключений выглядит страшнее, чем в реальности. Ведь если действительно уметь пользоваться исключениями, try и catch будут встречаться не так уж часто, а некоторые из них и вообще превратятся в элементарщину типа
Пример 6. while(1) { try { l.load( f ); } catch(Ex_EOF) { break; } ... }
Пример 7. class General_Ex { public: string where, what, why; void print() const; }; class Ex_Abort : public General_Ex { public: Ex_Abort( const char* wh ) { where = wh; what = "operation aborted"; why = ""; } }; class Ex_EOF : public General_Ex { public: Ex_EOF( const char* wh ) { where = wh; what = "EOF"; why = ""; } }; class Ex_Fail : public General_Ex { public: Ex_Fail( const char* wh, const char* wa, const char* wy ) { where = wh; what = wa; why = wy; } }; class Ex_Errno : public General_Ex { public: Ex_Errno( const char* wh, const char* wa, long e = errno() ) { where = wh; what = wa; char es[100]; sprintf( es, "%ld", e ); why = es; } };Пользоваться ими несложно:
Пример 8. try { ..... throw( Ex_Fail("db cleaner", "out of alcohol, nothiong to clean with","")); ... } catch( General_Ex ex ) { ex.print(); }Трех аргументов, обычно, хватет за глаза. Первый - место в коде, где возникла ошибка (помогает при обработке претензий пользователей, второй - суть проблемы. Третий - потенциальный виновник. Имя файла, при чтении которого случилось несчастье, запись, котору. не смогли понять - в общем, то, над чем трудился сгенеривший exception код.
Пример 9. void read_file() { FILE *fp = fopen( ... ); if( !fp ) throw( Ex_Errno("read_file", "can't open file" )); try { ... читаем файл ... } catch(...) // поймаем любое исключение { fclose( fp ); throw; // кинем то-же самое исключение дальше } // сюда попадем, если исключений не было fclose(fp); }Конечно, положительных эмоций такой код вызвать не может - он трудоемок в написании, громоздок и неудобен в отладке. Соответственно, создается впечатление, что сама идея системы исключений неудачна и использование ее всегда влечет за собой груды обработчиков исключений там и сям, необходимость дублирования частей кода (см. fclose в примере 9). Как будет видно ниже, впечатление ошибочное. Причиной ему является процедурно-ориентированная методика работы с ресурсами, а вовсе не исключения. Ведь при таком подходе к ресурсам, который использован в примере 9 (и в примере 1 в самом начале статьи) сложности с их освобождением неизбежны, если из функции существует более одной точки выхода. И не важно, чем она является - исключением, или лишним оператором return. И так, и так программист должен помнить о том, какие ресурсы он захватил, и в каждой точке выхода освободить их вручную. Решение этой проблемы в ОО-языках, тем не менее, есть, и появилось задолго до появления в С++ исключений. Заключается оно просто в необходимости последовательно придерживаться парадигмы ОО - выделять ресурсы в виде объектов же. Объектов, которые бы были в состоянии позаботиться о себе с помощью собственного деструктора. Возвращаясь к примеру 9, можно предложить воспользоваться для работы с файлами не библиотекой stdio, а C++ sreams - благо, в ОО программе этому есть и другие причины. Поскольку классы, используемые для работы с файлами в streams сами закрывают ассоциированные с ними файлы при уничтожении объектов этих классов, проблема освобождения ресурсов при выходе из функции при использовании streams просто пропадает. Достаточно для работы с ресурсами (с файлами, в данном случае) использовать автоматические переменные:
Пример 10. void read_file() { ifstream is( filename ); // К сожалению, ifstream не умеет бросать исключение сам if( !fp ) throw( Ex_Errno("read_file", "can't open file" )); // придется помочь ему ... читаем файл ... }Сравните примеры 9 и 10 - второй, мягко говоря, попроще будет, правда? Хитрость, думаю, вам уже понятна - при обработке исключений, когда управление передается вверх по цепочке вызовов функций (происходит "свертка" стека вызовов) в каждой сворачиваемой функции уничтожаются все ее автоматические переменные. При этом, знамо дело, вызываются их деструкторы, буде таковые обнаружатся. Соответственно, если в фрагменте "...читаем файл..." примера 10 произойдет исключение, работа функции будет завершена, переменная is уничтожена, а деструктор ее закроет соответствующий файл. И, напомню еще раз, это произойдет при любом возврате из read_file - хоть по return, хоть по exception, хоть по провалу на закрывающую скобку. Hе случится освобождения ресурсов только при использовании longjmp. Посему использовать longjmp в ОО-программах нельзя. Разве что для создания сопроцессов. Hо для этого в современных ОС есть нити (threads), рекомендую.
Hу, ладно, хоршо - с файлами так можно. А если, к примеру, семафор потребовался? Hичего страшного - маленький простенький класс-обертка решит проблему с семафором не менее изящно. Предположим, у нас есть класс SpinLock (замок), объекты которого являются семафорами. Захват семафора производится вызовом метода lock(), освобождение - unlock().
Пример 11, замок, он же - семафор. class SpinLock { private: unsigned long h; public: SpinLock(); ~SpinLock(); // { unlock(); } protected: friend class SLKey; void lock(); // Wait for resource and lock it void unlock(); // Release };Тогда для работы с ним создадим класс "ключ" (SLKey), и будем "запирать" замок не вручную, вызывая методы lock/unlock, а только путем создания ключа. Ключ же будет выполнять lock при создании, из конструктора, а unlock - перед смертью, из деструктора. (Кстати, поскольку методы lock и unlock замка находятся в protected секции класса, вручную вызвать их всяко не удастся.)
Пример 12, ключ к замку. class SLKey { SpinLock ≪ public: SLKey( SpinLock &l ) : ll(l) { ll.lock(); } ~SLKey( ) { ll.unlock(); } };Как пользоваться таким ключом? Все просто до безумия. Секцию, которая должна быть защищена семафором, необходимо заключить в фигурные скобки, и сразу после открывающей скобки создать объект-ключ. Все! Секция кода в скобках будет исполняться ТОЛЬКО при запертом семафоре, и, главное, семафор будет автоматически отперт, как бы вы не покинули эту секцию - хоть return'ом, хоть случись в ней exception, хоть (побойтесь Бога) goto за пределы секции.
void func_with_a_sema_locked_section() { extern SpinLock log_file_access_sema; ... делаем что-либо некритичное ... { // этот блок защищен семафором SLKey mykey(log_file_access_sema); // с этого момента семафор log_file_access_sema заперт ... выполняем критичную часть кода ... } ... здесь семафор снова не заперт ... }Совершенно аналогично обрабатываются работа с динамической памятью (класс-обертка для работы с буферами произвольного размера занимает 7 строк - думаю, вы их напишете без труда) и другими видами ресурсов. Конечно, я предполагаю, что для работы со строками вы не используете древних подходов с массивами типа char и дедушкиными функциями str..., а давно перешли на класс string, или как он там называется в вашей любимой библиотеке классов. Если еще нет - переходите, мой вам совет. Сбережете себе массу сил - и при написании, и при сопровождении (читаемость и самодокументированность кода существенно повышается), и при отладке (не пользуйтесь адресной арифметикой, не будет и горя с "бешеными" указателями). Я не призываю к максимализму и нетерпимости - бывает, что приходится изменить стилю в угоду сиюминутной выгоде, но, как правило, такие фрагменты-уступки все равно приходится приводить к приличному виду. По крайней мере, по моему опыту выходит так.
Второе, о чем нужно помнить, как я уже говорил выше - longjmp для выхода
из функций использовать нельзя. Это не представляет проблемы - вместо
longjmp можно использовать exception-же.
Обращайте внимание на то, как часто у вас встречается catch. Каждое его
использование должно быть объяснимо с точки зрения логики алгоритма, а не
ваших кодировочных потребностей. Если встречается "технический",
необъяснимый с позиции постановки задачи catch - значит, что-то неладно в
дизайне программы. К примеру, в моем коде catch встречается примерно один
раз на 250 строк. Конечно, это зависит от задачи, но порядок величиныЮ
думаю, вряд ли будет иным при правильном использовании методики.
Все, что вы прочитали в этой статье было выяснено, опробовано и отточено
при написании с нуля примерно 15000 строк кода и при переработке с
применением exceptions других 25000 строк кода. Думаю, это дает мне право
сказать с основанием - рекомендую.
(c) 1997 dz
Интересные ссылки:
Комментариев к странице: 0 | Добавить комментарий
Редактор: Дмитрий Бан
Оформление: Евгений Кулешов