Thu, 17 May  |   Login English version  |  OS2.Ru  
В начало
Об OS/2
Новости
Публикации
DevCenter
База данных
Каталог ресурсов
Биржа труда
TeamDB
Форумы и общение
Опросы и конкурсы
Russian Team OS/2
На первую страницу OS2.Ru
 Вокруг OS/2 |  Программы и технологии |  Аппаратура |  Разработчикам |  Мастерская
Поиск по: Добавить закладку OS2.Ru в панель Netscape 6/Mozilla
OS2.Ru > Articles > Dev > Prog > Sysprog > Es Excpts.phtml.ru
2001-09-14
Сергей Евтушенко
(версия для печати)

Системное программирование: обработка исключительных ситуаций

В жизни программиста рано или поздно наступает момент когда появляется желание поделиться накопленым опытом, в надежде, что это станет полезно еще кому-то. Сейчас достаточно много подобных статей как в печатных изданиях, так и в интеренете. Но все они ориентированы, в первую очередь, на прикладных программистов. И хотя это вполне объяснимо, я все же хотел бы попытаться посмотреть на программирование с точки зрения системного программиста. Большинство приемов, которые я собираюсь описать, не новы. Впрочем, подходы и требования системного программиста - в первую очередь требование эффективности - накладывают свой отпечаток. Речь пойдет, в первую очередь, о применении 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)


предыдущий материал


 Вокруг OS/2 |  Программы и технологии |  Аппаратура |  Разработчикам |  Мастерская


Новости
15/08: GoldenCode выпустит Java 1.4 для OS/2
14/06: Fix #16 rus / Warp4
30/05: Перерыв в работе OS2.Ru
Все новости..

В каталоге
Дерево каталога
Новые поступления

Публикации
Боремся с зависанием PM и зомби - WatchCat + HardKill
(Samorukov Alex , 2001-10-11)

DSync - куда может быть проще?
(Okounkov Konstantin, 2001-09-28)

WarpGoGo: переводим музыку в MP3
(Okounkov Konstantin, 2001-09-26)

Все материалы

Решения
Tips & tricks

Активные опросы
Используете ли Вы OS2.Ru tab в Netscape ?

Все опросы
Первая страница  |   Об OS/2  |   Новости  |   Публикации  |   База данных  |   Каталог ресурсов  |   Биржа труда  |   TeamDB  |   Форумы общения  |   Опросы и голосования  |   OS2.Ru DevCenter
Дизайн, оформление © 1996-2000 Copyright WebTeam. Использование материалов OS2.Ru без согласия авторов и координаторов запрещено
Powered by OS/2