Перейти к основному содержимому

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

· 15 мин. чтения

Задача: Медиана двух отсортированных массивов

Даны два отсортированных массива размерами n и m. Найдите медиану слияния этих двух массивов.
Временная сложность решения должна быть O(log(m + n)) ...

ANDROMEDA

1. Обзор

В этом руководстве мы рассмотрим основы обработки исключений в Java, а также некоторые из его ошибок.

2. Первые принципы

2.1. Что это?

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

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

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

2.2. Зачем это использовать?

Обычно мы пишем код в идеализированной среде: файловая система всегда содержит наши файлы, сеть исправна, а у JVM всегда достаточно памяти. Иногда мы называем это «счастливым путем».

Однако в производственной среде файловые системы могут быть повреждены, сети могут выйти из строя, а у JVM может не хватить памяти. Благополучие нашего кода зависит от того, как он справляется с «несчастливыми путями».

Мы должны обрабатывать эти условия, потому что они негативно влияют на поток приложения и формируют исключения :

public static List<Player> getPlayers() throws IOException {
Path path = Paths.get("players.dat");
List<String> players = Files.readAllLines(path);

return players.stream()
.map(Player::new)
.collect(Collectors.toList());
}

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

Но что может произойти в продакшене, если не будет player.dat ?

Exception in thread "main" java.nio.file.NoSuchFileException: players.dat <-- players.dat file doesn't exist
at sun.nio.fs.WindowsException.translateToIOException(Unknown Source)
at sun.nio.fs.WindowsException.rethrowAsIOException(Unknown Source)
// ... more stack trace
at java.nio.file.Files.readAllLines(Unknown Source)
at java.nio.file.Files.readAllLines(Unknown Source)
at Exceptions.getPlayers(Exceptions.java:12) <-- Exception arises in getPlayers() method, on line 12
at Exceptions.main(Exceptions.java:19) <-- getPlayers() is called by main(), on line 19

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

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

3. Иерархия исключений

В конечном счете, исключения — это просто объекты Java, и все они унаследованы от Throwable :

---> Throwable <--- 
| (checked) |
| |
| |
---> Exception Error
| (checked) (unchecked)
|
RuntimeException
(unchecked)

Существует три основных категории исключительных состояний:

  • Проверенные исключения
  • Непроверенные исключения / Исключения во время выполнения
  • Ошибки

Исключения времени выполнения и непроверенные исключения относятся к одному и тому же. Мы часто можем использовать их взаимозаменяемо.

3.1. Проверенные исключения

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

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

Несколько примеров проверенных исключений: IOException и ServletException.

3.2. Непроверенные исключения

Непроверенные исключения — это исключения, которые компилятор Java не требует от нас обработки.

Проще говоря, если мы создадим исключение, которое расширяет RuntimeException , оно будет непроверено; в противном случае он будет проверен.

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

Некоторыми примерами непроверенных исключений являются NullPointerException, IllegalArgumentException и SecurityException .

3.3. Ошибки

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

И хотя они не расширяют RuntimeException , они также не проверяются.

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

Несколько примеров ошибок: StackOverflowError и OutOfMemoryError .

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

В Java API есть много мест, где что-то может пойти не так, и некоторые из этих мест отмечены исключениями либо в подписи, либо в Javadoc:

/**
* @exception FileNotFoundException ...
*/
public Scanner(String fileName) throws FileNotFoundException {
// ...
}

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

4.1. бросает

Самый простой способ «обработать» исключение — повторно сгенерировать его:

public int getPlayerScore(String playerFile)
throws FileNotFoundException {

Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
}

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

parseInt может генерировать NumberFormatException , но, поскольку он не проверен, мы не обязаны его обрабатывать.

4.2. Попробуйте поймать

Если мы хотим попытаться обработать исключение самостоятельно, мы можем использовать блок try-catch . Мы можем справиться с этим, перегенерировав наше исключение:

public int getPlayerScore(String playerFile) {
try {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException noFile) {
throw new IllegalArgumentException("File not found");
}
}

Или выполнив шаги восстановления:

public int getPlayerScore(String playerFile) {
try {
Scanner contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch ( FileNotFoundException noFile ) {
logger.warn("File not found, resetting score.");
return 0;
}
}

4.3. в конце концов

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

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

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

Давайте сначала попробуем этот «ленивый» способ:

public int getPlayerScore(String playerFile)
throws FileNotFoundException {
Scanner contents = null;
try {
contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} finally {
if (contents != null) {
contents.close();
}
}
}

Здесь блок finally указывает, какой код мы хотим, чтобы Java запускала независимо от того, что произойдет при попытке чтения файла.

Даже если FileNotFoundException выбрасывается в стек вызовов, Java вызовет содержимое finally , прежде чем сделать это.

Мы также можем обработать исключение и убедиться, что наши ресурсы закрыты:

public int getPlayerScore(String playerFile) {
Scanner contents;
try {
contents = new Scanner(new File(playerFile));
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException noFile ) {
logger.warn("File not found, resetting score.");
return 0;
} finally {
try {
if (contents != null) {
contents.close();
}
} catch (IOException io) {
logger.error("Couldn't close the reader!", io);
}
}
}

Так как метод close также является «рискованным», нам также необходимо перехватывать его исключение!

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

4.4. попробуй -с-ресурсами

К счастью, начиная с Java 7, мы можем упростить приведенный выше синтаксис при работе с вещами, расширяющими AutoCloseable :

public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile))) {
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException e ) {
logger.warn("File not found, resetting score.");
return 0;
}
}

Когда мы помещаем ссылки, которые AutoClosable , в объявление try , нам не нужно закрывать ресурс самостоятельно.

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

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

4.5. Несколько блоков улова

Иногда код может генерировать более одного исключения, и у нас может быть более одного дескриптора блока catch по отдельности:

public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile))) {
return Integer.parseInt(contents.nextLine());
} catch (IOException e) {
logger.warn("Player file wouldn't load!", e);
return 0;
} catch (NumberFormatException e) {
logger.warn("Player file was corrupted!", e);
return 0;
}
}

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

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

Предположим, однако, что нам нужно обрабатывать FileNotFoundException иначе, чем более общий IOException :

public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile)) ) {
return Integer.parseInt(contents.nextLine());
} catch (FileNotFoundException e) {
logger.warn("Player file not found!", e);
return 0;
} catch (IOException e) {
logger.warn("Player file wouldn't load!", e);
return 0;
} catch (NumberFormatException e) {
logger.warn("Player file was corrupted!", e);
return 0;
}
}

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

4.6. Союз поймать блоки

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

public int getPlayerScore(String playerFile) {
try (Scanner contents = new Scanner(new File(playerFile))) {
return Integer.parseInt(contents.nextLine());
} catch (IOException | NumberFormatException e) {
logger.warn("Failed to load score!", e);
return 0;
}
}

5. Генерация исключений

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

Допустим, у нас есть следующее проверенное исключение, которое мы создали сами:

public class TimeoutException extends Exception {
public TimeoutException(String message) {
super(message);
}
}

и у нас есть метод, выполнение которого потенциально может занять много времени:

public List<Player> loadAllPlayers(String playersFile) {
// ... potentially long operation
}

5.1. Генерация проверенного исключения

Подобно возврату из метода, мы можем бросить в любой момент.

Конечно, мы должны бросать, когда пытаемся указать, что что-то пошло не так:

public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
while ( !tooLong ) {
// ... potentially long operation
}
throw new TimeoutException("This operation took too long");
}

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

5.2. Генерация непроверенного исключения

Если мы хотим сделать что-то вроде, скажем, проверки ввода, мы можем вместо этого использовать непроверенное исключение:

public List<Player> loadAllPlayers(String playersFile) throws TimeoutException {
if(!isFilenameValid(playersFile)) {
throw new IllegalArgumentException("Filename isn't valid!");
}

// ...
}

Поскольку флажок IllegalArgumentException снят, нам не нужно отмечать метод, хотя мы и приветствуем это.

Некоторые все равно отмечают метод как форму документации.

5.3. Оборачиваем и перебрасываем

Мы также можем повторно сгенерировать исключение, которое мы поймали:

public List<Player> loadAllPlayers(String playersFile) 
throws IOException {
try {
// ...
} catch (IOException io) {
throw io;
}
}

Или сделать обертку и перебросить:

public List<Player> loadAllPlayers(String playersFile) 
throws PlayerLoadException {
try {
// ...
} catch (IOException io) {
throw new PlayerLoadException(io);
}
}

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

5.4. Повторное создание Throwable или Exception

Теперь о частном случае.

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

public List<Player> loadAllPlayers(String playersFile) {
try {
throw new NullPointerException();
} catch (Throwable t) {
throw t;
}
}

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

Это удобно с прокси-классами и методами. Подробнее об этом можно узнать здесь .

5.5. Наследование

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

В случае, когда наш метод выдает проверенное исключение:

public class Exceptions {
public List<Player> loadAllPlayers(String playersFile)
throws TimeoutException {
// ...
}
}

Подкласс может иметь «менее рискованную» подпись:

public class FewerExceptions extends Exceptions {   
@Override
public List<Player> loadAllPlayers(String playersFile) {
// overridden
}
}

Но не « более рискованная» подпись:

public class MoreExceptions extends Exceptions {        
@Override
public List<Player> loadAllPlayers(String playersFile) throws MyCheckedException {
// overridden
}
}

