Pull to refresh

Коды возврата vs исключения — битва за контроль ошибок

Reading time7 min
Views21K
Судя по всему, неделя исключений на хабре успешно наступила :). Накопив достаточную «подушку» кармы чтобы не бояться уйти в минус, я, пожалуй, тоже выскажу свое мнение по данному вопросу. Сразу оговорюсь, что мнение сугубо личное, основанное на небольшом практическом опыте коммерческой разработки: C++, Objective-C, C#, Java, Python, Ruby.

Что такое ошибка?



Прежде чем приступать к кровавому месиву, желательно оценить поле боя и синхронизировать терминологию. Под «ошибками» в контексте разработки программного обеспечения можно понимать достаточно разные вещи, поэтому для начала я попробую эти сущности как-то обрисовать и обозвать:
  1. Самое простое что в программе может случиться — это сбой операционной системы или железа. Не сработавший системный вызов CreateEvent() или pthread_mutex_lock(), деление на ноль, мусор в результатах системного вызова — все это может случиться по разным причинам, начиная от вышедшего из строя железа и заканчивая вирусами в системе, но как правило от нас и нашей программы это не очень зависит.
  2. Чуть более сложная ситуация — это отсутствие нужных нам ресурсов. Неожиданно может закончиться память, хэндлы, файловые дескрипторы. Может не быть прав на запись или чтение нужных файлов. Пайп может не открыться. Или наоборот — не закрыться. Доступ к базе данных может быть — а может и не быть. Такая ситуация уже может быть вызвана как нашей программой (слишком много памяти восхотелось) так и нестабильностью системы (вирусу слишком много памяти восхотелось).
  3. А самая распространенная ситуация — это ошибка в логике программы или взаимодействия ее частей. Мы пытаемся удалить несуществующий элемент списка, вызывать метод с неверными аргументами, многопоточно выполнить неатомарную операцию. Как правило это приводит или к некорректному поведению программы («тулбар исчез») или к ее краху с access violation / unhandled exception.


Как видите, много всего разного и нехорошего может произойти — а ведь это далеко не полный список :). А что делать программисту? Тут, на мой взгляд, перед нами встает очень интересный и важный вопрос — как именно нашей программе реагировать на ту или иную ошибку? Пожалуй сейчас я еще раз напомню, что излагаю свое сугубо личное мнение. И скажу следующее — как именно реагировать на ошибку целиком зависит от конкретной программы. Если у нас закончилась память в драйвере — мы должны любой ценой выжить, чтобы пользователь не получил синего экрана смерти. Если же у нас закончилась память в игрушке типа веселая ферма — то имеет смысл упасть, извиниться и попросить отправить багрепорт разработчику. Системный сервис, призванный крутиться многие месяцы без перезагрузки, должен с пониманием отнестись к ошибке CreateEvent(). Та жа ошибка в прикладной программе типа Photoshop означает что скорее всего система через секунду умрет, и лучше честно упасть, нежели попытаться проглотить ошибку, дать пользователю сохранить файл и благополучно его испортить из-за последующего сбоя во время записи. Следовательно ошибки мы можем делить на ожидаемые и неожиданные. Для разных программ и разных требований одни и те же ошибки могут считаться как ожидаемыми, так и неожиданными. С ожидаемыми ошибками мы как-то работаем. Не получилось открыть файл — говорим об этом пользователю и продолжаем работу. Не удалось выделить память для загрузки туда гигабайтного файла — говорим об этом пользователю и продолжаем работу. С неожиданными ошибками мы в большинстве случаев не работаем. Закончилась память при попытке выделить двадцать байт для создания объекта — падаем. Не создался системный объект которых на всю программу три штуки — падаем. Не читается системный пайп который по спецификации должен читаться? Лучше упасть, чем оставить программу в нестабильном состоянии и потом испортить пользователю данные. Программу он если что перезапустит, а вот за испорченный файл возненавидит до конца дней своих. А для серьезных случаев есть автосейв и перезапускающий нас ежели чего watchdog.

Что было до исключений?



В эпоху расцвета процедурного программирования синтаксис работы с ошибками был тривиален и основывался на том, что вернула функция. Если функция возвращала 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" );
      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 );
      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;
      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 везде, где только можно.


Выводы



Что бы я хотел резюмировать. На мой взгляд, большинство проблем с исключениями были вызваны первыми, не очень удачными реализациями — особенно в C++. Так что выбор «использовать только коды возврата», «использовать исключения для неожиданных ошибок и коды возврата для ожидаемых» или «использовать исключения для всего» по большей части имеется только для C++. Надеюсь, мой краткий рассказ о причинах появления исключений в современных языках программирования поможет разработчикам чуть лучше ориентироваться в современных API и замечать места, где авторы использую исключения некорректно. Понимание какие из ошибок мы считаем для нашей программы ожидаемыми, какие — неожиданные и как это оптимальным образом ложится на предоставляемую языком и API модель исключений позволяет писать простой, понятный и внимательно следящий за ошибками код.

Tags:
Hubs:
If this publication inspired you and you want to support the author, do not hesitate to click on the button
Total votes 111: ↑100 and ↓11+89
Comments159

Articles