Работа над ошибками.

"Точно так же все верят в свою исключительность..."
А. Макаревич

Дмитpий Завалишин
( Dmitry Zavalishin <dz@dz.ru> )

Hе так давно я осознал, что все используемые мной компиляторы Си++, наконец-то, поддерживают exceptions. Сие долгожданное событие заставило меня пересмотреть свои взгляды на методику программирования на этом языке, и написать несколько тысяч строк кода для тренировки. Как результат, мне нынче кажется, что я знаю, как пользоваться механизмом исключительных ситуаций. Во всяком случае, нашел один из возможных подходов. Спешу поделиться! :)

Банальность на тему: ничто не дается даром. С использованием 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у да по порядку.

 


 

Ошибкология

За всю историю программирования, как мне кажется, человечество нащупало всего два варианта отношения к понятию "ошибка". Первое исторически, и наиболее популярное отношение сводилось к тому, что ошибка - это то, что бывает чрезвычайно редко, и означает необходимость напечатать на экране последнее "прощай" и дать дуба. Возможно - аккуратно дать дуба. Если совсем не лень и заняться больше нечем - ну, попробовать спросить у пользователя, какого именно дуба дать. Фортран, Паскаль и Си предоставляют пользователю одинаково богатые возможности в плане обработки ошибок - или повешение (здесь и далее, по умолчанию - в сишных терминах) через longjmp, или утопление через exit. Второе отношение наиболее последовательно, как мне кажется, изображено в языках класса пролога. Здесь программист может вести себя совсем по-свински - вместо того, чтобы решать свою задачу - свалить на язык все исходные данные, и пусть тот сидит, складывает кубики методом проб и ошибок - авось, набредет на верное решение. Прошу заметить: ошибка (failure) в Прологе - нечто само собой разумеющееся, случающееся на каждом шагу. Если вы порешили, что работа программы зашла в тупик, пролог-система откатит ее работу до предидущей развилки, причем все значения переменных тоже вернутся назад во времени. Прошу отметить во всем этом главный, с точки зрения нашего повествования, момент - ошибка в прологе переведена из категории "ой, блииин!" в категорию "эх, не вышло". Hу, не вышло. Hу и хрен с ним, попробуем иначе. Привлекательность такого подхода трудно переоценить. Hе знаю, почему пролог так и не выбился в люди - вероятно, процедурность у нас - в крови. Может, со временем, это предубеждение пройдет, и процедурные языки отползут и тихо сдохнут в закоулке истории... и захоронят их на одном кладбище с покойным ныне ассемблером и уже отмирающими необъектынми языками. Может быть. Hо еще нет. Сегодня бал правит процедурный C++, а справа от трона стоит (все еще с указкой в руках, но уже в малиновом пиджаке) объектный Паскаль. И я возвращаюсь от Пролога к ним. Возвращаюсь, но не выпускаю из рук магическое "эх, не вышло" - амулет от страха перед ошибками.

 


 

"Шахтеры делают ЭТО под землей"
(C) Yuri PQ :)

Как это делают в C++

Вернувшись из царства Проложьего с любовью к ошибкам в сердце, невольно задаешься вопросом - какие средства для любви ошибок существуют в родном плюс-плюсе. Как откатить работу фрагмента программы, не прилагая к этому титанических усилий при каждом вызове каждой функции? Да тут все просто: на это есть исключительные ситуации - exceptions. Суть их несложна. В некотором месте программы можно сказать - "давай-ка попробуем сделать вот это, а если в процессе выполнения случится облом такого-то типа, пусть выполнится такой-то код". Вот вам оператор try ("попробуем"):
Пример 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");
        delete r;
        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; }
    ...
    }

Это просто класс!

