Обработка ошибок с помощью исключений

Основная философия Java в том, что “плохо сформированный код не будет работать”.

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

Однако не все ошибки могут быть определены во время компиляции.

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

Обычно вы возвращали специальное значение или устанавливали флаг, а приемщику предлагалось взглянуть на это значение или на флаг и определить, было ли что-нибудь неправильно.

Если вы всякий раз проверяли состояние ошибки при вызове метода, ваш код мог превратиться нечитаемый ночной кошмар.

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

Слово “исключение” используется в смысле “Я беру исключение из этого”.

В том месте, где возникает проблема, вы можете не знать, что делать с ней, но вы знаете, что вы не можете просто весело продолжать; вы должны остановиться и кто-то, где-то должен определить, что делать дальше.

Но у вас нет достаточно информации в текущем контексте для устранения проблемы. Так что вы передаете проблему в более высокий контекст, где кто-то будет достаточно квалифицированным, чтобы принять правильное решение (как в цепочке команд).

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

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

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

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

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

Простой пример - деление. Если вы делите на ноль, стоит проверить, чтобы убедиться, что вы пройдете вперед и выполните деление. Но что это значит, что делитель равен нулю? Может быть, вы знаете, в контексте проблемы вы пробуете решить это в определенном методе, как поступать с делителем, равным нулю. Но если это не ожидаемое значение, вы не можете это определить внутри и раньше должны выбросить исключение, чем продолжать свой путь.

 

Когда вы выбрасываете исключение, случается несколько вещей.

Во-первых, создается объект исключения тем же способом, что и любой Java объект: в куче, с помощью new.

Затем текущий путь выполнения (который вы не можете продолжать) останавливается, и ссылка на объект исключения выталкивается из текущего контекста. Это выглядит так:

if(t == null)

      throw new NullPointerException();

Здесь выбрасывается исключение, которое позволяет вам — в текущем контексте — отказаться от ответственности, думая о будущем решении. Оно магически обработается где-то в другом месте.

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

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

 

Ловля исключения

Если метод выбросил исключение, он должен предполагать, что исключение будет “поймано” и устранено. Один из преимуществ обработки исключений Java в том, что это позволяет вам концентрироваться на проблеме, которую вы пробуете решить в одном месте, а затем принимать меры по ошибкам из этого кода в другом месте.

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

Блок try

Если вы находитесь внутри метода, и вы выбросили исключение (или другой метод, вызванный вами внутри этого метода, выбросил исключение), такой метод перейдет в процесс бросания.

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

try {
  // Код, который может сгенерировать исключение
}

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

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

 

Обработчики исключений

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

Обработчики исключений следуют сразу за блоком проверки и объявляются ключевым словом catch:

try {
  // Код, который может сгенерировать исключение
} catch(Type1 id1) {
  // Обработка исключения Type1
} catch(Type2 id2) {
  // Обработка исключения Type2
} catch(Type3 id3) {
  // Обработка исключения Type3
}
 
// и так далее...

Каждое catch предложение (обработчик исключения) как маленький метод, который принимает один и только один аргумент определенного типа. Идентификаторы (id1, id2 и так далее) могут быть использованы внутри обработчика, как аргумент метода. Иногда вы нигде не используете идентификатор, потому что тип исключения дает вам достаточно информации, чтобы разобраться с исключением, но идентификатор все равно должен быть.

Обработчики должны располагаться прямо после блока проверки. Если выброшено исключение, механизм обработки исключений идет охотится за первым обработчиком с таким аргументом, тип которого совпадает с типом исключения. Затем происходит вход в предложение catch, и рассматривается обработка исключения. Поиск обработчика, после остановки на предложении catch, заканчивается. Выполняется только совпавшее предложение catch; это не как инструкция switch, в которой вам необходим break после каждого case, чтобы предотвратить выполнение оставшейся части.

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

 

Прерывание против возобновления

Есть две основные модели в теории обработки исключений.

При прерывании (которое поддерживает Java и C++), вы предполагаете, что ошибка критична и нет способа вернуться туда, где возникло исключение. Кто бы ни выбросил исключение, он решил, что нет способа спасти ситуацию, и он не хочет возвращаться обратно.

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

В этом случае ваше исключение больше похоже на вызов метода, в котором вы должны произвести настройку ситуации в Java, после чего возможно возобновление. (То есть, не выбрасывать исключение; вызвать метод, который исправит проблему.) Альтернатива - поместить ваш блок try внутри цикла while, который производит повторный вход в блок try, пока не будет получен удовлетворительный результат.

 

Создание ваших собственных исключений

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

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

//: c10:SimpleExceptionDemo.java
// Наследование вашего собственного исключения.
class SimpleException extends Exception {} 
 
public class SimpleExceptionDemo {
  public void f() throws SimpleException {
    System.out.println(
      "Throwing SimpleException from f()");
    throw new SimpleException ();
  }
  public static void main(String[] args) {
    SimpleExceptionDemo sed = 
      new SimpleExceptionDemo();
    try {
      sed.f();
    } catch(SimpleException e) {
      System.err.println("Caught it!");
    }
  }
} ///:~

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

Вот результат, который печатается на консоль стандартной ошибки - поток для записи в System.err. Чаще всего это лучшее место для направления информации об ошибках, чем System.out, который может быть перенаправлен. Если вы посылаете вывод в System.err, он не может быть перенаправлен, в отличие от System.out, так что пользователю легче заметить его.

 

