Крэши, вызванные исключениями

На прошлой неделе я вместе с несколькими моими коллегами учавствовал в громкой речи о том факте, что Go обрабатывает ошибки в ожидаемых сценариях посредством возвращения кода ошибки вместо использования исключений или другого схожего механизма. Это довольно спорная тема, потому что люди привыкли избегать ошибки с помощью исключений, а Go возвращает улучшенную версию хорошо известной модели, ранее принятой несколькими языками — включая C — при которой ошибки передаются через возвращаемые значения. Это значит, что ошибки маячат перед глазами программиста и вынуждают иметь с ними дело все время. Кроме того, спор переходит в направление того факта, что в языках с исключениями каждая ошибка безо всяких дополнительных действий несет в себе полную информацию о том, что и где произошло, а это может быть полезно в некоторых случаях.

Однако все эти удобства имею стоимость, которую легко сформулировать:
Исключения учат разработчиков не заботиться об ошибках.

Печальным следствием является то, что это актуально, даже если Вы блестящий разработчик, так как на Вас оказывает влияние окружающий мир, который снисходителен к ошибкам. Проблема проявится в библиотеках, которые Вы импортируете, в приложениях, установленных на ваш компьютер, а также на серверах, которые хранят ваши данные.

Реймонд Чен так описал эту проблему в 2004:

Написание корректного кода в модели с выбрасыванием исключений в некотором смысле труднее, чем в модели с возвращением кода ошибки, так как что угодно может потерпеть неудачу и Вы должны быть готовы к этому. В модели с возвращением кода ошибки, момент когда вы должны произвести проверку на наличие ошибок очевиден: как только вы получили код ошибки. В модели с исключениями Вы просто должны знать, что ошибки могут произойти в любом месте.

Другими словами, в модели с возвращением кода ошибки, когда кто-то пропускает обработку ошибки это происходит явно: они не проверяют код ошибки. В то же время в модели с выбрасыванием исключений при рассмотрении кода, в котором кто-то обрабатывает ошибку все не так ясно, так как ошибка не указана явно.
(…)
Когда Вы пишете код, задумываетесь ли Вы о том, каковы могут быть последствия каждого исключения, которое может возникнуть каждой строчке кода? Вы должны делать это, если собираетесь писать корректный код.


Это абсолютно верно. Каждая строка, которая может вызвать исключение несет скрытую ветку «else» для ошибочного сценария, о которой очень легко забыть. Даже если внедрение кода для обработки ошибок кажется бессмысленным повторением, его написание заставляет разработчиков помнить об альтернативном сценарии, и довольно часто этот код оказывается не пустым.

Я не первый раз пишу об этом и, учитывая споры, которые окружают это заявление, поэтому я нашел пару примеров, которые подтверждают проблему. Лучший пример, который я смог найти на сегодняшний день находится в модуле pty стандартной библиотеки Python 3.3:

def spawn(argv, master_read=_read, stdin_read=_read):
    """Create a spawned process."""
    if type(argv) == type(''):
        argv = (argv,)
    pid, master_fd = fork()
    if pid == CHILD:
        os.execlp(argv[0], *argv)
    (...)


Каждый раз, когда кто-нибудь вызовет этот код с неправильным именем исполняемого файла в argv, будет порожден неиспользуемый, не подверженный сборки мусора и неизвестный приложению Python процесс, потому что execlp потерпит неудачу и форкнутый процесс будет проигнорирован. И будет ли клиент этого модуля ловить исключение или нет не имеет значения. Локальное обязательство не было выполнено. Конечно ошибка может быть исправлена тривиально добавлением try/except внутрь самой функции spawn. Однако, проблема в том, что это логика показалась нормальной всем, кто когда-либо видел эту функцию начиная с 1994 года, когда Гвидо ван Россум впервые закоммитил ее.

Вот другой интересный пример:

$ make clean
Sorry, command-not-found has crashed! Please file a bug report at:

https://bugs.launchpad.net/command-not-found/+filebug

Please include the following information with the report:

command-not-found version: 0.3
Python version: 3.2.3 final 0
Distributor ID: Ubuntu
Description:    Ubuntu 13.04
Release:        13.04
Codename:       raring
Exception information:

unsupported locale setting
Traceback (most recent call last):
  File "/.../CommandNotFound/util.py", line 24, in crash_guard
    callback()
  File "/usr/lib/command-not-found", line 69, in main
    enable_i18n()
  File "/usr/lib/command-not-found", line 40, in enable_i18n
    locale.setlocale(locale.LC_ALL, '')
  File "/usr/lib/python3.2/locale.py", line 541, in setlocale
    return _setlocale(category, locale)
locale.Error: unsupported locale setting