Именно. Тот тип, который указывается в аргументах catch и throw, может быть и типом класса, и, скорее всего, наверняка им и будет. Это - довольно полезное свойство исключений - ведь сгенерировав исключительную ситуацию в ловушку можно что-нибудь передать. Привет, например, или сообщение об ошиюке и информацию о том, что ее вызвало. Чрезвычайно полезно. Это, кстати, решило еще одну мою застарелую проблему - молчать, или не молчать. Дело вот в чем. Если некоторая служебная функция не смогла выполнить свой долг, она, что естественно, возвращает флаг ошибки, чтобы вызывающий знал, что не сложилось. Однако, если вызывающий код хочет порадовать этим пользователя, то оказывается, что он не знает, что сказать. "При парсинге строки возникла ошибка", а какая - парсер ее знает. И молчит, гад. Может быть, пусть парсер сам ругается вслух? Пусть поведает свою беду в деталях? Пусть. Только в этом случае его нельзя будет вызывать "на пробу" - чтобы выяснить, парсабельна данная строка, или нет. Он будет громко ругаться и озадачивать пользователя. Можно передавать ему флаг - "молчи и терпи", но это громоздко и вносит путаницу. С исключениями эта проблема отпадает сама собой. Всяк потерпевший перечисляет свои беды в специальном объекте и кидает его вместе с исключением. А уж обработчик ошибки решит на месте - молчать ли, кричать ли, и сколь детально признаваться во грехах. К примеру, я использую для передачи информации через исключения подобные классы:
Пример 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 код.

Следствия

Использование исключений часто приводит даже опытных программистов к весьма странному и тяжеловесному коду, ставящему под сомнение целесообразность концепции исключений вообще. Действительно, если использовать исключения в совокупности с процедурным стилем написания программ, результат выглядит чуть ли не хуже, чем если бы исключений и вовсе не было. Практически в каждой функции, которая использует какие-либо ресурсы (файлы, динамическое выделение памяти, etc) приходится отлавливать все исключения с тем, чтобы при откручивании стека иметь возможность эти ресурсы освободить. Код получается странным и неудобоваримым:
Пример 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 &ll;
    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, или как он там называется в вашей любимой библиотеке классов. Если еще нет - переходите, мой вам совет. Сбережете себе массу сил - и при написании, и при сопровождении (читаемость и самодокументированность кода существенно повышается), и при отладке (не пользуйтесь адресной арифметикой, не будет и горя с "бешеными" указателями). Я не призываю к максимализму и нетерпимости - бывает, что приходится изменить стилю в угоду сиюминутной выгоде, но, как правило, такие фрагменты-уступки все равно приходится приводить к приличному виду. По крайней мере, по моему опыту выходит так.

 


Подводные камни и общие соображения

По большому счету, он только один. Булыжник. Валун. Мыслить надо иначе. То есть четко привыкнуть к тому факту, что в последовательности "call1(); call2(); call3();" в любой точке исполнение может приостановиться, и от одного до трех операторов вышеозначенного фрагмента может оказаться не выполнено. Это совсем не страшно, если привыкнуть. :) Главный вывод - порядок исполнения равнозначных, казалось бы, фрагментов теперь может оказаться существенным.

Второе, о чем нужно помнить, как я уже говорил выше - longjmp для выхода из функций использовать нельзя. Это не представляет проблемы - вместо longjmp можно использовать exception-же.
Обращайте внимание на то, как часто у вас встречается catch. Каждое его использование должно быть объяснимо с точки зрения логики алгоритма, а не ваших кодировочных потребностей. Если встречается "технический", необъяснимый с позиции постановки задачи catch - значит, что-то неладно в дизайне программы. К примеру, в моем коде catch встречается примерно один раз на 250 строк. Конечно, это зависит от задачи, но порядок величины, думаю, вряд ли будет иным при правильном использовании методики. Все, что вы прочитали в этой статье было выяснено, опробовано и отточено при написании с нуля примерно 15000 строк кода и при переработке с применением exceptions других 25000 строк кода. Думаю, это дает мне право сказать с основанием - рекомендую.

(c) 1997 dz

Используются технологии uCoz