Почему исключения должны использоваться консервативно? - PullRequest
77 голосов
/ 16 ноября 2009

Я часто вижу / слышу, как люди говорят, что исключения следует использовать только редко, но никогда не объясняют почему. Хотя это может быть правдой, обоснование - это, как правило, проблеск: "это называется исключением по причине" , что, на мой взгляд, является своего рода объяснением, которое никогда не должно быть принято уважаемым программистом / инженером ,

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

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

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

Ответы [ 28 ]

89 голосов
/ 16 ноября 2009

Основная точка трения - это семантика. Многие разработчики злоупотребляют исключениями и выбрасывают их при каждой возможности. Идея состоит в том, чтобы использовать исключение для несколько исключительной ситуации. Например, неправильный пользовательский ввод не считается исключением, потому что вы ожидаете, что это произойдет и готовы к этому. Но если вы попытались создать файл, и на диске было недостаточно места, то да, это определенное исключение.

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

Отвечая прямо на ваш вопрос. Исключения следует использовать редко, потому что исключительные ситуации редки, а исключения дороги.

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

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

Это не значит, что не может быть никаких исключений в использовании исключений (улыбается). Иногда это может упростить структуру кода, если вы генерируете исключение вместо пересылки кодов возврата через множество уровней. Как простое правило: если вы ожидаете, что какой-то метод будет вызываться часто, и обнаружите некоторую «исключительную» ситуацию в половине случаев, то лучше найти другое решение. Однако, если вы ожидаете нормального выполнения операций большую часть времени, в то время как эта «исключительная» ситуация может возникать только в некоторых редких случаях, тогда будет просто исключить исключение.

@ Комментарии: Исключение определенно можно использовать в некоторых менее исключительных ситуациях, если это может сделать ваш код проще и проще. Эта опция открыта, но я бы сказал, что на практике она встречается довольно редко.

Почему неразумно использовать их для управления потоком?

Потому что исключения нарушают нормальный «поток управления». Вы вызываете исключение, и нормальное выполнение программы прекращается, потенциально оставляя объекты в несогласованном состоянии, а некоторые открытые ресурсы освобождаются. Конечно, в C # есть оператор using, который гарантирует, что объект будет удален, даже если из тела использования выдается исключение. Но давайте сейчас отвлечемся от языка. Предположим, что фреймворк не будет располагать объекты для вас. Вы делаете это вручную. У вас есть система для запроса и освобождения ресурсов и памяти. У вас есть общесистемное соглашение, которое отвечает за освобождение объектов и ресурсов в каких ситуациях. У вас есть правила, как обращаться с внешними библиотеками. Это прекрасно работает, если программа следует нормальному режиму работы. Но вдруг в середине исполнения вы бросаете исключение. Половина ресурсов оставлена ​​несвободной. Половина еще не была запрошена. Если операция должна была быть транзакционной, теперь она прерывается. Ваши правила обработки ресурсов не будут работать, потому что те части кода, которые отвечают за освобождение ресурсов, просто не будут выполняться. Если кто-то еще захочет использовать эти ресурсы, они могут найти их в несовместимом состоянии и также потерпеть крах, потому что не могут предсказать эту конкретную ситуацию.

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

С использованием исключений вы создаете способ привести вашу программу во многие новые непредсказуемые промежуточные состояния, которые трудно прогнозировать, понимать и иметь с ними дело. Это похоже на использование GOTO. Очень трудно спроектировать программу, которая может произвольно переходить от одного исполнения к другому. Также будет сложно поддерживать и отлаживать его. Когда программа усложняется, вы просто теряете обзор того, что, когда и где происходит меньше, чтобы ее исправить.

61 голосов
/ 16 ноября 2009

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

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

Конструкторы

Очень мало что можно сказать о каждом конструкторе для каждого класса, который может быть написан на C ++, но есть несколько вещей. Главным среди них является то, что построенные объекты (то есть, для которых конструктор успешно возвратился) будут разрушены. Вы не можете изменить это постусловие, потому что язык предполагает, что оно истинно, и будет вызывать деструкторы автоматически. (Технически вы можете принять возможность неопределенного поведения, для которого язык дает нет гарантии о что-нибудь, , но это, вероятно, лучше в другом месте.)