Это связано с тем, что контракты определяются во время компиляции ссылочным типом. Если я создам экземпляр MoreExceptions и сохраню его в Exceptions :

Exceptions exceptions = new MoreExceptions();
exceptions.loadAllPlayers("file");

Тогда JVM только скажет мне поймать TimeoutException , что неверно , поскольку я сказал, что MoreExceptions#loadAllPlayers выдает другое исключение.

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

6. Антипаттерны

6.1. Глотание исключений

Теперь есть еще один способ удовлетворить компилятор:

public int getPlayerScore(String playerFile) {
try {
// ...
} catch (Exception e) {} // <== catch and swallow
return 0;
}

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

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

public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
// this will never happen
}
}

Другой способ «проглотить» исключение — просто вывести исключение в поток ошибок:

public int getPlayerScore(String playerFile) {
try {
// ...
} catch (Exception e) {
e.printStackTrace();
}
return 0;
}

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

Однако для нас было бы лучше использовать регистратор:

public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
logger.error("Couldn't load the score", e);
return 0;
}
}

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

Наконец, мы можем непреднамеренно проглотить исключение, не указав его в качестве причины при генерации нового исключения:

public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
throw new PlayerScoreException();
}
}

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

Нам лучше сделать:

public int getPlayerScore(String playerFile) {
try {
// ...
} catch (IOException e) {
throw new PlayerScoreException(e);
}
}

Обратите внимание на тонкую разницу включения IOException в качестве причины PlayerScoreException .

6.2. Использование return в блоке finally

Еще один способ проглотить исключения — вернуться из блока finally . Это плохо, потому что при резком возврате JVM сбросит исключение, даже если оно было выброшено нашим кодом:

public int getPlayerScore(String playerFile) {
int score = 0;
try {
throw new IOException();
} finally {
return score; // <== the IOException is dropped
}
}

Согласно Спецификации языка Java :

  > Если выполнение блока try завершается внезапно по какой-либо другой причине  `R`  , то выполняется блок finally, и тогда есть выбор.

Если блок finally завершается нормально, то оператор try завершается внезапно по причине R. Если блок finally завершается внезапно по причине S, то оператор try завершается внезапно по причине S (и причина R отбрасывается).

6.3. Использование throw в блоке finally

Подобно использованию возврата в блоке finally , исключение, созданное в блоке finally , будет иметь приоритет над исключением, возникающим в блоке catch.

Это «сотрет» исходное исключение из блока try , и мы потеряем всю эту ценную информацию:

public int getPlayerScore(String playerFile) {
try {
// ...
} catch ( IOException io ) {
throw new IllegalStateException(io); // <== eaten by the finally
} finally {
throw new OtherException();
}
}

6.4. Использование throw в качестве перехода

Некоторые люди также поддались искушению использовать throw в качестве оператора goto :

public void doSomething() {
try {
// bunch of code
throw new MyException();
// second bunch of code
} catch (MyException e) {
// third bunch of code
}
}

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

7. Распространенные исключения и ошибки

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

7.1. Проверенные исключения

  • IOException — это исключение обычно указывает на сбой в сети, файловой системе или базе данных.

7.2. Исключения времени выполнения

  • ArrayIndexOutOfBoundsException — это исключение означает, что мы попытались получить доступ к несуществующему индексу массива, как при попытке получить индекс 5 из массива длины 3.
  • ClassCastException — это исключение означает, что мы попытались выполнить недопустимое приведение, например, преобразовать String в List . Обычно мы можем избежать этого, выполняя защитные проверки instanceof перед приведением.
  • IllegalArgumentException — это исключение является для нас общим способом сказать, что один из предоставленных параметров метода или конструктора недействителен.
  • IllegalStateException — это исключение — это общий способ сказать, что наше внутреннее состояние, как и состояние нашего объекта, недопустимо.
  • NullPointerException — это исключение означает, что мы попытались сослаться на нулевой объект. Обычно мы можем избежать этого, либо выполнив защитные проверки null , либо используя необязательный.
  • NumberFormatException — это исключение означает, что мы попытались преобразовать строку в число, но строка содержала недопустимые символы, например, попытка преобразовать «5f3» в число.

7.3. Ошибки

  • StackOverflowError — это исключение означает, что трассировка стека слишком велика. Иногда это может происходить в массовых приложениях; однако обычно это означает, что в нашем коде происходит некоторая бесконечная рекурсия.
  • NoClassDefFoundError — это исключение означает, что класс не удалось загрузить либо из-за отсутствия в пути к классам, либо из-за сбоя статической инициализации.
  • OutOfMemoryError — это исключение означает, что у JVM больше нет доступной памяти для выделения дополнительных объектов. Иногда это происходит из-за утечки памяти.

8. Заключение

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

Как всегда, весь код, найденный в этой статье, можно найти на GitHub !