Это довольно серьезный крэш из-за отсутствия данных о локали в системном приложении, которое, по иронии судьбы, должно сообщать пользователям, какие пакеты надо установить, если команда отсутствует. Заметьте, что на вершине сетка ссылка на crash_guard. Это функция предназначена для перехвата всех исключений на краю стека и отображении детальной системной информации и трейсбека, чтобы помочь в решении проблемы.

Такой «парашютный перехват» довольно распространен в исключение-оринетриованном программировании и это подход, как правило, дает разработчикам ложное чувство хорошей обработки ошибок в приложении. Вместо настоящей защиты приложения он становится просто удобным способом крэша. В данном случае, правильнее было бы вывести предупреждение, если это вообще необходимо, и позволить программе работать как обычно. Это можно было бы сделать простым оборачиванием вот этой строки:

try:
    locale.setlocale(locale.LC_ALL, '')
except Exception as e:
    print("Cannot change locale:", e)


Очевидно, это легко сделать. Но, опять же, проблема в том, что это было естественно не делать этого сразу. На самом деле, это более чем естественно: действительно кажется лучше не рассматривать ошибочный путь. В этом случае произойдет сокращение кода, он будет более прямолинейным, и в результате остается только тот, который приводит к желаемому результату.

В следствие этого, к сожалению, мы погружаемся в мир хрупкого программного обеспечения и розовых слонов. Хотя более выразительный стиль возвращения ошибок выстраивает правильное мышление: вернет ли функция или метод ошибку в результате? Как она будет обработана? Действительно ли функция взаимодействующая с системой не вернет ошибку? Как решается проблема, которая наверняка может возникнуть?

Удивительное количество крэшэй и просто непредсказуемое поведение является результатом такой непроизвольной небрежности.

Оригинал
Поделиться публикацией