Единственная альтернатива выбрасыванию исключения, когда конструктор не может завершиться успешно, - это изменить базовое определение класса («инвариант класса»), чтобы разрешить допустимые «нулевые» или зомби-состояния и, таким образом, позволить конструктору «преуспеть» с помощью Построение зомби.

Пример с зомби

Примером этой модификации зомби является std :: ifstream , и вы всегда должны проверять его состояние, прежде чем сможете его использовать. Так как, например, std :: string этого не происходит, вы всегда можете использовать его сразу после создания. Представьте, что вам нужно было написать код, подобный этому примеру, и если вы забыли проверить состояние зомби, вы либо молча получили неверные результаты, либо испортили другие части вашей программы:

string s = "abc";
if (s.memory_allocation_succeeded()) {
  do_something_with(s); // etc.
}

Даже присвоение имени этому методу является хорошим примером того, как вы должны изменить инвариант и интерфейс класса для ситуации string не может ни предсказать, ни обработать себя.

Пример валидации ввода

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

// boost::lexical_cast<int>() is the parsing function here
void show_square() {
  using namespace std;
  assert(cin); // precondition for show_square()
  cout << "Enter a number: ";
  string line;
  if (!getline(cin, line)) { // EOF on cin
    // error handling omitted, that EOF will not be reached is considered
    // part of the precondition for this function for the sake of example
    //
    // note: the below Python version throws an EOFError from raw_input
    //  in this case, and handling this situation is the only difference
    //  between the two
  }
  int n;
  try {
    n = boost::lexical_cast<int>(line);
    // lexical_cast returns an int
    // if line == "abc", it obviously cannot meet that postcondition
  }
  catch (boost::bad_lexical_cast&) {
    cout << "I can't do that, Dave.\n";
    return;
  }
  cout << n * n << '\n';
}

К сожалению, это показывает два примера того, как область видимости C ++ требует, чтобы вы нарушили RAII / SBRM. Пример в Python, который не имеет такой проблемы и показывает то, что я хотел бы, чтобы C ++ имел & ndash; попробуй еще:

# int() is the parsing "function" here
def show_square():
  line = raw_input("Enter a number: ") # same precondition as above
  # however, here raw_input will throw an exception instead of us
  # using assert
  try:
    n = int(line)
  except ValueError:
    print "I can't do that, Dave."
  else:
    print n * n

Предпосылки

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

В частности, противопоставьте ветви std :: logic_error и std :: runtime_error иерархии исключений stdlib. Первый часто используется для нарушений предусловий, а второй больше подходит для нарушений постусловий.

40 голосов
/ 17 ноября 2009
  1. Дорого
    Вызовы ядра (или другие вызовы системного API) для управления интерфейсами сигналов ядра (системы)
  2. Трудно анализировать
    Многие проблемы оператора goto относятся к исключениям. Они перепрыгивают через потенциально большие объемы кода часто в нескольких подпрограммах и исходных файлах. Это не всегда очевидно из чтения промежуточного исходного кода. (Это на Java.)
  3. Промежуточный код не всегда предвидится
    Перепрыгиваемый код может быть или не быть написан с возможностью выхода из исключения. Если изначально так написано, это, возможно, не было поддержано с учетом этого. Подумайте: утечки памяти, утечки файловых дескрипторов, утечки сокетов, кто знает?
  4. Осложнения осложнения
    Сложнее поддерживать код, который перебирает обработку исключений.
22 голосов
/ 16 ноября 2009

Бросок исключения в некоторой степени похож на оператор goto. Сделайте это для управления потоком, и вы получите непонятный код спагетти. Хуже того, в некоторых случаях вы даже не знаете, куда именно идет переход (т. Е. Если вы не ловите исключение в данном контексте). Это явно нарушает принцип «наименьшего удивления», который повышает удобство обслуживания.

16 голосов
/ 16 ноября 2009

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

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

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

Java имеет более широкое определение «действительно исключительное», чем C ++. Программисты на C ++ с большей вероятностью захотят взглянуть на возвращаемое значение функции, чем программисты на Java, поэтому в Java «действительно исключительное» может означать «Я не могу вернуть ненулевой объект как результат этой функции». В C ++ это более вероятно означает «я очень сомневаюсь, что мой абонент может продолжить». Таким образом, поток Java генерирует ошибку, если не может прочитать файл, тогда как поток C ++ (по умолчанию) возвращает значение, указывающее на ошибку. Однако во всех случаях все зависит от того, какой код вы хотите заставить своего вызывающего абонента писать. Так что это действительно вопрос стиля кодирования: вы должны прийти к общему мнению о том, как должен выглядеть ваш код, и сколько кода «проверяющего на наличие ошибок» вы хотите написать против того, сколько рассуждений о «исключительной безопасности» вы хотите сделать.

