|
В жизни программиста рано или поздно наступает момент когда появляется
желание поделиться накопленым опытом, в надежде, что это станет полезно еще
кому-то. Сейчас достаточно много подобных статей как в печатных изданиях, так
и в интеренете. Но все они ориентированы, в первую очередь, на прикладных
программистов. И хотя это вполне объяснимо, я все же хотел бы попытаться
посмотреть на программирование с точки зрения системного программиста.
Большинство приемов, которые я собираюсь описать, не новы. Впрочем, подходы и
требования системного программиста - в первую очередь требование
эффективности - накладывают свой отпечаток. Речь пойдет, в первую очередь,
о применении C++ для системного программирования. Принято считать, что C++
плохо подходит для системного программирования. К сожалению, комитет
по стандартизации приложил немало усилий для того, что бы это было так.
К счастью, попытка оказалась неудачной и при правильном применении C++
программы на нем проще в написании и отладке, легче в сопровождении и,
что наиболее важно, такие же или более эффективные, чем написаные на С.
Обработка исключительных ситуаций
Это популярная тема в прораммировании вообще и в С++ в частности.
Ей уж посвящен не один трактат, и, видимо, немалое количество еще дожидается своей очереди. Добавлю и я свои 5
копеек.
Чаще всего эта тема упоминается в связи с имеющимися возможностями в С++
или средствами имеющимися в некоторых операционных системах.
Встроеные средства ОС выходят за рамки данной статьи. Достоинства же механизма
обработки исключений в C++ описаны неоднократно, поэтому сразу перейдем к
недостаткам.
И так, первая проблема с которой приходится сталкиваться -
отсутствие конструкции finally. Теоретически она не нужна. И теория даже
почти воплощается в жизнь, если Вам удобно и хочется заворачивать все
(хендлы файлов, семафоры, пайпы, очереди и прочую мелочевку) в классы.
Вторая проблема - эффективность. Обработка исключений имеет совсем не
нулевые накладные расходы.
Третья проблема - возможность утечки памяти при использовании обычных
указателей. Эта проблема, конечно, решается использованием
"умных" указателей, но это достигается ценой создания новых классов (да, я в курсе
про auto_ptr, но пробовали ли Вы им пользоваться?), расходом памяти и
ресурсов процессора, не говоря уже об удобстве.
Итогом всего перечисленого становится то, что вместо снижения трудоемкости
при сохранении эффективности (Вы еще помните тезисы с которыми
популяризировался C++? :-) ) мы получаем снижение эффективности при
такой же или еще большей трудоемкости.
Тем не менее, существует решение которое имеет практически нулевые
накладные расходы, не ограничивает Вас в использовании тех или иных типов
данных и позволяет собрать весь "подчисточный" код в одном месте, так что
ошибочная ситуация и нормальное выполнение будут использовать один и тот же
код. Ну и, при необходимости, это решение почти так же хорошо работает в C как
и в C++. В описании ниже используется обычная функция, хотя нет никаких
причин, которые могли бы помешать применять подобный подход к методам классов.
О том, какую цену за это придется заплатить, поговорим позже, а
сейчас само решение:
do
{
}
while(0);
Хмм. Прямо скажем, выглядит не убедительно. Ладно, попробуем еще раз:
[initialization]
do
{
[processing]
}
while(0);
[cleanup]
Надюсь, так лучше? Но, думаю, пример кода будет гораздо интереснее:
int list_files(StrColl& List, char* mask)
{
//Precondition
if(!mask)
return ERROR_INVALID_PARAMETER;
//Initialization
int rc = 0;
char* pName = 0;
ULONG Count = 1;
FILEFINDBUF3 stFind;
HDIR hDir = HDIR_CREATE;
int iFlags = FILE_READONLY | FILE_HIDDEN | FILE_ARCHIVED | FILE_SYSTEM;
int iFound = 0;
//Check parameters
do
{
//Prepare temp storage
pName = new char[CCHMAXPATH + 1];
if(!pName)
{
rc = ERROR_NOT_ENOUH_MEMORY;
break;
}
//Processing
rc = DosFindFirst(mask, &hDir, iFlags, &stFind, sizeof(stFind),
&ulCount, FIL_STANDARD);
while(!rc)
{
//Query full name
rc = DosQueryPathInfo(stFind.achName, FIL_QUERYFULLNAME,
pName, CCHMAXPATH);
if(rc)
break;
rc = List.Add(new String(pName));
if(rc)
break;
iFound++;
//Get next file name
ulCount = 1;
rc = DosFindNext(hDir, &stFind, sizeof(stFind), &ulCount);
}
if(rc == ERROR_NO_MORE_FILES)
rc = 0;
if(rc)
break;
//Additional processing?
}
while(0);
//Cleanup
delete pName;
DosFindClose(hDir);
//Postcondition
if(!rc && !iFound)
rc = ERROR_FILE_NOT_FOUND; //No files found..
return rc;
}
Формально этот прием не явлется обработкой исключений. Это всего лишь
быстрое сворачивание при обнаружении ошибки. Тем не менее, этот прием
дает практически такой же результат, причем эффективно, с минимальными
накладными расходами, с любым компилятором и с любой операционной системой.
Почему бы им не воспользоваться?
Вероятно, сразу бросается в глаза обилие конструкций типа:
if(rc)
break;
Собственно, эта конструкция вместе с do{}while(0); и образует костяк
механизма. В каждой из этих точек мы либо транслируем ошибку дальше,
либо обрабатываем ее и, возможно, в случае другой ошибки, отправим дальше уже
другой код.
К достоинствам описаного подхода, помимо перечисленых выше, следует
отнести и снижение уровня вложенности выражений в коде, что резко улучшает
читабельность. Даже простые указатели, управление памятью для которых
выполняется вручную, и то проще контролировать: достаточно посчитать сколько
указателей объявлено в начале и сколько соответствующих delete (или free(),
или DosFreeMem()) в конце.
Концентрация "подчисток" в одном месте и полный контроль над порядком их
выполнения позволяет весьма прозрачно организовать, например, обработку
временных файлов (например стандартный алгоритм "используем временный файл и
если что-то сорвалось, то его удаляем, а если все нормально, то заменяем
исходный файл временным") без риска получить утечку хендлов или дискового
пространства:
HFILE hFile ...
do
{
...
rc = FileOpen(cTemp, &hFile);
...
}
while(0);
...
FileClose(hFile);
if(!rc)
rc = FileMove(cTemp, cDest, FILE_OVERWRITE);
if(rc)
FileDelete(cTemp); //Ignore error here
...
Аналогично организуется контроль за памятью, если функция возвращает
выделяемый ею кусок памяти.
Теперь поговорим о недостатках этого решения.
Самым, пожалуй, существенным недостатком, на мой взгляд, является плохая
стыковка такого подхода со стандартной библиотекой C/C++. С описаным выше
подходом очень естественно работают API, которые предполагают нулевой код
возврата в случае успеха и ненулевой код возврата в случае ошибки. При этом
данные, возвращаемые из вызова, передаются только через указатели или
ссылки. Конечно, есть простейшие функции, в которых ошибочные ситуации
невозможны, но их совсем не так много. К сожалению то, что в последствии стало стандартной
библиотекой C/C++ создавалась во времена, когда пользователи не делали
ошибок, апаратура работала безукоризненно, а объемы дисковой и оперативной
памяти были безграничны. Иначе объяснить подходы в дизайне, использованые в
ней, трудно. Еще труднее объяснить, что двигало комитетом, который _это_
принял в качестве стандарта. В примере приведенном выше есть одно из решений
для данной проблемы (см. обработку результата оператора new). Существует и
другой вариант, более удобный для многих ситуаций: написать свои врапперы для
нужных функций. К счастью, на практике таких вызовов не так уж много, а
многие популярные библиотеки и наборы API (например ZLIB, OS/2 DosXXX API)
используют аналогичный подход для сигнализации ошибок.
Вторая проблема - необходимость внимательного написания секции инициализации,
в частности обязательная инициализация всех локальных
переменных. Это вызвано тем, что выполнение может быть прервано в любой точке
и когда управление попадает в точку после do{...}while(0), как правило, нет
способа узнать, нужно ли освобождать ресурсы. Единственным надежным
источником является значение самой переменной. К счастью, практика
инициализации локальных переменных полезна сама по себе для исключение
"плавающих" ошибок, столь трудных в обнаружении.
Ну и самая большая проблема: при сочетании нескольких разных библиотек
возникает вопрос распределения и управления кодами ошибок в процессе работы
над проектом. Если доступны исходные тексты библиотек, то можно просто
исправить заголовочные файлы, в противном случае (если библиотека
используется много и интенсивно) вполне разумным представляется написание
врапперов транслирующих коды ошибок в нужный диапазон.
В любом случае немного внимательности и сводный файл с кодами и описанием
ошибок могут сослужить хорошую службу.
Теперь самое время рассмотреть смежные вопросы тесно связаные с описаным
выше приемом.
Прежде всего, как я уже писал, дизайн всех API удобнее всего делать так,
что бы возвращался код ошибки (0 - все хорошо, !0 - ошибка). Это, вобщем-то,
вопрос привычки и сложностей не вызывает. В добавок, регулярность внутреннего
API позволяет легче его осваивать при работе в группе и уменьшает количество
ошибок.
Еще более логично выплывает из данного подхода прием, который я называю
"раннее обнаружение": пишите короткие части алгоритма обработки раньше
длинных. Например, вместо:
if(<condition>)
{
for(...;.., iVal++)
{
...
}
}
else
iVal = 0;
if(!iVal)
break; //Nothing to do, processing done
Я стараюсь писать так:
if(!<condition>)
{
//Nothing to do, processing done
iVal = 0;
break;
}
for(...;.., iVal++)
{
...
}
Аналогично, я стараюсь обнаруживать ошибочные ситуации как можно
раньше и тут же прерывать выполнение по break. Именно "раннее обнаружение"
приводит к избавлению от ненужных уровней вложенности.
Если алгоритм обработки имеет две равноценные по мощности ветки, то часто
бывает удобно вынести эти ветки в отдельные функции, что бы не загромождать
тело функции и не городить новые уровни вложенности.
Ну и, наконец, с таким подходом хорошо сочетается весьма модный нынче
"Design-by-Contract". В упрощенной форме суть подхода можно изложить так:
каждая функция имеет четко описаные побочные эффекты, при этом ее входные
параметры должны соответствовать определенным критериям (предусловия), а по
завершению должны выполняться постусловия. Ни С, ни С++ не поддерживают эту
весьма полезную парадигму на уровне языка, но, как это часто бывает, и не
мешают ею пользоваться на уровне соглашений. За всей мудреностью формулировок
скрывается, вобщем-то, нехитрая програмерская мудрость: проверь параметры на
входе, не трогай то, что тебе не принадлежит и проверь условия на выходе. При
использовании описаного выше приема, постусловия легко добавляются (при
необходимости) в фазу "подчистки". За побочными эффектами следить придется
самостоятельно, а вот с предусловиями сложнее, как ни странно. В простых
случаях, как в приведенном выше коде, проверку параметров можно сделать в
самом начале. Но есть ситуации, когда ошибочность параметров можно установить
только проделав некоторые действия. В таком случае я, как правило, оставляю в
самом начале только простейшие проверки (например проверка указателей на 0),
а остальные проверки делаю позже, уже в теле do{}while(0);.
В заключение хотелось бы напомнить: описаный прием не панацея, не решение
на все случаи жизни. Он не избавляет программиста от необходимости думать.
Только внимательность и тщательность при написании кода позволят Вам избежать
многочасового сидения в отладчике. И тут все зависит только от Вас. Удачи.
* "Умные" указатели это объекты которые
максимально сохраняют семантику обычных указателей, но при этом самостоятельно
управляют памятью на которую ссылается указатель. Типичным примером реализации
является класс auto_ptr из STL.
© Сергей Евтушенко, <es@os2.ru>, сентябрь 2001 года.
Обсудить материал (число отзывов:21248) предыдущий материал |