Комментарии 24

    +11
    А на практике мы имеем привычку приложений, написанных на языках без исключений, падать без намека на причину.
      –1
      Пишу на Go и должен подтвердить, волей не волей начинаешь обрабатывать все ошибки, которые стоит обрабатывать. Хоть мои приложения и небольшие, но они ни разу ещё не падали. Обнаруживают ошибку — пишут в лог, прерывают выполнение текущего действия и продолжают работать как ни в чём не бывало.
        0
        А если добавить ещё статическую типизацию, то по сравнению с Python код начинает работать правильно с первого раза гораздо чаще. Если на Python я просто жду пока проблемный участок не выполнится и не скажет мне где я попутал имена переменных или ещё чего, то Go говорит мне об этом на стадии компиляции, и это здорово.
        +11
        Мне всё же кажется, что исключения более разумная политика сообщений об ошибках в большинстве случаев. Прежде всего они не допускают непредсказуемого поведения программы. Если кто-то не прочитал доки к функции (или прочитал, но забил) с исключениями, то ошибка всё равно вылезет, причем именно в месте её возникновения, и не позволит дальнейшее выполнение программы. А в случае возврата кодов ошибка может вылезти в «километрах» от места её возникновения и, главное, её проявление может быть самым разным, вплоть до катастрофического.

        Например, есть функция, формирующая where часть SQL-запроса. Имеется в виду что функция возвращает строку типа " WHERE id = ". Если сделать на ошибочное n возврат пустой строки (или приводимое к ней значение типа null или false), как флага ошибки, и забыть его проверить, то можем получить где-то запрос «DELETE FROM table», который успешно выполнится и спокойно удалит все строки из таблицы вместо одной. В случае с необработанным исключением мы бы получили краш. Спросите пользователей, что им лучше: краш при попытке удаления одной записи или тихое удаление всех?
          +2
          В Go если функция может вернуть ошибку, то она передается отдельным возвращаемым значением, например:
          n := -1
          if whereString, err := whereFunc(n), err != nil {
              fmt.Fprint(os.Stderr, err)
              return;
          }
          deleteFromTable(whereString)
          

          Это предотвратит и крэш и удаление всех записей.
            0
            Но если написать
            n := -1
            whereString, err := whereFunc(n)
            deleteFromTable(whereString)
            

            то результат будет е очень хорший. А уж если допустим синтаксис
            <source lang="go">
            n := -1
            whereString := whereFunc(n)
            deleteFromTable(whereString)
            

            то совсем плохой
              0
              Компилятор не позволит написать второй вариант. Должно быть как минимум
              n := -1
              whereString, _ := whereFunc(n)
              deleteFromTable(whereString)
              

              Так вы явно указываете, что вам пофиг на возвращаемую ошибку. И позже такие места легко найти в коде и дополнить их обработчиками ошибок.

              А если err указан, но не используется, то компилятор и на это сругается.
                +2
                Я думаю VolCh имел в виду не я зык Go, а то, что существуют другие языки без механизма исключений, в которых такой синтаксис будет корректным и приведет к драматичному исходу.
                  0
                  Существуют языки, где механизм исключений есть, но так «исторически сложилось» (отчасти из-за наследия Си, и в узком — прямые биндинги — и в широком — общий стиль разработки — смысле слова), что он то используется, то нет.
                  0
                  Ну, с таким контролем со стороны компилятора часть проблем (не знал или забыл) снимается. Но только часть — «не должно быть ошибки» или «потом обработчик сделаю» остается. Но когда ошибка всё-таки возникла, то локализовать её может быть весьма затруднительно, а проявления у неё самые неожиданные могут быть. С исключениями проще — место четко локализовано, а проявление — краш.
          • НЛО прилетело и опубликовало эту надпись здесь
              +1
              Гугл в Go, убрав исключения, предусмотрел соответствующие механизмы. Например, возможность одним return'ом возвращать несколько значений. Это позволяет не ломать основную логику работы.
              Значение возвращаемое функцией должно принадлежать одному пространству значений. Для реализации этого в традиционных языках требуются костыли, привносящие дополнительные издержки…
              • НЛО прилетело и опубликовало эту надпись здесь
                  +1
                  Добавление переменной по ссылке в аргументы функции — довольно старый способ и выглядит не хуже, чем try-catch.

                  Не хуже. пока вам нужно передавать данные об ошибке ровно на один уровень вверх… Когда же вдруг выяснится, что обрабатывать ошибку надо 4-5 уровней выше по стеку вызовов, вам тут же станет грустно…
                  А если же друг окажется, что на уровнях выше надо и другие ошибки передавать выше, вы добавите еще одну переменную в аргументы?

                  Нельзя в слое контроллера обрабатывать ошибки модели — что мы сможем сказать на этом уровне пользователю? Почему вообще должен слой контроллера знать хоть что-то об имлементации модели?

                  То, что вы можете обработать на месте возникновения ошибки, должно обрабатываться на месте. Если нет, то оно должно обрабатываться там, где это проще всего обработать. Если Exception модели не перехватили там где следовало это сделать, и выпустили в контроллер, то это не проблема исключений, это проблема кривых рук программиста…

                  Поверьте, нет такого метода обработки ошибок, который бы избавил программиста от необходимости иметь мозги. В конце-концов, вы можете не пользоваться исключениями для обработки ошибок. Вам нравится после каждого вызова функции писать кучу if/сase для анализа всех возможных в этом месте ошибок, то бог вам в помощь…

                  Я в своё время с трудом въезжал в исключения. Но теперь, пожалуй вряд ли смогу от них отказаться… Отсутствие исключений в Go это почти единственная причина, по которой я не решаюсь применять его в реальных проектах, как один из языков разработки…
                    0
                    Например, если в контроллере есть catch MemcachedConnectionTimeoutException — значит контроллер знает про Memcached, а он вообще о модели знать ничего не должен, кроме API модели.

                    Оставим за рамки входит ли Memcached в модель, но, да, соглашусь, что если входит, то ловить MemcachedConnectionTimeoutException в контроллере странновато. Это исключение должно перехватиться в модели и должно либо быть корректно обработано (повторный запрос, работа без Memcached и т. д.), либо сгенерировать исключение типа Model*Exception, которое будет входить в контракт модели и ловить которое контроллеру позволительно без нарушения инкапсуляции модели. Если же в ней оно не ловится, то должно идти именно на самый верхний уровень — сообщить пользователю «упс, что-то пошло не так, мы работаем над этим» и записать лог и/или отправить сообщение разрабам/админам.
                +8
                Исключения позволяют писать на порядки более простой (а значит и надежный) код за счет отделения прикладного кода от обработки ошибок.
                Это окупает любые недостатки присущие исключениям.
                (Под более простым кодом я понимаю например линейный код, вместо кода с ветвлениями, каждую ветку которых надо отлаживать или покрывать юниттестами, что возможно только для простейших случаев)

                Хотя приведенный здесь аргумент, что любая функция может вызвать исключение, и можно забыть его обработать, — не годится.
                1) У кодов возврата такая же проблема. Каждая функция может возвращать код возврата и его можно забыть обработать.
                2) Суть исключений в том что их надо не ловить на каждый чих, а обработать для какого-то крупного логически завершенного блока из нескольких вычислений. В правильно построенной программе вообще очень мало мест где надо ловить исключения.

                  +1
                  Присоединяюсь — лучше написать я-бы не смог.

                  Я довольно много писал на С, и добрая половина строк кода там — обработка веток всевозможных ошибок — написание которых, это выброшенное на ветер время (а делать приходится).

                  А практически каждый мой код-ревью Сишного кода заключался в добавлении кучи проверок, которые многие (особенно начинающие) программеры забывают добавлять, тем самым приводя программу к непредсказуемому поведению в непредсказуемых местах (далеко от места ошибки).

                  Отлаживать и исправлять необработанные исключительные ситуации на порядок проще, чем необработанные коды ошибок.
                  +4
                  Однако все эти удобства имею стоимость, которую легко сформулировать:
                  Исключения учат разработчиков не заботиться об ошибках.
                  Исключения учат заботиться об ошибках не везде и постоянно, а только там и тогда, когда их реально как-то обработать.
                    +1
                    Не только там где реально, но и там где это вообще имеет смысл :)
                    0
                    Есть такого рода ошибки, которые невозможно правильно обработать на уровне абстракции приложения где это случилось… Например: Вы соединяетесь с серверной частью и запрашиваете данные через низкоуровневый сокет, но внезапно серверная часть упала… по логике приложения вам надо cостыковаться с другим сервисом используя совершенно другой API. Для этого надо переинициализировать объект отвечающий за выдачу этих данных…
                    На уровне сокета вы можете получить ошибку, но что-то с ней сделать вменяемое не получится. Её придется выводить наверх…

                    При традиционном методе, вам придется сохранять значение в глобальной переменной и вписывать в середину кода метода/процедуры return'ы. И вернувшись на нужный уровень отследить значение этой глобальной переменной и обработать ошибку на этом уровне… Будет ли такой код более читабелен, чем с исключениями — спорный вопрос.

                    Вообще говоря это всё те же исключения только выполняются они руками… И такая реализация потребует кучу дополнительных return'ов и возможно преобразований типов и прочая… Ну и смысл?

                    Мы сначала пишем базовый функционал, демонстрируем клиенту, а потом доводим до ума обработку нештатных ситуаций… В противном случае сроки будут слишком велики…
                      0
                      Одна проблема в том, что используя стороннюю библиотеку или просто чужой код, нельзя сказать упадет он или нет и если упадет, то когда, поэтому приходится либо ловить все подряд, либо изучать исходники. Вторая проблема — это тьма объявленных исключений в стороннем API помноженная на свои собственные. И третья проблема — авторы редко задумываются об осмысленном тексте для исключений, что приводит потраченному времени, особенно на простейших ошибках.
                      Например, код, который обеспечивает повтор RMI вызова, практически невозможно написать без ошибок (тут конечно и заслуга самого RMI).
                        0
                        Такие же проблемы и с возвратом кодов ошибок. В Go с этим, как мне показали выше, получше, но полностью проблема не решена.
                        0
                        Почему то мне кажется странным наличие такого холивара, то есть странно, что кто то хочет повсюду коды ошибок без разбора использовать, или наоборот исключения. Как мне кажется сценарии достаточно хорошо определяют что нужно в данный момент, код или исключение.
                        Как пример, если мы делаем поиск по словарю и у нас нет значения, то если у нас заранее определенное количество значений, то неизвестный ключ должен вызывать исключение. А если заранее неизвестно, например обработка данных полученных из сети, или разбор XML и нужно известные обработать, неизвестные проигнорировать, то код ошибки.
                        Если у нас какое то длительное вычисление и любая ошибка делает результат невалидным — исключение.
                        Если же это поток мелких действий, и это ожидаемо, что принятое для обработки сообщение может оказаться «плохим», то код ошибки.

                        Исключение удобно, когда много кода, ошибка может возникнуть в любом месте и ее придется пробрасывать наверх через десяток методов, и нужно много кодов ошибки. Исключение позволяет спрятать передачу «кода ошибки», так же как вызов метода у объекта прячет this.

                        Код ошибки хорош тем, что ошибка может случиться сотню и тысячу раз в секунду, и те десятки тысяч правильных сообщений не пострадают, Исключение же, случившееся пару сотен раз в секунду займет 100% процессора и не даст выполнять полезную работу.
                          0
                          Собственно слово «исключение» об этом и говорит. Исключение должно бросаться в исключительных ситуациях, когда продолжение работы текущей функции/метода/слоя невозможны. Функция ищет что-то в базе, но база отвечает или ошибка синтаксиса sql — исключение. Но никто (вроде бы) не предлагает бросать исключения в функции поиска подстроки в строке, если она не найдена.

                          Пожалуй, можно сформулировать правило выбора так: ситуация описана в бизнес-логике (логике предметной области) — ошибка, не описана (какая-то техническая проблема или на вход приходит то, что не должно прийти) — исключение. При этом стоит иметь в виду, что в большинстве случаев пользовательский ввод и вообще внешние данные (файлы, БД, сеть и т. п.) должны вызывать ошибку, а вот невозможность их получить — исключение. Разве что подразумевается, что внешние данные пишутся самой программой, конкретно для неё или соответствуют какому-то стандарту — тогда исключение (хотя со стандартами всё сложно).

                          А вот сотни и тысячи раз за секунду — это уже из области оптимизации, а не «идеального» кода. :)

                        Только полноправные пользователи могут оставлять комментарии. Войдите, пожалуйста.

                        Самое читаемое