Широкий консенсус по всем языкам, по-видимому, заключается в том, что это лучше всего делать с точки зрения вероятности исправления ошибки (поскольку неисправимые ошибки не приводят к коду без исключений, но все же требуется проверка и возврат-ваш -own-error в коде, который использует возврат ошибок). Поэтому люди ожидают, что «эта функция, которую я вызываю, вызывает исключение», что означает « I не может продолжаться», а не просто « it не может продолжаться». Это не присуще исключениям, это просто обычай, но, как и любая хорошая практика программирования, это обычай, отстаиваемый умными людьми, которые попробовали его по-другому и не получили результатов. У меня тоже были неудачные опыты, когда я выкидывал слишком много исключений. Так что лично я думаю в терминах «действительно исключительных», если что-то в этой ситуации не делает исключение особенно привлекательным.

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

11 голосов
/ 16 ноября 2009

Там действительно нет единого мнения. Весь вопрос несколько субъективен, потому что «целесообразность» создания исключения часто предлагается существующими практиками в стандартной библиотеке самого языка. Стандартная библиотека C ++ генерирует исключения гораздо реже, чем, скажем, стандартная библиотека Java, которая почти всегда предпочитает исключения, даже для ожидаемых ошибок, таких как недопустимый пользовательский ввод (например, Scanner.nextInt). Я полагаю, что это существенно влияет на мнения разработчиков о том, когда следует создавать исключение.

Как программист на C ++, я лично предпочитаю зарезервировать исключения для очень «исключительных» обстоятельств, например, нехватка памяти, нехватка дискового пространства, произошел апокалипсис и т. д. Но я не настаиваю на том, что это абсолютно правильный способ сделать что-то.

7 голосов
/ 16 ноября 2009

РЕДАКТИРОВАТЬ 11/20/2009 :

Я только что прочитал эту статью MSDN о повышении производительности управляемого кода , и эта часть напомнила мне этот вопрос:

Затраты на выполнение броска исключения значительны. Хотя структурированная обработка исключений является рекомендуемым способом обработки состояний ошибок, убедитесь, что вы используете исключения только в исключительных случаях, когда возникают условия ошибок. Не используйте исключения для регулярного потока управления.

Конечно, это только для .NET, и оно также предназначено специально для тех, кто разрабатывает высокопроизводительные приложения (такие как я); так что это явно не универсальная правда Тем не менее, среди нас есть много разработчиков .NET, поэтому я чувствовал, что это стоит отметить.

EDIT

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

  1. Постер просит дать объективное обоснование общепринятому мнению, что исключения следует использовать с осторожностью. Мы можем обсуждать удобочитаемость и правильное оформление всего, что хотим; но это субъективные вопросы, когда люди готовы спорить с обеих сторон. Я думаю, что плакат знает об этом. Дело в том, что использование исключений для управления потоком программ часто является неэффективным способом действий. Нет, не всегда , но часто . Вот почему разумно советовать экономно использовать исключения, точно так же, как это хороший совет: есть красное мясо или пить вино экономно.

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

Чтобы проиллюстрировать мою точку зрения, рассмотрим следующие примеры кода C #.

Пример 1. Обнаружение неверного ввода пользователя

Это пример того, что я бы назвал исключением злоупотребление .

int value = -1;
string input = GetInput();
bool inputChecksOut = false;

while (!inputChecksOut) {
    try {
        value = int.Parse(input);
        inputChecksOut = true;

    } catch (FormatException) {
        input = GetInput();
    }
}

Этот код для меня нелепый. Конечно это работает . Никто не спорит с этим. Но это должно быть примерно таким:

int value = -1;
string input = GetInput();

while (!int.TryParse(input, out value)) {
    input = GetInput();
}

Пример 2. Проверка наличия файла

Я думаю, что этот сценарий на самом деле очень распространен. Это, конечно, кажется намного более "приемлемым" для многих людей, поскольку имеет дело с файловым вводом / выводом:

string text = null;
string path = GetInput();
bool inputChecksOut = false;