Спецификация исключения

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

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

        void f() throws TooBig, TooSmall, DivZero { //... 

Если вы скажете

        void f() { // ...

это будет означать, что исключения не выбрасываются из этого метода. (Кроме исключения, типа RuntimeException, которое может быть выброшено в любом месте — это будет описано позже.)

 

Перехват любого исключения

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

catch(Exception e) {
  System.err.println("Caught an exception");
}

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

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

String getMessage( )
String getLocalizedMessage( )
Получает подробное сообщение или сообщение, отрегулированное по его месту действия.

String toString( )
Возвращает короткое описание Throwable, включая подробности сообщения, если они есть.

void printStackTrace( )
void printStackTrace(PrintStream)
void printStackTrace(PrintWriter)
Печатает Throwable и трассировку вызовов Throwable. Вызов стека показывает последовательность вызовов методов, которые подвели вас к точке, в которой было выброшено исключение. Первая версия печатает в поток стандартный поток ошибки, второй и третий печатают в выбранный вами поток (в Главе 11, вы поймете, почему есть два типа потоков).

Throwable fillInStackTrace( )
Запись информации в этот Throwable объекте о текущем состоянии кадра стека. Это полезно, когда приложение вновь выбрасывает ошибки или исключение (дальше об этом будет подробнее).

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

 

Повторное выбрасывание исключений

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

catch(Exception e) {
  System.err.println("An exception was thrown");
  throw e;
}

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

 

Особый случай RuntimeException

Первый пример в этой главе был:

if(t == null)
  throw new NullPointerException();

Это может быть немного пугающим: думать, что вы должны проверять на null каждую ссылку, передаваемую в метод (так как вы не можете знать, что при вызове была передана правильная ссылка). К счастью вам не нужно это, поскольку Java выполняет стандартную проверку во время выполнения за вас и, если вы вызываете метод для null ссылки, Java автоматически выбросит NullPointerException. Так что приведенную выше часть кода всегда излишняя.

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

Также вам никогда не нужно писать спецификацию исключения, объявляя, что метод может выбросить RuntimeException, так как это просто предполагается. Так как они указывают на ошибки, вы, фактически, никогда не выбрасываете RuntimeException — это делается автоматически. Если вы заставляете ваш код выполнять проверку на RuntimeExceptions, он может стать грязным. Хотя вы обычно не ловите RuntimeExceptions, в ваших собственных пакетах вы можете по выбору выбрасывать некоторые из RuntimeException.

Что случится, если вы не выбросите это исключение? Так как компилятор не заставляет включать спецификацию исключений для этого случая, достаточно правдоподобно, что RuntimeException могут принизывать насквозь ваш метод main( ) и не ловится. Чтобы увидеть, что случится в этом случае, попробуйте следующий пример:

//: c10:NeverCaught.java
// Игнорирование RuntimeExceptions.
 
public class NeverCaught {
  static void f() {
    throw new RuntimeException("From f()");
  }
  static void g() {
    f();
  }
  public static void main(String[] args) {
    g();
  }
} ///:~

Вы уже видели, что RuntimeException (или любое, унаследованное от него) - это особый случай, так как компилятор не требует спецификации этих типов.

Вот что получится при выводе:

Exception in thread "main"
java.lang.RuntimeException: From f()
        at NeverCaught.f(NeverCaught.java:9)
        at NeverCaught.g(NeverCaught.java:12)
        at NeverCaught.main(NeverCaught.java:15)

Так что получим такой ответ: Если получаем RuntimeException, все пути ведут к выходу из main( ) без поимки, для такого исключения вызывается printStackTrace( ), и происходит выход из программы.

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

  1. Ошибка, которую вы не можете поймать (получение null ссылки, передаваемой в ваш метод клиентским программистом, например).
  2. Ошибки, которые вы, как программист, должны проверять в вашем коде (такие как ArrayIndexOutOfBoundsException, где вы должны обращать внимание на размер массива).

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

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

 

Выполнение очистки с помощью finally

Часто есть такие места кода, которые вы хотите выполнить независимо от того, было ли выброшено исключение в блоке try, или нет. Это обычно относится к некоторым операциям, отличным от утилизации памяти (так как об этом заботится сборщик мусора). Для достижения этого эффекта вы используете предложение finally [53] в конце списка всех обработчиков исключений. Полная картина секции обработки исключений выглядит так:

try {
  // Критическая область: Опасная активность,
  // при которой могут быть выброшены A, B или C 
} catch(A a1) {
  // Обработчик ситуации A
} catch(B b1) {
  // Обработчик ситуации B
} catch(C c1) {
  // Обработчик ситуации C
} finally {
  // Действия, совершаемые всякий раз
}

 

 

Руководство по исключениям

Используйте исключения для:

  1. Исправления проблем и нового вызова метода, который явился причиной исключения.
  2. Исправления вещей и продолжения без повторной попытки метода.
  3. Подсчета какого-то альтернативного результата вместо того, который должен был вычислить метод.
  4. Выполнения того, что вы можете в текущем контексте и повторного выброса того же исключения в более старший контекст.
  5. Выполнения того, что вы можете в текущем контексте и повторного выброса другого исключения в более старший контекст.
  6. Прекращения программы.
  7. Упрощения. (Если ваша схема исключений делает вещи более сложными, то это приводит к тягостному и мучительному использованию.)
  8. Создать более безопасные библиотеки и программы. (Для краткосрочной инвестиции - для отладки - и для долгосрочной инвестиции (Для устойчивости приложения).)