Судя по всему, неделя исключений на хабре успешно наступила :). Накопив достаточную «подушку» кармы чтобы не бояться уйти в минус, я, пожалуй, тоже выскажу свое мнение по данному вопросу. Сразу оговорюсь, что мнение сугубо личное, основанное на небольшом практическом опыте коммерческой разработки: C++, Objective-C, C#, Java, Python, Ruby.
Прежде чем приступать к кровавому месиву, желательно оценить поле боя и синхронизировать терминологию. Под «ошибками» в контексте разработки программного обеспечения можно понимать достаточно разные вещи, поэтому для начала я попробую эти сущности как-то обрисовать и обозвать:
Как видите, много всего разного и нехорошего может произойти — а ведь это далеко не полный список :). А что делать программисту? Тут, на мой взгляд, перед нами встает очень интересный и важный вопрос — как именно нашей программе реагировать на ту или иную ошибку? Пожалуй сейчас я еще раз напомню, что излагаю свое сугубо личное мнение. И скажу следующее — как именно реагировать на ошибку целиком зависит от конкретной программы. Если у нас закончилась память в драйвере — мы должны любой ценой выжить, чтобы пользователь не получил синего экрана смерти. Если же у нас закончилась память в игрушке типа веселая ферма — то имеет смысл упасть, извиниться и попросить отправить багрепорт разработчику. Системный сервис, призванный крутиться многие месяцы без перезагрузки, должен с пониманием отнестись к ошибке CreateEvent(). Та жа ошибка в прикладной программе типа Photoshop означает что скорее всего система через секунду умрет, и лучше честно упасть, нежели попытаться проглотить ошибку, дать пользователю сохранить файл и благополучно его испортить из-за последующего сбоя во время записи. Следовательно ошибки мы можем делить на ожидаемые и неожиданные. Для разных программ и разных требований одни и те же ошибки могут считаться как ожидаемыми, так и неожиданными. С ожидаемыми ошибками мы как-то работаем. Не получилось открыть файл — говорим об этом пользователю и продолжаем работу. Не удалось выделить память для загрузки туда гигабайтного файла — говорим об этом пользователю и продолжаем работу. С неожиданными ошибками мы в большинстве случаев не работаем. Закончилась память при попытке выделить двадцать байт для создания объекта — падаем. Не создался системный объект которых на всю программу три штуки — падаем. Не читается системный пайп который по спецификации должен читаться? Лучше упасть, чем оставить программу в нестабильном состоянии и потом испортить пользователю данные. Программу он если что перезапустит, а вот за испорченный файл возненавидит до конца дней своих. А для серьезных случаев есть автосейв и перезапускающий нас ежели чего watchdog.
В эпоху расцвета процедурного программирования синтаксис работы с ошибками был тривиален и основывался на том, что вернула функция. Если функция возвращала TRUE — все хорошо, если же FALSE — то произошла ошибка. При этом сразу выделились два подхода к работе с ошибками:
Через некоторое время разработчики заметили, что большинство успешных решений использует ООП и решили что неплохо бы его вынести в синтаксис языка, дабы писать больше кода по делу и меньше — повторяющегося кода для поддержки архитектуры.
Давайте возьмем код выше и посмотрим, как он трансформировался после добавления ООП в синтаксис языков программирования. Конструирование и уничтожение объектов (fopen, fclose) стало конструкторами и деструкторами. Переброс неожиданной ошибки (BOOL ret в первом примере, макрос ENSURE во втором) однозначно стал исключением.
А вот с ожидаемой ошибкой случилось самое интересное — случился выбор. Можно было использовать возвращаемое значение — теперь, когда заботу о неожиданных ошибках взяли на себя исключения, возвращаемое значение снова стало в полном распоряжении программиста. А можно было использовать исключения другого типа — если функции копирования файлов самой не нужно обрабатывать ожидаемые ошибки то логично вместо if и REQUIRE просто ничего не делать — и оба типа ошибок уйдут вверх по стеку. Соответственно, у программистов снова получилось два варианта:
Здесь я еще раз напомню, что высказываю свое личное мнение и открыт к обсуждению :). Итак, если внимательно посмотреть на два приведенных выше фрагмента кода то становится не совсем понятно почему выжил второй. Кода в нем объективно больше. Выглядит менее красиво. Если функция возвращает объект — то использовать коды возврата совсем неудобно. Вопрос — почему коды возврата вообще выжили в языках с поддержкой объектно-ориентированного программирования и исключений на уровне синтаксиса? Что я могу по этому поводу сказать:
Что бы я хотел резюмировать. На мой взгляд, большинство проблем с исключениями были вызваны первыми, не очень удачными реализациями — особенно в C++. Так что выбор «использовать только коды возврата», «использовать исключения для неожиданных ошибок и коды возврата для ожидаемых» или «использовать исключения для всего» по большей части имеется только для C++. Надеюсь, мой краткий рассказ о причинах появления исключений в современных языках программирования поможет разработчикам чуть лучше ориентироваться в современных API и замечать места, где авторы использую исключения некорректно. Понимание какие из ошибок мы считаем для нашей программы ожидаемыми, какие — неожиданные и как это оптимальным образом ложится на предоставляемую языком и API модель исключений позволяет писать простой, понятный и внимательно следящий за ошибками код.
Что такое ошибка?
Прежде чем приступать к кровавому месиву, желательно оценить поле боя и синхронизировать терминологию. Под «ошибками» в контексте разработки программного обеспечения можно понимать достаточно разные вещи, поэтому для начала я попробую эти сущности как-то обрисовать и обозвать:
- Самое простое что в программе может случиться — это сбой операционной системы или железа. Не сработавший системный вызов CreateEvent() или pthread_mutex_lock(), деление на ноль, мусор в результатах системного вызова — все это может случиться по разным причинам, начиная от вышедшего из строя железа и заканчивая вирусами в системе, но как правило от нас и нашей программы это не очень зависит.
- Чуть более сложная ситуация — это отсутствие нужных нам ресурсов. Неожиданно может закончиться память, хэндлы, файловые дескрипторы. Может не быть прав на запись или чтение нужных файлов. Пайп может не открыться. Или наоборот — не закрыться. Доступ к базе данных может быть — а может и не быть. Такая ситуация уже может быть вызвана как нашей программой (слишком много памяти восхотелось) так и нестабильностью системы (вирусу слишком много памяти восхотелось).
- А самая распространенная ситуация — это ошибка в логике программы или взаимодействия ее частей. Мы пытаемся удалить несуществующий элемент списка, вызывать метод с неверными аргументами, многопоточно выполнить неатомарную операцию. Как правило это приводит или к некорректному поведению программы («тулбар исчез») или к ее краху с 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 модель исключений позволяет писать простой, понятный и внимательно следящий за ошибками код.