while (!inputChecksOut) {
    try {
        using (FileStream fs = new FileStream(path, FileMode.Open)) {
            using (StreamReader sr = new StreamReader(fs)) {
                text = sr.ReadToEnd();
            }
        }

        inputChecksOut = true;

    } catch (FileNotFoundException) {
        path = GetInput();
    }
}

Это кажется достаточно разумным, верно? Мы пытаемся открыть файл; если его там нет, мы ловим это исключение и пытаемся открыть другой файл ... Что с этим не так?

Ничего, правда. Но рассмотрим эту альтернативу, которая не не выдает никаких исключений:

string text = null;
string path = GetInput();

while (!File.Exists(path)) path = GetInput();

using (FileStream fs = new FileStream(path, FileMode.Open)) {
    using (StreamReader sr = new StreamReader(fs)) {
        text = sr.ReadToEnd();
    }
}

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

Использование try / catch блока: 25,455 секунд
Использование int.TryParse: 1,637 миллисекунд

Для второго примера я сделал в основном то же самое: составил список из 10000 случайных строк, ни одна из которых не была допустимым путем, затем добавил допустимый путь в самый конец. Это были результаты:

Использование блока try / catch: 29,989 секунд
Использование File.Exists: 22,820 миллисекунд

Многие люди ответили бы на это словами: «Да, ну, бросить и поймать 10 000 исключений крайне нереально; это преувеличивает результаты». Конечно, это так. Разница между выдачей одного исключения и обработкой неверного ввода самостоятельно не будет заметна для пользователя. Факт остается фактом: использование исключений в этих двух случаях от 1000 до более 10000 раз медленнее , чем альтернативные подходы, которые столь же удобочитаемы - если не больше.

Вот почему я включил пример метода GetNine() ниже. Нельзя сказать, что это невыносимо медленно или недопустимо медленно; это то, что он медленнее, чем должен быть ... без веской причины .

Опять же, это всего лишь два примера. Из курса будут времена, когда снижение производительности при использовании исключений не будет таким серьезным (Павел прав; в конце концов, это зависит от реализации). Все, что я говорю: давайте посмотрим правде в глаза, ребята - в случаях, подобных приведенному выше, создание и отлов исключений аналогичны GetNine(); это просто неэффективный способ сделать что-то , что можно легко сделать лучше .


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

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

Вы могли бы также спросить: почему этот код плох?

private int GetNine() {
    for (int i = 0; i < 10; i++) {
        if (i == 9) return i;
    }
}

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

Это то, что люди имеют в виду, когда говорят об исключении "злоупотребления".

7 голосов
/ 16 ноября 2009

Не думаю, что исключения следует использовать редко. Но.

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

Если вы собираетесь широко использовать исключения, то:

  • будьте готовы рассказать своим людям, что такое исключительная безопасность
  • вы не должны использовать необработанное управление памятью
  • широко используйте RAII

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

  • вы не пропустите или игнорируете ошибки
  • вы не должны писать эти проверки кодов возврата, даже не зная, что делать с неправильным кодом на низком уровне
  • когда вы вынуждены писать код, безопасный для исключений, он становится более структурированным
6 голосов
/ 16 ноября 2009

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

Когда использование исключений попадает в ту же область, что и "как я узнаю, является ли этот код одним классом или двумя?" Это частично стилистическая проблема, частично предпочтение. Исключения - это инструмент. Их можно использовать и злоупотреблять, и найти грань между ними - часть искусства и навыка программирования.

Есть много мнений и компромиссов. Найдите что-то, что говорит с вами, и следуйте за этим.

6 голосов
/ 16 ноября 2009

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

Причина проста: исключения внезапно завершают работу функции и распространяются вверх по стеку до блока catch. Этот процесс очень затратен в вычислительном отношении: C ++ строит свою систему исключений так, чтобы при использовании «обычных» вызовов функций было немного накладных расходов, поэтому, когда возникает исключение, ему нужно проделать большую работу, чтобы найти, куда идти. Более того, поскольку каждая строка кода может вызывать исключение. Если у нас есть какая-то функция f, которая часто вызывает исключения, теперь мы должны позаботиться о том, чтобы использовать наши блоки try / catch вокруг каждого вызова f. Это довольно плохая связь между интерфейсом и реализацией.

Добро пожаловать на сайт PullRequest, где вы можете задавать вопросы и получать ответы от других членов сообщества.
...